specshift 1.0.0__py3-none-any.whl
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/__init__.py +20 -0
- specshift/ai_summary.py +201 -0
- specshift/cli.py +256 -0
- specshift/config.py +79 -0
- specshift/differ.py +721 -0
- specshift/git_utils.py +82 -0
- specshift/models.py +110 -0
- specshift/notifier.py +66 -0
- specshift/reporter.py +167 -0
- specshift/spec_loader.py +122 -0
- specshift-1.0.0.dist-info/METADATA +340 -0
- specshift-1.0.0.dist-info/RECORD +16 -0
- specshift-1.0.0.dist-info/WHEEL +5 -0
- specshift-1.0.0.dist-info/entry_points.txt +2 -0
- specshift-1.0.0.dist-info/licenses/LICENSE +21 -0
- specshift-1.0.0.dist-info/top_level.txt +1 -0
specshift/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
specshift - a CLI tool that detects, classifies, and (optionally)
|
|
3
|
+
summarizes changes in OpenAPI and Swagger contracts using AI.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from specshift.differ import diff_specs
|
|
7
|
+
from specshift.models import Change, Severity, ChangeType, DiffResult
|
|
8
|
+
from specshift.spec_loader import load_spec
|
|
9
|
+
|
|
10
|
+
__version__ = "1.0.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"diff_specs",
|
|
14
|
+
"load_spec",
|
|
15
|
+
"Change",
|
|
16
|
+
"Severity",
|
|
17
|
+
"ChangeType",
|
|
18
|
+
"DiffResult",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
specshift/ai_summary.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generates a natural-language summary from a DiffResult.
|
|
3
|
+
|
|
4
|
+
This module is entirely optional. If neither GROQ_API_KEY nor GEMINI_API_KEY
|
|
5
|
+
is set, a rule-based summary is produced without making any network calls.
|
|
6
|
+
This keeps the project fully functional without depending on any paid
|
|
7
|
+
service.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from specshift.models import DiffResult, Severity
|
|
18
|
+
|
|
19
|
+
GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
|
20
|
+
GROQ_DEFAULT_MODEL = "llama-3.3-70b-versatile"
|
|
21
|
+
|
|
22
|
+
GEMINI_API_URL_TEMPLATE = (
|
|
23
|
+
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
|
|
24
|
+
)
|
|
25
|
+
GEMINI_DEFAULT_MODEL = "gemini-2.0-flash"
|
|
26
|
+
|
|
27
|
+
OPENAI_COMPATIBLE_URL_ENV = "SPECSHIFT_OPENAI_BASE_URL"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AISummaryError(Exception):
|
|
31
|
+
"""Raised when the AI summary could not be generated."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_summary(
|
|
35
|
+
result: DiffResult,
|
|
36
|
+
provider: Optional[str] = None,
|
|
37
|
+
model: Optional[str] = None,
|
|
38
|
+
timeout: int = 30,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Returns a natural-language summary using a configured AI provider when
|
|
42
|
+
possible, otherwise falls back to a rule-based summary. Never raises an
|
|
43
|
+
exception that would halt execution; failures fall back silently.
|
|
44
|
+
"""
|
|
45
|
+
prompt = _build_prompt(result)
|
|
46
|
+
|
|
47
|
+
chosen_provider = provider or _detect_provider()
|
|
48
|
+
|
|
49
|
+
if chosen_provider == "groq" and os.environ.get("GROQ_API_KEY"):
|
|
50
|
+
try:
|
|
51
|
+
return _call_groq(prompt, model or GROQ_DEFAULT_MODEL, timeout)
|
|
52
|
+
except AISummaryError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
if chosen_provider == "gemini" and os.environ.get("GEMINI_API_KEY"):
|
|
56
|
+
try:
|
|
57
|
+
return _call_gemini(prompt, model or GEMINI_DEFAULT_MODEL, timeout)
|
|
58
|
+
except AISummaryError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
if chosen_provider == "openai_compatible" and os.environ.get("SPECSHIFT_API_KEY"):
|
|
62
|
+
try:
|
|
63
|
+
return _call_openai_compatible(prompt, model, timeout)
|
|
64
|
+
except AISummaryError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
return build_template_summary(result)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _detect_provider() -> Optional[str]:
|
|
71
|
+
if os.environ.get("GROQ_API_KEY"):
|
|
72
|
+
return "groq"
|
|
73
|
+
if os.environ.get("GEMINI_API_KEY"):
|
|
74
|
+
return "gemini"
|
|
75
|
+
if os.environ.get("SPECSHIFT_API_KEY") and os.environ.get(OPENAI_COMPATIBLE_URL_ENV):
|
|
76
|
+
return "openai_compatible"
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_prompt(result: DiffResult) -> str:
|
|
81
|
+
counts = result.summary_counts()
|
|
82
|
+
lines = [
|
|
83
|
+
"Below is a list of contract changes detected between two versions "
|
|
84
|
+
"of an API. Read this list and write a short, clear, professional "
|
|
85
|
+
"summary in English that a developer consuming this API would "
|
|
86
|
+
"understand. First highlight the most important breaking changes, "
|
|
87
|
+
"then anything worth paying attention to, and finally briefly "
|
|
88
|
+
"mention minor additions. Do not use lists or headings, write in "
|
|
89
|
+
"flowing paragraphs. Do not invent information, base the summary "
|
|
90
|
+
"only on the changes given.",
|
|
91
|
+
"",
|
|
92
|
+
f"Total: {counts['breaking']} breaking, {counts['warning']} warning, {counts['info']} informational changes.",
|
|
93
|
+
"",
|
|
94
|
+
]
|
|
95
|
+
for change in result.sorted_changes()[:60]:
|
|
96
|
+
lines.append(f"- [{change.severity.value}] {change.location}: {change.message}")
|
|
97
|
+
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _call_groq(prompt: str, model: str, timeout: int) -> str:
|
|
102
|
+
api_key = os.environ["GROQ_API_KEY"]
|
|
103
|
+
try:
|
|
104
|
+
response = requests.post(
|
|
105
|
+
GROQ_API_URL,
|
|
106
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
107
|
+
json={
|
|
108
|
+
"model": model,
|
|
109
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
110
|
+
"temperature": 0.3,
|
|
111
|
+
"max_tokens": 700,
|
|
112
|
+
},
|
|
113
|
+
timeout=timeout,
|
|
114
|
+
)
|
|
115
|
+
response.raise_for_status()
|
|
116
|
+
data = response.json()
|
|
117
|
+
return data["choices"][0]["message"]["content"].strip()
|
|
118
|
+
except (requests.RequestException, KeyError, IndexError, ValueError) as exc:
|
|
119
|
+
raise AISummaryError(f"Groq request failed: {exc}") from exc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _call_gemini(prompt: str, model: str, timeout: int) -> str:
|
|
123
|
+
api_key = os.environ["GEMINI_API_KEY"]
|
|
124
|
+
url = GEMINI_API_URL_TEMPLATE.format(model=model, key=api_key)
|
|
125
|
+
try:
|
|
126
|
+
response = requests.post(
|
|
127
|
+
url,
|
|
128
|
+
json={
|
|
129
|
+
"contents": [{"parts": [{"text": prompt}]}],
|
|
130
|
+
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 700},
|
|
131
|
+
},
|
|
132
|
+
timeout=timeout,
|
|
133
|
+
)
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
data = response.json()
|
|
136
|
+
return data["candidates"][0]["content"]["parts"][0]["text"].strip()
|
|
137
|
+
except (requests.RequestException, KeyError, IndexError, ValueError) as exc:
|
|
138
|
+
raise AISummaryError(f"Gemini request failed: {exc}") from exc
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _call_openai_compatible(prompt: str, model: Optional[str], timeout: int) -> str:
|
|
142
|
+
api_key = os.environ["SPECSHIFT_API_KEY"]
|
|
143
|
+
base_url = os.environ[OPENAI_COMPATIBLE_URL_ENV].rstrip("/")
|
|
144
|
+
try:
|
|
145
|
+
response = requests.post(
|
|
146
|
+
f"{base_url}/chat/completions",
|
|
147
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
148
|
+
json={
|
|
149
|
+
"model": model or "default",
|
|
150
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
151
|
+
"temperature": 0.3,
|
|
152
|
+
"max_tokens": 700,
|
|
153
|
+
},
|
|
154
|
+
timeout=timeout,
|
|
155
|
+
)
|
|
156
|
+
response.raise_for_status()
|
|
157
|
+
data = response.json()
|
|
158
|
+
return data["choices"][0]["message"]["content"].strip()
|
|
159
|
+
except (requests.RequestException, KeyError, IndexError, ValueError) as exc:
|
|
160
|
+
raise AISummaryError(f"Custom endpoint request failed: {exc}") from exc
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_template_summary(result: DiffResult) -> str:
|
|
164
|
+
"""A deterministic, rule-based summary that works without any API key."""
|
|
165
|
+
counts = result.summary_counts()
|
|
166
|
+
|
|
167
|
+
if result.is_empty:
|
|
168
|
+
return "No differences were detected between the two specifications."
|
|
169
|
+
|
|
170
|
+
parts: list[str] = []
|
|
171
|
+
|
|
172
|
+
if counts["breaking"] > 0:
|
|
173
|
+
top_breaking = result.breaking_changes[:5]
|
|
174
|
+
breaking_desc = "; ".join(f"{c.location} ({c.message.rstrip('.')})" for c in top_breaking)
|
|
175
|
+
extra = counts["breaking"] - len(top_breaking)
|
|
176
|
+
extra_note = f" and {extra} more breaking change(s)" if extra > 0 else ""
|
|
177
|
+
parts.append(
|
|
178
|
+
f"This update contains {counts['breaking']} breaking change(s) and may affect "
|
|
179
|
+
f"existing API consumers. Notably: {breaking_desc}{extra_note}. "
|
|
180
|
+
"Clients using this API should review these points before migrating."
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
parts.append(
|
|
184
|
+
"This update does not contain any breaking changes, existing clients "
|
|
185
|
+
"can continue working without any additional action."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if counts["warning"] > 0:
|
|
189
|
+
parts.append(
|
|
190
|
+
f"There are also {counts['warning']} change(s) worth paying attention to; "
|
|
191
|
+
"these are not directly breaking but may cause behavioral differences "
|
|
192
|
+
"in some usage scenarios."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if counts["info"] > 0:
|
|
196
|
+
parts.append(
|
|
197
|
+
f"The remaining {counts['info']} change(s) are new features or improvements "
|
|
198
|
+
"and do not pose a risk to existing integrations."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return " ".join(parts)
|
specshift/cli.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""SpecShift command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from specshift import __version__
|
|
14
|
+
from specshift.ai_summary import generate_summary
|
|
15
|
+
from specshift.config import SpecShiftConfig, write_default_config
|
|
16
|
+
from specshift.differ import diff_specs
|
|
17
|
+
from specshift.git_utils import GitError, load_spec_from_git
|
|
18
|
+
from specshift.models import DiffResult
|
|
19
|
+
from specshift.notifier import NotifyError, notify_discord, notify_slack
|
|
20
|
+
from specshift.reporter import print_console_report, render_json_report, render_markdown_report
|
|
21
|
+
from specshift.spec_loader import SpecLoadError, load_spec
|
|
22
|
+
|
|
23
|
+
CACHE_DIR = Path(".specshift_cache")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="specshift",
|
|
29
|
+
description="Detects and classifies changes in OpenAPI/Swagger specifications.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"specshift {__version__}")
|
|
32
|
+
|
|
33
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
34
|
+
|
|
35
|
+
diff_parser = subparsers.add_parser("diff", help="Compare two specifications")
|
|
36
|
+
diff_parser.add_argument("old", help="Old specification: file path or URL")
|
|
37
|
+
diff_parser.add_argument("new", help="New specification: file path or URL")
|
|
38
|
+
_add_common_diff_args(diff_parser)
|
|
39
|
+
|
|
40
|
+
check_parser = subparsers.add_parser(
|
|
41
|
+
"check", help="For CI: compares the current specification against a git reference"
|
|
42
|
+
)
|
|
43
|
+
check_parser.add_argument("--spec", help="Path to the specification file (overrides config)")
|
|
44
|
+
check_parser.add_argument("--base-ref", help="Git reference to compare against (overrides config)")
|
|
45
|
+
check_parser.add_argument("--config", default=None, help="Path to the configuration file")
|
|
46
|
+
_add_common_diff_args(check_parser)
|
|
47
|
+
|
|
48
|
+
watch_parser = subparsers.add_parser(
|
|
49
|
+
"watch", help="Periodically watches a URL and sends a notification when it changes"
|
|
50
|
+
)
|
|
51
|
+
watch_parser.add_argument("url", help="URL of the OpenAPI specification to watch")
|
|
52
|
+
watch_parser.add_argument("--interval", type=int, default=300, help="Polling interval in seconds, default 300")
|
|
53
|
+
watch_parser.add_argument("--once", action="store_true", help="Check only once and exit")
|
|
54
|
+
watch_parser.add_argument("--slack-webhook", help="Slack webhook URL")
|
|
55
|
+
watch_parser.add_argument("--discord-webhook", help="Discord webhook URL")
|
|
56
|
+
watch_parser.add_argument("--ai", action="store_true", help="Add an AI summary to notifications")
|
|
57
|
+
watch_parser.add_argument("--ai-provider", choices=["groq", "gemini", "openai_compatible"], default=None)
|
|
58
|
+
|
|
59
|
+
init_parser = subparsers.add_parser("init", help="Creates a sample .specshift.yml configuration file")
|
|
60
|
+
init_parser.add_argument("--path", default=".specshift.yml", help="Path of the file to create")
|
|
61
|
+
init_parser.add_argument("--force", action="store_true", help="Overwrite an existing file")
|
|
62
|
+
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _add_common_diff_args(parser: argparse.ArgumentParser) -> None:
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--format", choices=["console", "markdown", "json"], default="console", help="Output format"
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument("--output", help="Write the output to a file (stdout if omitted)")
|
|
71
|
+
parser.add_argument("--ai", action="store_true", help="Generate a natural-language AI summary")
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--ai-provider", choices=["groq", "gemini", "openai_compatible"], default=None, help="AI provider to use"
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument("--ai-model", default=None, help="Name of the AI model to use")
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--fail-on",
|
|
78
|
+
choices=["breaking", "warning", "none"],
|
|
79
|
+
default="breaking",
|
|
80
|
+
help="Determines at which severity level a non-zero exit code is returned",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument("--quiet", action="store_true", help="Only print the summary line")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
86
|
+
parser = build_parser()
|
|
87
|
+
args = parser.parse_args(argv)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
if args.command == "diff":
|
|
91
|
+
return _run_diff(args)
|
|
92
|
+
if args.command == "check":
|
|
93
|
+
return _run_check(args)
|
|
94
|
+
if args.command == "watch":
|
|
95
|
+
return _run_watch(args)
|
|
96
|
+
if args.command == "init":
|
|
97
|
+
return _run_init(args)
|
|
98
|
+
except (SpecLoadError, GitError) as exc:
|
|
99
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
except KeyboardInterrupt:
|
|
102
|
+
print("\nStopped.")
|
|
103
|
+
return 130
|
|
104
|
+
|
|
105
|
+
parser.print_help()
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run_diff(args: argparse.Namespace) -> int:
|
|
110
|
+
old_spec = load_spec(args.old)
|
|
111
|
+
new_spec = load_spec(args.new)
|
|
112
|
+
result = diff_specs(old_spec, new_spec)
|
|
113
|
+
return _emit_result(result, args)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run_check(args: argparse.Namespace) -> int:
|
|
117
|
+
config = SpecShiftConfig.load(args.config)
|
|
118
|
+
spec_path = args.spec or config.spec_path
|
|
119
|
+
base_ref = args.base_ref or config.base_ref
|
|
120
|
+
|
|
121
|
+
old_spec = load_spec_from_git(spec_path, base_ref)
|
|
122
|
+
new_spec = load_spec(spec_path)
|
|
123
|
+
|
|
124
|
+
result = diff_specs(old_spec, new_spec)
|
|
125
|
+
|
|
126
|
+
if not args.ai_provider and config.ai_provider:
|
|
127
|
+
args.ai_provider = config.ai_provider
|
|
128
|
+
if not args.ai_model and config.ai_model:
|
|
129
|
+
args.ai_model = config.ai_model
|
|
130
|
+
if args.fail_on == "breaking" and config.fail_on != "breaking":
|
|
131
|
+
args.fail_on = config.fail_on
|
|
132
|
+
|
|
133
|
+
exit_code = _emit_result(result, args)
|
|
134
|
+
|
|
135
|
+
if config.slack_webhook or config.discord_webhook:
|
|
136
|
+
ai_summary = None
|
|
137
|
+
if args.ai:
|
|
138
|
+
ai_summary = generate_summary(result, provider=args.ai_provider, model=args.ai_model)
|
|
139
|
+
_send_notifications(result, config.slack_webhook, config.discord_webhook, ai_summary)
|
|
140
|
+
|
|
141
|
+
return exit_code
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _emit_result(result: DiffResult, args: argparse.Namespace) -> int:
|
|
145
|
+
ai_summary = None
|
|
146
|
+
if args.ai:
|
|
147
|
+
ai_summary = generate_summary(result, provider=args.ai_provider, model=args.ai_model)
|
|
148
|
+
|
|
149
|
+
output_text: str
|
|
150
|
+
if args.format == "json":
|
|
151
|
+
output_text = render_json_report(result, ai_summary=ai_summary)
|
|
152
|
+
elif args.format == "markdown":
|
|
153
|
+
output_text = render_markdown_report(result, ai_summary=ai_summary)
|
|
154
|
+
else:
|
|
155
|
+
output_text = None # console format is printed directly
|
|
156
|
+
|
|
157
|
+
if args.format == "console":
|
|
158
|
+
if args.output:
|
|
159
|
+
from specshift.reporter import render_plain_text_report
|
|
160
|
+
|
|
161
|
+
Path(args.output).write_text(render_plain_text_report(result), encoding="utf-8")
|
|
162
|
+
if not args.quiet:
|
|
163
|
+
print(f"Report written to: {args.output}")
|
|
164
|
+
else:
|
|
165
|
+
print_console_report(result, verbose=not args.quiet)
|
|
166
|
+
if ai_summary:
|
|
167
|
+
print(f"\nAI Summary:\n{ai_summary}")
|
|
168
|
+
else:
|
|
169
|
+
if args.output:
|
|
170
|
+
Path(args.output).write_text(output_text, encoding="utf-8")
|
|
171
|
+
print(f"Report written to: {args.output}")
|
|
172
|
+
else:
|
|
173
|
+
print(output_text)
|
|
174
|
+
|
|
175
|
+
return _exit_code_for(result, args.fail_on)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _exit_code_for(result: DiffResult, fail_on: str) -> int:
|
|
179
|
+
if fail_on == "none":
|
|
180
|
+
return 0
|
|
181
|
+
if fail_on == "warning":
|
|
182
|
+
return 1 if (result.has_breaking_changes or result.warnings) else 0
|
|
183
|
+
return 1 if result.has_breaking_changes else 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _run_init(args: argparse.Namespace) -> int:
|
|
187
|
+
target = Path(args.path)
|
|
188
|
+
if target.exists() and not args.force:
|
|
189
|
+
print(f"'{target}' already exists. Use --force to overwrite it.", file=sys.stderr)
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
write_default_config(str(target))
|
|
193
|
+
print(f"Configuration file created: {target}")
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _run_watch(args: argparse.Namespace) -> int:
|
|
198
|
+
CACHE_DIR.mkdir(exist_ok=True)
|
|
199
|
+
cache_key = hashlib.sha256(args.url.encode("utf-8")).hexdigest()[:16]
|
|
200
|
+
cache_file = CACHE_DIR / f"{cache_key}.json"
|
|
201
|
+
|
|
202
|
+
while True:
|
|
203
|
+
try:
|
|
204
|
+
current_spec = load_spec(args.url)
|
|
205
|
+
except SpecLoadError as exc:
|
|
206
|
+
print(f"Warning: could not fetch specification ({exc}), will retry.", file=sys.stderr)
|
|
207
|
+
if args.once:
|
|
208
|
+
return 2
|
|
209
|
+
time.sleep(args.interval)
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
if cache_file.exists():
|
|
213
|
+
previous_spec = json.loads(cache_file.read_text(encoding="utf-8"))
|
|
214
|
+
result = diff_specs(previous_spec, current_spec)
|
|
215
|
+
|
|
216
|
+
if not result.is_empty:
|
|
217
|
+
print_console_report(result)
|
|
218
|
+
ai_summary = None
|
|
219
|
+
if args.ai:
|
|
220
|
+
ai_summary = generate_summary(result, provider=args.ai_provider)
|
|
221
|
+
|
|
222
|
+
_send_notifications(result, args.slack_webhook, args.discord_webhook, ai_summary)
|
|
223
|
+
else:
|
|
224
|
+
print("No changes found.")
|
|
225
|
+
else:
|
|
226
|
+
print(f"Initial snapshot saved: {args.url}")
|
|
227
|
+
|
|
228
|
+
cache_file.write_text(json.dumps(current_spec), encoding="utf-8")
|
|
229
|
+
|
|
230
|
+
if args.once:
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
time.sleep(args.interval)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _send_notifications(
|
|
237
|
+
result: DiffResult, slack_webhook: Optional[str], discord_webhook: Optional[str], ai_summary: Optional[str]
|
|
238
|
+
) -> None:
|
|
239
|
+
if result.is_empty:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
if slack_webhook:
|
|
243
|
+
try:
|
|
244
|
+
notify_slack(slack_webhook, result, ai_summary=ai_summary)
|
|
245
|
+
except NotifyError as exc:
|
|
246
|
+
print(f"Failed to send Slack notification: {exc}", file=sys.stderr)
|
|
247
|
+
|
|
248
|
+
if discord_webhook:
|
|
249
|
+
try:
|
|
250
|
+
notify_discord(discord_webhook, result, ai_summary=ai_summary)
|
|
251
|
+
except NotifyError as exc:
|
|
252
|
+
print(f"Failed to send Discord notification: {exc}", file=sys.stderr)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
sys.exit(main())
|
specshift/config.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Reads the '.specshift.yml' configuration file and merges it with defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG_FILENAMES = (".specshift.yml", ".specshift.yaml")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SpecShiftConfig:
|
|
16
|
+
spec_path: str = "openapi.yaml"
|
|
17
|
+
base_ref: str = "main"
|
|
18
|
+
fail_on: str = "breaking" # breaking | warning | none
|
|
19
|
+
ai_provider: Optional[str] = None
|
|
20
|
+
ai_model: Optional[str] = None
|
|
21
|
+
slack_webhook: Optional[str] = None
|
|
22
|
+
discord_webhook: Optional[str] = None
|
|
23
|
+
ignore_paths: list = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def load(cls, path: Optional[str] = None) -> "SpecShiftConfig":
|
|
27
|
+
candidates = [path] if path else list(DEFAULT_CONFIG_FILENAMES)
|
|
28
|
+
|
|
29
|
+
for candidate in candidates:
|
|
30
|
+
if not candidate:
|
|
31
|
+
continue
|
|
32
|
+
file_path = Path(candidate)
|
|
33
|
+
if file_path.exists():
|
|
34
|
+
data = yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
|
|
35
|
+
return cls._from_dict(data)
|
|
36
|
+
|
|
37
|
+
return cls()
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def _from_dict(cls, data: dict) -> "SpecShiftConfig":
|
|
41
|
+
return cls(
|
|
42
|
+
spec_path=data.get("spec_path", cls.spec_path),
|
|
43
|
+
base_ref=data.get("base_ref", cls.base_ref),
|
|
44
|
+
fail_on=data.get("fail_on", cls.fail_on),
|
|
45
|
+
ai_provider=data.get("ai_provider"),
|
|
46
|
+
ai_model=data.get("ai_model"),
|
|
47
|
+
slack_webhook=data.get("slack_webhook"),
|
|
48
|
+
discord_webhook=data.get("discord_webhook"),
|
|
49
|
+
ignore_paths=data.get("ignore_paths", []),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_default_config(path: str = ".specshift.yml") -> None:
|
|
54
|
+
content = """\
|
|
55
|
+
# specshift configuration file
|
|
56
|
+
# For detailed documentation, see: https://github.com/Lethe044/specshift
|
|
57
|
+
|
|
58
|
+
# Path to the OpenAPI/Swagger file to track (relative to the repo root)
|
|
59
|
+
spec_path: openapi.yaml
|
|
60
|
+
|
|
61
|
+
# The base git reference to compare against (branch, tag, or commit)
|
|
62
|
+
base_ref: main
|
|
63
|
+
|
|
64
|
+
# Determines at which severity level the CI build should fail: breaking | warning | none
|
|
65
|
+
fail_on: breaking
|
|
66
|
+
|
|
67
|
+
# Optional: AI provider for the natural-language summary (groq | gemini | openai_compatible)
|
|
68
|
+
# The API key must be provided as an environment variable: GROQ_API_KEY or GEMINI_API_KEY
|
|
69
|
+
# ai_provider: groq
|
|
70
|
+
# ai_model: llama-3.3-70b-versatile
|
|
71
|
+
|
|
72
|
+
# Optional: notification webhook URLs
|
|
73
|
+
# slack_webhook: https://hooks.slack.com/services/...
|
|
74
|
+
# discord_webhook: https://discord.com/api/webhooks/...
|
|
75
|
+
|
|
76
|
+
# Path patterns to ignore during the diff (optional)
|
|
77
|
+
ignore_paths: []
|
|
78
|
+
"""
|
|
79
|
+
Path(path).write_text(content, encoding="utf-8")
|