diff --git a/.github/template-files/config.yml b/.github/template-files/config.yml index 9cfaa93f..9f11716c 100644 --- a/.github/template-files/config.yml +++ b/.github/template-files/config.yml @@ -7,7 +7,7 @@ conda/infrastructure: - .github/workflows/cla.yml - .github/workflows/update.yml - # [optional] to include repo in https://github.com/orgs/conda/projects/2 + # [optional] project management workflows - .github/workflows/issues.yml - .github/workflows/labels.yml - .github/workflows/project.yml @@ -48,3 +48,8 @@ conda/infrastructure: # dst: rever.xsh # codespell:ignore rever # - src: templates/releases/TEMPLATE # dst: news/TEMPLATE + +conda/actions: + # [optional] lint workflow (requires .pre-commit-config.yaml) + - src: lint/workflow.yml.tmpl + dst: .github/workflows/lint.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0fa11d04..bc9d1716 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -131,9 +131,98 @@ jobs: ${{ steps.templates-error.outputs.summary }} GITHUB_TOKEN: ${{ secrets.SANDBOX_TEMPLATE_TOKEN }} + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout Source + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Filter Changes + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + lint: + - 'lint/**' + + # Test 1: Success case - clean code should pass lint + - name: Run lint on success test data + id: lint-success + continue-on-error: true + uses: ./lint + env: + WORKING_DIRECTORY: lint/data/success + with: + token: ${{ secrets.SANDBOX_TEMPLATE_TOKEN }} + checkout: false + working-directory: ${{ env.WORKING_DIRECTORY }} + config: pre-commit-config.yaml + # Only comment on PR if lint code changed + pr-number: ${{ steps.filter.outputs.lint == 'true' && github.event.pull_request.number || '' }} + comment-anchor: lint-comment-test-success + comment-on-success: ${{ steps.filter.outputs.lint == 'true' }} + comment-header: | + > [!WARNING] + > **Working directory:** `${{ env.WORKING_DIRECTORY }}` + > This is what the lint comment looks like when there are no lint issues. + + # Test 2: Error case - code with issues should fail lint + - name: Run lint on error test data + id: lint-error + continue-on-error: true + uses: ./lint + env: + WORKING_DIRECTORY: lint/data/error + with: + token: ${{ secrets.SANDBOX_TEMPLATE_TOKEN }} + checkout: false + working-directory: ${{ env.WORKING_DIRECTORY }} + config: pre-commit-config.yaml + # Only comment on PR if lint code changed + pr-number: ${{ steps.filter.outputs.lint == 'true' && github.event.pull_request.number || '' }} + comment-anchor: lint-comment-test-error + comment-header: | + > [!WARNING] + > **Working directory:** `${{ env.WORKING_DIRECTORY }}` + > This is what the lint comment looks like when there are lint issues. + + - name: Reset test data changes + run: git checkout -- lint/data/ + + # Verify all test outcomes + - name: Verify test outcomes + run: | + failed=0 + + # Test 1: Success case should pass + if [[ "${{ steps.lint-success.outputs.outcome }}" != "success" ]]; then + echo "::error::Test 1 (success): Expected outcome=success, got ${{ steps.lint-success.outputs.outcome }}" + failed=1 + fi + if [[ -z "${{ steps.lint-success.outputs.output }}" ]]; then + echo "::error::Test 1 (success): Expected output to be non-empty" + failed=1 + fi + + # Test 2: Error case should fail + if [[ "${{ steps.lint-error.outputs.outcome }}" != "failure" ]]; then + echo "::error::Test 2 (error): Expected outcome=failure, got ${{ steps.lint-error.outputs.outcome }}" + failed=1 + fi + if ! echo "${{ steps.lint-error.outputs.output }}" | grep -q "F401"; then + echo "::error::Test 2 (error): Expected output to contain F401 (unused import)" + failed=1 + fi + if [[ -z "${{ steps.lint-error.outputs.diff }}" ]]; then + echo "::error::Test 2 (error): Expected diff output to be non-empty" + failed=1 + fi + + exit $failed + # required check analyze: - needs: [pytest, read-file, template-files] + needs: [pytest, read-file, template-files, lint] if: '!cancelled()' runs-on: ubuntu-latest steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 441b4873..b3a3814a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,6 @@ +# exclude test files with intentional lint issues +exclude: ^lint/data/error/ + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 diff --git a/lint/README.md b/lint/README.md new file mode 100644 index 00000000..00d55747 --- /dev/null +++ b/lint/README.md @@ -0,0 +1,150 @@ +# Lint Action + +A composite GitHub Action that runs [prek](https://github.com/j178/prek) (a fast pre-commit hook runner) with optional autofix and PR comments. + +## Files + +- `action.yml` - Composite action with all logic +- `workflow.yml.tmpl` - Workflow template for syncing to repos + +## Syncing to Repositories + +To adopt this workflow via template-files, add to your `.github/template-files/config.yml`: + +```yaml +- source: lint/workflow.yml.tmpl + target: .github/workflows/lint.yml + # Optional: additional branch patterns (main is always included) + # branches: + # - '2[0-9].[0-9]+.x' # CalVer release branches + # Optional: Python version for prek hooks + # python_version: '3.12' +``` + +## Features + +- Installs and runs prek with your existing `.pre-commit-config.yaml` +- Captures command output and git diff for PR comments +- Creates/updates sticky PR comments showing lint issues and suggested fixes +- Updates comment to show success when issues are resolved +- Optionally commits and pushes fixes (autofix mode) +- Reacts to trigger comments with 👀 → 🎉/😕 + +## Usage + +### Basic Usage (lint check only) + +```yaml +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: conda/actions/lint@main +``` + +The action automatically fails if lint issues are found (unless `autofix: true`). + +### With Autofix via Comment Trigger + +```yaml +on: + pull_request: + issue_comment: + types: [created] + +jobs: + lint: + if: >- + github.event_name == 'pull_request' + || ( + github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.comment.body == '@conda-bot prek autofix' + ) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: conda/actions/lint@main + with: + autofix: ${{ github.event_name == 'issue_comment' }} + comment-id: ${{ github.event.comment.id }} + pr-number: ${{ github.event.issue.number }} +``` + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `token` | GitHub token for PR comments and pushing | No | `${{ github.token }}` | +| `autofix` | Whether to commit and push fixes | No | `'false'` | +| `comment-id` | Comment ID to react to (for issue_comment triggers) | No | `''` | +| `pr-number` | PR number (defaults to current PR, override for issue_comment triggers) | No | `${{ github.event.pull_request.number }}` | +| `git-user-name` | Git user name for autofix commits | No | `conda-bot` | +| `git-user-email` | Git user email for autofix commits | No | `18747875+conda-bot@users.noreply.github.com` | +| `python-version` | Python version for running prek hooks | No | `'3.12'` | +| `checkout` | Whether to checkout the repository (set to false if already checked out) | No | `'true'` | +| `working-directory` | Directory to run prek in (defaults to repo root) | No | `'.'` | +| `config` | Path to pre-commit config file (defaults to auto-discovery) | No | `''` | +| `comment-anchor` | Unique anchor for sticky comment (customize to avoid conflicts with parallel workflows) | No | `'lint-comment'` | +| `comment-header` | Optional header text to prepend to comments (e.g., to mark test comments) | No | `''` | +| `comment-on-success` | Create success comment even without prior lint failure (useful for testing) | No | `'false'` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `outcome` | `success` if no lint issues, `failure` if issues found | +| `output` | The prek command output | +| `diff` | The git diff of suggested fixes (only if outcome is failure) | + +## Disabling PR Comments + +To run lint without creating PR comments, omit the `pr-number` input: + +```yaml +- uses: conda/actions/lint@main + with: + pr-number: '' # No PR comments +``` + +This is useful for: +- Running lint in contexts without a PR (e.g., scheduled runs) +- CI test scenarios where you don't want test comments cluttering PRs +- Conditional commenting based on file changes (see tests.yml for an example) + +## PR Comments + +The action creates a sticky comment (identified by ``) that: + +- Shows prek output and git diff on failure +- Shows a note for fork PRs (autofix cannot push to forks) +- Shows a warning if autofix was attempted but push failed +- Updates to "✅ Lint issues fixed" when resolved +- Includes link to workflow run for details + +## Fork PRs + +By default, `GITHUB_TOKEN` cannot push to fork PRs. The action detects this and shows a helpful message explaining how to fix locally. + +To enable autofix for fork PRs, use a GitHub App token instead (**untested**): + +```yaml +- uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + +- uses: conda/actions/lint@main + with: + token: ${{ steps.app-token.outputs.token }} + autofix: true + comment-id: ${{ github.event.comment.id }} + pr-number: ${{ github.event.issue.number }} +``` + +Requirements: +- GitHub App with `contents: write` and `pull-requests: write` permissions +- PR author must enable "Allow edits from maintainers" diff --git a/lint/action.yml b/lint/action.yml new file mode 100644 index 00000000..04655906 --- /dev/null +++ b/lint/action.yml @@ -0,0 +1,270 @@ +name: Lint +description: Run prek/pre-commit hooks with optional autofix and PR comments. +inputs: + token: + description: GitHub token for PR comments and pushing + default: ${{ github.token }} + autofix: + description: Whether to commit and push fixes + default: 'false' + comment-id: + description: Comment ID to react to (for issue_comment triggers) + default: '' + pr-number: + description: PR number (defaults to current PR, override for issue_comment triggers) + default: ${{ github.event.pull_request.number }} + git-user-name: + description: Git user name for autofix commits + default: conda-bot + git-user-email: + description: Git user email for autofix commits + default: 18747875+conda-bot@users.noreply.github.com + python-version: + description: Python version for running prek hooks + default: '3.12' + checkout: + description: Whether to checkout the repository (set to false if already checked out) + default: 'true' + working-directory: + description: Directory to run prek in (defaults to repo root) + default: . + config: + description: Path to pre-commit config file (defaults to auto-discovery) + default: '' + comment-anchor: + description: Unique anchor for sticky comment (customize to avoid conflicts with parallel workflows) + default: lint-comment + comment-header: + description: Optional header text to prepend to comments (e.g., to mark test comments) + default: '' + comment-on-success: + description: Create success comment even without prior lint failure (useful for testing) + default: 'false' +outputs: + outcome: + description: "'success' if no lint issues, 'failure' if issues found" + value: ${{ steps.prek.outputs.outcome }} + output: + description: The prek command output + value: ${{ steps.prek.outputs.output }} + diff: + description: The git diff of suggested fixes (only if outcome is failure) + value: ${{ steps.diff.outputs.diff }} + +runs: + using: composite + steps: + # Validate that pr-number is set when comment-id is provided + - name: Validate inputs + if: inputs.comment-id && !inputs.pr-number + shell: bash + run: | + echo "::error::pr-number is required when comment-id is provided" + exit 1 + + # React with eyes to indicate processing (autofix only) + - if: inputs.comment-id + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + token: ${{ inputs.token }} + comment-id: ${{ inputs.comment-id }} + reactions: eyes + reactions-edit-mode: replace + + - if: inputs.checkout == 'true' + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + # Checkout PR branch for autofix (gh handles fork remotes automatically) + - if: inputs.pr-number + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + run: gh pr checkout ${{ inputs.pr-number }} + + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ inputs.python-version }} + + # install-only so we can capture prek output + - uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12 + with: + install-only: true + + - name: Run prek + id: prek + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + CONFIG_ARG: ${{ inputs.config && format('--config {0}', inputs.config) || '' }} + run: | + { + echo 'output<&1 && exit_code=0 || exit_code=$? + echo 'EOF' + } >> "$GITHUB_OUTPUT" + # fail if prek exited non-zero OR made changes + if [[ $exit_code -ne 0 ]] || [[ -n "$(git status --porcelain)" ]]; then + echo "outcome=failure" >> "$GITHUB_OUTPUT" + else + echo "outcome=success" >> "$GITHUB_OUTPUT" + fi + + - name: Capture diff + id: diff + if: steps.prek.outputs.outcome == 'failure' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + { + echo 'diff<> "$GITHUB_OUTPUT" + + # Find existing lint comment (skip on push events - no PR context) + - name: Find existing lint comment + if: inputs.pr-number + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 + id: lint-comment + with: + token: ${{ inputs.token }} + issue-number: ${{ inputs.pr-number }} + body-includes: + + # Commit and push fixes (autofix only) + - name: Commit and push fixes + id: push + if: steps.prek.outputs.outcome == 'failure' && inputs.autofix == 'true' + continue-on-error: true + shell: bash + run: | + git config user.name '${{ inputs.git-user-name }}' + git config user.email '${{ inputs.git-user-email }}' + git add -A + git commit --message 'style: auto-fix lint issues' + git push + + # Reset changes if not autofix mode, or if autofix push failed + - name: Reset changes + if: steps.prek.outputs.outcome == 'failure' && (inputs.autofix != 'true' || steps.push.outcome == 'failure') + shell: bash + run: git checkout -- . + + # React to command comment on successful autofix + - name: React on successful autofix + if: steps.push.outcome == 'success' && inputs.comment-id + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + token: ${{ inputs.token }} + comment-id: ${{ inputs.comment-id }} + reactions: hooray + reactions-edit-mode: replace + + # Update lint comment to show success (lint passed or autofix pushed) + - name: Update lint comment on success + if: (steps.prek.outputs.outcome == 'success' || steps.push.outcome == 'success') && (steps.lint-comment.outputs.comment-id || inputs.comment-on-success == 'true') + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + token: ${{ inputs.token }} + comment-id: ${{ steps.lint-comment.outputs.comment-id }} + issue-number: ${{ inputs.pr-number }} + edit-mode: replace + body: | + + ${{ inputs.comment-header }} + ## ✅ Lint issues fixed + + All lint issues have been resolved. + + ###### See ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} for details. + + # Admonition for autofix failures or fork PRs + - name: Set fork/failure admonition + id: admonition + if: steps.push.outcome == 'failure' || github.event.pull_request.head.repo.full_name != github.repository + shell: bash + env: + ADMONITION_FAILED: | + > [!WARNING] + > Autofix failed to push. This is likely because the PR is from a fork, and GitHub Actions cannot push to forks with the default token. + ADMONITION_FORK: | + > [!NOTE] + > This PR is from a fork. Autofix cannot push to forks, so please fix locally. + run: | + { + echo 'admonition<> "$GITHUB_OUTPUT" + + # Comment on PR with lint errors (after push so we know if autofix failed) + - name: Comment on PR with errors + if: steps.prek.outputs.outcome == 'failure' && (github.event_name == 'pull_request' || steps.push.outcome == 'failure') + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + token: ${{ inputs.token }} + comment-id: ${{ steps.lint-comment.outputs.comment-id }} + issue-number: ${{ inputs.pr-number }} + edit-mode: replace + body: | + + ${{ inputs.comment-header }} + ## ⚠️ Lint issues found + + ${{ steps.admonition.outputs.admonition }} + + prek found issues that need to be fixed. You can either: + 1. Fix them locally and push + 2. Comment `@conda-bot prek autofix` to auto-commit fixes (requires write access) + + ```bash + # install prek: https://github.com/j178/prek?tab=readme-ov-file#installation + prek run --all-files + git add -A && git commit -m "style: auto-fix lint issues" && git push + ``` + +
+ 🔍 prek output (click to expand) + + ``` + ${{ steps.prek.outputs.output }} + ``` + +
+ +
+ 📝 Suggested fixes (click to expand) + + ```diff + ${{ steps.diff.outputs.diff }} + ``` + +
+ + ###### See ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} for details. + + # React to command comment on failed autofix + - name: React on failed autofix + if: steps.push.outcome == 'failure' && inputs.comment-id + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + token: ${{ inputs.token }} + comment-id: ${{ inputs.comment-id }} + reactions: confused + reactions-edit-mode: replace + + # React to command comment if no lint issues (autofix only, no push needed) + - name: React on no issues + if: steps.prek.outputs.outcome == 'success' && inputs.comment-id + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + token: ${{ inputs.token }} + comment-id: ${{ inputs.comment-id }} + reactions: hooray + reactions-edit-mode: replace + + # Fail if lint found issues (not in autofix mode) or if autofix push failed + - name: Fail if lint found issues + if: (steps.prek.outputs.outcome == 'failure' && inputs.autofix != 'true') || steps.push.outcome == 'failure' + shell: bash + run: exit 1 diff --git a/lint/data/error/example.py b/lint/data/error/example.py new file mode 100644 index 00000000..7bf344fd --- /dev/null +++ b/lint/data/error/example.py @@ -0,0 +1,7 @@ +# File with intentional lint issues (unused imports) +import os +import sys + + +def hello(): + print("hello") diff --git a/lint/data/error/pre-commit-config.yaml b/lint/data/error/pre-commit-config.yaml new file mode 100644 index 00000000..d324f987 --- /dev/null +++ b/lint/data/error/pre-commit-config.yaml @@ -0,0 +1,8 @@ +# Minimal pre-commit config for testing lint action (error case) +files: ^lint/data/error/ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.11 + hooks: + - id: ruff-check + args: [--fix, --show-fixes] diff --git a/lint/data/error/pyproject.toml b/lint/data/error/pyproject.toml new file mode 100644 index 00000000..4e527849 --- /dev/null +++ b/lint/data/error/pyproject.toml @@ -0,0 +1,3 @@ +# Minimal ruff config for testing lint action (error case) +[tool.ruff.lint] +select = ["F"] # pyflakes, includes F401 (unused-import) diff --git a/lint/data/success/example.py b/lint/data/success/example.py new file mode 100644 index 00000000..2f481d3e --- /dev/null +++ b/lint/data/success/example.py @@ -0,0 +1,5 @@ +# Clean file with no lint issues + + +def hello(): + print("hello") diff --git a/lint/data/success/pre-commit-config.yaml b/lint/data/success/pre-commit-config.yaml new file mode 100644 index 00000000..89285400 --- /dev/null +++ b/lint/data/success/pre-commit-config.yaml @@ -0,0 +1,8 @@ +# Minimal pre-commit config for testing lint action (success case) +files: ^lint/data/success/ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.11 + hooks: + - id: ruff-check + args: [--fix, --show-fixes] diff --git a/lint/data/success/pyproject.toml b/lint/data/success/pyproject.toml new file mode 100644 index 00000000..84cdb120 --- /dev/null +++ b/lint/data/success/pyproject.toml @@ -0,0 +1,4 @@ +# Minimal ruff config for testing lint action (success case) + +[tool.ruff.lint] +select = ["F"] # pyflakes diff --git a/lint/workflow.yml.tmpl b/lint/workflow.yml.tmpl new file mode 100644 index 00000000..3ca982da --- /dev/null +++ b/lint/workflow.yml.tmpl @@ -0,0 +1,51 @@ +name: Lint + +on: + # https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads#push + push: + branches: + - main +[% for branch in branches | default([]) %] + - '[[ branch ]]' +[% endfor %] + + # https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads#pull_request + pull_request: + + # https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads#issue_comment + issue_comment: + types: [created] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.sha }} + cancel-in-progress: true + +jobs: + lint: + # run on push/PR, or on "@conda-bot prek autofix" comment from maintainers or PR author + if: >- + !github.event.repository.fork + && ( + github.event_name == 'push' + || github.event_name == 'pull_request' + || ( + github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.comment.body == '@conda-bot prek autofix' + && ( + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + || github.event.comment.user.login == github.event.issue.user.login + ) + ) + ) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: conda/actions/lint@main + with: + autofix: ${{ github.event_name == 'issue_comment' }} + comment-id: ${{ github.event.comment.id }} + pr-number: ${{ github.event.issue.number }} + python-version: '[[ python_version | default("3.12") ]]' diff --git a/pyproject.toml b/pyproject.toml index 2acdb683..05c8e8f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ addopts = [ ] [tool.ruff] +# exclude test files with intentional lint issues +exclude = ["lint/data/error/"] show-fixes = true target-version = "py310"