Repository: akaihola/darker

Issue:
Add option to add linters (flake8, mypy, etc.) in the GH Action
There is no other way to add the supported linters other than creating separate steps to install.

Discussion:
akaihola: @roniemartinez, could you review this PR? If you are willing to accept the invitation I've sent to you to become a collaborator on this project, I can assign you as a reviewer.
cclauss: bandit --recursive --skip B101 .  # B101 is assert statements which are in ./action/tests/test_main.py
akaihola: Hm, [Bandit 1.7.4](https://pypi.org/project/bandit/1.7.4/) was released on March 4th, 2022, and [`bandit-report-artifacts/requirements.txt`](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/requirements.txt) doesn't pin the Bandit version. Maybe the 1.7.4 version changed Bandit's behavior. I'll look into this.
cclauss: Might also get some hints from https://github.com/PyCQA/bandit-action
akaihola: I tried to see if Bandit 1.7.2 vs 1.7.4 would behave differently, but I don't see any differences. I believe the command lines I use are the same which [`bandit-report-artifacts/entrypoint.sh`](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/entrypoint.sh) executes (apart from the `-o <filename>.txt` option to write results into a text file):

```bash
$ bandit --version
bandit 1.7.2
  python version = 3.10.0 (default, Oct  4 2021, 17:55:55) [GCC 10.3.0]
```

<details>
<summary><code>$ bandit -c .bandit.code.yaml -r . -f txt</code></summary>

```bash
[main]	INFO	profile include tests: None
[main]	INFO	profile exclude tests: None
[main]	INFO	cli include tests: None
[main]	INFO	cli exclude tests: None
[main]	INFO	using config: .bandit.code.yaml
[main]	INFO	running on Python 3.10.0
Run started:2022-03-23 18:31:07.064305

Test results:
	No issues identified.

Code scanned:
	Total lines of code: 2779
	Total lines skipped (#nosec): 9

Run metrics:
	Total issues (by severity):
		Undefined: 0.0
		Low: 0.0
		Medium: 0.0
		High: 0.0
	Total issues (by confidence):
		Undefined: 0.0
		Low: 0.0
		Medium: 0.0
		High: 0.0
Files skipped (0):
```
</details>

<details>
<summary><code>$ bandit -c .bandit.tests.yaml -r src/darker/tests -f txt</code></summary>

```bash
[main]	INFO	profile include tests: None
[main]	INFO	profile exclude tests: B101
[main]	INFO	cli include tests: None
[main]	INFO	cli exclude tests: None
[main]	INFO	using config: .bandit.tests.yaml
[main]	INFO	running on Python 3.10.0
Run started:2022-03-23 18:30:28.711222

Test results:
	No issues identified.

Code scanned:
	Total lines of code: 4275
	Total lines skipped (#nosec): 4

Run metrics:
	Total issues (by severity):
		Undefined: 0.0
		Low: 0.0
		Medium: 0.0
		High: 0.0
	Total issues (by confidence):
		Undefined: 0.0
		Low: 0.0
		Medium: 0.0
		High: 0.0
Files skipped (0):
```
</details>

<details>
<summary><code>$ bandit -c .bandit.tests.yaml -r action/tests -f txt</code></summary>

```bash
[main]	INFO	profile include tests: None
[main]	INFO	profile exclude tests: B101
[main]	INFO	cli include tests: None
[main]	INFO	cli exclude tests: None
[main]	INFO	using config: .bandit.tests.yaml
[main]	INFO	running on Python 3.10.0
Run started:2022-03-23 18:30:13.909150

Test results:
	No issues identified.

Code scanned:
	Total lines of code: 190
	Total lines skipped (#nosec): 1

Run metrics:
	Total issues (by severity):
		Undefined: 0.0
		Low: 0.0
		Medium: 0.0
		High: 0.0
	Total issues (by confidence):
		Undefined: 0.0
		Low: 0.0
		Medium: 0.0
		High: 0.0
Files skipped (0):
```
</details>

```bash
$ pip install -U bandit
Collecting bandit
  Using cached bandit-1.7.4-py3-none-any.whl (118 kB)
Installing collected packages: bandit
  Attempting uninstall: bandit
    Found existing installation: bandit 1.7.2
    Uninstalling bandit-1.7.2:
      Successfully uninstalled bandit-1.7.2
Successfully installed bandit-1.7.4
```

<details>
<summary><code>$ bandit -c .bandit.code.yaml -r . -f txt</code></summary>

```bash
[main]	INFO	profile include tests: None
[main]	INFO	profile exclude tests: None
[main]	INFO	cli include tests: None
[main]	INFO	cli exclude tests: None
[main]	INFO	using config: .bandit.code.yaml
[main]	INFO	running on Python 3.10.0
Run started:2022-03-23 18:31:17.317269

Test results:
	No issues identified.

Code scanned:
	Total lines of code: 2779
	Total lines skipped (#nosec): 9
	Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0

Run metrics:
	Total issues (by severity):
		Undefined: 0
		Low: 0
		Medium: 0
		High: 0
	Total issues (by confidence):
		Undefined: 0
		Low: 0
		Medium: 0
		High: 0
Files skipped (0):
```
</details>

<details>
<summary><code>$ bandit -c .bandit.tests.yaml -r src/darker/tests -f txt</code></summary>

```bash
[main]	INFO	profile include tests: None
[main]	INFO	profile exclude tests: B101
[main]	INFO	cli include tests: None
[main]	INFO	cli exclude tests: None
[main]	INFO	using config: .bandit.tests.yaml
[main]	INFO	running on Python 3.10.0
Run started:2022-03-23 18:31:21.972722

Test results:
	No issues identified.

Code scanned:
	Total lines of code: 4275
	Total lines skipped (#nosec): 4
	Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0

Run metrics:
	Total issues (by severity):
		Undefined: 0
		Low: 0
		Medium: 0
		High: 0
	Total issues (by confidence):
		Undefined: 0
		Low: 0
		Medium: 0
		High: 0
Files skipped (0):
```
</details>

<details>
<summary><code>$ bandit -c .bandit.tests.yaml -r action/tests -f txt</code></summary>

```bash
[main]	INFO	profile include tests: None
[main]	INFO	profile exclude tests: B101
[main]	INFO	cli include tests: None
[main]	INFO	cli exclude tests: None
[main]	INFO	using config: .bandit.tests.yaml
[main]	INFO	running on Python 3.10.0
Run started:2022-03-23 18:31:25.908424

Test results:
	No issues identified.

Code scanned:
	Total lines of code: 190
	Total lines skipped (#nosec): 1
	Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0

Run metrics:
	Total issues (by severity):
		Undefined: 0
		Low: 0
		Medium: 0
		High: 0
	Total issues (by confidence):
		Undefined: 0
		Low: 0
		Medium: 0
		High: 0
Files skipped (0):
```
</details>
akaihola: I also tried using Bandit 1.7.3, and the results were still the same. So this still remains a mystery.

One thing I now realized is that each of the three Bandit steps writes results into the same file (see [`bandit-report-artifacts/entrypoint.sh:12`](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/entrypoint.sh#L12)):
```bash
-o "${GITHUB_WORKSPACE}/output/security_report.txt"
```

I'll see if separating the runs into different builds instead of steps in the same build would help.
akaihola: Separate jobs didn't help.

This looks really strange. In this screenshot, you can see the three Bandit steps succeed (`bandit-code`, `bandit-tests` and `bandit-action-tests`). But before them as part of the `lintAllTheThings` job from `.github/workflows/yaml-lint.yml` we can see `x Bandit comments` which is the step causing the failure.

![image](https://user-images.githubusercontent.com/13725/159774191-a8e32619-4176-416f-8a93-6982b82f7ead.png)

akaihola: It seems that the title `Bandit comments` comes from [bandit-report-artifacts/main.py:99](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/main.py#L99) in `bandit_run_check()` which is [called](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/main.py#L142-L144) from the main program.

The main program then [calls `gh()`](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/main.py#L145-L150) with the option `data='{"name": "Bandit comments", ...}'`. `gh()` in turn calls the `/check-runs` GitHub API endpoint with that JSON as the body. The annotations we see in the screenshot above come from `{"output": {"annotations": ...}, ...}` in that same JSON.

So how does all this end up shown in the output from the `Yaml Lint` workflow which doesn't even call the Bandit action?
akaihola: I think I understand what's happening with Bandit.

In [entrypoint.sh](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/entrypoint.sh), `bandit` is called _twice_.

The [first call](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/entrypoint.sh#L12) happens directly and uses the configuration file specified in the workflow. Note that `${BANDIT_CONFIG}` is passed as the first argument:
```bash
bandit ${BANDIT_CONFIG} \
  -r "${INPUT_PROJECT_PATH}" \
  -o "${GITHUB_WORKSPACE}/output/security_report.txt" \
  -f 'txt'
```
The second call happens inside [main.py](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/main.py) which is [called](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/entrypoint.sh#L15) right after the previous call, and notably doesn't pass on the value for `${BANDIT_CONFIG}`:
```bash
GITHUB_TOKEN=$INPUT_REPO_TOKEN python /main.py -r $INPUT_PROJECT_PATH
```
The main program in main.py [calls `run_bandit()`](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/main.py#L140), passing on the above command line options.
The value of `argv[1:]` is `["-r", p]` where `p` comes from the `$INPUT_PROJECT_PATH` environment variable:
```python
    bandit_results = run_bandit(argv[1:], env={"PATH": environ["PATH"]})
```
In [`main.run_bandit()`](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/main.py#L34-L43), `bandit` is [called](https://github.com/ioggstream/bandit-report-artifacts/blob/d706d74be4004d4300a56feca4b6ca98d70211ac/main.py#L36-L39) with the additional `-f json` option but without the configuration file argument:
```python
    my_args = ["bandit", "-f", "json"] + args
    out = run(  # nosec - this input cannot execute different commands.
        my_args, shell=False, capture_output=True, env=env or {"PATH": environ["PATH"]},
    )
```

So I guess the purpose of the first call in `entrypoint.sh` is to get a plaintext report and the status code from Bandit. In the second call from `main.py`, check annotations are collected as JSON so they can be passed on to GitHub Actions.

Unfortunately the two calls use different configurations in our case, so the latter call fails to apply `skips: B101` from [`.bandit.tests.yaml`](https://github.com/akaihola/darker/blob/c62fde8e1bdaa335de9e629d5d0e5cf68fabde2f/.bandit.tests.yaml) when run on test directories.

@ioggstream, could you verify that I've understood the problem correctly? Would you be willing to accept a patch to [bandit-report-artifacts](https://github.com/ioggstream/bandit-report-artifacts) which makes `main.py` apply the configuration to the `bandit` call as well?
akaihola: I think I've fixed the `bandit` issue by using my own branch [akaihola/bandit-report-artifacts@use-config](https://github.com/akaihola/bandit-report-artifacts/commits/use-config) (see ioggstream/bandit-report-artifacts#8).

I'll move this change into its own branch since it's unrelated to this PR.
ioggstream: @akaihola merged, thx++


Patch:
diff --git a/.bandit.code.yaml b/.bandit.code.yaml
index 2656ce2..a356e95 100644
--- a/.bandit.code.yaml
+++ b/.bandit.code.yaml
@@ -5,3 +5,4 @@
 targets: .
 exclude_dirs:
   - ./src/darker/tests
+  - ./action/tests
diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml
index 854169d..5479305 100644
--- a/.github/workflows/bandit.yml
+++ b/.github/workflows/bandit.yml
@@ -21,3 +21,9 @@ jobs:
         with:
           project_path: ./src/darker/tests
           config_file: ./.bandit.tests.yaml
+
+      - name: Bandit security check for GitHub Action `main.py` tests
+        uses: akaihola/bandit-report-artifacts@use-config
+        with:
+          project_path: ./action/tests
+          config_file: ./.bandit.tests.yaml
diff --git a/.github/workflows/yaml-lint.yml b/.github/workflows/yaml-lint.yml
index 2ec753f..abd3fc9 100644
--- a/.github/workflows/yaml-lint.yml
+++ b/.github/workflows/yaml-lint.yml
@@ -5,7 +5,7 @@ jobs:
   lintAllTheThings:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           fetch-depth: 0
       - name: yaml-lint
diff --git a/CHANGES.rst b/CHANGES.rst
index fc285c2..c709a0d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -7,7 +7,8 @@ Added
 -----
 - The ``--jobs`` option now specifies how many Darker jobs are used to process files in
   parallel to complete reformatting/linting faster.
-
+- Linters can now be run in the GitHub Action using the ``lint:`` option.
+  
 Fixed
 -----
 - Avoid memory leak from using ``@lru_cache`` on a method.
diff --git a/README.rst b/README.rst
index 6f63f63..00d836b 100644
--- a/README.rst
+++ b/README.rst
@@ -565,15 +565,21 @@ Create a file named ``.github/workflows/darker.yml`` inside your repository with
      lint:
        runs-on: ubuntu-latest
        steps:
-         - uses: actions/checkout@v2
+         - uses: actions/checkout@v3
            with:
              fetch-depth: 0 
+         - uses: actions/setup-python@v2
          - uses: akaihola/darker@1.4.2
            with:
              options: "--check --diff"
              revision: "master..."
              src: "./src"
              version: "1.4.2"
+             lint: "flake8,pylint"
+
+There needs to be a working Python environment, set up using ``actions/setup-python``
+in the above example. Darker will be installed in an isolated virtualenv to prevent
+conflicts with other workflows.
 
 ``"uses:"`` specifies which Darker release to get the GitHub Action definition from.
 We recommend to pin this to a specific release.
@@ -587,11 +593,6 @@ to compare the current branch to the branching point from main branch
 when determining which source code lines have been changed.
 If omitted, the Darker GitHub Action will determine the commit range automatically.
 
-*New in version 1.4.1:*
-The ``revision:`` option, with smart default value if omitted.
-
-Note that external software like linters are not yet available.
-
 ``"src:"`` defines the root directory to run Darker for.
 This is typically the source tree, but you can use ``"."`` (the default)
 to also reformat Python files like ``"setup.py"`` in the root of the whole repository.
@@ -600,10 +601,19 @@ You can also configure other arguments passed to Darker via ``"options:"``.
 It defaults to ``"--check --diff"``.
 You can e.g. add ``"--isort"`` to sort imports, or ``"--verbose"`` for debug logging.
 
+To run linters through Darker, you can provide a comma separated list of linters using
+the ``lint:`` option. Only ``flake8``, ``pylint`` and ``mypy`` are supported.
+
 *New in version 1.1.0:*
 GitHub Actions integration. Modeled after how Black_ does it,
 thanks to Black authors for the example!
 
+*New in version 1.4.1:*
+The ``revision:`` option, with smart default value if omitted.
+
+*New in version 1.5.0:*
+The ``lint:`` option.
+
 
 .. _Using linters:
 
diff --git a/action.yml b/action.yml
index 618c3a2..ae370cb 100644
--- a/action.yml
+++ b/action.yml
@@ -22,6 +22,12 @@ inputs:
       Git revision range to compare when determining modified lines.
       Determined automatically if omitted.
     required: false
+  lint:
+    description: >-
+      Comma-separated list of linters to `pip install` and run from Darker.
+      Example: flake8,pylint
+    required: false
+    default: ''
 branding:
   color: "black"
   icon: "check-circle"
@@ -61,6 +67,7 @@ runs:
         INPUT_SRC: ${{ inputs.src }}
         INPUT_VERSION: ${{ inputs.version }}
         INPUT_REVISION: ${{ inputs.revision }}
+        INPUT_LINT: ${{ inputs.lint }}
         INPUT_COMMIT_RANGE: ${{ steps.commit-range.outputs.commit-range }}
         pythonioencoding: utf-8
       shell: bash
diff --git a/action/main.py b/action/main.py
index 52a09f3..83c0033 100644
--- a/action/main.py
+++ b/action/main.py
@@ -4,6 +4,7 @@ import sys
 from pathlib import Path
 from subprocess import PIPE, STDOUT, run  # nosec
 
+LINTER_WHITELIST = {"flake8", "pylint", "mypy"}
 ACTION_PATH = Path(os.environ["GITHUB_ACTION_PATH"])
 ENV_PATH = ACTION_PATH / ".darker-env"
 ENV_BIN = ENV_PATH / ("Scripts" if sys.platform == "win32" else "bin")
@@ -16,26 +17,50 @@ REVISION = os.getenv(
 
 run([sys.executable, "-m", "venv", str(ENV_PATH)], check=True)
 
-req = "darker[isort]"
+req = ["darker[isort]"]
 if VERSION:
-    req += f"=={VERSION}"
+    req[0] += f"=={VERSION}"
+linter_options = []
+for linter_ in os.getenv("INPUT_LINT", default="").split(","):
+    linter = linter_.strip()
+    if not linter:
+        continue
+    if linter not in LINTER_WHITELIST:
+        raise RuntimeError(
+            f"{linter!r} is not supported as a linter by the GitHub Action"
+        )
+    req.append(linter)
+    linter_options.extend(["--lint", linter])
+
 pip_proc = run(  # nosec
-    [str(ENV_BIN / "python"), "-m", "pip", "install", req],
+    [str(ENV_BIN / "python"), "-m", "pip", "install"] + req,
     check=False,
     stdout=PIPE,
     stderr=STDOUT,
     encoding="utf-8",
 )
+print(pip_proc.stdout, end="")
 if pip_proc.returncode:
-    print(pip_proc.stdout)
-    print("::error::Failed to install Darker.", flush=True)
+    print(f"::error::Failed to install {' '.join(req)}.", flush=True)
     sys.exit(pip_proc.returncode)
 
 
 base_cmd = [str(ENV_BIN / "darker")]
 proc = run(  # nosec
-    [*base_cmd, *shlex.split(OPTIONS), "--revision", REVISION, *shlex.split(SRC)],
+    [
+        *base_cmd,
+        *shlex.split(OPTIONS),
+        *linter_options,
+        "--revision",
+        REVISION,
+        *shlex.split(SRC),
+    ],
     check=False,
+    stdout=PIPE,
+    stderr=STDOUT,
+    env={**os.environ, "PATH": f"{ENV_BIN}:{os.environ['PATH']}"},
+    encoding="utf-8",
 )
+print(proc.stdout, end="")
 
 sys.exit(proc.returncode)
diff --git a/mypy.ini b/mypy.ini
index a801264..5a107ae 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -31,6 +31,11 @@ strict_equality = True
 show_error_context = True
 show_error_codes = True
 
+[mypy-test_main]
+disallow_any_decorated = False
+disallow_untyped_calls = False
+disallow_untyped_defs = False
+
 [mypy-airium.*]
 ignore_missing_imports = True
 
diff --git a/pyproject.toml b/pyproject.toml
index ae1749d..23d5b00 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,7 @@ known_third_party = ["pytest"]
 
 [tool.darker]
 src = [
+    "action",
     "src",
 ]
 revision = "origin/master..."


Test patch:
diff --git a/action/tests/test_main.py b/action/tests/test_main.py
new file mode 100644
index 0000000..6b43519
--- /dev/null
+++ b/action/tests/test_main.py
@@ -0,0 +1,238 @@
+"""Tests for the GitHub Action ``main.py`` module"""
+
+import re
+import sys
+from contextlib import contextmanager
+from pathlib import Path
+from runpy import run_module
+from subprocess import PIPE, STDOUT, CompletedProcess  # nosec
+from types import SimpleNamespace
+from typing import Dict, Generator
+from unittest.mock import ANY, Mock, call, patch
+
+import pytest
+
+# pylint: disable=redefined-outer-name,unused-argument
+
+
+BIN = "Scripts" if sys.platform == "win32" else "bin"
+
+
+class SysExitCalled(Exception):
+    """Mock exception to catch a call to `sys.exit`"""
+
+
+@pytest.fixture
+def run_main_env() -> Dict[str, str]:
+    """By default, call `main.py` with just `GITHUB_ACTION_PATH` in the environment"""
+    return {}
+
+
+@contextmanager
+def patch_main(
+    tmp_path: Path,
+    run_main_env: Dict[str, str],
+    pip_returncode: int = 0,
+) -> Generator[SimpleNamespace, None, None]:
+    """Patch `subprocess.run`, `sys.exit` and environment variables
+
+    :param tmp_path: Path to use for the `GITHUB_ACTION_PATH` environment variable
+    :param run_main_env: Additional environment for running ``main.py``
+    :yield: An object with `.subprocess.run` and `.sys.exit` mock objects
+
+    """
+
+    def run(args, **kwargs):
+        returncode = pip_returncode if args[1:3] == ["-m", "pip"] else 0
+        return CompletedProcess(args, returncode, stdout="", stderr="")
+
+    run_mock = Mock(wraps=run)
+    exit_ = Mock(side_effect=SysExitCalled)
+    with patch("subprocess.run", run_mock), patch("sys.exit", exit_), patch.dict(
+        "os.environ", {"GITHUB_ACTION_PATH": str(tmp_path), **run_main_env}
+    ):
+
+        yield SimpleNamespace(
+            subprocess=SimpleNamespace(run=run_mock), sys=SimpleNamespace(exit=exit_)
+        )
+
+
+@pytest.fixture
+def main_patch(
+    tmp_path: Path, run_main_env: Dict[str, str]
+) -> Generator[SimpleNamespace, None, None]:
+    """`subprocess.run, `sys.exit` and environment variables patching as Pytest fixture
+
+    :param tmp_path: Path to use for the `GITHUB_ACTION_PATH` environment variable
+    :param run_main_env: Additional environment for running ``main.py``
+    :yield: An object with `.subprocess.run` and `.sys.exit` mock objects
+
+    """
+    with patch_main(tmp_path, run_main_env) as run_main_fixture:
+        yield run_main_fixture
+
+
+def test_creates_virtualenv(tmp_path, main_patch):
+    """The GitHub action creates a virtualenv for Darker"""
+    with pytest.raises(SysExitCalled):
+
+        run_module("main")
+
+    assert main_patch.subprocess.run.call_args_list[0] == call(
+        [sys.executable, "-m", "venv", str(tmp_path / ".darker-env")],
+        check=True,
+    )
+
+
+@pytest.mark.kwparametrize(
+    dict(run_main_env={}, expect=["darker[isort]"]),
+    dict(run_main_env={"INPUT_VERSION": "1.5.0"}, expect=["darker[isort]==1.5.0"]),
+    dict(
+        run_main_env={"INPUT_LINT": "pylint"},
+        expect=["darker[isort]", "pylint"],
+    ),
+    dict(
+        run_main_env={"INPUT_LINT": "pylint,flake8"},
+        expect=["darker[isort]", "pylint", "flake8"],
+    ),
+    dict(
+        run_main_env={"INPUT_LINT": "  flake8  "},
+        expect=["darker[isort]", "flake8"],
+    ),
+    dict(
+        run_main_env={"INPUT_LINT": "  flake8  ,  pylint  "},
+        expect=["darker[isort]", "flake8", "pylint"],
+    ),
+)
+def test_installs_packages(tmp_path, main_patch, run_main_env, expect):
+    """Darker, isort and linters are installed in the virtualenv using pip"""
+    with pytest.raises(SysExitCalled):
+
+        run_module("main")
+
+    assert main_patch.subprocess.run.call_args_list[1] == call(
+        [
+            str(tmp_path / ".darker-env" / BIN / "python"),
+            "-m",
+            "pip",
+            "install",
+        ]
+        + expect,
+        check=False,
+        stdout=PIPE,
+        stderr=STDOUT,
+        encoding="utf-8",
+    )
+
+
+@pytest.mark.parametrize(
+    "linters", ["foo", "  foo  ", "foo,bar", "  foo  ,  bar  ", "pylint,foo"]
+)
+def test_wont_install_unknown_packages(tmp_path, linters):
+    """Non-whitelisted linters raise an exception"""
+    with patch_main(tmp_path, {"INPUT_LINT": linters}) as main_patch, pytest.raises(
+        RuntimeError,
+        match=re.escape("'foo' is not supported as a linter by the GitHub Action"),
+    ):
+
+        run_module("main")
+
+    # only virtualenv `run` called, `pip` and `darker` not called
+    (venv_create,) = main_patch.subprocess.run.call_args_list
+    assert venv_create == call([ANY, "-m", "venv", ANY], check=True)
+    assert not main_patch.sys.exit.called
+
+
+@pytest.mark.kwparametrize(
+    dict(env={"INPUT_SRC": "."}, expect=["--revision", "HEAD^", "."]),
+    dict(
+        env={"INPUT_SRC": "subdir/ myfile.py"},
+        expect=["--revision", "HEAD^", "subdir/", "myfile.py"],
+    ),
+    dict(
+        env={"INPUT_SRC": ".", "INPUT_OPTIONS": "--isort"},
+        expect=["--isort", "--revision", "HEAD^", "."],
+    ),
+    dict(
+        env={"INPUT_SRC": ".", "INPUT_REVISION": "master..."},
+        expect=["--revision", "master...", "."],
+    ),
+    dict(
+        env={"INPUT_SRC": ".", "INPUT_COMMIT_RANGE": "master..."},
+        expect=["--revision", "master...", "."],
+    ),
+    dict(
+        env={
+            "INPUT_SRC": ".",
+            "INPUT_REVISION": "master...",
+            "INPUT_COMMIT_RANGE": "ignored",
+        },
+        expect=["--revision", "master...", "."],
+    ),
+    dict(
+        env={"INPUT_SRC": ".", "INPUT_LINT": "pylint,flake8"},
+        expect=["--lint", "pylint", "--lint", "flake8", "--revision", "HEAD^", "."],
+    ),
+    dict(
+        env={
+            "INPUT_SRC": "here.py there/too",
+            "INPUT_OPTIONS": "--isort --verbose",
+            "INPUT_REVISION": "master...",
+            "INPUT_COMMIT_RANGE": "ignored",
+            "INPUT_LINT": "pylint,flake8",
+        },
+        expect=[
+            "--isort",
+            "--verbose",
+            "--lint",
+            "pylint",
+            "--lint",
+            "flake8",
+            "--revision",
+            "master...",
+            "here.py",
+            "there/too",
+        ],
+    ),
+)
+def test_runs_darker(tmp_path, env, expect):
+    """Configuration translates correctly into a Darker command line"""
+    with patch_main(tmp_path, env) as main_patch, pytest.raises(SysExitCalled):
+
+        run_module("main")
+
+    darker = str(tmp_path / ".darker-env" / BIN / "darker")
+    # We can change `c[0][0][0]` to `c.args[0][0]` after dropping Python 3.7 support.
+    # This gets the first list item of the first positional argument to the `run` call.
+    assert darker in [c[0][0][0] for c in main_patch.subprocess.run.call_args_list]
+
+
+def test_error_if_pip_fails(tmp_path, capsys):
+    """Returns an error and the pip error code if pip fails"""
+    with patch_main(tmp_path, {}, pip_returncode=42) as main_patch, pytest.raises(
+        SysExitCalled
+    ):
+
+        run_module("main")
+
+    assert main_patch.subprocess.run.call_args_list[-1] == call(
+        [ANY, "-m", "pip", "install", "darker[isort]"],
+        check=False,
+        stdout=PIPE,
+        stderr=STDOUT,
+        encoding="utf-8",
+    )
+    assert (
+        capsys.readouterr().out.splitlines()[-1]
+        == "::error::Failed to install darker[isort]."
+    )
+    main_patch.sys.exit.assert_called_once_with(42)
+
+
+def test_exits(main_patch):
+    """A successful run exits with a zero return code"""
+    with pytest.raises(SysExitCalled):
+
+        run_module("main")
+
+    main_patch.sys.exit.assert_called_once_with(0)