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.
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/lint-commits.yml +1 -1
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/release.yml +1 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/PKG-INFO +36 -5
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/README.md +35 -4
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/docs/index.html +44 -1
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/src/git_commit_guard/__init__.py +157 -12
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/tests/test_git_commit_guard.py +403 -13
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.editorconfig +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/coverage-baseline.yml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/coverage-comment.yml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/lint-md.yml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/lint-python.yml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/lint-workflows.yml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.github/workflows/test.yml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.gitignore +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.markdownlint.json +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.pre-commit-hooks.yaml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/.python-version +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/LICENSE +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/action.yml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/cliff.toml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/docs/commit-guard-icon.svg +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/pyproject.toml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/ruff.toml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.0}/tests/__init__.py +0 -0
- {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@
|
|
25
|
+
uses: benner/commit-guard@7704c563540b24bb10394e373e508dc664a7f01f # v0.19.0
|
|
26
26
|
with:
|
|
27
27
|
range: origin/${{ github.base_ref }}..HEAD
|
|
28
28
|
disable: signature
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-commit-guard
|
|
3
|
-
Version: 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
|
|
260
|
-
|
|
261
|
-
["git", "
|
|
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
|
-
|
|
270
|
+
stderr=subprocess.PIPE,
|
|
265
271
|
timeout=_git_timeout(),
|
|
266
|
-
)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
544
|
-
def
|
|
545
|
-
|
|
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
|
-
|
|
549
|
-
assert not r.ok
|
|
715
|
+
assert _verify_gpg("abc123", "gpg key data") is False
|
|
550
716
|
|
|
551
|
-
def
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
775
|
+
def test_ssh_verified_via_github(self):
|
|
560
776
|
r = Result()
|
|
561
|
-
|
|
562
|
-
|
|
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(
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|