git-commit-guard 0.20.1__tar.gz → 0.21.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.20.1 → git_commit_guard-0.21.0}/.github/workflows/lint-commits.yml +1 -1
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/PKG-INFO +28 -14
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/README.md +27 -13
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/docs/index.html +36 -16
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/src/git_commit_guard/__init__.py +69 -14
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/tests/test_git_commit_guard.py +191 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.editorconfig +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.github/workflows/coverage-baseline.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.github/workflows/coverage-comment.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.github/workflows/lint-md.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.github/workflows/lint-python.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.github/workflows/lint-workflows.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.github/workflows/release.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.github/workflows/test.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.gitignore +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.markdownlint.json +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.pre-commit-hooks.yaml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/.python-version +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/LICENSE +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/action.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/cliff.toml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/docs/commit-guard-icon.svg +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/pyproject.toml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/ruff.toml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/tests/__init__.py +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.21.0}/uv.lock +0 -0
|
@@ -22,6 +22,6 @@ 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@87889d2f3a1fb68bfa55dbbab28c96def415341e # v0.20.1
|
|
26
26
|
with:
|
|
27
27
|
range: origin/${{ github.base_ref }}..HEAD
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-commit-guard
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.21.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
|
|
@@ -21,18 +21,32 @@ Description-Content-Type: text/markdown
|
|
|
21
21
|
|
|
22
22
|
# commit-guard
|
|
23
23
|
|
|
24
|
+
<!-- markdownlint-disable MD013 -->
|
|
25
|
+
[](https://pypi.org/project/git-commit-guard/)
|
|
26
|
+
[](https://pypi.org/project/git-commit-guard/)
|
|
27
|
+
[](https://github.com/benner/commit-guard/actions/workflows/test.yml)
|
|
28
|
+
[](https://pre-commit.com/)
|
|
29
|
+
<!-- markdownlint-restore -->
|
|
30
|
+
|
|
24
31
|
Opinionated conventional commit message linter with imperative mood detection.
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
imperative
|
|
33
|
+
## Why commit-guard?
|
|
34
|
+
|
|
35
|
+
* **NLP imperative detection.** Descriptions must start with an imperative
|
|
36
|
+
verb, verified via nltk POS tagging — not a hand-coded regex of "bad"
|
|
37
|
+
words.
|
|
38
|
+
* **Signature verification without a local keyring.** Resolves the commit
|
|
39
|
+
author via the GitHub API and verifies GPG/SSH against their published
|
|
40
|
+
`.gpg`/`.keys` — no per-runner key management.
|
|
41
|
+
* **Strict by default.** Subject format, body, trailers, `Signed-off-by`,
|
|
42
|
+
and signature all enforced out of the box; opt out with `--disable`.
|
|
29
43
|
|
|
30
44
|
## Example
|
|
31
45
|
|
|
32
46
|
```bash
|
|
33
47
|
$ commit-guard
|
|
34
48
|
✗ [subject] subject does not match 'type(scope): description': WIP
|
|
35
|
-
✗ [signed-off] missing 'Signed-off-by' trailer
|
|
49
|
+
✗ [signed-off] missing 'Signed-off-by' trailer — use 'git commit -s'
|
|
36
50
|
✗ [signature] commit is not signed (GPG/SSH)
|
|
37
51
|
```
|
|
38
52
|
|
|
@@ -94,12 +108,12 @@ commit-guard --disable body,signed-off,signature
|
|
|
94
108
|
Available checks:
|
|
95
109
|
|
|
96
110
|
* `subject` - Format matches `type(scope): description`, valid type,
|
|
97
|
-
|
|
111
|
+
lowercase start, no trailing `.` `!` `?` or space, max 72 chars
|
|
98
112
|
* `imperative` - First word is an imperative verb (for example `add` not `added`)
|
|
99
113
|
* `body` - Blank line separates subject from body, and body is non-empty
|
|
100
114
|
* `signed-off` - `Signed-off-by:` trailer exists
|
|
101
115
|
* `signature` - Verify GPG or SSH signature via the GitHub Commits API or
|
|
102
|
-
|
|
116
|
+
public key lookup
|
|
103
117
|
|
|
104
118
|
### Subject length
|
|
105
119
|
|
|
@@ -296,7 +310,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
|
|
|
296
310
|
In GitHub Actions, set it at the step or job level:
|
|
297
311
|
|
|
298
312
|
```yaml
|
|
299
|
-
- uses: benner/commit-guard@v0.
|
|
313
|
+
- uses: benner/commit-guard@v0.21.0
|
|
300
314
|
env:
|
|
301
315
|
COMMIT_GUARD_GIT_TIMEOUT: 30
|
|
302
316
|
with:
|
|
@@ -380,7 +394,7 @@ steps:
|
|
|
380
394
|
- uses: actions/checkout@v4
|
|
381
395
|
with:
|
|
382
396
|
fetch-depth: 0
|
|
383
|
-
- uses: benner/commit-guard@v0.
|
|
397
|
+
- uses: benner/commit-guard@v0.21.0
|
|
384
398
|
```
|
|
385
399
|
|
|
386
400
|
Check all commits in a pull request:
|
|
@@ -396,7 +410,7 @@ jobs:
|
|
|
396
410
|
- uses: actions/checkout@v4
|
|
397
411
|
with:
|
|
398
412
|
fetch-depth: 0
|
|
399
|
-
- uses: benner/commit-guard@v0.
|
|
413
|
+
- uses: benner/commit-guard@v0.21.0
|
|
400
414
|
with:
|
|
401
415
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
402
416
|
```
|
|
@@ -404,7 +418,7 @@ jobs:
|
|
|
404
418
|
Check a specific commit SHA (mirrors the positional CLI argument):
|
|
405
419
|
|
|
406
420
|
```yaml
|
|
407
|
-
- uses: benner/commit-guard@v0.
|
|
421
|
+
- uses: benner/commit-guard@v0.21.0
|
|
408
422
|
with:
|
|
409
423
|
rev: ${{ github.sha }}
|
|
410
424
|
```
|
|
@@ -422,7 +436,7 @@ jobs:
|
|
|
422
436
|
- uses: actions/checkout@v4
|
|
423
437
|
with:
|
|
424
438
|
fetch-depth: 0
|
|
425
|
-
- uses: benner/commit-guard@v0.
|
|
439
|
+
- uses: benner/commit-guard@v0.21.0
|
|
426
440
|
with:
|
|
427
441
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
428
442
|
disable: signed-off,signature
|
|
@@ -442,7 +456,7 @@ jobs:
|
|
|
442
456
|
When `output-file` is set the action exposes the path as an output:
|
|
443
457
|
|
|
444
458
|
```yaml
|
|
445
|
-
- uses: benner/commit-guard@v0.
|
|
459
|
+
- uses: benner/commit-guard@v0.21.0
|
|
446
460
|
id: cg
|
|
447
461
|
with:
|
|
448
462
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
@@ -458,7 +472,7 @@ Add to your `.pre-commit-config.yaml`:
|
|
|
458
472
|
---
|
|
459
473
|
repos:
|
|
460
474
|
- repo: https://github.com/benner/commit-guard
|
|
461
|
-
rev: v0.
|
|
475
|
+
rev: v0.21.0
|
|
462
476
|
hooks:
|
|
463
477
|
- id: commit-guard
|
|
464
478
|
- id: commit-guard-signature
|
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
# commit-guard
|
|
2
2
|
|
|
3
|
+
<!-- markdownlint-disable MD013 -->
|
|
4
|
+
[](https://pypi.org/project/git-commit-guard/)
|
|
5
|
+
[](https://pypi.org/project/git-commit-guard/)
|
|
6
|
+
[](https://github.com/benner/commit-guard/actions/workflows/test.yml)
|
|
7
|
+
[](https://pre-commit.com/)
|
|
8
|
+
<!-- markdownlint-restore -->
|
|
9
|
+
|
|
3
10
|
Opinionated conventional commit message linter with imperative mood detection.
|
|
4
11
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
imperative
|
|
12
|
+
## Why commit-guard?
|
|
13
|
+
|
|
14
|
+
* **NLP imperative detection.** Descriptions must start with an imperative
|
|
15
|
+
verb, verified via nltk POS tagging — not a hand-coded regex of "bad"
|
|
16
|
+
words.
|
|
17
|
+
* **Signature verification without a local keyring.** Resolves the commit
|
|
18
|
+
author via the GitHub API and verifies GPG/SSH against their published
|
|
19
|
+
`.gpg`/`.keys` — no per-runner key management.
|
|
20
|
+
* **Strict by default.** Subject format, body, trailers, `Signed-off-by`,
|
|
21
|
+
and signature all enforced out of the box; opt out with `--disable`.
|
|
8
22
|
|
|
9
23
|
## Example
|
|
10
24
|
|
|
11
25
|
```bash
|
|
12
26
|
$ commit-guard
|
|
13
27
|
✗ [subject] subject does not match 'type(scope): description': WIP
|
|
14
|
-
✗ [signed-off] missing 'Signed-off-by' trailer
|
|
28
|
+
✗ [signed-off] missing 'Signed-off-by' trailer — use 'git commit -s'
|
|
15
29
|
✗ [signature] commit is not signed (GPG/SSH)
|
|
16
30
|
```
|
|
17
31
|
|
|
@@ -73,12 +87,12 @@ commit-guard --disable body,signed-off,signature
|
|
|
73
87
|
Available checks:
|
|
74
88
|
|
|
75
89
|
* `subject` - Format matches `type(scope): description`, valid type,
|
|
76
|
-
|
|
90
|
+
lowercase start, no trailing `.` `!` `?` or space, max 72 chars
|
|
77
91
|
* `imperative` - First word is an imperative verb (for example `add` not `added`)
|
|
78
92
|
* `body` - Blank line separates subject from body, and body is non-empty
|
|
79
93
|
* `signed-off` - `Signed-off-by:` trailer exists
|
|
80
94
|
* `signature` - Verify GPG or SSH signature via the GitHub Commits API or
|
|
81
|
-
|
|
95
|
+
public key lookup
|
|
82
96
|
|
|
83
97
|
### Subject length
|
|
84
98
|
|
|
@@ -275,7 +289,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
|
|
|
275
289
|
In GitHub Actions, set it at the step or job level:
|
|
276
290
|
|
|
277
291
|
```yaml
|
|
278
|
-
- uses: benner/commit-guard@v0.
|
|
292
|
+
- uses: benner/commit-guard@v0.21.0
|
|
279
293
|
env:
|
|
280
294
|
COMMIT_GUARD_GIT_TIMEOUT: 30
|
|
281
295
|
with:
|
|
@@ -359,7 +373,7 @@ steps:
|
|
|
359
373
|
- uses: actions/checkout@v4
|
|
360
374
|
with:
|
|
361
375
|
fetch-depth: 0
|
|
362
|
-
- uses: benner/commit-guard@v0.
|
|
376
|
+
- uses: benner/commit-guard@v0.21.0
|
|
363
377
|
```
|
|
364
378
|
|
|
365
379
|
Check all commits in a pull request:
|
|
@@ -375,7 +389,7 @@ jobs:
|
|
|
375
389
|
- uses: actions/checkout@v4
|
|
376
390
|
with:
|
|
377
391
|
fetch-depth: 0
|
|
378
|
-
- uses: benner/commit-guard@v0.
|
|
392
|
+
- uses: benner/commit-guard@v0.21.0
|
|
379
393
|
with:
|
|
380
394
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
381
395
|
```
|
|
@@ -383,7 +397,7 @@ jobs:
|
|
|
383
397
|
Check a specific commit SHA (mirrors the positional CLI argument):
|
|
384
398
|
|
|
385
399
|
```yaml
|
|
386
|
-
- uses: benner/commit-guard@v0.
|
|
400
|
+
- uses: benner/commit-guard@v0.21.0
|
|
387
401
|
with:
|
|
388
402
|
rev: ${{ github.sha }}
|
|
389
403
|
```
|
|
@@ -401,7 +415,7 @@ jobs:
|
|
|
401
415
|
- uses: actions/checkout@v4
|
|
402
416
|
with:
|
|
403
417
|
fetch-depth: 0
|
|
404
|
-
- uses: benner/commit-guard@v0.
|
|
418
|
+
- uses: benner/commit-guard@v0.21.0
|
|
405
419
|
with:
|
|
406
420
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
407
421
|
disable: signed-off,signature
|
|
@@ -421,7 +435,7 @@ jobs:
|
|
|
421
435
|
When `output-file` is set the action exposes the path as an output:
|
|
422
436
|
|
|
423
437
|
```yaml
|
|
424
|
-
- uses: benner/commit-guard@v0.
|
|
438
|
+
- uses: benner/commit-guard@v0.21.0
|
|
425
439
|
id: cg
|
|
426
440
|
with:
|
|
427
441
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
@@ -437,7 +451,7 @@ Add to your `.pre-commit-config.yaml`:
|
|
|
437
451
|
---
|
|
438
452
|
repos:
|
|
439
453
|
- repo: https://github.com/benner/commit-guard
|
|
440
|
-
rev: v0.
|
|
454
|
+
rev: v0.21.0
|
|
441
455
|
hooks:
|
|
442
456
|
- id: commit-guard
|
|
443
457
|
- id: commit-guard-signature
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
.hero-terminal pre {
|
|
116
116
|
margin: 0;
|
|
117
117
|
font-size: 0.95rem;
|
|
118
|
-
width:
|
|
118
|
+
width: 80ch;
|
|
119
119
|
height: 12em;
|
|
120
120
|
overflow: hidden;
|
|
121
121
|
}
|
|
@@ -291,12 +291,10 @@
|
|
|
291
291
|
<h1>commit-guard</h1>
|
|
292
292
|
<p>Conventional commit linting with imperative mood detection.</p>
|
|
293
293
|
<div class="hero-terminal">
|
|
294
|
-
<pre><code id="hero-code">$ commit-guard
|
|
295
|
-
<span class="c-dim">
|
|
296
|
-
<span class="c-
|
|
297
|
-
<span class="c-dim">
|
|
298
|
-
<span class="c-red">✗</span> <span class="c-dim">[subject]</span> unknown type: wip
|
|
299
|
-
<span class="c-red">✗</span> <span class="c-dim">[body]</span> missing body</code></pre>
|
|
294
|
+
<pre><code id="hero-code">$ commit-guard
|
|
295
|
+
<span class="c-red">✗</span> <span class="c-dim">[subject]</span> subject does not match 'type(scope): description': WIP
|
|
296
|
+
<span class="c-red">✗</span> <span class="c-dim">[signed-off]</span> missing 'Signed-off-by' trailer — use 'git commit -s'
|
|
297
|
+
<span class="c-red">✗</span> <span class="c-dim">[signature]</span> commit is not signed (GPG/SSH)</code></pre>
|
|
300
298
|
</div>
|
|
301
299
|
<div class="hero-dots">
|
|
302
300
|
<button class="hero-dot" data-i="0"></button>
|
|
@@ -305,6 +303,28 @@
|
|
|
305
303
|
</div>
|
|
306
304
|
</section>
|
|
307
305
|
|
|
306
|
+
<section id="why">
|
|
307
|
+
<h2>Why commit-guard? <a href="#why" class="anchor">#</a></h2>
|
|
308
|
+
<ul>
|
|
309
|
+
<li>
|
|
310
|
+
<strong>NLP imperative detection.</strong> Descriptions must start
|
|
311
|
+
with an imperative verb, verified via nltk POS tagging — not a
|
|
312
|
+
hand-coded regex of "bad" words.
|
|
313
|
+
</li>
|
|
314
|
+
<li>
|
|
315
|
+
<strong>Signature verification without a local keyring.</strong>
|
|
316
|
+
Resolves the commit author via the GitHub API and verifies GPG/SSH
|
|
317
|
+
against their published <code>.gpg</code>/<code>.keys</code> — no
|
|
318
|
+
per-runner key management.
|
|
319
|
+
</li>
|
|
320
|
+
<li>
|
|
321
|
+
<strong>Strict by default.</strong> Subject format, body, trailers,
|
|
322
|
+
<code>Signed-off-by</code>, and signature all enforced out of the
|
|
323
|
+
box; opt out with <code>--disable</code>.
|
|
324
|
+
</li>
|
|
325
|
+
</ul>
|
|
326
|
+
</section>
|
|
327
|
+
|
|
308
328
|
<section id="install">
|
|
309
329
|
<h2>Install <a href="#install" class="anchor">#</a></h2>
|
|
310
330
|
<div class="install-tabs">
|
|
@@ -538,13 +558,13 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
|
|
|
538
558
|
- uses: actions/checkout@v4
|
|
539
559
|
with:
|
|
540
560
|
fetch-depth: 0
|
|
541
|
-
- uses: benner/commit-guard@v0.
|
|
561
|
+
- uses: benner/commit-guard@v0.21.0
|
|
542
562
|
with:
|
|
543
563
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
544
564
|
disable: signed-off,signature</code></pre>
|
|
545
565
|
|
|
546
566
|
<p>Check a specific commit SHA:</p>
|
|
547
|
-
<pre><code class="language-yaml"> - uses: benner/commit-guard@v0.
|
|
567
|
+
<pre><code class="language-yaml"> - uses: benner/commit-guard@v0.21.0
|
|
548
568
|
with:
|
|
549
569
|
rev: ${{ github.sha }}</code></pre>
|
|
550
570
|
|
|
@@ -563,7 +583,7 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
|
|
|
563
583
|
When <code>output-file</code> is set the action exposes the path as
|
|
564
584
|
a step output, making JSONL results available to subsequent steps:
|
|
565
585
|
</p>
|
|
566
|
-
<pre><code class="language-yaml"> - uses: benner/commit-guard@v0.
|
|
586
|
+
<pre><code class="language-yaml"> - uses: benner/commit-guard@v0.21.0
|
|
567
587
|
id: cg
|
|
568
588
|
with:
|
|
569
589
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
@@ -576,7 +596,7 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
|
|
|
576
596
|
<p>Add to <code>.pre-commit-config.yaml</code>:</p>
|
|
577
597
|
<pre><code class="language-yaml">repos:
|
|
578
598
|
- repo: https://github.com/benner/commit-guard
|
|
579
|
-
rev: v0.
|
|
599
|
+
rev: v0.21.0
|
|
580
600
|
hooks:
|
|
581
601
|
- id: commit-guard
|
|
582
602
|
- id: commit-guard-signature</code></pre>
|
|
@@ -596,16 +616,16 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
|
|
|
596
616
|
const EXAMPLES = [
|
|
597
617
|
[
|
|
598
618
|
["$ commit-guard\n ", null],
|
|
599
|
-
["✗", "c-red"], [" [subject]", "c-dim"], ["
|
|
600
|
-
["✗", "c-red"], [" [
|
|
601
|
-
["✗", "c-red"], [" [
|
|
619
|
+
["✗", "c-red"], [" [subject]", "c-dim"], [" subject does not match 'type(scope): description': WIP\n ", null],
|
|
620
|
+
["✗", "c-red"], [" [signed-off]", "c-dim"], [" missing 'Signed-off-by' trailer — use 'git commit -s'\n ", null],
|
|
621
|
+
["✗", "c-red"], [" [signature]", "c-dim"], [" commit is not signed (GPG/SSH)", null],
|
|
602
622
|
],
|
|
603
623
|
[
|
|
604
624
|
["$ commit-guard --range origin/main..HEAD\n", null],
|
|
605
625
|
["abc1234", "c-dim"], [" feat: add user authentication\n ", null],
|
|
606
626
|
["✓", "c-green"], [" all checks passed\n", null],
|
|
607
|
-
["def5678", "c-dim"], ["
|
|
608
|
-
["✗", "c-red"], [" [
|
|
627
|
+
["def5678", "c-dim"], [" chore: added logging\n ", null],
|
|
628
|
+
["✗", "c-red"], [" [imperative]", "c-dim"], [" expected imperative verb, got 'added' (non-imperative suffix)\n ", null],
|
|
609
629
|
["✗", "c-red"], [" [body]", "c-dim"], [" missing body", null],
|
|
610
630
|
],
|
|
611
631
|
[
|
|
@@ -11,6 +11,7 @@ import urllib.request
|
|
|
11
11
|
from argparse import ArgumentParser
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
13
|
from enum import StrEnum
|
|
14
|
+
from http import HTTPStatus
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
16
17
|
import nltk
|
|
@@ -150,6 +151,12 @@ def _download_if_missing(resource):
|
|
|
150
151
|
nltk.download(resource.rsplit("/", maxsplit=1)[-1], quiet=True)
|
|
151
152
|
|
|
152
153
|
|
|
154
|
+
def _format_allowed_hint(allowed, kind):
|
|
155
|
+
if len(allowed) <= len(TYPES):
|
|
156
|
+
return f"(allowed: {', '.join(sorted(allowed))})"
|
|
157
|
+
return f"(see configured {kind})"
|
|
158
|
+
|
|
159
|
+
|
|
153
160
|
def _strip_comments(message):
|
|
154
161
|
return "\n".join(
|
|
155
162
|
line for line in message.split("\n") if not line.lstrip().startswith("#")
|
|
@@ -177,13 +184,16 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (9
|
|
|
177
184
|
return None
|
|
178
185
|
|
|
179
186
|
if m.group("type") not in allowed_types:
|
|
180
|
-
|
|
187
|
+
bad_type = m.group("type")
|
|
188
|
+
hint = _format_allowed_hint(allowed_types, "types")
|
|
189
|
+
result.error(f"unknown type: {bad_type} {hint}", check=Check.SUBJECT)
|
|
181
190
|
|
|
182
191
|
scope = m.group("scope")
|
|
183
192
|
if require_scope and scope is None:
|
|
184
193
|
result.error("scope is required", check=Check.SUBJECT)
|
|
185
194
|
if allowed_scopes and scope is not None and scope not in allowed_scopes:
|
|
186
|
-
|
|
195
|
+
hint = _format_allowed_hint(allowed_scopes, "scopes")
|
|
196
|
+
result.error(f"unknown scope: {scope} {hint}", check=Check.SUBJECT)
|
|
187
197
|
|
|
188
198
|
desc = m.group("desc")
|
|
189
199
|
if require_lowercase and desc[0].isupper():
|
|
@@ -248,7 +258,10 @@ def check_body(lines, result):
|
|
|
248
258
|
|
|
249
259
|
def check_signed_off(message, result):
|
|
250
260
|
if not SIGNED_OFF_RE.search(message):
|
|
251
|
-
result.error(
|
|
261
|
+
result.error(
|
|
262
|
+
"missing 'Signed-off-by' trailer — use 'git commit -s'",
|
|
263
|
+
check=Check.SIGNED_OFF,
|
|
264
|
+
)
|
|
252
265
|
|
|
253
266
|
|
|
254
267
|
def check_subject_pattern(subject, pattern, result):
|
|
@@ -387,22 +400,46 @@ def _verify_ssh(rev, email, ssh_text):
|
|
|
387
400
|
Path(signers_path).unlink(missing_ok=True)
|
|
388
401
|
|
|
389
402
|
|
|
403
|
+
def _resolve_github_username(rev, email):
|
|
404
|
+
username = None
|
|
405
|
+
commits_api_404 = False
|
|
406
|
+
remote = _get_github_remote_info()
|
|
407
|
+
if remote:
|
|
408
|
+
owner, repo = remote
|
|
409
|
+
try:
|
|
410
|
+
username = _fetch_github_commit_author(owner, repo, rev)
|
|
411
|
+
except urllib.error.HTTPError as e:
|
|
412
|
+
if e.code == HTTPStatus.NOT_FOUND:
|
|
413
|
+
commits_api_404 = True
|
|
414
|
+
elif e.code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
|
415
|
+
raise
|
|
416
|
+
except (urllib.error.URLError, TimeoutError):
|
|
417
|
+
pass
|
|
418
|
+
if username is None:
|
|
419
|
+
username = _parse_noreply_username(email)
|
|
420
|
+
if username is None:
|
|
421
|
+
username = _fetch_github_username(email)
|
|
422
|
+
return username, commits_api_404
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _author_not_found_message(commits_api_404):
|
|
426
|
+
had_token = bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"))
|
|
427
|
+
if commits_api_404 and not had_token:
|
|
428
|
+
return (
|
|
429
|
+
"commit author not found on GitHub — if the repo is private, "
|
|
430
|
+
"set GITHUB_TOKEN in the workflow step "
|
|
431
|
+
"(env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }})"
|
|
432
|
+
)
|
|
433
|
+
return "commit author not found on GitHub — cannot verify signature"
|
|
434
|
+
|
|
435
|
+
|
|
390
436
|
def check_signature(rev, result):
|
|
391
437
|
try:
|
|
392
438
|
email = _get_author_email(rev)
|
|
393
|
-
username =
|
|
394
|
-
remote = _get_github_remote_info()
|
|
395
|
-
if remote:
|
|
396
|
-
owner, repo = remote
|
|
397
|
-
with contextlib.suppress(urllib.error.URLError, TimeoutError):
|
|
398
|
-
username = _fetch_github_commit_author(owner, repo, rev)
|
|
399
|
-
if username is None:
|
|
400
|
-
username = _parse_noreply_username(email)
|
|
401
|
-
if username is None:
|
|
402
|
-
username = _fetch_github_username(email)
|
|
439
|
+
username, commits_api_404 = _resolve_github_username(rev, email)
|
|
403
440
|
if username is None:
|
|
404
441
|
result.error(
|
|
405
|
-
|
|
442
|
+
_author_not_found_message(commits_api_404),
|
|
406
443
|
check=Check.SIGNATURE,
|
|
407
444
|
)
|
|
408
445
|
return
|
|
@@ -419,6 +456,24 @@ def check_signature(rev, result):
|
|
|
419
456
|
"git operation timed out — cannot verify signature",
|
|
420
457
|
check=Check.SIGNATURE,
|
|
421
458
|
)
|
|
459
|
+
except urllib.error.HTTPError as e:
|
|
460
|
+
if e.code == HTTPStatus.UNAUTHORIZED:
|
|
461
|
+
result.error(
|
|
462
|
+
"GitHub API rejected token (HTTP 401) — "
|
|
463
|
+
"GITHUB_TOKEN may be invalid or expired",
|
|
464
|
+
check=Check.SIGNATURE,
|
|
465
|
+
)
|
|
466
|
+
elif e.code == HTTPStatus.FORBIDDEN:
|
|
467
|
+
result.error(
|
|
468
|
+
"GitHub API forbidden (HTTP 403) — GITHUB_TOKEN may lack "
|
|
469
|
+
"'repo' scope, or you are unauthenticated and rate-limited",
|
|
470
|
+
check=Check.SIGNATURE,
|
|
471
|
+
)
|
|
472
|
+
else:
|
|
473
|
+
result.error(
|
|
474
|
+
f"GitHub API error (HTTP {e.code}) — cannot verify signature",
|
|
475
|
+
check=Check.SIGNATURE,
|
|
476
|
+
)
|
|
422
477
|
except (urllib.error.URLError, TimeoutError):
|
|
423
478
|
result.error(
|
|
424
479
|
"GitHub API unreachable — cannot verify signature",
|
|
@@ -124,6 +124,25 @@ class TestCheckSubject:
|
|
|
124
124
|
check_subject("unknown: add thing", r)
|
|
125
125
|
assert not r.ok
|
|
126
126
|
|
|
127
|
+
def test_unknown_type_with_default_lists_all_allowed(self):
|
|
128
|
+
r = Result()
|
|
129
|
+
check_subject("unknown: add thing", r)
|
|
130
|
+
assert any(
|
|
131
|
+
"allowed: " in m and "feat" in m and "fix" in m for _, _, m in r.errors
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def test_unknown_type_with_smaller_set_lists_them(self):
|
|
135
|
+
r = Result()
|
|
136
|
+
check_subject("foo: add thing", r, allowed_types=frozenset({"feat", "fix"}))
|
|
137
|
+
assert any("allowed: feat, fix" in m for _, _, m in r.errors)
|
|
138
|
+
|
|
139
|
+
def test_unknown_type_with_larger_than_default_points_at_config(self):
|
|
140
|
+
r = Result()
|
|
141
|
+
oversized = frozenset({f"t{i}" for i in range(20)})
|
|
142
|
+
check_subject("foo: add thing", r, allowed_types=oversized)
|
|
143
|
+
assert any("see configured types" in m for _, _, m in r.errors)
|
|
144
|
+
assert not any("allowed:" in m for _, _, m in r.errors)
|
|
145
|
+
|
|
127
146
|
def test_uppercase_description(self):
|
|
128
147
|
r = Result()
|
|
129
148
|
check_subject("fix: Add token", r)
|
|
@@ -184,6 +203,22 @@ class TestCheckSubject:
|
|
|
184
203
|
check_subject("fix(api): add token", r, allowed_scopes=frozenset(["auth"]))
|
|
185
204
|
assert not r.ok
|
|
186
205
|
|
|
206
|
+
def test_unknown_scope_with_small_set_lists_them(self):
|
|
207
|
+
r = Result()
|
|
208
|
+
check_subject(
|
|
209
|
+
"fix(api): add token",
|
|
210
|
+
r,
|
|
211
|
+
allowed_scopes=frozenset({"auth", "db"}),
|
|
212
|
+
)
|
|
213
|
+
assert any("allowed: auth, db" in m for _, _, m in r.errors)
|
|
214
|
+
|
|
215
|
+
def test_unknown_scope_with_larger_than_default_points_at_config(self):
|
|
216
|
+
r = Result()
|
|
217
|
+
oversized = frozenset({f"s{i}" for i in range(20)})
|
|
218
|
+
check_subject("fix(foo): add token", r, allowed_scopes=oversized)
|
|
219
|
+
assert any("see configured scopes" in m for _, _, m in r.errors)
|
|
220
|
+
assert not any("allowed:" in m for _, _, m in r.errors)
|
|
221
|
+
|
|
187
222
|
def test_no_scope_with_allowlist_passes(self):
|
|
188
223
|
r = Result()
|
|
189
224
|
check_subject("fix: add token", r, allowed_scopes=frozenset(["auth"]))
|
|
@@ -325,6 +360,11 @@ class TestCheckSignedOff:
|
|
|
325
360
|
check_signed_off("fix: add thing\n\nbody", r)
|
|
326
361
|
assert not r.ok
|
|
327
362
|
|
|
363
|
+
def test_missing_message_hints_at_git_commit_dash_s(self):
|
|
364
|
+
r = Result()
|
|
365
|
+
check_signed_off("fix: add thing\n\nbody", r)
|
|
366
|
+
assert any("git commit -s" in m for _, _, m in r.errors)
|
|
367
|
+
|
|
328
368
|
def test_malformed_no_email(self):
|
|
329
369
|
r = Result()
|
|
330
370
|
check_signed_off("fix: add thing\n\nSigned-off-by: John Doe", r)
|
|
@@ -816,6 +856,157 @@ class TestCheckSignature:
|
|
|
816
856
|
assert not r.ok
|
|
817
857
|
assert any("not found on GitHub" in msg for _, _, msg in r.errors)
|
|
818
858
|
|
|
859
|
+
def test_commits_api_404_without_token_hints_at_token(self):
|
|
860
|
+
r = Result()
|
|
861
|
+
env = {
|
|
862
|
+
k: v for k, v in os.environ.items() if k not in ("GITHUB_TOKEN", "GH_TOKEN")
|
|
863
|
+
}
|
|
864
|
+
with (
|
|
865
|
+
patch(
|
|
866
|
+
"git_commit_guard._get_author_email", return_value="user@example.com"
|
|
867
|
+
),
|
|
868
|
+
patch(
|
|
869
|
+
"git_commit_guard._get_github_remote_info",
|
|
870
|
+
return_value=("owner", "repo"),
|
|
871
|
+
),
|
|
872
|
+
patch(
|
|
873
|
+
"git_commit_guard._fetch_github_commit_author",
|
|
874
|
+
side_effect=urllib.error.HTTPError(
|
|
875
|
+
url="", code=404, msg="Not Found", hdrs=None, fp=None
|
|
876
|
+
),
|
|
877
|
+
),
|
|
878
|
+
patch("git_commit_guard._fetch_github_username", return_value=None),
|
|
879
|
+
patch.dict("os.environ", env, clear=True),
|
|
880
|
+
):
|
|
881
|
+
check_signature("abc123", r)
|
|
882
|
+
assert not r.ok
|
|
883
|
+
assert any("set GITHUB_TOKEN" in msg for _, _, msg in r.errors)
|
|
884
|
+
|
|
885
|
+
def test_commits_api_404_with_token_keeps_generic_message(self):
|
|
886
|
+
r = Result()
|
|
887
|
+
with (
|
|
888
|
+
patch(
|
|
889
|
+
"git_commit_guard._get_author_email", return_value="user@example.com"
|
|
890
|
+
),
|
|
891
|
+
patch(
|
|
892
|
+
"git_commit_guard._get_github_remote_info",
|
|
893
|
+
return_value=("owner", "repo"),
|
|
894
|
+
),
|
|
895
|
+
patch(
|
|
896
|
+
"git_commit_guard._fetch_github_commit_author",
|
|
897
|
+
side_effect=urllib.error.HTTPError(
|
|
898
|
+
url="", code=404, msg="Not Found", hdrs=None, fp=None
|
|
899
|
+
),
|
|
900
|
+
),
|
|
901
|
+
patch("git_commit_guard._fetch_github_username", return_value=None),
|
|
902
|
+
patch.dict("os.environ", {"GITHUB_TOKEN": "x"}, clear=False),
|
|
903
|
+
):
|
|
904
|
+
check_signature("abc123", r)
|
|
905
|
+
assert not r.ok
|
|
906
|
+
assert not any("set GITHUB_TOKEN" in msg for _, _, msg in r.errors)
|
|
907
|
+
assert any("cannot verify signature" in msg for _, _, msg in r.errors)
|
|
908
|
+
|
|
909
|
+
def test_commits_api_non_404_http_error_falls_through(self):
|
|
910
|
+
r = Result()
|
|
911
|
+
with (
|
|
912
|
+
patch(
|
|
913
|
+
"git_commit_guard._get_author_email", return_value="user@example.com"
|
|
914
|
+
),
|
|
915
|
+
patch(
|
|
916
|
+
"git_commit_guard._get_github_remote_info",
|
|
917
|
+
return_value=("owner", "repo"),
|
|
918
|
+
),
|
|
919
|
+
patch(
|
|
920
|
+
"git_commit_guard._fetch_github_commit_author",
|
|
921
|
+
side_effect=urllib.error.HTTPError(
|
|
922
|
+
url="", code=500, msg="Server Error", hdrs=None, fp=None
|
|
923
|
+
),
|
|
924
|
+
),
|
|
925
|
+
patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
|
|
926
|
+
patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
|
|
927
|
+
patch("git_commit_guard._verify_gpg", return_value=True),
|
|
928
|
+
):
|
|
929
|
+
check_signature("abc123", r)
|
|
930
|
+
assert r.ok
|
|
931
|
+
|
|
932
|
+
def test_commits_api_401_surfaces_token_message(self):
|
|
933
|
+
r = Result()
|
|
934
|
+
with (
|
|
935
|
+
patch(
|
|
936
|
+
"git_commit_guard._get_author_email", return_value="user@example.com"
|
|
937
|
+
),
|
|
938
|
+
patch(
|
|
939
|
+
"git_commit_guard._get_github_remote_info",
|
|
940
|
+
return_value=("owner", "repo"),
|
|
941
|
+
),
|
|
942
|
+
patch(
|
|
943
|
+
"git_commit_guard._fetch_github_commit_author",
|
|
944
|
+
side_effect=urllib.error.HTTPError(
|
|
945
|
+
url="", code=401, msg="Unauthorized", hdrs=None, fp=None
|
|
946
|
+
),
|
|
947
|
+
),
|
|
948
|
+
):
|
|
949
|
+
check_signature("abc123", r)
|
|
950
|
+
assert not r.ok
|
|
951
|
+
assert any("rejected token (HTTP 401)" in msg for _, _, msg in r.errors)
|
|
952
|
+
|
|
953
|
+
def test_commits_api_403_surfaces_token_message(self):
|
|
954
|
+
r = Result()
|
|
955
|
+
with (
|
|
956
|
+
patch(
|
|
957
|
+
"git_commit_guard._get_author_email", return_value="user@example.com"
|
|
958
|
+
),
|
|
959
|
+
patch(
|
|
960
|
+
"git_commit_guard._get_github_remote_info",
|
|
961
|
+
return_value=("owner", "repo"),
|
|
962
|
+
),
|
|
963
|
+
patch(
|
|
964
|
+
"git_commit_guard._fetch_github_commit_author",
|
|
965
|
+
side_effect=urllib.error.HTTPError(
|
|
966
|
+
url="", code=403, msg="Forbidden", hdrs=None, fp=None
|
|
967
|
+
),
|
|
968
|
+
),
|
|
969
|
+
):
|
|
970
|
+
check_signature("abc123", r)
|
|
971
|
+
assert not r.ok
|
|
972
|
+
assert any("forbidden (HTTP 403)" in msg for _, _, msg in r.errors)
|
|
973
|
+
|
|
974
|
+
def test_search_api_403_surfaces_rate_limit_message(self):
|
|
975
|
+
r = Result()
|
|
976
|
+
with (
|
|
977
|
+
patch(
|
|
978
|
+
"git_commit_guard._get_author_email", return_value="corp@example.com"
|
|
979
|
+
),
|
|
980
|
+
patch("git_commit_guard._get_github_remote_info", return_value=None),
|
|
981
|
+
patch(
|
|
982
|
+
"git_commit_guard._fetch_github_username",
|
|
983
|
+
side_effect=urllib.error.HTTPError(
|
|
984
|
+
url="", code=403, msg="Forbidden", hdrs=None, fp=None
|
|
985
|
+
),
|
|
986
|
+
),
|
|
987
|
+
):
|
|
988
|
+
check_signature("abc123", r)
|
|
989
|
+
assert not r.ok
|
|
990
|
+
assert any("forbidden (HTTP 403)" in msg for _, _, msg in r.errors)
|
|
991
|
+
|
|
992
|
+
def test_other_http_error_uses_generic_http_message(self):
|
|
993
|
+
r = Result()
|
|
994
|
+
with (
|
|
995
|
+
patch(
|
|
996
|
+
"git_commit_guard._get_author_email", return_value="corp@example.com"
|
|
997
|
+
),
|
|
998
|
+
patch("git_commit_guard._get_github_remote_info", return_value=None),
|
|
999
|
+
patch(
|
|
1000
|
+
"git_commit_guard._fetch_github_username",
|
|
1001
|
+
side_effect=urllib.error.HTTPError(
|
|
1002
|
+
url="", code=500, msg="Server Error", hdrs=None, fp=None
|
|
1003
|
+
),
|
|
1004
|
+
),
|
|
1005
|
+
):
|
|
1006
|
+
check_signature("abc123", r)
|
|
1007
|
+
assert not r.ok
|
|
1008
|
+
assert any("GitHub API error (HTTP 500)" in msg for _, _, msg in r.errors)
|
|
1009
|
+
|
|
819
1010
|
def test_url_error_fails(self):
|
|
820
1011
|
r = Result()
|
|
821
1012
|
with patch(
|
|
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
|
|
File without changes
|