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.
Files changed (33) hide show
  1. {specshift-1.2.0/specshift.egg-info → specshift-1.3.0}/PKG-INFO +56 -11
  2. {specshift-1.2.0 → specshift-1.3.0}/README.md +54 -9
  3. {specshift-1.2.0 → specshift-1.3.0}/pyproject.toml +3 -1
  4. {specshift-1.2.0 → specshift-1.3.0}/specshift/__init__.py +4 -1
  5. specshift-1.3.0/specshift/changelog.py +164 -0
  6. {specshift-1.2.0 → specshift-1.3.0}/specshift/cli.py +50 -1
  7. {specshift-1.2.0 → specshift-1.3.0}/specshift/git_utils.py +63 -9
  8. {specshift-1.2.0 → specshift-1.3.0}/specshift/protobuf_differ.py +179 -46
  9. {specshift-1.2.0 → specshift-1.3.0}/specshift/reporter.py +88 -1
  10. {specshift-1.2.0 → specshift-1.3.0/specshift.egg-info}/PKG-INFO +56 -11
  11. {specshift-1.2.0 → specshift-1.3.0}/specshift.egg-info/SOURCES.txt +2 -0
  12. specshift-1.3.0/tests/test_changelog.py +72 -0
  13. {specshift-1.2.0 → specshift-1.3.0}/tests/test_cli.py +38 -0
  14. {specshift-1.2.0 → specshift-1.3.0}/tests/test_protobuf_differ.py +32 -0
  15. {specshift-1.2.0 → specshift-1.3.0}/tests/test_reporter.py +38 -0
  16. {specshift-1.2.0 → specshift-1.3.0}/LICENSE +0 -0
  17. {specshift-1.2.0 → specshift-1.3.0}/setup.cfg +0 -0
  18. {specshift-1.2.0 → specshift-1.3.0}/specshift/ai_summary.py +0 -0
  19. {specshift-1.2.0 → specshift-1.3.0}/specshift/config.py +0 -0
  20. {specshift-1.2.0 → specshift-1.3.0}/specshift/differ.py +0 -0
  21. {specshift-1.2.0 → specshift-1.3.0}/specshift/graphql_differ.py +0 -0
  22. {specshift-1.2.0 → specshift-1.3.0}/specshift/models.py +0 -0
  23. {specshift-1.2.0 → specshift-1.3.0}/specshift/notifier.py +0 -0
  24. {specshift-1.2.0 → specshift-1.3.0}/specshift/spec_loader.py +0 -0
  25. {specshift-1.2.0 → specshift-1.3.0}/specshift/webapp.py +0 -0
  26. {specshift-1.2.0 → specshift-1.3.0}/specshift.egg-info/dependency_links.txt +0 -0
  27. {specshift-1.2.0 → specshift-1.3.0}/specshift.egg-info/entry_points.txt +0 -0
  28. {specshift-1.2.0 → specshift-1.3.0}/specshift.egg-info/requires.txt +0 -0
  29. {specshift-1.2.0 → specshift-1.3.0}/specshift.egg-info/top_level.txt +0 -0
  30. {specshift-1.2.0 → specshift-1.3.0}/tests/test_differ.py +0 -0
  31. {specshift-1.2.0 → specshift-1.3.0}/tests/test_graphql_differ.py +0 -0
  32. {specshift-1.2.0 → specshift-1.3.0}/tests/test_spec_loader.py +0 -0
  33. {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.2.0
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
- - **Four output formats**: a colored console table, a Markdown report
128
- (ideal for PR comments), JSON (for integrating with other tools), and a
129
- standalone, filterable HTML report you can open in any browser.
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`), `github-token`, and
334
- `specshift-version` to pin a specific release. Outputs: `has-breaking-changes`,
335
- `breaking-count`, `warning-count`, `info-count`, and `report-path`.
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
- - **Four output formats**: a colored console table, a Markdown report
89
- (ideal for PR comments), JSON (for integrating with other tools), and a
90
- standalone, filterable HTML report you can open in any browser.
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`), `github-token`, and
295
- `specshift-version` to pin a specific release. Outputs: `has-breaking-changes`,
296
- `breaking-count`, `warning-count`, `info-count`, and `report-path`.
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.2.0"
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.2.0"
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 load_spec_from_git(file_path: str, ref: str, repo_path: str = ".") -> dict:
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
- git_path = str(rel_to_root).replace("\\", "/")
48
+ return str(rel_to_root).replace("\\", "/")
54
49
  except (GitError, ValueError):
55
- git_path = str(relative_path).replace("\\", "/")
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()