diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6c5049e..f07384a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,8 @@ updates: github-actions: patterns: - "*" + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c3a72bb --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + +jobs: + test: + runs-on: ${{ vars.RUNNER_STANDARD }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install --quiet ".[dev]" + + - name: Run tests + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddde573 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +.pytest_cache/ diff --git a/action.yaml b/action.yaml index 6360f4d..c513782 100644 --- a/action.yaml +++ b/action.yaml @@ -35,6 +35,10 @@ runs: with: python-version: ${{ inputs.python-version }} + - name: Install action scripts + shell: bash + run: pip install --quiet "${{ github.action_path }}" + - name: Cache pre-commit hook environments uses: actions/cache@v5 with: @@ -46,14 +50,7 @@ runs: - if: inputs.node-version != '' id: detect-pm shell: bash - run: | - if [ -f pnpm-lock.yaml ]; then - echo "pm=pnpm" >> "$GITHUB_OUTPUT" - elif [ -f yarn.lock ]; then - echo "pm=yarn" >> "$GITHUB_OUTPUT" - else - echo "pm=npm" >> "$GITHUB_OUTPUT" - fi + run: python -m pre_commit_action.detect_pm >> "$GITHUB_OUTPUT" # Only let setup-node@v5 auto-cache npm. pnpm/yarn need a corepack shim on # PATH before setup-node can resolve their cache dirs, but corepack ships @@ -83,24 +80,7 @@ runs: - if: inputs.node-version != '' name: Install node dependencies shell: bash - run: | - case "${{ steps.detect-pm.outputs.pm }}" in - pnpm) - corepack enable - pnpm install --frozen-lockfile - ;; - yarn) - corepack enable - if [ -f .yarnrc.yml ]; then - yarn install --immutable - else - yarn install --frozen-lockfile - fi - ;; - npm) - npm ci - ;; - esac + run: python -m pre_commit_action.install_node_deps ${{ steps.detect-pm.outputs.pm }} - name: Run pre-commit id: pre-commit @@ -109,83 +89,18 @@ runs: env: PIP_EXTRA_INDEX_URL: https://europe-north1-python.pkg.dev/two-artifacts/pypi-virtual/simple/ run: | - set +e -o pipefail - uvx pre-commit run --from-ref origin/${{ github.base_ref }} --to-ref HEAD --show-diff-on-failure | tee pre-commit-output - exit_code=$? - set -e +o pipefail - echo "EXIT_CODE=$exit_code" >> "$GITHUB_OUTPUT" - exit $exit_code + python -m pre_commit_action.run_hooks ${{ github.base_ref }} HEAD pre-commit-output + echo "EXIT_CODE=$?" >> "$GITHUB_OUTPUT" - # Read pre-commit-output from disk rather than splicing it into the script - # source via `${{ steps.pre-commit.outputs.OUTPUT }}`. Inlined templates of - # unbounded user output are passed to node24 as argv and blow past the - # Linux ARG_MAX (~128KB) on large diffs, killing the step with E2BIG before - # any code runs. Middle-truncating to a safe ceiling preserves both the - # headline failure (start) and the final summary (end), since the dense - # middle is the most compressible part. - name: Render comment if: github.event_name == 'pull_request' - uses: actions/github-script@v9 + shell: bash env: PRE_COMMIT_EXIT_CODE: ${{ steps.pre-commit.outputs.EXIT_CODE }} PRE_COMMIT_OUTCOME: ${{ steps.pre-commit.outcome }} PRE_COMMIT_BASE_REF: ${{ github.base_ref }} PRE_COMMIT_ACTOR: ${{ github.actor }} - with: - script: | - const fs = require('fs'); - const MAX_OUTPUT_BYTES = 60 * 1024; - const outcome = process.env.PRE_COMMIT_OUTCOME; - const exitCode = process.env.PRE_COMMIT_EXIT_CODE; - const baseRef = process.env.PRE_COMMIT_BASE_REF; - const actor = process.env.PRE_COMMIT_ACTOR; - const emoji = outcome === 'success' ? '🏆' : '🚫'; - let output = ''; - try { - output = fs.readFileSync('pre-commit-output', 'utf8'); - } catch (err) { - output = `(failed to read pre-commit-output: ${err.message})`; - } - // Middle-truncate: both the start (which hook failed, headline error) - // and the tail (final summary, exit context) are usually load-bearing. - // The dense middle (per-file diffs, line-by-line lint chatter) is the - // most compressible part. - const buf = Buffer.from(output, 'utf8'); - if (buf.length > MAX_OUTPUT_BYTES) { - const half = Math.floor((MAX_OUTPUT_BYTES - 80) / 2); - const removed = buf.length - 2 * half; - const head = buf.slice(0, half).toString('utf8'); - const tail = buf.slice(buf.length - half).toString('utf8'); - output = `${head}\n\n... [truncated ${removed} bytes from the middle] ...\n\n${tail}`; - } - const escaped = output.replace(/`/g, '\\`'); - const hint = outcome === 'success' ? '' : ` - Looks like the PR is missing pre-commit changes. Please run the following locally and commit changes to fix this issue: - - \`\`\` - pre-commit install # only if you do not have it installed already - git fetch origin - pre-commit run --from-ref origin/${baseRef} --to-ref HEAD - git commit -a - git push - \`\`\` - `; - const body = `# 🖌 Pre-commit ${outcome} ${emoji} - - ${hint} - -
Details - - \`\`\` - ${escaped} - \`\`\` - - Exit code: ${exitCode} - -
- - Author ✍️@${actor}`; - fs.writeFileSync('pre-commit-comment.md', body); + run: python -m pre_commit_action.render_comment pre-commit-output pre-commit-comment.md - name: Post sticky PR comment if: github.event_name == 'pull_request' diff --git a/pre_commit_action/__init__.py b/pre_commit_action/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pre_commit_action/detect_pm.py b/pre_commit_action/detect_pm.py new file mode 100644 index 0000000..617e203 --- /dev/null +++ b/pre_commit_action/detect_pm.py @@ -0,0 +1,15 @@ +import os +import sys + + +def detect_pm(workspace: str) -> str: + if os.path.isfile(os.path.join(workspace, "pnpm-lock.yaml")): + return "pnpm" + if os.path.isfile(os.path.join(workspace, "yarn.lock")): + return "yarn" + return "npm" + + +if __name__ == "__main__": + workspace = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() + print(f"pm={detect_pm(workspace)}") diff --git a/pre_commit_action/install_node_deps.py b/pre_commit_action/install_node_deps.py new file mode 100644 index 0000000..800ec4a --- /dev/null +++ b/pre_commit_action/install_node_deps.py @@ -0,0 +1,23 @@ +import os +import subprocess +import sys + + +def install(pm: str, workspace: str) -> None: + if pm == "pnpm": + subprocess.run(["corepack", "enable"], check=True) + subprocess.run(["pnpm", "install", "--frozen-lockfile"], check=True) + elif pm == "yarn": + subprocess.run(["corepack", "enable"], check=True) + if os.path.isfile(os.path.join(workspace, ".yarnrc.yml")): + subprocess.run(["yarn", "install", "--immutable"], check=True) + else: + subprocess.run(["yarn", "install", "--frozen-lockfile"], check=True) + else: + subprocess.run(["npm", "ci"], check=True) + + +if __name__ == "__main__": + pm = sys.argv[1] + workspace = sys.argv[2] if len(sys.argv) > 2 else os.getcwd() + install(pm, workspace) diff --git a/pre_commit_action/render_comment.py b/pre_commit_action/render_comment.py new file mode 100644 index 0000000..c398a33 --- /dev/null +++ b/pre_commit_action/render_comment.py @@ -0,0 +1,74 @@ +import os +import sys + +MAX_OUTPUT_BYTES = 60 * 1024 + + +def middle_truncate(text: str, max_bytes: int = MAX_OUTPUT_BYTES) -> str: + buf = text.encode("utf-8") + if len(buf) <= max_bytes: + return text + half = (max_bytes - 80) // 2 + removed = len(buf) - 2 * half + head = buf[:half].decode("utf-8", errors="replace") + tail = buf[len(buf) - half :].decode("utf-8", errors="replace") + return f"{head}\n\n... [truncated {removed} bytes from the middle] ...\n\n{tail}" + + +def render_comment( + output_path: str, + exit_code: str, + outcome: str, + base_ref: str, + actor: str, +) -> str: + try: + with open(output_path) as f: + raw = f.read() + except OSError as err: + raw = f"(failed to read {output_path}: {err})" + + output = middle_truncate(raw) + emoji = "🏆" if outcome == "success" else "🚫" + + hint = ( + "" + if outcome == "success" + else f""" +Looks like the PR is missing pre-commit changes. Please run the following locally and commit changes to fix this issue: + +``` +pre-commit install # only if you do not have it installed already +git fetch origin +pre-commit run --from-ref origin/{base_ref} --to-ref HEAD +git commit -a +git push +``` +""" + ) + + return f"""# 🖌 Pre-commit {outcome} {emoji} +{hint} +
Details + +``` +{output} +``` + +Exit code: {exit_code} + +
+ +Author ✍️@{actor}""" + + +if __name__ == "__main__": + output_path = sys.argv[1] + comment_path = sys.argv[2] if len(sys.argv) > 2 else "pre-commit-comment.md" + exit_code = os.environ["PRE_COMMIT_EXIT_CODE"] + outcome = os.environ["PRE_COMMIT_OUTCOME"] + base_ref = os.environ["PRE_COMMIT_BASE_REF"] + actor = os.environ["PRE_COMMIT_ACTOR"] + comment = render_comment(output_path, exit_code, outcome, base_ref, actor) + with open(comment_path, "w") as f: + f.write(comment) diff --git a/pre_commit_action/run_hooks.py b/pre_commit_action/run_hooks.py new file mode 100644 index 0000000..88db0b8 --- /dev/null +++ b/pre_commit_action/run_hooks.py @@ -0,0 +1,31 @@ +import subprocess +import sys + + +def run_precommit(base_ref: str, to_ref: str, output_path: str) -> int: + with open(output_path, "w") as f: + result = subprocess.run( + [ + "uvx", + "pre-commit", + "run", + "--from-ref", + f"origin/{base_ref}", + "--to-ref", + to_ref, + "--show-diff-on-failure", + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + print(result.stdout, end="") + f.write(result.stdout) + return result.returncode + + +if __name__ == "__main__": + base_ref = sys.argv[1] + to_ref = sys.argv[2] + output_path = sys.argv[3] + sys.exit(run_precommit(base_ref, to_ref, output_path)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e528d58 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pre-commit-action" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest>=8"] + +[tool.hatch.build.targets.wheel] +packages = ["pre_commit_action"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_detect_pm.py b/tests/test_detect_pm.py new file mode 100644 index 0000000..c59428a --- /dev/null +++ b/tests/test_detect_pm.py @@ -0,0 +1,21 @@ +from pre_commit_action.detect_pm import detect_pm + + +def test_detects_pnpm(tmp_path): + (tmp_path / "pnpm-lock.yaml").write_text("") + assert detect_pm(str(tmp_path)) == "pnpm" + + +def test_detects_yarn(tmp_path): + (tmp_path / "yarn.lock").write_text("") + assert detect_pm(str(tmp_path)) == "yarn" + + +def test_defaults_to_npm(tmp_path): + assert detect_pm(str(tmp_path)) == "npm" + + +def test_pnpm_takes_priority_over_yarn(tmp_path): + (tmp_path / "pnpm-lock.yaml").write_text("") + (tmp_path / "yarn.lock").write_text("") + assert detect_pm(str(tmp_path)) == "pnpm" diff --git a/tests/test_install_node_deps.py b/tests/test_install_node_deps.py new file mode 100644 index 0000000..d01704e --- /dev/null +++ b/tests/test_install_node_deps.py @@ -0,0 +1,37 @@ +from unittest.mock import call, patch + +from pre_commit_action.install_node_deps import install + + +def test_npm_ci(tmp_path): + with patch("pre_commit_action.install_node_deps.subprocess.run") as mock_run: + install("npm", str(tmp_path)) + mock_run.assert_called_once_with(["npm", "ci"], check=True) + + +def test_pnpm_frozen_lockfile(tmp_path): + with patch("pre_commit_action.install_node_deps.subprocess.run") as mock_run: + install("pnpm", str(tmp_path)) + assert mock_run.call_args_list == [ + call(["corepack", "enable"], check=True), + call(["pnpm", "install", "--frozen-lockfile"], check=True), + ] + + +def test_yarn_modern_uses_immutable(tmp_path): + (tmp_path / ".yarnrc.yml").write_text("") + with patch("pre_commit_action.install_node_deps.subprocess.run") as mock_run: + install("yarn", str(tmp_path)) + assert mock_run.call_args_list == [ + call(["corepack", "enable"], check=True), + call(["yarn", "install", "--immutable"], check=True), + ] + + +def test_yarn_classic_uses_frozen_lockfile(tmp_path): + with patch("pre_commit_action.install_node_deps.subprocess.run") as mock_run: + install("yarn", str(tmp_path)) + assert mock_run.call_args_list == [ + call(["corepack", "enable"], check=True), + call(["yarn", "install", "--frozen-lockfile"], check=True), + ] diff --git a/tests/test_render_comment.py b/tests/test_render_comment.py new file mode 100644 index 0000000..8429e67 --- /dev/null +++ b/tests/test_render_comment.py @@ -0,0 +1,59 @@ +from pre_commit_action.render_comment import MAX_OUTPUT_BYTES, middle_truncate, render_comment + + +def test_success_shows_trophy(tmp_path): + out = tmp_path / "output.txt" + out.write_text("All hooks passed.") + result = render_comment(str(out), "0", "success", "main", "alice") + assert "🏆" in result + assert "@alice" in result + assert "All hooks passed." in result + + +def test_failure_shows_stop_sign_and_hint(tmp_path): + out = tmp_path / "output.txt" + out.write_text("Hook failed.") + result = render_comment(str(out), "1", "failure", "main", "bob") + assert "🚫" in result + assert "pre-commit run --from-ref origin/main" in result + assert "@bob" in result + + +def test_success_has_no_hint(tmp_path): + out = tmp_path / "output.txt" + out.write_text("ok") + result = render_comment(str(out), "0", "success", "main", "carol") + assert "pre-commit install" not in result + + +def test_exit_code_in_comment(tmp_path): + out = tmp_path / "output.txt" + out.write_text("done") + result = render_comment(str(out), "42", "failure", "main", "x") + assert "Exit code: 42" in result + + +def test_missing_output_file(tmp_path): + result = render_comment(str(tmp_path / "nonexistent.txt"), "1", "failure", "main", "x") + assert "failed to read" in result + + +def test_middle_truncate_short_passthrough(): + text = "hello world" + assert middle_truncate(text) == text + + +def test_middle_truncate_long_string(): + big = "x" * (MAX_OUTPUT_BYTES + 2000) + result = middle_truncate(big) + assert "truncated" in result + assert len(result.encode("utf-8")) < MAX_OUTPUT_BYTES + 500 + + +def test_middle_truncate_preserves_head_and_tail(): + head = "START" + "a" * (MAX_OUTPUT_BYTES // 2) + tail = "b" * (MAX_OUTPUT_BYTES // 2) + "END" + big = head + tail + result = middle_truncate(big) + assert result.startswith("START") + assert result.endswith("END") diff --git a/tests/test_run_hooks.py b/tests/test_run_hooks.py new file mode 100644 index 0000000..e9c4a04 --- /dev/null +++ b/tests/test_run_hooks.py @@ -0,0 +1,44 @@ +from unittest.mock import MagicMock, patch + +from pre_commit_action.run_hooks import run_precommit + + +def _mock_result(returncode, stdout): + r = MagicMock() + r.returncode = returncode + r.stdout = stdout + return r + + +def test_success_returns_zero(tmp_path): + output = tmp_path / "out.txt" + with patch( + "pre_commit_action.run_hooks.subprocess.run", + return_value=_mock_result(0, "All hooks passed.\n"), + ): + code = run_precommit("main", "HEAD", str(output)) + assert code == 0 + assert output.read_text() == "All hooks passed.\n" + + +def test_failure_returns_nonzero(tmp_path): + output = tmp_path / "out.txt" + with patch( + "pre_commit_action.run_hooks.subprocess.run", + return_value=_mock_result(1, "Hook failed.\n"), + ): + code = run_precommit("main", "HEAD", str(output)) + assert code == 1 + assert output.read_text() == "Hook failed.\n" + + +def test_command_includes_base_ref(tmp_path): + output = tmp_path / "out.txt" + with patch( + "pre_commit_action.run_hooks.subprocess.run", + return_value=_mock_result(0, ""), + ) as mock_run: + run_precommit("release", "HEAD", str(output)) + cmd = mock_run.call_args[0][0] + assert "origin/release" in cmd + assert "--show-diff-on-failure" in cmd