Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ updates:
github-actions:
patterns:
- "*"

- package-ecosystem: pip
directory: /
schedule:
interval: weekly
28 changes: 28 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
.pytest_cache/
105 changes: 10 additions & 95 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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><summary>Details</summary>

\`\`\`
${escaped}
\`\`\`

Exit code: ${exitCode}

</details>

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'
Expand Down
Empty file added pre_commit_action/__init__.py
Empty file.
15 changes: 15 additions & 0 deletions pre_commit_action/detect_pm.py
Original file line number Diff line number Diff line change
@@ -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)}")
23 changes: 23 additions & 0 deletions pre_commit_action/install_node_deps.py
Original file line number Diff line number Diff line change
@@ -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)
74 changes: 74 additions & 0 deletions pre_commit_action/render_comment.py
Original file line number Diff line number Diff line change
@@ -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><summary>Details</summary>

```
{output}
```

Exit code: {exit_code}

</details>

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)
31 changes: 31 additions & 0 deletions pre_commit_action/run_hooks.py
Original file line number Diff line number Diff line change
@@ -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))
18 changes: 18 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
Empty file added tests/__init__.py
Empty file.
21 changes: 21 additions & 0 deletions tests/test_detect_pm.py
Original file line number Diff line number Diff line change
@@ -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"
Loading