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