diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index e7258100..29f0fd6c 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -1,5 +1,10 @@ { "entries": { + "actions/checkout@v6.0.2": { + "repo": "actions/checkout", + "version": "v6.0.2", + "sha": "de0fac2e4500dabe0009e67214ff5f5447ce83dd" + }, "actions/create-github-app-token@v3.1.1": { "repo": "actions/create-github-app-token", "version": "v3.1.1", @@ -30,13 +35,13 @@ "version": "v7", "sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" }, - "github/gh-aw-actions/setup@v0.74.4": { - "repo": "github/gh-aw-actions/setup", + "github/gh-aw-actions/setup-cli@v0.74.4": { + "repo": "github/gh-aw-actions/setup-cli", "version": "v0.74.4", "sha": "d3abfe96a194bce3a523ed2093ddedd5704cdf62" }, - "github/gh-aw-actions/setup-cli@v0.74.4": { - "repo": "github/gh-aw-actions/setup-cli", + "github/gh-aw-actions/setup@v0.74.4": { + "repo": "github/gh-aw-actions/setup", "version": "v0.74.4", "sha": "d3abfe96a194bce3a523ed2093ddedd5704cdf62" }, diff --git a/.github/workflows/crane.lock.yml b/.github/workflows/crane.lock.yml index d2ecd50d..50993493 100644 --- a/.github/workflows/crane.lock.yml +++ b/.github/workflows/crane.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"77180725977e429c5bcf8041816c2ab32f0033a3daef9c95e5c10506af075349","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"945fcb394f5dd72a3c6cbaa1f01ebe75f7bdfa8b2f16531bd2a6276c84c615e7","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"a309ff8b426b58ec0e2a45f0f869d46889d02405","version":"v6.2.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) @@ -84,6 +84,11 @@ on: - opened - edited - reopened + # permissions: # Permissions applied to pre-activation job + # checks: read + # contents: read + # issues: read + # pull-requests: read pull_request: types: - opened @@ -95,6 +100,41 @@ on: - edited schedule: - cron: "*/20 * * * *" + # steps: # Steps injected into pre-activation job + # - name: Checkout scheduler sources + # uses: actions/checkout@v6.0.2 + # with: + # fetch-depth: 1 + # persist-credentials: false + # sparse-checkout: | + # .github/workflows/scripts + # .crane + # - env: + # GH_TOKEN: ${{ github.token }} + # GITHUB_REPOSITORY: ${{ github.repository }} + # GITHUB_SERVER_URL: ${{ github.server_url }} + # name: Clone repo-memory for scheduling + # run: | + # MEMORY_DIR="/tmp/gh-aw/repo-memory/crane" + # BRANCH="memory/crane" + # mkdir -p "$(dirname "$MEMORY_DIR")" + # REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + # AUTH_URL="$(echo "$REPO_URL" | sed "s|https://|https://x-access-token:${GH_TOKEN}@|")" + # if git ls-remote --exit-code --heads "$AUTH_URL" "$BRANCH" > /dev/null 2>&1; then + # git clone --single-branch --branch "$BRANCH" --depth 1 "$AUTH_URL" "$MEMORY_DIR" 2>&1 + # echo "Cloned repo-memory branch to $MEMORY_DIR" + # else + # mkdir -p "$MEMORY_DIR" + # echo "No repo-memory branch found yet (first run). Created empty directory." + # fi + # - env: + # CRANE_MIGRATION: ${{ github.event.inputs.migration }} + # GITHUB_REPOSITORY: ${{ github.repository }} + # GITHUB_TOKEN: ${{ github.token }} + # id: crane_due + # name: Check whether Crane has due work + # run: | + # python3 .github/workflows/scripts/crane_scheduler.py workflow_dispatch: inputs: aw_context: @@ -118,7 +158,18 @@ run-name: "Crane" jobs: activation: needs: pre_activation - if: "needs.pre_activation.outputs.activated == 'true' && ((github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment') && (github.event_name == 'issues' && (startsWith(github.event.issue.body, '/crane ') || startsWith(github.event.issue.body, '/crane\n') || github.event.issue.body == '/crane') || github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/crane ') || startsWith(github.event.comment.body, '/crane\n') || github.event.comment.body == '/crane') && github.event.issue.pull_request == null || github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/crane ') || startsWith(github.event.comment.body, '/crane\n') || github.event.comment.body == '/crane') && github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' && (startsWith(github.event.comment.body, '/crane ') || startsWith(github.event.comment.body, '/crane\n') || github.event.comment.body == '/crane') || github.event_name == 'pull_request' && (startsWith(github.event.pull_request.body, '/crane ') || startsWith(github.event.pull_request.body, '/crane\n') || github.event.pull_request.body == '/crane') || github.event_name == 'discussion' && (startsWith(github.event.discussion.body, '/crane ') || startsWith(github.event.discussion.body, '/crane\n') || github.event.discussion.body == '/crane') || github.event_name == 'discussion_comment' && (startsWith(github.event.comment.body, '/crane ') || startsWith(github.event.comment.body, '/crane\n') || github.event.comment.body == '/crane')) || (!(github.event_name == 'issues')) && (!(github.event_name == 'issue_comment')) && (!(github.event_name == 'pull_request')) && (!(github.event_name == 'pull_request_review_comment')) && (!(github.event_name == 'discussion')) && (!(github.event_name == 'discussion_comment')))" + if: > + needs.pre_activation.outputs.activated == 'true' && (needs.pre_activation.outputs.crane_has_work == 'true' && ( + needs.pre_activation.outputs.matched_command == 'crane' || + ( + github.event_name != 'issues' && + github.event_name != 'issue_comment' && + github.event_name != 'pull_request' && + github.event_name != 'pull_request_review_comment' && + github.event_name != 'discussion' && + github.event_name != 'discussion_comment' + ) + )) runs-on: ubuntu-slim permissions: actions: read @@ -289,25 +340,25 @@ jobs: run: | bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_8302442c7cf3aa0c_EOF' + cat << 'GH_AW_PROMPT_614db64b1dd56d21_EOF' - GH_AW_PROMPT_8302442c7cf3aa0c_EOF + GH_AW_PROMPT_614db64b1dd56d21_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/repo_memory_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_8302442c7cf3aa0c_EOF' + cat << 'GH_AW_PROMPT_614db64b1dd56d21_EOF' Tools: add_comment(max:7), create_issue, update_issue(max:3), create_pull_request, add_labels(max:2), remove_labels(max:2), push_to_pull_request_branch, missing_tool, missing_data, noop - GH_AW_PROMPT_8302442c7cf3aa0c_EOF + GH_AW_PROMPT_614db64b1dd56d21_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_push_to_pr_branch.md" - cat << 'GH_AW_PROMPT_8302442c7cf3aa0c_EOF' + cat << 'GH_AW_PROMPT_614db64b1dd56d21_EOF' - GH_AW_PROMPT_8302442c7cf3aa0c_EOF + GH_AW_PROMPT_614db64b1dd56d21_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_8302442c7cf3aa0c_EOF' + cat << 'GH_AW_PROMPT_614db64b1dd56d21_EOF' The following GitHub context information is available for this workflow: {{#if github.actor}} @@ -339,7 +390,7 @@ jobs: - **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it without proper authentication. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches). - GH_AW_PROMPT_8302442c7cf3aa0c_EOF + GH_AW_PROMPT_614db64b1dd56d21_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" 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 "${RUNNER_TEMP}/gh-aw/prompts/pr_context_prompt.md" @@ -347,11 +398,11 @@ jobs: 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 "${RUNNER_TEMP}/gh-aw/prompts/pr_context_push_to_pr_branch_guidance.md" fi - cat << 'GH_AW_PROMPT_8302442c7cf3aa0c_EOF' + cat << 'GH_AW_PROMPT_614db64b1dd56d21_EOF' {{#runtime-import .github/workflows/shared/reporting.md}} {{#runtime-import .github/workflows/crane.md}} - GH_AW_PROMPT_8302442c7cf3aa0c_EOF + GH_AW_PROMPT_614db64b1dd56d21_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -611,9 +662,9 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_4166aa9d5606a2a8_EOF' + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_910554b6250010b6_EOF' {"add_comment":{"hide_older_comments":false,"max":7,"target":"*"},"add_labels":{"max":2,"target":"*"},"create_issue":{"labels":["automation","crane"],"max":1},"create_pull_request":{"draft":true,"labels":["automation","crane"],"max":1,"max_patch_files":100,"max_patch_size":10240,"preserve_branch_name":true,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"allowed"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"push_repo_memory":{"memories":[{"dir":"/tmp/gh-aw/repo-memory/default","id":"default","max_file_count":100,"max_file_size":40960,"max_patch_size":10240}]},"push_to_pull_request_branch":{"if_no_changes":"warn","max":1,"max_patch_size":10240,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"target":"*","title_prefix":"[Crane"},"remove_labels":{"max":2,"target":"*"},"report_incomplete":{},"update_issue":{"allow_body":true,"max":3,"target":"*","title_prefix":"[Crane"}} - GH_AW_SAFE_OUTPUTS_CONFIG_4166aa9d5606a2a8_EOF + GH_AW_SAFE_OUTPUTS_CONFIG_910554b6250010b6_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -1002,7 +1053,7 @@ jobs: mkdir -p /home/runner/.copilot GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_bcd0aa590392fbf8_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_9d530df1d017e269_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { @@ -1043,7 +1094,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_bcd0aa590392fbf8_EOF + GH_AW_MCP_CONFIG_9d530df1d017e269_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -1668,10 +1719,18 @@ jobs: } pre_activation: - if: "(github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment' || contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)) && ((github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment') && (github.event_name == 'issues' && (startsWith(github.event.issue.body, '/crane ') || startsWith(github.event.issue.body, '/crane\n') || github.event.issue.body == '/crane') || github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/crane ') || startsWith(github.event.comment.body, '/crane\n') || github.event.comment.body == '/crane') && github.event.issue.pull_request == null || github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/crane ') || startsWith(github.event.comment.body, '/crane\n') || github.event.comment.body == '/crane') && github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' && (startsWith(github.event.comment.body, '/crane ') || startsWith(github.event.comment.body, '/crane\n') || github.event.comment.body == '/crane') || github.event_name == 'pull_request' && (startsWith(github.event.pull_request.body, '/crane ') || startsWith(github.event.pull_request.body, '/crane\n') || github.event.pull_request.body == '/crane') || github.event_name == 'discussion' && (startsWith(github.event.discussion.body, '/crane ') || startsWith(github.event.discussion.body, '/crane\n') || github.event.discussion.body == '/crane') || github.event_name == 'discussion_comment' && (startsWith(github.event.comment.body, '/crane ') || startsWith(github.event.comment.body, '/crane\n') || github.event.comment.body == '/crane')) || (!(github.event_name == 'issues')) && (!(github.event_name == 'issue_comment')) && (!(github.event_name == 'pull_request')) && (!(github.event_name == 'pull_request_review_comment')) && (!(github.event_name == 'discussion')) && (!(github.event_name == 'discussion_comment')))" runs-on: ubuntu-slim + permissions: + checks: read + contents: read + issues: read + pull-requests: read outputs: activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_command_position.outputs.command_position_ok == 'true' }} + crane_due_result: ${{ steps.crane_due.outcome }} + crane_has_work: ${{ steps.crane_due.outputs.has_work }} + crane_no_migrations: ${{ steps.crane_due.outputs.no_migrations }} + crane_not_due: ${{ steps.crane_due.outputs.not_due }} matched_command: ${{ steps.check_command_position.outputs.matched_command }} setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} setup-span-id: ${{ steps.setup.outputs.span-id }} @@ -1712,6 +1771,40 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_command_position.cjs'); await main(); + - name: Checkout scheduler sources + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + sparse-checkout: | + .github/workflows/scripts + .crane + - name: Clone repo-memory for scheduling + run: | + MEMORY_DIR="/tmp/gh-aw/repo-memory/crane" + BRANCH="memory/crane" + mkdir -p "$(dirname "$MEMORY_DIR")" + REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + AUTH_URL="$(echo "$REPO_URL" | sed "s|https://|https://x-access-token:${GH_TOKEN}@|")" + if git ls-remote --exit-code --heads "$AUTH_URL" "$BRANCH" > /dev/null 2>&1; then + git clone --single-branch --branch "$BRANCH" --depth 1 "$AUTH_URL" "$MEMORY_DIR" 2>&1 + echo "Cloned repo-memory branch to $MEMORY_DIR" + else + mkdir -p "$MEMORY_DIR" + echo "No repo-memory branch found yet (first run). Created empty directory." + fi + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + - name: Check whether Crane has due work + id: crane_due + run: | + python3 .github/workflows/scripts/crane_scheduler.py + env: + CRANE_MIGRATION: ${{ github.event.inputs.migration }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ github.token }} push_repo_memory: needs: diff --git a/.github/workflows/crane.md b/.github/workflows/crane.md index a11741b6..cc65e9f5 100644 --- a/.github/workflows/crane.md +++ b/.github/workflows/crane.md @@ -22,6 +22,69 @@ on: type: string slash_command: name: crane + permissions: + contents: read + issues: read + pull-requests: read + checks: read + steps: + - name: Checkout scheduler sources + uses: actions/checkout@v6.0.2 + with: + persist-credentials: false + sparse-checkout: | + .github/workflows/scripts + .crane + fetch-depth: 1 + + - name: Clone repo-memory for scheduling + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + run: | + MEMORY_DIR="/tmp/gh-aw/repo-memory/crane" + BRANCH="memory/crane" + mkdir -p "$(dirname "$MEMORY_DIR")" + REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + AUTH_URL="$(echo "$REPO_URL" | sed "s|https://|https://x-access-token:${GH_TOKEN}@|")" + if git ls-remote --exit-code --heads "$AUTH_URL" "$BRANCH" > /dev/null 2>&1; then + git clone --single-branch --branch "$BRANCH" --depth 1 "$AUTH_URL" "$MEMORY_DIR" 2>&1 + echo "Cloned repo-memory branch to $MEMORY_DIR" + else + mkdir -p "$MEMORY_DIR" + echo "No repo-memory branch found yet (first run). Created empty directory." + fi + + - name: Check whether Crane has due work + id: crane_due + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + CRANE_MIGRATION: ${{ github.event.inputs.migration }} + run: | + python3 .github/workflows/scripts/crane_scheduler.py + +jobs: + pre-activation: + outputs: + crane_has_work: ${{ steps.crane_due.outputs.has_work }} + crane_not_due: ${{ steps.crane_due.outputs.not_due }} + crane_no_migrations: ${{ steps.crane_due.outputs.no_migrations }} + +if: > + needs.pre_activation.outputs.crane_has_work == 'true' && + ( + needs.pre_activation.outputs.matched_command == 'crane' || + ( + github.event_name != 'issues' && + github.event_name != 'issue_comment' && + github.event_name != 'pull_request' && + github.event_name != 'pull_request_review_comment' && + github.event_name != 'discussion' && + github.event_name != 'discussion_comment' + ) + ) permissions: read-all diff --git a/.github/workflows/scripts/crane_scheduler.py b/.github/workflows/scripts/crane_scheduler.py index 23ceb28f..2b30fe89 100644 --- a/.github/workflows/scripts/crane_scheduler.py +++ b/.github/workflows/scripts/crane_scheduler.py @@ -15,10 +15,9 @@ * Always writes ``/tmp/gh-aw/crane.json``. Exit codes: - 0 - a migration was selected, or there are unconfigured migrations to - report on (the agent step should run). - 1 - nothing to do this run (no due migrations, no unconfigured - migrations); the workflow should skip the agent step. + 0 - scheduling completed successfully. ``has_work=false`` is emitted to + ``GITHUB_OUTPUT`` when no migration is due. + 1 - invalid scheduler input or another hard scheduler error. Environment variables: GITHUB_TOKEN - token used to query the issues API. @@ -63,6 +62,20 @@ STATE_FILE_MAX_BYTES = 40960 +def _write_github_outputs(**values): + """Write simple string outputs for GitHub Actions steps.""" + output_path = os.environ.get("GITHUB_OUTPUT") + if not output_path: + return + with open(output_path, "a", encoding="utf-8") as f: + for key, value in values.items(): + if isinstance(value, bool): + value = "true" if value else "false" + elif value is None: + value = "" + f.write("{}={}\n".format(key, value)) + + # --------------------------------------------------------------------------- # Pure helpers (unit-tested directly) # --------------------------------------------------------------------------- @@ -765,12 +778,19 @@ def main(): "unconfigured": [], "stale_completed_state": [], "no_migrations": True, + "not_due": False, "head_branch": None, "existing_pr": None, }, f, ) - sys.exit(0) + _write_github_outputs( + has_work=False, + no_migrations=True, + not_due=False, + selected="", + ) + return 0 now = datetime.now(timezone.utc) due = [] @@ -921,7 +941,7 @@ def main(): if error: print("ERROR: {}".format(error)) - sys.exit(1) + return 1 if forced_migration and selected: print("FORCED: running migration '{}' (manual dispatch)".format(forced_migration)) @@ -937,6 +957,9 @@ def main(): print(" Warning: existing PR lookup failed for {}: {}".format(selected, e)) existing_pr = None + has_work = bool(selected or unconfigured) + not_due = bool(migration_files and not has_work) + result = { "selected": selected, "selected_file": selected_file, @@ -954,6 +977,7 @@ def main(): "skipped": skipped, "unconfigured": unconfigured, "no_migrations": False, + "not_due": not_due, "head_branch": head_branch, "existing_pr": existing_pr, } @@ -967,10 +991,20 @@ def main(): print("Migrations skipped: {}".format([s["name"] for s in skipped] or "(none)")) print("Migrations unconfigured: {}".format(unconfigured or "(none)")) + _write_github_outputs( + has_work=has_work, + no_migrations=False, + not_due=not_due, + selected=selected or "", + unconfigured_count=len(unconfigured), + ) + if not selected and not unconfigured: - print("\nNo migrations due this run. Exiting early.") - sys.exit(1) # Non-zero exit skips the agent step + print("\nNo migrations due this run. Exiting successfully.") + return 0 + + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/tests/unit/test_crane_scheduler.py b/tests/unit/test_crane_scheduler.py index 0f9dc648..bda7ebee 100644 --- a/tests/unit/test_crane_scheduler.py +++ b/tests/unit/test_crane_scheduler.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import json from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -13,6 +14,95 @@ spec.loader.exec_module(crane_scheduler) +def _write_migration(path: Path, *, schedule: str = "every 6h") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join( + [ + "---", + f"schedule: {schedule}", + "strategy: greenfield", + "source-language: python", + "target-languages: [go]", + "target-metric: 1.0", + "metric_direction: higher", + "---", + "# Test Migration", + "## Source", + "- python", + "## Target", + "- go", + "## Verification", + "run tests", + "", + ] + ), + encoding="utf-8", + ) + + +def test_main_exits_zero_and_outputs_no_work_when_no_migrations_are_due( + tmp_path, monkeypatch +) -> None: + _write_migration(tmp_path / ".crane" / "migrations" / "sample.md", schedule="weekly") + output_dir = tmp_path / "out" + github_output = tmp_path / "github-output.txt" + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(crane_scheduler, "OUTPUT_DIR", str(output_dir)) + monkeypatch.setattr(crane_scheduler, "OUTPUT_FILE", str(output_dir / "crane.json")) + monkeypatch.setattr(crane_scheduler, "ISSUE_MIGRATIONS_DIR", str(tmp_path / "issues")) + monkeypatch.setattr(crane_scheduler, "_fetch_issue_migrations", lambda *_args: ([], {})) + monkeypatch.setattr( + crane_scheduler, + "read_migration_state", + lambda _name: {"last_run": "2026-06-05T16:10:36Z", "iteration_count": 72}, + ) + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) + + assert crane_scheduler.main() == 0 + + result = json.loads((output_dir / "crane.json").read_text(encoding="utf-8")) + assert result["selected"] is None + assert result["not_due"] is True + assert result["skipped"] == [ + { + "name": "sample", + "reason": "not due yet", + "next_due": "2026-06-12T16:10:36+00:00", + } + ] + + outputs = github_output.read_text(encoding="utf-8").splitlines() + assert "has_work=false" in outputs + assert "not_due=true" in outputs + assert "no_migrations=false" in outputs + + +def test_main_outputs_has_work_when_migration_is_due(tmp_path, monkeypatch) -> None: + _write_migration(tmp_path / ".crane" / "migrations" / "sample.md") + output_dir = tmp_path / "out" + github_output = tmp_path / "github-output.txt" + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(crane_scheduler, "OUTPUT_DIR", str(output_dir)) + monkeypatch.setattr(crane_scheduler, "OUTPUT_FILE", str(output_dir / "crane.json")) + monkeypatch.setattr(crane_scheduler, "ISSUE_MIGRATIONS_DIR", str(tmp_path / "issues")) + monkeypatch.setattr(crane_scheduler, "_fetch_issue_migrations", lambda *_args: ([], {})) + monkeypatch.setattr(crane_scheduler, "read_migration_state", lambda _name: {}) + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) + + assert crane_scheduler.main() == 0 + + result = json.loads((output_dir / "crane.json").read_text(encoding="utf-8")) + assert result["selected"] == "sample" + assert result["not_due"] is False + + outputs = github_output.read_text(encoding="utf-8").splitlines() + assert "has_work=true" in outputs + assert "selected=sample" in outputs + + def test_completed_state_skips_inactive_migration() -> None: should_skip, reason = crane_scheduler.check_skip_conditions({"completed": True})