git-commit-guard 0.19.0__tar.gz → 0.20.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/lint-commits.yml +1 -1
  2. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/release.yml +1 -0
  3. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/PKG-INFO +36 -5
  4. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/README.md +35 -4
  5. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/docs/index.html +44 -1
  6. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/src/git_commit_guard/__init__.py +157 -12
  7. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/tests/test_git_commit_guard.py +403 -13
  8. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.editorconfig +0 -0
  9. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/coverage-baseline.yml +0 -0
  10. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/coverage-comment.yml +0 -0
  11. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/lint-md.yml +0 -0
  12. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/lint-python.yml +0 -0
  13. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/lint-workflows.yml +0 -0
  14. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/test.yml +0 -0
  15. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.gitignore +0 -0
  16. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.markdownlint.json +0 -0
  17. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.pre-commit-hooks.yaml +0 -0
  18. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.python-version +0 -0
  19. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/LICENSE +0 -0
  20. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/action.yml +0 -0
  21. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/cliff.toml +0 -0
  22. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/docs/commit-guard-icon.svg +0 -0
  23. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/pyproject.toml +0 -0
  24. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/ruff.toml +0 -0
  25. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/tests/__init__.py +0 -0
  26. {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/uv.lock +0 -0
@@ -22,7 +22,7 @@ jobs:
22
22
  key: nltk-averaged-perceptron-tagger-punkt
23
23
  - name: Lint commits
24
24
  # yamllint disable-line rule:line-length
25
- uses: benner/commit-guard@8a007fe3ebc1346ec111f7782b26af7e4ca32025 # v0.18.0
25
+ uses: benner/commit-guard@7704c563540b24bb10394e373e508dc664a7f01f # v0.19.0
26
26
  with:
27
27
  range: origin/${{ github.base_ref }}..HEAD
28
28
  disable: signature
@@ -10,6 +10,7 @@ permissions:
10
10
  jobs:
11
11
  release:
12
12
  runs-on: ubuntu-latest
13
+ environment: pypi
13
14
  steps:
14
15
  - name: Checkout code
15
16
  # yamllint disable-line rule:line-length
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-guard
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: Opinionated conventional commit message linter with imperative mood detection
5
5
  Project-URL: Homepage, https://github.com/benner/commit-guard
6
6
  Project-URL: Repository, https://github.com/benner/commit-guard
@@ -98,7 +98,8 @@ Available checks:
98
98
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
99
99
  * `body` - Blank line separates subject from body, and body is non-empty
100
100
  * `signed-off` - `Signed-off-by:` trailer exists
101
- * `signature` - Verify GPG or SSH signature
101
+ * `signature` - Verify GPG or SSH signature via the GitHub Commits API or
102
+ public key lookup
102
103
 
103
104
  ### Subject length
104
105
 
@@ -220,6 +221,34 @@ Trailer matching is case-sensitive and requires at least one non-space
220
221
  character after the colon (e.g. `Closes: #42`). This check runs
221
222
  independently of `--enable`/`--disable`.
222
223
 
224
+ ### Signature verification
225
+
226
+ The `signature` check verifies the commit without any local keyring setup:
227
+
228
+ 1. If the repo has a GitHub remote, call the Commits API
229
+ (`GET /repos/{owner}/{repo}/commits/{sha}`) to resolve the author's GitHub
230
+ username — this works for corporate emails, noreply addresses, or any email
231
+ not listed publicly on a GitHub profile.
232
+ 2. If the Commits API is unavailable (no GitHub remote, commit not yet pushed,
233
+ or API error), parse the username directly from a GitHub noreply address
234
+ (`{id}+{username}@users.noreply.github.com` or
235
+ `{username}@users.noreply.github.com`) — no API call needed.
236
+ 3. If neither of the above resolves a username, fall back to searching GitHub
237
+ by the commit author's email.
238
+ 4. Fetch the resolved user's public keys from `github.com/{username}.gpg` and
239
+ `github.com/{username}.keys`.
240
+ 5. Try GPG verification: import the fetched key into a temporary keyring and
241
+ run `git verify-commit`.
242
+ 6. Try SSH verification: write a temporary `allowed_signers` file and run
243
+ `git verify-commit` with the SSH allowed-signers config.
244
+ 7. If any key verifies, the check passes. If none do, it fails.
245
+
246
+ If the author cannot be resolved via either method, or the GitHub API is
247
+ unreachable, the check fails with a clear error.
248
+
249
+ For private repositories, set `GITHUB_TOKEN` or `GH_TOKEN` so the Commits API
250
+ can authenticate.
251
+
223
252
  ### Configuration file
224
253
 
225
254
  Place `.commit-guard.toml` in your project root (or any parent directory) to
@@ -254,9 +283,11 @@ full precedence and ignore config file values when provided.
254
283
 
255
284
  ### Environment variables
256
285
 
257
- | Variable | Default | Description |
258
- | -------------------------- | ------- | -------------------------------------------- |
259
- | `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
286
+ | Variable | Default | Description |
287
+ | -------------------------- | ------- | ------------------------------------------------------------------------- |
288
+ | `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
289
+ | `GITHUB_TOKEN` | — | GitHub token for Commits API access on private repos (signature check). |
290
+ | `GH_TOKEN` | — | Alias for `GITHUB_TOKEN`; used when `GITHUB_TOKEN` is not set. |
260
291
 
261
292
  ```bash
262
293
  COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
@@ -77,7 +77,8 @@ Available checks:
77
77
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
78
78
  * `body` - Blank line separates subject from body, and body is non-empty
79
79
  * `signed-off` - `Signed-off-by:` trailer exists
80
- * `signature` - Verify GPG or SSH signature
80
+ * `signature` - Verify GPG or SSH signature via the GitHub Commits API or
81
+ public key lookup
81
82
 
82
83
  ### Subject length
83
84
 
@@ -199,6 +200,34 @@ Trailer matching is case-sensitive and requires at least one non-space
199
200
  character after the colon (e.g. `Closes: #42`). This check runs
200
201
  independently of `--enable`/`--disable`.
201
202
 
203
+ ### Signature verification
204
+
205
+ The `signature` check verifies the commit without any local keyring setup:
206
+
207
+ 1. If the repo has a GitHub remote, call the Commits API
208
+ (`GET /repos/{owner}/{repo}/commits/{sha}`) to resolve the author's GitHub
209
+ username — this works for corporate emails, noreply addresses, or any email
210
+ not listed publicly on a GitHub profile.
211
+ 2. If the Commits API is unavailable (no GitHub remote, commit not yet pushed,
212
+ or API error), parse the username directly from a GitHub noreply address
213
+ (`{id}+{username}@users.noreply.github.com` or
214
+ `{username}@users.noreply.github.com`) — no API call needed.
215
+ 3. If neither of the above resolves a username, fall back to searching GitHub
216
+ by the commit author's email.
217
+ 4. Fetch the resolved user's public keys from `github.com/{username}.gpg` and
218
+ `github.com/{username}.keys`.
219
+ 5. Try GPG verification: import the fetched key into a temporary keyring and
220
+ run `git verify-commit`.
221
+ 6. Try SSH verification: write a temporary `allowed_signers` file and run
222
+ `git verify-commit` with the SSH allowed-signers config.
223
+ 7. If any key verifies, the check passes. If none do, it fails.
224
+
225
+ If the author cannot be resolved via either method, or the GitHub API is
226
+ unreachable, the check fails with a clear error.
227
+
228
+ For private repositories, set `GITHUB_TOKEN` or `GH_TOKEN` so the Commits API
229
+ can authenticate.
230
+
202
231
  ### Configuration file
203
232
 
204
233
  Place `.commit-guard.toml` in your project root (or any parent directory) to
@@ -233,9 +262,11 @@ full precedence and ignore config file values when provided.
233
262
 
234
263
  ### Environment variables
235
264
 
236
- | Variable | Default | Description |
237
- | -------------------------- | ------- | -------------------------------------------- |
238
- | `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
265
+ | Variable | Default | Description |
266
+ | -------------------------- | ------- | ------------------------------------------------------------------------- |
267
+ | `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
268
+ | `GITHUB_TOKEN` | — | GitHub token for Commits API access on private repos (signature check). |
269
+ | `GH_TOKEN` | — | Alias for `GITHUB_TOKEN`; used when `GITHUB_TOKEN` is not set. |
239
270
 
240
271
  ```bash
241
272
  COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
@@ -377,7 +377,7 @@ $ echo "fix(auth): add token refresh" | commit-guard</code></pre>
377
377
  </tr>
378
378
  <tr>
379
379
  <td><code>signature</code></td>
380
- <td>GPG or SSH signature is valid</td>
380
+ <td>GPG or SSH signature is valid — verified via GitHub Commits API or public key lookup</td>
381
381
  </tr>
382
382
  </tbody>
383
383
  </table>
@@ -426,6 +426,39 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
426
426
  </p>
427
427
  <pre><code class="language-bash">commit-guard --require-trailer "Closes,Reviewed-by"</code></pre>
428
428
 
429
+ <h3>Signature verification</h3>
430
+ <p>
431
+ The <code>signature</code> check verifies commits without requiring a
432
+ pre-configured local keyring:
433
+ </p>
434
+ <ol>
435
+ <li>If the repo has a GitHub remote, call the Commits API
436
+ (<code>GET /repos/{owner}/{repo}/commits/{sha}</code>) to resolve
437
+ the author's GitHub username — works for corporate emails, noreply
438
+ addresses, or any email not listed publicly on a GitHub profile.</li>
439
+ <li>If the Commits API is unavailable (no GitHub remote, commit not
440
+ yet pushed, or API error), parse the username directly from a
441
+ GitHub noreply address
442
+ (<code>{id}+{username}@users.noreply.github.com</code>) — no API
443
+ call needed.</li>
444
+ <li>If neither of the above resolves a username, fall back to
445
+ searching GitHub by the commit author's email.</li>
446
+ <li>Fetch the resolved user's public keys from
447
+ <code>github.com/{username}.gpg</code> and
448
+ <code>github.com/{username}.keys</code>.</li>
449
+ <li>Try GPG verification using a temporary keyring.</li>
450
+ <li>Try SSH verification using a temporary <code>allowed_signers</code> file.</li>
451
+ <li>Pass if any key verifies; fail if none do.</li>
452
+ </ol>
453
+ <p>
454
+ If the author cannot be resolved via either method, or the GitHub API
455
+ is unreachable, the check fails with a clear error. For private
456
+ repositories, set <code>GITHUB_TOKEN</code> or <code>GH_TOKEN</code>
457
+ so the Commits API can authenticate. Disable the
458
+ <code>signature</code> check if GitHub API access is unavailable:
459
+ </p>
460
+ <pre><code class="language-bash">commit-guard --disable signature</code></pre>
461
+
429
462
  <h3>Range options</h3>
430
463
  <p>
431
464
  When using <code>--range</code>, merge commits are excluded by
@@ -451,6 +484,16 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
451
484
  <td><code>10</code></td>
452
485
  <td>Timeout in seconds for git subprocess calls</td>
453
486
  </tr>
487
+ <tr>
488
+ <td><code>GITHUB_TOKEN</code></td>
489
+ <td>—</td>
490
+ <td>GitHub token for Commits API access on private repos (signature check)</td>
491
+ </tr>
492
+ <tr>
493
+ <td><code>GH_TOKEN</code></td>
494
+ <td>—</td>
495
+ <td>Alias for <code>GITHUB_TOKEN</code>; used when <code>GITHUB_TOKEN</code> is not set</td>
496
+ </tr>
454
497
  </tbody>
455
498
  </table>
456
499
  </figure>
@@ -4,7 +4,10 @@ import os
4
4
  import re
5
5
  import subprocess
6
6
  import sys
7
+ import tempfile
7
8
  import tomllib
9
+ import urllib.error
10
+ import urllib.request
8
11
  from argparse import ArgumentParser
9
12
  from dataclasses import dataclass, field
10
13
  from enum import StrEnum
@@ -31,6 +34,10 @@ TYPES = frozenset(
31
34
 
32
35
  _NON_IMPERATIVE_SUFFIX_RE = re.compile(r"(?:ing|ed)$")
33
36
  _TRAILER_RE = re.compile(r"^[\w-]+:\s+\S")
37
+ _GITHUB_REMOTE_RE = re.compile(
38
+ r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/\s]+?)(?:\.git)?$"
39
+ )
40
+ _NOREPLY_RE = re.compile(r"^(?:\d+\+)?(?P<username>[^@]+)@users\.noreply\.github\.com$")
34
41
 
35
42
  SUBJECT_RE = re.compile(
36
43
  r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?!?:\s+(?P<desc>.+)$",
@@ -256,21 +263,159 @@ def check_required_trailers(message, required, result):
256
263
  result.error(f"missing required trailer: {trailer}")
257
264
 
258
265
 
259
- def check_signature(rev, result):
260
- proc = subprocess.run( # noqa: S603
261
- ["git", "verify-commit", rev], # noqa: S607
262
- capture_output=True,
266
+ def _get_author_email(rev):
267
+ return subprocess.check_output( # noqa: S603
268
+ ["git", "log", "-1", "--format=%ae", rev], # noqa: S607
263
269
  text=True,
264
- check=False,
270
+ stderr=subprocess.PIPE,
265
271
  timeout=_git_timeout(),
266
- )
267
- if proc.returncode != 0:
268
- result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE)
269
- return
272
+ ).strip()
273
+
274
+
275
+ def _get_github_remote_info():
276
+ try:
277
+ url = subprocess.check_output(
278
+ ["git", "remote", "get-url", "origin"], # noqa: S607 Starting a process with a partial executable path
279
+ text=True,
280
+ stderr=subprocess.PIPE,
281
+ timeout=_git_timeout(),
282
+ ).strip()
283
+ except subprocess.CalledProcessError:
284
+ return None
285
+ match = _GITHUB_REMOTE_RE.search(url)
286
+ if not match:
287
+ return None
288
+ return match.group("owner"), match.group("repo")
289
+
290
+
291
+ def _fetch_github_commit_author(owner, repo, sha):
292
+ url = f"https://api.github.com/repos/{owner}/{repo}/commits/{sha}"
293
+ headers = {"Accept": "application/vnd.github+json"}
294
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
295
+ if token:
296
+ headers["Authorization"] = f"Bearer {token}"
297
+ req = urllib.request.Request(url, headers=headers) # noqa: S310 Audit URL open for permitted schemes
298
+ with urllib.request.urlopen(req, timeout=_git_timeout()) as resp: # noqa: S310 Audit URL open for permitted schemes
299
+ data = json.loads(resp.read())
300
+ author = data.get("author")
301
+ return author["login"] if author else None
302
+
303
+
304
+ def _parse_noreply_username(email):
305
+ match = _NOREPLY_RE.match(email)
306
+ return match.group("username") if match else None
307
+
308
+
309
+ def _fetch_github_username(email):
310
+ url = f"https://api.github.com/search/users?q={email}+in:email"
311
+ req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) # noqa: S310 Audit URL open for permitted schemes
312
+ with urllib.request.urlopen(req, timeout=_git_timeout()) as resp: # noqa: S310 Audit URL open for permitted schemes
313
+ data = json.loads(resp.read())
314
+ items = data.get("items", [])
315
+ return items[0]["login"] if items else None
316
+
317
+
318
+ def _fetch_url(url):
319
+ with urllib.request.urlopen(url, timeout=_git_timeout()) as resp: # noqa: S310
320
+ return resp.read().decode()
321
+
270
322
 
271
- output = proc.stderr.lower()
272
- sig_type = "SSH" if "ssh" in output else "GPG"
273
- result.info(f"signature type: {sig_type}", check=Check.SIGNATURE)
323
+ def _fetch_github_keys(username):
324
+ gpg = _fetch_url(f"https://github.com/{username}.gpg")
325
+ ssh = _fetch_url(f"https://github.com/{username}.keys")
326
+ return gpg.strip(), ssh.strip()
327
+
328
+
329
+ def _verify_gpg(rev, gpg_text):
330
+ if not gpg_text:
331
+ return False
332
+ with tempfile.TemporaryDirectory() as homedir:
333
+ env = {**os.environ, "GNUPGHOME": homedir}
334
+ import_proc = subprocess.run(
335
+ ["gpg", "--batch", "--import"], # noqa: S607
336
+ input=gpg_text,
337
+ text=True,
338
+ capture_output=True,
339
+ env=env,
340
+ check=False,
341
+ )
342
+ if import_proc.returncode != 0:
343
+ return False
344
+ verify_proc = subprocess.run( # noqa: S603
345
+ ["git", "-c", "gpg.ssh.allowedSignersFile=/dev/null", "verify-commit", rev], # noqa: S607
346
+ capture_output=True,
347
+ text=True,
348
+ env=env,
349
+ check=False,
350
+ timeout=_git_timeout(),
351
+ )
352
+ return verify_proc.returncode == 0
353
+
354
+
355
+ def _verify_ssh(rev, email, ssh_text):
356
+ if not ssh_text:
357
+ return False
358
+ with tempfile.NamedTemporaryFile(
359
+ mode="w", suffix=".allowedSigners", delete=False
360
+ ) as f:
361
+ for raw_line in ssh_text.splitlines():
362
+ stripped = raw_line.strip()
363
+ if stripped:
364
+ f.write(f"{email} {stripped}\n")
365
+ signers_path = f.name
366
+ try:
367
+ proc = subprocess.run( # noqa: S603
368
+ [ # noqa: S607
369
+ "git",
370
+ "-c",
371
+ "gpg.format=ssh",
372
+ "-c",
373
+ f"gpg.ssh.allowedSignersFile={signers_path}",
374
+ "verify-commit",
375
+ rev,
376
+ ],
377
+ capture_output=True,
378
+ text=True,
379
+ check=False,
380
+ timeout=_git_timeout(),
381
+ )
382
+ return proc.returncode == 0
383
+ finally:
384
+ Path(signers_path).unlink(missing_ok=True)
385
+
386
+
387
+ def check_signature(rev, result):
388
+ try:
389
+ email = _get_author_email(rev)
390
+ username = None
391
+ remote = _get_github_remote_info()
392
+ if remote:
393
+ owner, repo = remote
394
+ with contextlib.suppress(urllib.error.URLError, TimeoutError):
395
+ username = _fetch_github_commit_author(owner, repo, rev)
396
+ if username is None:
397
+ username = _parse_noreply_username(email)
398
+ if username is None:
399
+ username = _fetch_github_username(email)
400
+ if username is None:
401
+ result.error(
402
+ "commit author not found on GitHub — cannot verify signature",
403
+ check=Check.SIGNATURE,
404
+ )
405
+ return
406
+ gpg_text, ssh_text = _fetch_github_keys(username)
407
+ if _verify_gpg(rev, gpg_text):
408
+ result.info("signature type: GPG", check=Check.SIGNATURE)
409
+ return
410
+ if _verify_ssh(rev, email, ssh_text):
411
+ result.info("signature type: SSH", check=Check.SIGNATURE)
412
+ return
413
+ result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE)
414
+ except (urllib.error.URLError, TimeoutError):
415
+ result.error(
416
+ "GitHub API unreachable — cannot verify signature",
417
+ check=Check.SIGNATURE,
418
+ )
274
419
 
275
420
 
276
421
  def _get_message(rev):
@@ -1,6 +1,8 @@
1
1
  import json
2
+ import os
2
3
  import re
3
4
  import subprocess
5
+ import urllib.error
4
6
  from argparse import ArgumentParser, Namespace
5
7
  from unittest.mock import MagicMock, patch
6
8
 
@@ -13,12 +15,19 @@ from git_commit_guard import (
13
15
  Result,
14
16
  _download_if_missing,
15
17
  _ensure_nltk_data,
18
+ _fetch_github_commit_author,
19
+ _fetch_github_keys,
20
+ _fetch_github_username,
21
+ _fetch_url,
22
+ _get_author_email,
23
+ _get_github_remote_info,
16
24
  _get_message,
17
25
  _get_range_revs,
18
26
  _git_timeout,
19
27
  _load_config,
20
28
  _parse_checks,
21
29
  _parse_config_checks,
30
+ _parse_noreply_username,
22
31
  _report_jsonl,
23
32
  _report_text,
24
33
  _resolve_max_subject_length,
@@ -29,6 +38,8 @@ from git_commit_guard import (
29
38
  _resolve_subject_pattern,
30
39
  _resolve_types,
31
40
  _strip_comments,
41
+ _verify_gpg,
42
+ _verify_ssh,
32
43
  check_body,
33
44
  check_imperative,
34
45
  check_required_trailers,
@@ -540,30 +551,404 @@ class TestDownloadIfMissing:
540
551
  mock_dl.assert_called_once_with("punkt_tab", quiet=True)
541
552
 
542
553
 
543
- class TestCheckSignature:
544
- def test_unsigned_commit(self):
545
- r = Result()
554
+ class TestGetAuthorEmail:
555
+ def test_returns_stripped_email(self):
556
+ with patch(
557
+ "git_commit_guard.subprocess.check_output",
558
+ return_value="user@example.com\n",
559
+ ):
560
+ assert _get_author_email("abc123") == "user@example.com"
561
+
562
+
563
+ class TestFetchUrl:
564
+ def test_returns_decoded_content(self):
565
+ mock_resp = MagicMock()
566
+ mock_resp.__enter__ = lambda s: s
567
+ mock_resp.__exit__ = MagicMock(return_value=False)
568
+ mock_resp.read.return_value = b"key data"
569
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=mock_resp):
570
+ assert _fetch_url("https://github.com/user.keys") == "key data"
571
+
572
+
573
+ class TestParseNoreplyUsername:
574
+ def test_id_plus_username_format(self):
575
+ assert (
576
+ _parse_noreply_username("12345678+alice@users.noreply.github.com")
577
+ == "alice"
578
+ )
579
+
580
+ def test_plain_username_format(self):
581
+ assert _parse_noreply_username("alice@users.noreply.github.com") == "alice"
582
+
583
+ def test_regular_email_returns_none(self):
584
+ assert _parse_noreply_username("alice@example.com") is None
585
+
586
+ def test_wrong_domain_returns_none(self):
587
+ assert _parse_noreply_username("alice@users.noreply.gitlab.com") is None
588
+
589
+
590
+ class TestFetchGithubUsername:
591
+ def _mock_response(self, data):
592
+ mock_resp = MagicMock()
593
+ mock_resp.__enter__ = lambda s: s
594
+ mock_resp.__exit__ = MagicMock(return_value=False)
595
+ mock_resp.read.return_value = json.dumps(data).encode()
596
+ return mock_resp
597
+
598
+ def test_found_returns_login(self):
599
+ resp = self._mock_response({"items": [{"login": "testuser"}]})
600
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
601
+ assert _fetch_github_username("test@example.com") == "testuser"
602
+
603
+ def test_not_found_returns_none(self):
604
+ resp = self._mock_response({"items": []})
605
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
606
+ assert _fetch_github_username("unknown@example.com") is None
607
+
608
+
609
+ class TestGetGithubRemoteInfo:
610
+ def test_https_url_returns_owner_repo(self):
611
+ with patch(
612
+ "git_commit_guard.subprocess.check_output",
613
+ return_value="https://github.com/owner/repo.git\n",
614
+ ):
615
+ assert _get_github_remote_info() == ("owner", "repo")
616
+
617
+ def test_ssh_url_returns_owner_repo(self):
618
+ with patch(
619
+ "git_commit_guard.subprocess.check_output",
620
+ return_value="git@github.com:owner/repo.git\n",
621
+ ):
622
+ assert _get_github_remote_info() == ("owner", "repo")
623
+
624
+ def test_https_url_without_git_suffix(self):
625
+ with patch(
626
+ "git_commit_guard.subprocess.check_output",
627
+ return_value="https://github.com/owner/repo\n",
628
+ ):
629
+ assert _get_github_remote_info() == ("owner", "repo")
630
+
631
+ def test_no_remote_returns_none(self):
632
+ err = subprocess.CalledProcessError(128, "git")
633
+ err.stderr = "fatal: No such remote 'origin'"
634
+ with patch("git_commit_guard.subprocess.check_output", side_effect=err):
635
+ assert _get_github_remote_info() is None
636
+
637
+ def test_non_github_remote_returns_none(self):
638
+ with patch(
639
+ "git_commit_guard.subprocess.check_output",
640
+ return_value="https://gitlab.com/owner/repo.git\n",
641
+ ):
642
+ assert _get_github_remote_info() is None
643
+
644
+
645
+ class TestFetchGithubCommitAuthor:
646
+ def _mock_response(self, data):
647
+ mock_resp = MagicMock()
648
+ mock_resp.__enter__ = lambda s: s
649
+ mock_resp.__exit__ = MagicMock(return_value=False)
650
+ mock_resp.read.return_value = json.dumps(data).encode()
651
+ return mock_resp
652
+
653
+ def test_returns_author_login(self):
654
+ resp = self._mock_response({"author": {"login": "commituser"}})
655
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
656
+ assert (
657
+ _fetch_github_commit_author("owner", "repo", "abc123") == "commituser"
658
+ )
659
+
660
+ def test_null_author_returns_none(self):
661
+ resp = self._mock_response({"author": None})
662
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
663
+ assert _fetch_github_commit_author("owner", "repo", "abc123") is None
664
+
665
+ def test_github_token_sent_in_header(self):
666
+ resp = self._mock_response({"author": {"login": "user"}})
667
+ captured = []
668
+
669
+ def mock_urlopen(req, **_):
670
+ captured.append(req)
671
+ return resp
672
+
673
+ with (
674
+ patch("git_commit_guard.urllib.request.urlopen", side_effect=mock_urlopen),
675
+ patch.dict("os.environ", {"GITHUB_TOKEN": "mytoken"}, clear=False),
676
+ ):
677
+ _fetch_github_commit_author("owner", "repo", "abc123")
678
+ assert captured[0].get_header("Authorization") == "Bearer mytoken"
679
+
680
+ def test_gh_token_used_when_github_token_absent(self):
681
+ resp = self._mock_response({"author": {"login": "user"}})
682
+ captured = []
683
+
684
+ def mock_urlopen(req, **_):
685
+ captured.append(req)
686
+ return resp
687
+
688
+ env = {k: v for k, v in os.environ.items() if k != "GITHUB_TOKEN"}
689
+ env["GH_TOKEN"] = "ghtoken" # noqa: S105 Possible hardcoded password assigned to: "GH_TOKEN"
690
+ with (
691
+ patch("git_commit_guard.urllib.request.urlopen", side_effect=mock_urlopen),
692
+ patch.dict("os.environ", env, clear=True),
693
+ ):
694
+ _fetch_github_commit_author("owner", "repo", "abc123")
695
+ assert captured[0].get_header("Authorization") == "Bearer ghtoken"
696
+
697
+
698
+ class TestFetchGithubKeys:
699
+ def test_returns_gpg_and_ssh(self):
700
+ with patch(
701
+ "git_commit_guard._fetch_url", side_effect=["GPG KEY\n", "SSH KEY\n"]
702
+ ):
703
+ gpg, ssh = _fetch_github_keys("testuser")
704
+ assert gpg == "GPG KEY"
705
+ assert ssh == "SSH KEY"
706
+
707
+
708
+ class TestVerifyGpg:
709
+ def test_empty_gpg_returns_false(self):
710
+ assert _verify_gpg("abc123", "") is False
711
+
712
+ def test_import_failure_returns_false(self):
546
713
  proc = MagicMock(returncode=1)
547
714
  with patch("git_commit_guard.subprocess.run", return_value=proc):
548
- check_signature("abc123", r)
549
- assert not r.ok
715
+ assert _verify_gpg("abc123", "gpg key data") is False
550
716
 
551
- def test_gpg_signed_commit(self):
552
- r = Result()
553
- proc = MagicMock(returncode=0, stderr="gpg signature verified")
717
+ def test_verify_success_returns_true(self):
718
+ import_proc = MagicMock(returncode=0)
719
+ verify_proc = MagicMock(returncode=0)
720
+ with patch(
721
+ "git_commit_guard.subprocess.run", side_effect=[import_proc, verify_proc]
722
+ ) as mock_run:
723
+ assert _verify_gpg("abc123", "gpg key data") is True
724
+ verify_cmd = mock_run.call_args_list[1][0][0]
725
+ assert "gpg.ssh.allowedSignersFile=/dev/null" in verify_cmd
726
+
727
+ def test_verify_failure_returns_false(self):
728
+ import_proc = MagicMock(returncode=0)
729
+ verify_proc = MagicMock(returncode=1)
730
+ with patch(
731
+ "git_commit_guard.subprocess.run", side_effect=[import_proc, verify_proc]
732
+ ):
733
+ assert _verify_gpg("abc123", "gpg key data") is False
734
+
735
+
736
+ class TestVerifySSH:
737
+ def test_empty_ssh_returns_false(self):
738
+ assert _verify_ssh("abc123", "user@example.com", "") is False
739
+
740
+ def test_verify_success_returns_true(self):
741
+ proc = MagicMock(returncode=0)
742
+ with patch("git_commit_guard.subprocess.run", return_value=proc) as mock_run:
743
+ assert (
744
+ _verify_ssh("abc123", "user@example.com", "ssh-ed25519 AAAA...") is True
745
+ )
746
+ verify_cmd = mock_run.call_args[0][0]
747
+ assert "-c" in verify_cmd
748
+ assert "gpg.format=ssh" in verify_cmd
749
+
750
+ def test_verify_failure_returns_false(self):
751
+ proc = MagicMock(returncode=1)
554
752
  with patch("git_commit_guard.subprocess.run", return_value=proc):
753
+ assert (
754
+ _verify_ssh("abc123", "user@example.com", "ssh-ed25519 AAAA...")
755
+ is False
756
+ )
757
+
758
+
759
+ class TestCheckSignature:
760
+ def test_gpg_verified_via_github(self):
761
+ r = Result()
762
+ with (
763
+ patch(
764
+ "git_commit_guard._get_author_email", return_value="user@example.com"
765
+ ),
766
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
767
+ patch("git_commit_guard._fetch_github_username", return_value="testuser"),
768
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
769
+ patch("git_commit_guard._verify_gpg", return_value=True),
770
+ ):
555
771
  check_signature("abc123", r)
556
772
  assert r.ok
557
773
  assert any("GPG" in msg for _, _, msg in r.errors)
558
774
 
559
- def test_ssh_signed_commit(self):
775
+ def test_ssh_verified_via_github(self):
560
776
  r = Result()
561
- proc = MagicMock(returncode=0, stderr="Good ssh signature")
562
- with patch("git_commit_guard.subprocess.run", return_value=proc):
777
+ with (
778
+ patch(
779
+ "git_commit_guard._get_author_email", return_value="user@example.com"
780
+ ),
781
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
782
+ patch("git_commit_guard._fetch_github_username", return_value="testuser"),
783
+ patch("git_commit_guard._fetch_github_keys", return_value=("", "SSH KEY")),
784
+ patch("git_commit_guard._verify_gpg", return_value=False),
785
+ patch("git_commit_guard._verify_ssh", return_value=True),
786
+ ):
563
787
  check_signature("abc123", r)
564
788
  assert r.ok
565
789
  assert any("SSH" in msg for _, _, msg in r.errors)
566
790
 
791
+ def test_no_matching_key_fails(self):
792
+ r = Result()
793
+ with (
794
+ patch(
795
+ "git_commit_guard._get_author_email", return_value="user@example.com"
796
+ ),
797
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
798
+ patch("git_commit_guard._fetch_github_username", return_value="testuser"),
799
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG", "SSH")),
800
+ patch("git_commit_guard._verify_gpg", return_value=False),
801
+ patch("git_commit_guard._verify_ssh", return_value=False),
802
+ ):
803
+ check_signature("abc123", r)
804
+ assert not r.ok
805
+
806
+ def test_username_not_found_fails(self):
807
+ r = Result()
808
+ with (
809
+ patch(
810
+ "git_commit_guard._get_author_email", return_value="user@example.com"
811
+ ),
812
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
813
+ patch("git_commit_guard._fetch_github_username", return_value=None),
814
+ ):
815
+ check_signature("abc123", r)
816
+ assert not r.ok
817
+ assert any("not found on GitHub" in msg for _, _, msg in r.errors)
818
+
819
+ def test_url_error_fails(self):
820
+ r = Result()
821
+ with patch(
822
+ "git_commit_guard._get_author_email",
823
+ side_effect=urllib.error.URLError("unreachable"),
824
+ ):
825
+ check_signature("abc123", r)
826
+ assert not r.ok
827
+ assert any("API unreachable" in msg for _, _, msg in r.errors)
828
+
829
+ def test_timeout_error_fails(self):
830
+ r = Result()
831
+ with patch("git_commit_guard._get_author_email", side_effect=TimeoutError()):
832
+ check_signature("abc123", r)
833
+ assert not r.ok
834
+ assert any("API unreachable" in msg for _, _, msg in r.errors)
835
+
836
+ def test_commits_api_resolves_username(self):
837
+ r = Result()
838
+ with (
839
+ patch(
840
+ "git_commit_guard._get_author_email", return_value="corp@example.com"
841
+ ),
842
+ patch(
843
+ "git_commit_guard._get_github_remote_info",
844
+ return_value=("owner", "repo"),
845
+ ),
846
+ patch(
847
+ "git_commit_guard._fetch_github_commit_author", return_value="corpuser"
848
+ ),
849
+ patch("git_commit_guard._fetch_github_username") as mock_email_search,
850
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
851
+ patch("git_commit_guard._verify_gpg", return_value=True),
852
+ ):
853
+ check_signature("abc123", r)
854
+ assert r.ok
855
+ mock_email_search.assert_not_called()
856
+
857
+ def test_commits_api_error_falls_back_to_email_search(self):
858
+ r = Result()
859
+ with (
860
+ patch(
861
+ "git_commit_guard._get_author_email", return_value="corp@example.com"
862
+ ),
863
+ patch(
864
+ "git_commit_guard._get_github_remote_info",
865
+ return_value=("owner", "repo"),
866
+ ),
867
+ patch(
868
+ "git_commit_guard._fetch_github_commit_author",
869
+ side_effect=urllib.error.URLError("not found"),
870
+ ),
871
+ patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
872
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
873
+ patch("git_commit_guard._verify_gpg", return_value=True),
874
+ ):
875
+ check_signature("abc123", r)
876
+ assert r.ok
877
+
878
+ def test_commits_api_null_author_falls_back_to_email_search(self):
879
+ r = Result()
880
+ with (
881
+ patch(
882
+ "git_commit_guard._get_author_email", return_value="user@example.com"
883
+ ),
884
+ patch(
885
+ "git_commit_guard._get_github_remote_info",
886
+ return_value=("owner", "repo"),
887
+ ),
888
+ patch("git_commit_guard._fetch_github_commit_author", return_value=None),
889
+ patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
890
+ patch("git_commit_guard._fetch_github_keys", return_value=("", "SSH KEY")),
891
+ patch("git_commit_guard._verify_gpg", return_value=False),
892
+ patch("git_commit_guard._verify_ssh", return_value=True),
893
+ ):
894
+ check_signature("abc123", r)
895
+ assert r.ok
896
+
897
+ def test_no_github_remote_uses_email_search(self):
898
+ r = Result()
899
+ with (
900
+ patch(
901
+ "git_commit_guard._get_author_email", return_value="user@example.com"
902
+ ),
903
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
904
+ patch("git_commit_guard._fetch_github_commit_author") as mock_commits_api,
905
+ patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
906
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
907
+ patch("git_commit_guard._verify_gpg", return_value=True),
908
+ ):
909
+ check_signature("abc123", r)
910
+ assert r.ok
911
+ mock_commits_api.assert_not_called()
912
+
913
+ def test_noreply_email_skips_email_search(self):
914
+ r = Result()
915
+ with (
916
+ patch(
917
+ "git_commit_guard._get_author_email",
918
+ return_value="12345678+alice@users.noreply.github.com",
919
+ ),
920
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
921
+ patch("git_commit_guard._fetch_github_username") as mock_email_search,
922
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
923
+ patch("git_commit_guard._verify_gpg", return_value=True),
924
+ ):
925
+ check_signature("abc123", r)
926
+ assert r.ok
927
+ mock_email_search.assert_not_called()
928
+
929
+ def test_noreply_fallback_after_commits_api_failure(self):
930
+ r = Result()
931
+ with (
932
+ patch(
933
+ "git_commit_guard._get_author_email",
934
+ return_value="12345678+alice@users.noreply.github.com",
935
+ ),
936
+ patch(
937
+ "git_commit_guard._get_github_remote_info",
938
+ return_value=("owner", "repo"),
939
+ ),
940
+ patch(
941
+ "git_commit_guard._fetch_github_commit_author",
942
+ side_effect=urllib.error.URLError("not found"),
943
+ ),
944
+ patch("git_commit_guard._fetch_github_username") as mock_email_search,
945
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
946
+ patch("git_commit_guard._verify_gpg", return_value=True),
947
+ ):
948
+ check_signature("abc123", r)
949
+ assert r.ok
950
+ mock_email_search.assert_not_called()
951
+
567
952
 
568
953
  class TestGetMessage:
569
954
  def test_success(self):
@@ -929,11 +1314,16 @@ class TestMain:
929
1314
  assert main() == 0
930
1315
 
931
1316
  def test_signature_with_rev(self):
932
- proc = MagicMock(returncode=0, stderr="gpg signature verified")
933
1317
  with (
934
1318
  patch("sys.argv", ["cg", "abc123", "--enable", "signature"]),
935
1319
  patch("git_commit_guard._get_message", return_value=_VALID_MSG),
936
- patch("git_commit_guard.subprocess.run", return_value=proc),
1320
+ patch(
1321
+ "git_commit_guard._get_author_email", return_value="user@example.com"
1322
+ ),
1323
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
1324
+ patch("git_commit_guard._fetch_github_username", return_value="testuser"),
1325
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
1326
+ patch("git_commit_guard._verify_gpg", return_value=True),
937
1327
  ):
938
1328
  assert main() == 0
939
1329