specshift 1.2.0__tar.gz → 1.3.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.
- {specshift-1.2.0/specshift.egg-info → specshift-1.3.0}/PKG-INFO +56 -11
- {specshift-1.2.0 → specshift-1.3.0}/README.md +54 -9
- {specshift-1.2.0 → specshift-1.3.0}/pyproject.toml +3 -1
- {specshift-1.2.0 → specshift-1.3.0}/specshift/__init__.py +4 -1
- specshift-1.3.0/specshift/changelog.py +164 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift/cli.py +50 -1
- {specshift-1.2.0 → specshift-1.3.0}/specshift/git_utils.py +63 -9
- {specshift-1.2.0 → specshift-1.3.0}/specshift/protobuf_differ.py +179 -46
- {specshift-1.2.0 → specshift-1.3.0}/specshift/reporter.py +88 -1
- {specshift-1.2.0 → specshift-1.3.0/specshift.egg-info}/PKG-INFO +56 -11
- {specshift-1.2.0 → specshift-1.3.0}/specshift.egg-info/SOURCES.txt +2 -0
- specshift-1.3.0/tests/test_changelog.py +72 -0
- {specshift-1.2.0 → specshift-1.3.0}/tests/test_cli.py +38 -0
- {specshift-1.2.0 → specshift-1.3.0}/tests/test_protobuf_differ.py +32 -0
- {specshift-1.2.0 → specshift-1.3.0}/tests/test_reporter.py +38 -0
- {specshift-1.2.0 → specshift-1.3.0}/LICENSE +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/setup.cfg +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift/ai_summary.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift/config.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift/differ.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift/graphql_differ.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift/models.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift/notifier.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift/spec_loader.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift/webapp.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift.egg-info/dependency_links.txt +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift.egg-info/entry_points.txt +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift.egg-info/requires.txt +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/specshift.egg-info/top_level.txt +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/tests/test_differ.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/tests/test_graphql_differ.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/tests/test_spec_loader.py +0 -0
- {specshift-1.2.0 → specshift-1.3.0}/tests/test_webapp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: specshift
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Detects, classifies, and optionally summarizes breaking changes in OpenAPI/Swagger, GraphQL, and Protobuf/gRPC contracts using AI
|
|
5
5
|
Author: Lethe044
|
|
6
6
|
License: MIT
|
|
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://github.com/Lethe044/specshift
|
|
|
8
8
|
Project-URL: Repository, https://github.com/Lethe044/specshift
|
|
9
9
|
Project-URL: Issues, https://github.com/Lethe044/specshift/issues
|
|
10
10
|
Project-URL: Changelog, https://github.com/Lethe044/specshift/blob/main/CHANGELOG.md
|
|
11
|
-
Keywords: openapi,swagger,graphql,grpc,protobuf,api,breaking-changes,contract-testing,api-diff,devtools,cli,ci-cd,github-actions
|
|
11
|
+
Keywords: openapi,swagger,graphql,grpc,protobuf,api,breaking-changes,contract-testing,api-diff,devtools,cli,ci-cd,github-actions,sarif,changelog
|
|
12
12
|
Classifier: Development Status :: 5 - Production/Stable
|
|
13
13
|
Classifier: Environment :: Console
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
@@ -124,9 +124,14 @@ requirement.
|
|
|
124
124
|
for comparing specs interactively (drag in files or paste text), with
|
|
125
125
|
zero extra dependencies since it runs entirely on Python's standard
|
|
126
126
|
library and stays on localhost.
|
|
127
|
-
- **
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
- **Auto-generated changelog**: `specshift changelog` walks your spec
|
|
128
|
+
file's history across git tags and produces a single Markdown document
|
|
129
|
+
summarizing what changed (and whether it was breaking) between every
|
|
130
|
+
release, plus an "Unreleased" section for the current working tree.
|
|
131
|
+
- **Five output formats**: a colored console table, a Markdown report
|
|
132
|
+
(ideal for PR comments), JSON (for integrating with other tools), a
|
|
133
|
+
standalone filterable HTML report, and SARIF 2.1.0 for GitHub Code
|
|
134
|
+
Scanning.
|
|
130
135
|
|
|
131
136
|
## Installation
|
|
132
137
|
|
|
@@ -226,7 +231,7 @@ Useful options:
|
|
|
226
231
|
|
|
227
232
|
| Option | Description |
|
|
228
233
|
|---|---|
|
|
229
|
-
| `--format console\|markdown\|json\|html` | Output format (default: console) |
|
|
234
|
+
| `--format console\|markdown\|json\|html\|sarif` | Output format (default: console) |
|
|
230
235
|
| `--output <file>` | Writes the output to a file |
|
|
231
236
|
| `--ai` | Adds a natural-language summary |
|
|
232
237
|
| `--ai-provider groq\|gemini\|openai_compatible` | Chooses the AI provider |
|
|
@@ -290,6 +295,21 @@ specshift watch https://api.example.com/openapi.json \
|
|
|
290
295
|
--slack-webhook "$SLACK_WEBHOOK_URL"
|
|
291
296
|
```
|
|
292
297
|
|
|
298
|
+
### `specshift changelog <spec-path>`
|
|
299
|
+
|
|
300
|
+
Walks the given file's history across git tags and generates a single
|
|
301
|
+
Markdown changelog, diffing each consecutive pair of tagged versions. By
|
|
302
|
+
default it also appends an "Unreleased" section comparing the latest tag
|
|
303
|
+
to the current working tree, if they differ.
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
specshift changelog openapi.yaml --output CHANGELOG-API.md
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Options: `--tag-pattern` (glob, default `*`, e.g. `v*` to only consider
|
|
310
|
+
tags starting with `v`), `--no-working-tree` to skip the "Unreleased"
|
|
311
|
+
section, and `--output` to write to a file instead of stdout.
|
|
312
|
+
|
|
293
313
|
### `specshift init`
|
|
294
314
|
|
|
295
315
|
Creates a sample `.specshift.yml` file.
|
|
@@ -330,9 +350,32 @@ jobs:
|
|
|
330
350
|
|
|
331
351
|
Available inputs: `spec-path`, `base-ref` (defaults to the PR base branch),
|
|
332
352
|
`fail-on` (`breaking` | `warning` | `none`), `ai-summary` (`true`/`false`),
|
|
333
|
-
`ai-provider`, `comment-on-pr` (`true`/`false`), `
|
|
334
|
-
`
|
|
335
|
-
`breaking-
|
|
353
|
+
`ai-provider`, `comment-on-pr` (`true`/`false`), `upload-sarif`
|
|
354
|
+
(`true`/`false`, see below), `github-token`, and `specshift-version` to
|
|
355
|
+
pin a specific release. Outputs: `has-breaking-changes`, `breaking-count`,
|
|
356
|
+
`warning-count`, `info-count`, `report-path`, and `sarif-path`.
|
|
357
|
+
|
|
358
|
+
To also surface results in GitHub's Code Scanning tab, enable
|
|
359
|
+
`upload-sarif` and grant the extra permission it needs:
|
|
360
|
+
|
|
361
|
+
```yaml
|
|
362
|
+
permissions:
|
|
363
|
+
pull-requests: write
|
|
364
|
+
security-events: write
|
|
365
|
+
|
|
366
|
+
jobs:
|
|
367
|
+
contract-check:
|
|
368
|
+
runs-on: ubuntu-latest
|
|
369
|
+
steps:
|
|
370
|
+
- uses: actions/checkout@v4
|
|
371
|
+
with:
|
|
372
|
+
fetch-depth: 0
|
|
373
|
+
|
|
374
|
+
- uses: Lethe044/SpecShift@v1
|
|
375
|
+
with:
|
|
376
|
+
spec-path: openapi.yaml
|
|
377
|
+
upload-sarif: 'true'
|
|
378
|
+
```
|
|
336
379
|
|
|
337
380
|
### Option 2: calling the CLI directly
|
|
338
381
|
|
|
@@ -418,6 +461,8 @@ judged by wire type category (see the `diff-proto` section above).
|
|
|
418
461
|
| Natural-language summary | Yes (optional) | No | No |
|
|
419
462
|
| Standalone HTML report | Yes | No | Rarely |
|
|
420
463
|
| Local interactive dashboard | Yes | No | Rarely |
|
|
464
|
+
| Auto-generated changelog across git tags | Yes | No | Rarely |
|
|
465
|
+
| GitHub Code Scanning (SARIF) integration | Yes | No | Rarely |
|
|
421
466
|
| Free to use | Fully free | Free | Usually free |
|
|
422
467
|
| CI integration | Built-in (`check` + official Action) | Manual | Varies |
|
|
423
468
|
| Live URL monitoring | Built-in (`watch`) | No | Rarely |
|
|
@@ -429,10 +474,10 @@ This project is under active development. Some planned areas:
|
|
|
429
474
|
- Path parameter pattern (regex) and discriminator-level rule refinements
|
|
430
475
|
- Deeper GraphQL support: directive-aware deprecation reasons, custom
|
|
431
476
|
scalar compatibility hints, and federation-aware schema composition
|
|
432
|
-
- Protobuf `reserved` statement awareness, so a properly reserved field
|
|
433
|
-
number isn't flagged the same way as an unreserved removal
|
|
434
477
|
- Optional shared/hosted history for the `serve` dashboard (currently
|
|
435
478
|
in-memory and per-session by design)
|
|
479
|
+
- Changelog generation from raw commit history (not just tags), for
|
|
480
|
+
repositories that don't tag every release
|
|
436
481
|
|
|
437
482
|
Feel free to open an issue if you have a feature request.
|
|
438
483
|
|
|
@@ -85,9 +85,14 @@ requirement.
|
|
|
85
85
|
for comparing specs interactively (drag in files or paste text), with
|
|
86
86
|
zero extra dependencies since it runs entirely on Python's standard
|
|
87
87
|
library and stays on localhost.
|
|
88
|
-
- **
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
- **Auto-generated changelog**: `specshift changelog` walks your spec
|
|
89
|
+
file's history across git tags and produces a single Markdown document
|
|
90
|
+
summarizing what changed (and whether it was breaking) between every
|
|
91
|
+
release, plus an "Unreleased" section for the current working tree.
|
|
92
|
+
- **Five output formats**: a colored console table, a Markdown report
|
|
93
|
+
(ideal for PR comments), JSON (for integrating with other tools), a
|
|
94
|
+
standalone filterable HTML report, and SARIF 2.1.0 for GitHub Code
|
|
95
|
+
Scanning.
|
|
91
96
|
|
|
92
97
|
## Installation
|
|
93
98
|
|
|
@@ -187,7 +192,7 @@ Useful options:
|
|
|
187
192
|
|
|
188
193
|
| Option | Description |
|
|
189
194
|
|---|---|
|
|
190
|
-
| `--format console\|markdown\|json\|html` | Output format (default: console) |
|
|
195
|
+
| `--format console\|markdown\|json\|html\|sarif` | Output format (default: console) |
|
|
191
196
|
| `--output <file>` | Writes the output to a file |
|
|
192
197
|
| `--ai` | Adds a natural-language summary |
|
|
193
198
|
| `--ai-provider groq\|gemini\|openai_compatible` | Chooses the AI provider |
|
|
@@ -251,6 +256,21 @@ specshift watch https://api.example.com/openapi.json \
|
|
|
251
256
|
--slack-webhook "$SLACK_WEBHOOK_URL"
|
|
252
257
|
```
|
|
253
258
|
|
|
259
|
+
### `specshift changelog <spec-path>`
|
|
260
|
+
|
|
261
|
+
Walks the given file's history across git tags and generates a single
|
|
262
|
+
Markdown changelog, diffing each consecutive pair of tagged versions. By
|
|
263
|
+
default it also appends an "Unreleased" section comparing the latest tag
|
|
264
|
+
to the current working tree, if they differ.
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
specshift changelog openapi.yaml --output CHANGELOG-API.md
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Options: `--tag-pattern` (glob, default `*`, e.g. `v*` to only consider
|
|
271
|
+
tags starting with `v`), `--no-working-tree` to skip the "Unreleased"
|
|
272
|
+
section, and `--output` to write to a file instead of stdout.
|
|
273
|
+
|
|
254
274
|
### `specshift init`
|
|
255
275
|
|
|
256
276
|
Creates a sample `.specshift.yml` file.
|
|
@@ -291,9 +311,32 @@ jobs:
|
|
|
291
311
|
|
|
292
312
|
Available inputs: `spec-path`, `base-ref` (defaults to the PR base branch),
|
|
293
313
|
`fail-on` (`breaking` | `warning` | `none`), `ai-summary` (`true`/`false`),
|
|
294
|
-
`ai-provider`, `comment-on-pr` (`true`/`false`), `
|
|
295
|
-
`
|
|
296
|
-
`breaking-
|
|
314
|
+
`ai-provider`, `comment-on-pr` (`true`/`false`), `upload-sarif`
|
|
315
|
+
(`true`/`false`, see below), `github-token`, and `specshift-version` to
|
|
316
|
+
pin a specific release. Outputs: `has-breaking-changes`, `breaking-count`,
|
|
317
|
+
`warning-count`, `info-count`, `report-path`, and `sarif-path`.
|
|
318
|
+
|
|
319
|
+
To also surface results in GitHub's Code Scanning tab, enable
|
|
320
|
+
`upload-sarif` and grant the extra permission it needs:
|
|
321
|
+
|
|
322
|
+
```yaml
|
|
323
|
+
permissions:
|
|
324
|
+
pull-requests: write
|
|
325
|
+
security-events: write
|
|
326
|
+
|
|
327
|
+
jobs:
|
|
328
|
+
contract-check:
|
|
329
|
+
runs-on: ubuntu-latest
|
|
330
|
+
steps:
|
|
331
|
+
- uses: actions/checkout@v4
|
|
332
|
+
with:
|
|
333
|
+
fetch-depth: 0
|
|
334
|
+
|
|
335
|
+
- uses: Lethe044/SpecShift@v1
|
|
336
|
+
with:
|
|
337
|
+
spec-path: openapi.yaml
|
|
338
|
+
upload-sarif: 'true'
|
|
339
|
+
```
|
|
297
340
|
|
|
298
341
|
### Option 2: calling the CLI directly
|
|
299
342
|
|
|
@@ -379,6 +422,8 @@ judged by wire type category (see the `diff-proto` section above).
|
|
|
379
422
|
| Natural-language summary | Yes (optional) | No | No |
|
|
380
423
|
| Standalone HTML report | Yes | No | Rarely |
|
|
381
424
|
| Local interactive dashboard | Yes | No | Rarely |
|
|
425
|
+
| Auto-generated changelog across git tags | Yes | No | Rarely |
|
|
426
|
+
| GitHub Code Scanning (SARIF) integration | Yes | No | Rarely |
|
|
382
427
|
| Free to use | Fully free | Free | Usually free |
|
|
383
428
|
| CI integration | Built-in (`check` + official Action) | Manual | Varies |
|
|
384
429
|
| Live URL monitoring | Built-in (`watch`) | No | Rarely |
|
|
@@ -390,10 +435,10 @@ This project is under active development. Some planned areas:
|
|
|
390
435
|
- Path parameter pattern (regex) and discriminator-level rule refinements
|
|
391
436
|
- Deeper GraphQL support: directive-aware deprecation reasons, custom
|
|
392
437
|
scalar compatibility hints, and federation-aware schema composition
|
|
393
|
-
- Protobuf `reserved` statement awareness, so a properly reserved field
|
|
394
|
-
number isn't flagged the same way as an unreserved removal
|
|
395
438
|
- Optional shared/hosted history for the `serve` dashboard (currently
|
|
396
439
|
in-memory and per-session by design)
|
|
440
|
+
- Changelog generation from raw commit history (not just tags), for
|
|
441
|
+
repositories that don't tag every release
|
|
397
442
|
|
|
398
443
|
Feel free to open an issue if you have a feature request.
|
|
399
444
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specshift"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.3.0"
|
|
8
8
|
description = "Detects, classifies, and optionally summarizes breaking changes in OpenAPI/Swagger, GraphQL, and Protobuf/gRPC contracts using AI"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -24,6 +24,8 @@ keywords = [
|
|
|
24
24
|
"cli",
|
|
25
25
|
"ci-cd",
|
|
26
26
|
"github-actions",
|
|
27
|
+
"sarif",
|
|
28
|
+
"changelog",
|
|
27
29
|
]
|
|
28
30
|
classifiers = [
|
|
29
31
|
"Development Status :: 5 - Production/Stable",
|
|
@@ -3,18 +3,21 @@ specshift - a CLI tool that detects, classifies, and (optionally)
|
|
|
3
3
|
summarizes changes in OpenAPI and Swagger contracts using AI.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
from specshift.changelog import build_changelog, relevant_tags
|
|
6
7
|
from specshift.differ import diff_specs
|
|
7
8
|
from specshift.graphql_differ import diff_graphql, load_sdl as load_graphql_sdl
|
|
8
9
|
from specshift.models import Change, Severity, ChangeType, DiffResult
|
|
9
10
|
from specshift.protobuf_differ import diff_protos, load_proto
|
|
10
11
|
from specshift.spec_loader import load_spec
|
|
11
12
|
|
|
12
|
-
__version__ = "1.
|
|
13
|
+
__version__ = "1.3.0"
|
|
13
14
|
|
|
14
15
|
__all__ = [
|
|
15
16
|
"diff_specs",
|
|
16
17
|
"diff_graphql",
|
|
17
18
|
"diff_protos",
|
|
19
|
+
"build_changelog",
|
|
20
|
+
"relevant_tags",
|
|
18
21
|
"load_spec",
|
|
19
22
|
"load_graphql_sdl",
|
|
20
23
|
"load_proto",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Walks a spec file's history across git tags and generates a single
|
|
3
|
+
Markdown changelog document summarizing what changed (and whether it was
|
|
4
|
+
breaking) between each consecutive release.
|
|
5
|
+
|
|
6
|
+
This turns specshift from a point-in-time diff tool into something that
|
|
7
|
+
can answer "what changed across all our releases", which is a common
|
|
8
|
+
maintenance task most API teams currently do by hand.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import fnmatch
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from specshift.differ import diff_specs
|
|
18
|
+
from specshift.git_utils import (
|
|
19
|
+
GitError,
|
|
20
|
+
current_branch,
|
|
21
|
+
file_exists_at_ref,
|
|
22
|
+
list_tags_chronological,
|
|
23
|
+
read_file_at_ref,
|
|
24
|
+
tag_date,
|
|
25
|
+
)
|
|
26
|
+
from specshift.graphql_differ import diff_graphql
|
|
27
|
+
from specshift.models import DiffResult
|
|
28
|
+
from specshift.protobuf_differ import diff_protos
|
|
29
|
+
from specshift.spec_loader import SpecLoadError, parse_spec_text
|
|
30
|
+
|
|
31
|
+
_GRAPHQL_EXTENSIONS = {".graphql", ".gql"}
|
|
32
|
+
_PROTO_EXTENSIONS = {".proto"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ChangelogError(Exception):
|
|
36
|
+
"""Raised when a changelog cannot be generated."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _detect_engine(spec_path: str) -> str:
|
|
40
|
+
suffix = Path(spec_path).suffix.lower()
|
|
41
|
+
if suffix in _GRAPHQL_EXTENSIONS:
|
|
42
|
+
return "graphql"
|
|
43
|
+
if suffix in _PROTO_EXTENSIONS:
|
|
44
|
+
return "proto"
|
|
45
|
+
return "openapi"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _diff_by_engine(engine: str, old_text: str, new_text: str) -> DiffResult:
|
|
49
|
+
if engine == "graphql":
|
|
50
|
+
return diff_graphql(old_text, new_text)
|
|
51
|
+
if engine == "proto":
|
|
52
|
+
return diff_protos(old_text, new_text)
|
|
53
|
+
old_spec = parse_spec_text(old_text, hint="old")
|
|
54
|
+
new_spec = parse_spec_text(new_text, hint="new")
|
|
55
|
+
return diff_specs(old_spec, new_spec)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def relevant_tags(spec_path: str, pattern: str = "*", repo_path: str = ".") -> list[str]:
|
|
59
|
+
"""Returns, in chronological order, the tags where the given file exists and matches the pattern."""
|
|
60
|
+
all_tags = list_tags_chronological(repo_path=repo_path)
|
|
61
|
+
matching = [tag for tag in all_tags if fnmatch.fnmatch(tag, pattern)]
|
|
62
|
+
return [tag for tag in matching if file_exists_at_ref(spec_path, tag, repo_path=repo_path)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_changelog(
|
|
66
|
+
spec_path: str,
|
|
67
|
+
tags: list[str],
|
|
68
|
+
repo_path: str = ".",
|
|
69
|
+
include_working_tree: bool = True,
|
|
70
|
+
) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Builds a single Markdown changelog document by diffing the spec file
|
|
73
|
+
across each consecutive pair of tags, and optionally a final section
|
|
74
|
+
comparing the latest tag to the current working tree state.
|
|
75
|
+
"""
|
|
76
|
+
engine = _detect_engine(spec_path)
|
|
77
|
+
|
|
78
|
+
if len(tags) < 1 and not include_working_tree:
|
|
79
|
+
raise ChangelogError(
|
|
80
|
+
f"No tags contain '{spec_path}'. Nothing to compare. "
|
|
81
|
+
"Try a different --tag-pattern, or pass --include-working to compare against the current file."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
sections: list[str] = []
|
|
85
|
+
transitions: list[tuple[str, str, str, str]] = [] # (old_label, new_label, old_text, new_text)
|
|
86
|
+
|
|
87
|
+
for old_tag, new_tag in zip(tags, tags[1:]):
|
|
88
|
+
try:
|
|
89
|
+
old_text = read_file_at_ref(spec_path, old_tag, repo_path=repo_path)
|
|
90
|
+
new_text = read_file_at_ref(spec_path, new_tag, repo_path=repo_path)
|
|
91
|
+
except GitError as exc:
|
|
92
|
+
raise ChangelogError(str(exc)) from exc
|
|
93
|
+
transitions.append((old_tag, new_tag, old_text, new_text))
|
|
94
|
+
|
|
95
|
+
if include_working_tree:
|
|
96
|
+
current_path = Path(repo_path) / spec_path
|
|
97
|
+
if current_path.exists():
|
|
98
|
+
current_text = current_path.read_text(encoding="utf-8")
|
|
99
|
+
baseline_tag = tags[-1] if tags else None
|
|
100
|
+
if baseline_tag is not None:
|
|
101
|
+
try:
|
|
102
|
+
baseline_text = read_file_at_ref(spec_path, baseline_tag, repo_path=repo_path)
|
|
103
|
+
except GitError as exc:
|
|
104
|
+
raise ChangelogError(str(exc)) from exc
|
|
105
|
+
if baseline_text.strip() != current_text.strip():
|
|
106
|
+
branch = current_branch(repo_path=repo_path)
|
|
107
|
+
transitions.append((baseline_tag, f"Unreleased ({branch})", baseline_text, current_text))
|
|
108
|
+
|
|
109
|
+
if not transitions:
|
|
110
|
+
raise ChangelogError(
|
|
111
|
+
f"Found no version transitions to compare for '{spec_path}'. "
|
|
112
|
+
"The file may not have changed across the available tags."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
116
|
+
header = [
|
|
117
|
+
f"# API Changelog: {spec_path}",
|
|
118
|
+
"",
|
|
119
|
+
f"Generated {generated_at} from {len(transitions)} version transition(s).",
|
|
120
|
+
"",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
for old_label, new_label, old_text, new_text in reversed(transitions):
|
|
124
|
+
try:
|
|
125
|
+
result = _diff_by_engine(engine, old_text, new_text)
|
|
126
|
+
except SpecLoadError as exc:
|
|
127
|
+
raise ChangelogError(f"Could not parse '{spec_path}' at '{old_label}' or '{new_label}': {exc}") from exc
|
|
128
|
+
|
|
129
|
+
sections.append(_render_section(old_label, new_label, result, repo_path=repo_path))
|
|
130
|
+
|
|
131
|
+
return "\n".join(header) + "\n" + "\n".join(sections)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _render_section(old_label: str, new_label: str, result: DiffResult, repo_path: str) -> str:
|
|
135
|
+
counts = result.summary_counts()
|
|
136
|
+
date_str = tag_date(new_label, repo_path=repo_path) if not new_label.startswith("Unreleased") else ""
|
|
137
|
+
date_suffix = f" ({date_str})" if date_str else ""
|
|
138
|
+
|
|
139
|
+
lines = [f"## {old_label} -> {new_label}{date_suffix}", ""]
|
|
140
|
+
|
|
141
|
+
if result.is_empty:
|
|
142
|
+
lines.append("No changes detected in this file between these versions.")
|
|
143
|
+
lines.append("")
|
|
144
|
+
return "\n".join(lines)
|
|
145
|
+
|
|
146
|
+
lines.append(
|
|
147
|
+
f"**{counts['breaking']} breaking, {counts['warning']} warning, {counts['info']} info** change(s)."
|
|
148
|
+
)
|
|
149
|
+
lines.append("")
|
|
150
|
+
|
|
151
|
+
for severity_label, changes in (
|
|
152
|
+
("Breaking", result.breaking_changes),
|
|
153
|
+
("Warnings", result.warnings),
|
|
154
|
+
("Additions & other changes", result.info_changes),
|
|
155
|
+
):
|
|
156
|
+
if not changes:
|
|
157
|
+
continue
|
|
158
|
+
lines.append(f"**{severity_label}:**")
|
|
159
|
+
lines.append("")
|
|
160
|
+
for change in changes:
|
|
161
|
+
lines.append(f"- `{change.location}`: {change.message}")
|
|
162
|
+
lines.append("")
|
|
163
|
+
|
|
164
|
+
return "\n".join(lines)
|
|
@@ -12,6 +12,7 @@ from typing import Optional
|
|
|
12
12
|
|
|
13
13
|
from specshift import __version__
|
|
14
14
|
from specshift.ai_summary import generate_summary
|
|
15
|
+
from specshift.changelog import ChangelogError, build_changelog, relevant_tags
|
|
15
16
|
from specshift.config import SpecShiftConfig, write_default_config
|
|
16
17
|
from specshift.differ import diff_specs
|
|
17
18
|
from specshift.git_utils import GitError, load_spec_from_git
|
|
@@ -24,6 +25,7 @@ from specshift.reporter import (
|
|
|
24
25
|
render_html_report,
|
|
25
26
|
render_json_report,
|
|
26
27
|
render_markdown_report,
|
|
28
|
+
render_sarif_report,
|
|
27
29
|
)
|
|
28
30
|
from specshift.spec_loader import SpecLoadError, load_spec
|
|
29
31
|
|
|
@@ -73,6 +75,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
73
75
|
watch_parser.add_argument("--ai", action="store_true", help="Add an AI summary to notifications")
|
|
74
76
|
watch_parser.add_argument("--ai-provider", choices=["groq", "gemini", "openai_compatible"], default=None)
|
|
75
77
|
|
|
78
|
+
changelog_parser = subparsers.add_parser(
|
|
79
|
+
"changelog", help="Generates a Markdown changelog of breaking changes across git tags"
|
|
80
|
+
)
|
|
81
|
+
changelog_parser.add_argument("spec", help="Path to the specification file (OpenAPI, GraphQL, or .proto)")
|
|
82
|
+
changelog_parser.add_argument(
|
|
83
|
+
"--tag-pattern", default="*", help="Glob pattern to filter tags, e.g. 'v*' (default: all tags)"
|
|
84
|
+
)
|
|
85
|
+
changelog_parser.add_argument(
|
|
86
|
+
"--no-working-tree",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="Don't include an 'Unreleased' section comparing the latest tag to the current file",
|
|
89
|
+
)
|
|
90
|
+
changelog_parser.add_argument("--output", default=None, help="Write the changelog to a file instead of stdout")
|
|
91
|
+
|
|
76
92
|
init_parser = subparsers.add_parser("init", help="Creates a sample .specshift.yml configuration file")
|
|
77
93
|
init_parser.add_argument("--path", default=".specshift.yml", help="Path of the file to create")
|
|
78
94
|
init_parser.add_argument("--force", action="store_true", help="Overwrite an existing file")
|
|
@@ -89,7 +105,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
89
105
|
|
|
90
106
|
def _add_common_diff_args(parser: argparse.ArgumentParser) -> None:
|
|
91
107
|
parser.add_argument(
|
|
92
|
-
"--format", choices=["console", "markdown", "json", "html"], default="console", help="Output format"
|
|
108
|
+
"--format", choices=["console", "markdown", "json", "html", "sarif"], default="console", help="Output format"
|
|
93
109
|
)
|
|
94
110
|
parser.add_argument("--output", help="Write the output to a file (stdout if omitted)")
|
|
95
111
|
parser.add_argument("--ai", action="store_true", help="Generate a natural-language AI summary")
|
|
@@ -104,6 +120,11 @@ def _add_common_diff_args(parser: argparse.ArgumentParser) -> None:
|
|
|
104
120
|
help="Determines at which severity level a non-zero exit code is returned",
|
|
105
121
|
)
|
|
106
122
|
parser.add_argument("--quiet", action="store_true", help="Only print the summary line")
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--artifact-path",
|
|
125
|
+
default=None,
|
|
126
|
+
help="File path recorded in --format sarif output (defaults to the 'new' spec argument, when available)",
|
|
127
|
+
)
|
|
107
128
|
|
|
108
129
|
|
|
109
130
|
def main(argv: Optional[list[str]] = None) -> int:
|
|
@@ -121,6 +142,8 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
121
142
|
return _run_check(args)
|
|
122
143
|
if args.command == "watch":
|
|
123
144
|
return _run_watch(args)
|
|
145
|
+
if args.command == "changelog":
|
|
146
|
+
return _run_changelog(args)
|
|
124
147
|
if args.command == "init":
|
|
125
148
|
return _run_init(args)
|
|
126
149
|
if args.command == "serve":
|
|
@@ -128,6 +151,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
128
151
|
except (SpecLoadError, GitError, GraphQLLoadError, ProtoLoadError) as exc:
|
|
129
152
|
print(f"Error: {exc}", file=sys.stderr)
|
|
130
153
|
return 2
|
|
154
|
+
except ChangelogError as exc:
|
|
155
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
156
|
+
return 2
|
|
131
157
|
except KeyboardInterrupt:
|
|
132
158
|
print("\nStopped.")
|
|
133
159
|
return 130
|
|
@@ -161,6 +187,7 @@ def _run_check(args: argparse.Namespace) -> int:
|
|
|
161
187
|
config = SpecShiftConfig.load(args.config)
|
|
162
188
|
spec_path = args.spec or config.spec_path
|
|
163
189
|
base_ref = args.base_ref or config.base_ref
|
|
190
|
+
args.spec = spec_path # resolved path, used as the SARIF artifact location if --artifact-path isn't set
|
|
164
191
|
|
|
165
192
|
old_spec = load_spec_from_git(spec_path, base_ref)
|
|
166
193
|
new_spec = load_spec(spec_path)
|
|
@@ -197,6 +224,9 @@ def _emit_result(result: DiffResult, args: argparse.Namespace) -> int:
|
|
|
197
224
|
output_text = render_markdown_report(result, ai_summary=ai_summary)
|
|
198
225
|
elif args.format == "html":
|
|
199
226
|
output_text = render_html_report(result, ai_summary=ai_summary)
|
|
227
|
+
elif args.format == "sarif":
|
|
228
|
+
artifact_path = args.artifact_path or getattr(args, "new", None) or getattr(args, "spec", None) or "specification"
|
|
229
|
+
output_text = render_sarif_report(result, artifact_path=artifact_path)
|
|
200
230
|
else:
|
|
201
231
|
output_text = None # console format is printed directly
|
|
202
232
|
|
|
@@ -215,6 +245,8 @@ def _emit_result(result: DiffResult, args: argparse.Namespace) -> int:
|
|
|
215
245
|
output_path = args.output
|
|
216
246
|
if args.format == "html" and not output_path:
|
|
217
247
|
output_path = "specshift-report.html"
|
|
248
|
+
if args.format == "sarif" and not output_path:
|
|
249
|
+
output_path = "specshift-results.sarif"
|
|
218
250
|
if output_path:
|
|
219
251
|
Path(output_path).write_text(output_text, encoding="utf-8")
|
|
220
252
|
print(f"Report written to: {output_path}")
|
|
@@ -250,6 +282,23 @@ def _run_serve(args: argparse.Namespace) -> int:
|
|
|
250
282
|
return 0
|
|
251
283
|
|
|
252
284
|
|
|
285
|
+
def _run_changelog(args: argparse.Namespace) -> int:
|
|
286
|
+
tags = relevant_tags(args.spec, pattern=args.tag_pattern)
|
|
287
|
+
markdown = build_changelog(
|
|
288
|
+
args.spec,
|
|
289
|
+
tags,
|
|
290
|
+
include_working_tree=not args.no_working_tree,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if args.output:
|
|
294
|
+
Path(args.output).write_text(markdown, encoding="utf-8")
|
|
295
|
+
print(f"Changelog written to: {args.output}")
|
|
296
|
+
else:
|
|
297
|
+
print(markdown)
|
|
298
|
+
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
253
302
|
def _run_watch(args: argparse.Namespace) -> int:
|
|
254
303
|
CACHE_DIR.mkdir(exist_ok=True)
|
|
255
304
|
cache_key = hashlib.sha256(args.url.encode("utf-8")).hexdigest()[:16]
|
|
@@ -37,22 +37,25 @@ def is_git_repo(path: str = ".") -> bool:
|
|
|
37
37
|
return False
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def
|
|
41
|
-
"""
|
|
42
|
-
Reads the content of a file at a given git reference (e.g. 'main',
|
|
43
|
-
'HEAD~1', a tag, or a commit hash) and parses it as a specification.
|
|
44
|
-
"""
|
|
40
|
+
def _resolve_git_path(file_path: str, repo_path: str = ".") -> str:
|
|
41
|
+
"""Normalizes a file path to be relative to the repository root, as git expects."""
|
|
45
42
|
relative_path = Path(file_path)
|
|
46
43
|
try:
|
|
47
|
-
# The command may be invoked from outside the repo root, so
|
|
48
|
-
# normalize the path relative to the repository root.
|
|
49
44
|
toplevel = _run_git(["rev-parse", "--show-toplevel"], cwd=repo_path).strip()
|
|
50
45
|
repo_root = Path(toplevel)
|
|
51
46
|
abs_target = (Path(repo_path).resolve() / relative_path).resolve()
|
|
52
47
|
rel_to_root = abs_target.relative_to(repo_root)
|
|
53
|
-
|
|
48
|
+
return str(rel_to_root).replace("\\", "/")
|
|
54
49
|
except (GitError, ValueError):
|
|
55
|
-
|
|
50
|
+
return str(relative_path).replace("\\", "/")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_spec_from_git(file_path: str, ref: str, repo_path: str = ".") -> dict:
|
|
54
|
+
"""
|
|
55
|
+
Reads the content of a file at a given git reference (e.g. 'main',
|
|
56
|
+
'HEAD~1', a tag, or a commit hash) and parses it as a specification.
|
|
57
|
+
"""
|
|
58
|
+
git_path = _resolve_git_path(file_path, repo_path)
|
|
56
59
|
|
|
57
60
|
try:
|
|
58
61
|
content = _run_git(["show", f"{ref}:{git_path}"], cwd=repo_path)
|
|
@@ -68,6 +71,57 @@ def load_spec_from_git(file_path: str, ref: str, repo_path: str = ".") -> dict:
|
|
|
68
71
|
raise GitError(str(exc)) from exc
|
|
69
72
|
|
|
70
73
|
|
|
74
|
+
def read_file_at_ref(file_path: str, ref: str, repo_path: str = ".") -> str:
|
|
75
|
+
"""Reads the raw text content of a file at a given git reference, without parsing it."""
|
|
76
|
+
git_path = _resolve_git_path(file_path, repo_path)
|
|
77
|
+
try:
|
|
78
|
+
return _run_git(["show", f"{ref}:{git_path}"], cwd=repo_path)
|
|
79
|
+
except GitError as exc:
|
|
80
|
+
raise GitError(
|
|
81
|
+
f"Could not read '{ref}:{git_path}'. Make sure the file path and reference are correct. "
|
|
82
|
+
f"Details: {exc}"
|
|
83
|
+
) from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def file_exists_at_ref(file_path: str, ref: str, repo_path: str = ".") -> bool:
|
|
87
|
+
"""Returns True if the given file exists at the given git reference."""
|
|
88
|
+
git_path = _resolve_git_path(file_path, repo_path)
|
|
89
|
+
try:
|
|
90
|
+
_run_git(["cat-file", "-e", f"{ref}:{git_path}"], cwd=repo_path)
|
|
91
|
+
return True
|
|
92
|
+
except GitError:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def list_tags_chronological(repo_path: str = ".") -> list[str]:
|
|
97
|
+
"""
|
|
98
|
+
Returns all git tags in chronological (creation) order, oldest first.
|
|
99
|
+
Uses for-each-ref rather than 'git tag --sort', which behaves more
|
|
100
|
+
consistently across both lightweight and annotated tags.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
output = _run_git(
|
|
104
|
+
["for-each-ref", "--sort=creatordate", "--format=%(refname:short)", "refs/tags"],
|
|
105
|
+
cwd=repo_path,
|
|
106
|
+
)
|
|
107
|
+
except GitError:
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
return [line.strip() for line in output.splitlines() if line.strip()]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def tag_date(tag: str, repo_path: str = ".") -> str:
|
|
114
|
+
"""Returns the ISO creation date of a tag, or an empty string if unavailable."""
|
|
115
|
+
try:
|
|
116
|
+
output = _run_git(
|
|
117
|
+
["for-each-ref", "--format=%(creatordate:short)", f"refs/tags/{tag}"],
|
|
118
|
+
cwd=repo_path,
|
|
119
|
+
)
|
|
120
|
+
return output.strip()
|
|
121
|
+
except GitError:
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
|
|
71
125
|
def current_branch(repo_path: str = ".") -> str:
|
|
72
126
|
try:
|
|
73
127
|
return _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path).strip()
|