diffrag 0.2.3__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.
- diffrag/__init__.py +101 -0
- diffrag/__main__.py +3 -0
- diffrag/ai/__init__.py +15 -0
- diffrag/ai/base.py +106 -0
- diffrag/ai/ollama.py +144 -0
- diffrag/ai/openai_compat.py +193 -0
- diffrag/cli/__init__.py +5 -0
- diffrag/cli/main.py +440 -0
- diffrag/config/__init__.py +21 -0
- diffrag/config/settings.py +190 -0
- diffrag/data/config.example.toml +53 -0
- diffrag/diff/__init__.py +11 -0
- diffrag/diff/git.py +227 -0
- diffrag/diff/models.py +76 -0
- diffrag/exceptions.py +21 -0
- diffrag/indexing/__init__.py +5 -0
- diffrag/indexing/indexer.py +387 -0
- diffrag/indexing/splitter.py +296 -0
- diffrag/py.typed +0 -0
- diffrag/review/__init__.py +5 -0
- diffrag/review/prompts.py +75 -0
- diffrag/review/reviewer.py +322 -0
- diffrag-0.2.3.dist-info/METADATA +425 -0
- diffrag-0.2.3.dist-info/RECORD +26 -0
- diffrag-0.2.3.dist-info/WHEEL +4 -0
- diffrag-0.2.3.dist-info/entry_points.txt +3 -0
diffrag/cli/main.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Entry-point for the ``diffrag`` CLI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import dataclasses
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.markdown import Markdown
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from ..ai.openai_compat import OpenAICompatClient
|
|
18
|
+
from ..config.settings import Settings, default_db_path, user_config_path
|
|
19
|
+
from ..diff.git import GitRepository
|
|
20
|
+
from ..exceptions import AICodeReviewerError
|
|
21
|
+
from ..indexing.indexer import RepoIndexer
|
|
22
|
+
from ..review.reviewer import CodeReviewer
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
err_console = Console(stderr=True, style="bold red")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Helpers
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_settings(config: str | None) -> Settings:
|
|
34
|
+
"""Load settings using a three-tier lookup (first match wins).
|
|
35
|
+
|
|
36
|
+
1. ``--config <path>`` — explicit CLI flag
|
|
37
|
+
2. ``.diffrag.toml`` in the current working directory
|
|
38
|
+
3. :func:`~diffrag.config.settings.user_config_path` — OS user config dir
|
|
39
|
+
4. Built-in defaults
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
config: Optional path to a ``.toml`` configuration file.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Resolved :class:`~diffrag.config.Settings` instance.
|
|
46
|
+
"""
|
|
47
|
+
if config:
|
|
48
|
+
return Settings.from_toml(Path(config))
|
|
49
|
+
project = Path(".diffrag.toml")
|
|
50
|
+
if project.exists():
|
|
51
|
+
return Settings.from_toml(project)
|
|
52
|
+
user = user_config_path()
|
|
53
|
+
if user.exists():
|
|
54
|
+
return Settings.from_toml(user)
|
|
55
|
+
return Settings.from_defaults()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _build_reviewer(
|
|
59
|
+
settings: Settings,
|
|
60
|
+
repo_path: Path,
|
|
61
|
+
verbosity: str = "standard",
|
|
62
|
+
) -> tuple[CodeReviewer, GitRepository | None]:
|
|
63
|
+
"""Instantiate the :class:`CodeReviewer` from *settings*.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
settings: Resolved settings object.
|
|
67
|
+
repo_path: Canonical path of the repository being reviewed.
|
|
68
|
+
Used to derive the per-repository database location.
|
|
69
|
+
verbosity: Detail level for generated reviews.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A tuple of ``(CodeReviewer, None)``; repository is opened separately.
|
|
73
|
+
"""
|
|
74
|
+
ai_client = OpenAICompatClient(
|
|
75
|
+
base_url=settings.ai.base_url,
|
|
76
|
+
model=settings.ai.model,
|
|
77
|
+
api_key=settings.ai.api_key,
|
|
78
|
+
timeout=settings.ai.timeout,
|
|
79
|
+
)
|
|
80
|
+
embed_client = OpenAICompatClient(
|
|
81
|
+
base_url=settings.embedding.base_url,
|
|
82
|
+
model=settings.embedding.model,
|
|
83
|
+
api_key=settings.embedding.api_key,
|
|
84
|
+
timeout=settings.embedding.timeout,
|
|
85
|
+
)
|
|
86
|
+
indexer = RepoIndexer(
|
|
87
|
+
db_path=default_db_path(repo_path),
|
|
88
|
+
embedding_client=embed_client,
|
|
89
|
+
chunk_size=settings.indexing.chunk_size,
|
|
90
|
+
chunk_overlap=settings.indexing.chunk_overlap,
|
|
91
|
+
excluded_patterns=settings.indexing.excluded_patterns,
|
|
92
|
+
max_file_size=settings.indexing.max_file_size,
|
|
93
|
+
)
|
|
94
|
+
reviewer = CodeReviewer(
|
|
95
|
+
ai_client=ai_client,
|
|
96
|
+
indexer=indexer,
|
|
97
|
+
similarity_top_k=settings.review.similarity_top_k,
|
|
98
|
+
max_prompt_tokens=settings.review.max_prompt_tokens,
|
|
99
|
+
verbosity=verbosity,
|
|
100
|
+
)
|
|
101
|
+
return reviewer, None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# CLI group
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@click.group()
|
|
110
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
|
|
111
|
+
def cli(verbose: bool) -> None:
|
|
112
|
+
"""AI-powered git diff analysis and code review with RAG context.
|
|
113
|
+
|
|
114
|
+
Run ``diffrag COMMAND --help`` for details on each command.
|
|
115
|
+
"""
|
|
116
|
+
level = logging.DEBUG if verbose else logging.WARNING
|
|
117
|
+
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# review command
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@cli.command()
|
|
126
|
+
@click.option(
|
|
127
|
+
"--repo",
|
|
128
|
+
"-r",
|
|
129
|
+
"repo_path",
|
|
130
|
+
default=".",
|
|
131
|
+
show_default=True,
|
|
132
|
+
type=click.Path(exists=True, file_okay=False),
|
|
133
|
+
help="Path to the git repository.",
|
|
134
|
+
)
|
|
135
|
+
@click.option("--base", "-b", default=None, help="Base branch/ref (default from config or 'main').")
|
|
136
|
+
@click.option("--head", "-H", default="HEAD", show_default=True, help="Head branch/ref to review.")
|
|
137
|
+
@click.option(
|
|
138
|
+
"--config",
|
|
139
|
+
"-c",
|
|
140
|
+
default=None,
|
|
141
|
+
type=click.Path(exists=True),
|
|
142
|
+
help="Path to a .toml config file.",
|
|
143
|
+
)
|
|
144
|
+
@click.option(
|
|
145
|
+
"--verbosity",
|
|
146
|
+
"-V",
|
|
147
|
+
default=None,
|
|
148
|
+
type=click.Choice(["brief", "standard", "detailed"]),
|
|
149
|
+
help="Review detail level (overrides config default).",
|
|
150
|
+
)
|
|
151
|
+
def review(
|
|
152
|
+
repo_path: str,
|
|
153
|
+
base: str | None,
|
|
154
|
+
head: str,
|
|
155
|
+
config: str | None,
|
|
156
|
+
verbosity: str | None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Generate an AI code review for changes between two git refs.
|
|
159
|
+
|
|
160
|
+
The repository index is built automatically on the first run and reused on
|
|
161
|
+
subsequent runs as long as HEAD has not changed. A fresh commit triggers a
|
|
162
|
+
rebuild; switching branches also triggers a rebuild.
|
|
163
|
+
|
|
164
|
+
Examples:
|
|
165
|
+
|
|
166
|
+
\b
|
|
167
|
+
diffrag review --base main
|
|
168
|
+
diffrag review --base main --head feature/my-feature
|
|
169
|
+
diffrag review --repo /path/to/repo --base v1.0.0 --head HEAD
|
|
170
|
+
"""
|
|
171
|
+
settings = _load_settings(config)
|
|
172
|
+
effective_base = base or settings.review.base_branch
|
|
173
|
+
effective_verbosity = verbosity or settings.review.verbosity
|
|
174
|
+
resolved_repo = Path(repo_path).resolve()
|
|
175
|
+
reviewer, _ = _build_reviewer(settings, repo_path=resolved_repo, verbosity=effective_verbosity)
|
|
176
|
+
repo = GitRepository(Path(repo_path))
|
|
177
|
+
|
|
178
|
+
async def _run() -> None:
|
|
179
|
+
with console.status("Preparing...", spinner="dots") as status:
|
|
180
|
+
|
|
181
|
+
def on_progress(msg: str) -> None:
|
|
182
|
+
status.update(f"[bold cyan]{msg}[/bold cyan]")
|
|
183
|
+
if not msg.endswith("..."):
|
|
184
|
+
console.print(f"[green]✓[/green] {msg}")
|
|
185
|
+
|
|
186
|
+
result = await reviewer.review(
|
|
187
|
+
repo,
|
|
188
|
+
effective_base,
|
|
189
|
+
head,
|
|
190
|
+
on_progress=on_progress,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if result.summary == "No changes detected between the two refs.":
|
|
194
|
+
console.print(Panel("[yellow]No changes detected.[/yellow]", title="Review"))
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
n_files = len(result.file_reviews)
|
|
198
|
+
console.print(
|
|
199
|
+
Panel(
|
|
200
|
+
f"[bold]{effective_base}[/bold] → [bold cyan]{head}[/bold cyan]"
|
|
201
|
+
f" [dim]{n_files} file review(s)[/dim]",
|
|
202
|
+
title="[bold blue]AI Code Review[/bold blue]",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if len(result.file_reviews) > 1:
|
|
207
|
+
for fr in result.file_reviews:
|
|
208
|
+
console.rule(f"[cyan]{fr.identifier}[/cyan]")
|
|
209
|
+
console.print(Markdown(fr.content))
|
|
210
|
+
|
|
211
|
+
console.rule("[bold]Consolidated Review[/bold]")
|
|
212
|
+
|
|
213
|
+
console.print(Markdown(result.summary))
|
|
214
|
+
console.print(
|
|
215
|
+
f"\n[dim]Tokens — prompt: {result.total_prompt_tokens} "
|
|
216
|
+
f"completion: {result.total_completion_tokens}[/dim]"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
asyncio.run(_run())
|
|
221
|
+
except AICodeReviewerError as exc:
|
|
222
|
+
err_console.print(f"Error: {exc}")
|
|
223
|
+
sys.exit(1)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# ask command
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@cli.command()
|
|
232
|
+
@click.option(
|
|
233
|
+
"--repo",
|
|
234
|
+
"-r",
|
|
235
|
+
"repo_path",
|
|
236
|
+
default=".",
|
|
237
|
+
show_default=True,
|
|
238
|
+
type=click.Path(exists=True, file_okay=False),
|
|
239
|
+
help="Path to the git repository.",
|
|
240
|
+
)
|
|
241
|
+
@click.option("--base", "-b", default=None, help="Base branch/ref.")
|
|
242
|
+
@click.option("--head", "-H", default="HEAD", show_default=True, help="Head branch/ref.")
|
|
243
|
+
@click.option(
|
|
244
|
+
"--config",
|
|
245
|
+
"-c",
|
|
246
|
+
default=None,
|
|
247
|
+
type=click.Path(exists=True),
|
|
248
|
+
help="Path to a .toml config file.",
|
|
249
|
+
)
|
|
250
|
+
@click.option(
|
|
251
|
+
"--verbosity",
|
|
252
|
+
"-V",
|
|
253
|
+
default=None,
|
|
254
|
+
type=click.Choice(["brief", "standard", "detailed"]),
|
|
255
|
+
help="Answer detail level (overrides config default).",
|
|
256
|
+
)
|
|
257
|
+
def ask(
|
|
258
|
+
repo_path: str,
|
|
259
|
+
base: str | None,
|
|
260
|
+
head: str,
|
|
261
|
+
config: str | None,
|
|
262
|
+
verbosity: str | None,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Interactively ask questions about the diff and codebase.
|
|
265
|
+
|
|
266
|
+
Launches a REPL where each question is answered using RAG context
|
|
267
|
+
from the indexed repository plus the current diff. Type ``exit`` or
|
|
268
|
+
press Ctrl-D to quit.
|
|
269
|
+
|
|
270
|
+
Example:
|
|
271
|
+
|
|
272
|
+
\b
|
|
273
|
+
diffrag ask --base main
|
|
274
|
+
"""
|
|
275
|
+
settings = _load_settings(config)
|
|
276
|
+
effective_base = base or settings.review.base_branch
|
|
277
|
+
effective_verbosity = verbosity or settings.review.verbosity
|
|
278
|
+
resolved_repo = Path(repo_path).resolve()
|
|
279
|
+
reviewer, _ = _build_reviewer(settings, repo_path=resolved_repo, verbosity=effective_verbosity)
|
|
280
|
+
repo = GitRepository(Path(repo_path))
|
|
281
|
+
diff_result = repo.get_diff(effective_base, head)
|
|
282
|
+
|
|
283
|
+
console.print(
|
|
284
|
+
Panel(
|
|
285
|
+
f"Asking about [bold]{effective_base}[/bold] → [bold cyan]{head}[/bold cyan]\n"
|
|
286
|
+
"[dim]Type your question and press Enter. Type 'exit' or Ctrl-D to quit.[/dim]",
|
|
287
|
+
title="[bold green]Interactive Q&A[/bold green]",
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
history: list[tuple[str, str]] = []
|
|
292
|
+
while True:
|
|
293
|
+
try:
|
|
294
|
+
question = console.input("\n[bold cyan]>[/bold cyan] ").strip()
|
|
295
|
+
except (EOFError, KeyboardInterrupt):
|
|
296
|
+
console.print("\n[dim]Bye.[/dim]")
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
if question.lower() in ("exit", "quit", "q"):
|
|
300
|
+
break
|
|
301
|
+
if not question:
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
async def _answer(q: str = question, hist: list[tuple[str, str]] = history) -> str:
|
|
305
|
+
with console.status("[bold green]Thinking…[/]", spinner="dots"):
|
|
306
|
+
return await reviewer.ask(q, diff_result, history=hist)
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
answer = asyncio.run(_answer())
|
|
310
|
+
console.print(Markdown(answer))
|
|
311
|
+
history.append((question, answer))
|
|
312
|
+
except AICodeReviewerError as exc:
|
|
313
|
+
err_console.print(f"Error: {exc}")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# create-config command
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
_EXAMPLE_CONFIG = importlib.resources.files("diffrag").joinpath("data/config.example.toml")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@cli.command("create-config")
|
|
324
|
+
@click.option(
|
|
325
|
+
"--out",
|
|
326
|
+
"-o",
|
|
327
|
+
default=None,
|
|
328
|
+
type=click.Path(dir_okay=False),
|
|
329
|
+
help="Destination path (default: user-level config returned by config-path).",
|
|
330
|
+
)
|
|
331
|
+
@click.option("--force", "-f", is_flag=True, help="Overwrite if the file already exists.")
|
|
332
|
+
def create_config(out: str | None, force: bool) -> None:
|
|
333
|
+
"""Create a config file pre-populated with all available settings.
|
|
334
|
+
|
|
335
|
+
By default the file is written to the user-level config directory so it
|
|
336
|
+
applies to every repository on this machine. Pass ``--out`` to write a
|
|
337
|
+
project-level ``.diffrag.toml`` instead.
|
|
338
|
+
|
|
339
|
+
\b
|
|
340
|
+
diffrag create-config # global user config
|
|
341
|
+
diffrag create-config --out .diffrag.toml # project override
|
|
342
|
+
diffrag create-config --force # overwrite existing
|
|
343
|
+
"""
|
|
344
|
+
dest = Path(out) if out else user_config_path()
|
|
345
|
+
|
|
346
|
+
if dest.exists() and not force:
|
|
347
|
+
err_console.print(
|
|
348
|
+
f"[bold]{dest}[/bold] already exists. Use [bold]--force[/bold] to overwrite."
|
|
349
|
+
)
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
|
|
352
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
353
|
+
dest.write_text(_EXAMPLE_CONFIG.read_text(encoding="utf-8"), encoding="utf-8")
|
|
354
|
+
console.print(f"[green]✓[/green] Config written to [bold]{dest}[/bold]")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# db-path command
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@cli.command("db-path")
|
|
363
|
+
@click.option(
|
|
364
|
+
"--repo",
|
|
365
|
+
"-r",
|
|
366
|
+
"repo_path",
|
|
367
|
+
default=".",
|
|
368
|
+
show_default=True,
|
|
369
|
+
type=click.Path(exists=True, file_okay=False),
|
|
370
|
+
help="Path to the git repository.",
|
|
371
|
+
)
|
|
372
|
+
def db_path_cmd(repo_path: str) -> None:
|
|
373
|
+
"""Print the database path for a repository.
|
|
374
|
+
|
|
375
|
+
The index for each repository is stored in a unique subdirectory of the
|
|
376
|
+
OS user cache directory, derived from the repository's canonical path.
|
|
377
|
+
The directory may not exist yet (it is created on first run).
|
|
378
|
+
|
|
379
|
+
\b
|
|
380
|
+
diffrag db-path
|
|
381
|
+
diffrag db-path --repo /path/to/repo
|
|
382
|
+
"""
|
|
383
|
+
console.print(str(default_db_path(Path(repo_path).resolve())))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# config-path command
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@cli.command("config-path")
|
|
392
|
+
def config_path_cmd() -> None:
|
|
393
|
+
"""Print the path to the user-level config file."""
|
|
394
|
+
console.print(str(user_config_path()))
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# show-config command
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@cli.command("show-config")
|
|
403
|
+
@click.option(
|
|
404
|
+
"--config",
|
|
405
|
+
"-c",
|
|
406
|
+
default=None,
|
|
407
|
+
type=click.Path(exists=True),
|
|
408
|
+
help="Path to a .toml config file.",
|
|
409
|
+
)
|
|
410
|
+
def show_config(config: str | None) -> None:
|
|
411
|
+
"""Display the effective configuration as JSON.
|
|
412
|
+
|
|
413
|
+
Useful for verifying which settings are active, including defaults.
|
|
414
|
+
"""
|
|
415
|
+
settings = _load_settings(config)
|
|
416
|
+
|
|
417
|
+
def _serialise(obj: object) -> object:
|
|
418
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
419
|
+
return {f.name: _serialise(getattr(obj, f.name)) for f in dataclasses.fields(obj)}
|
|
420
|
+
if isinstance(obj, Path):
|
|
421
|
+
return str(obj)
|
|
422
|
+
if isinstance(obj, list):
|
|
423
|
+
return [_serialise(v) for v in obj]
|
|
424
|
+
return obj
|
|
425
|
+
|
|
426
|
+
table = Table(title="Effective Configuration", show_header=True)
|
|
427
|
+
table.add_column("Section", style="cyan")
|
|
428
|
+
table.add_column("Key", style="white")
|
|
429
|
+
table.add_column("Value", style="green")
|
|
430
|
+
|
|
431
|
+
raw = _serialise(settings)
|
|
432
|
+
assert isinstance(raw, dict)
|
|
433
|
+
for section, values in raw.items():
|
|
434
|
+
if isinstance(values, dict):
|
|
435
|
+
for key, value in values.items():
|
|
436
|
+
table.add_row(str(section), str(key), json.dumps(value))
|
|
437
|
+
else:
|
|
438
|
+
table.add_row("", str(section), json.dumps(values))
|
|
439
|
+
|
|
440
|
+
console.print(table)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Configuration loading and data models."""
|
|
2
|
+
|
|
3
|
+
from .settings import (
|
|
4
|
+
AISettings,
|
|
5
|
+
EmbeddingSettings,
|
|
6
|
+
IndexingSettings,
|
|
7
|
+
ReviewSettings,
|
|
8
|
+
Settings,
|
|
9
|
+
default_db_path,
|
|
10
|
+
user_config_path,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AISettings",
|
|
15
|
+
"EmbeddingSettings",
|
|
16
|
+
"IndexingSettings",
|
|
17
|
+
"ReviewSettings",
|
|
18
|
+
"Settings",
|
|
19
|
+
"default_db_path",
|
|
20
|
+
"user_config_path",
|
|
21
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Settings loaded from a TOML configuration file."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import platformdirs
|
|
10
|
+
|
|
11
|
+
from ..exceptions import ConfigurationError
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def user_config_path() -> Path:
|
|
17
|
+
"""Return the OS-appropriate user-level config file path.
|
|
18
|
+
|
|
19
|
+
- Linux / macOS: ``~/.config/diffrag/config.toml``
|
|
20
|
+
- Windows: ``%APPDATA%\\diffrag\\config.toml``
|
|
21
|
+
|
|
22
|
+
The file may not exist yet; call this to find where to create it.
|
|
23
|
+
"""
|
|
24
|
+
return Path(platformdirs.user_config_dir("diffrag")) / "config.toml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_db_path(repo_path: Path) -> Path:
|
|
28
|
+
"""Return the cache-directory DB path for *repo_path*.
|
|
29
|
+
|
|
30
|
+
Each repository maps to a unique subdirectory inside the OS user cache dir,
|
|
31
|
+
named after the first 12 hex characters of the SHA-256 hash of the repo's
|
|
32
|
+
canonical absolute path.
|
|
33
|
+
|
|
34
|
+
- Linux / macOS: ``~/.cache/diffrag/<hash>/``
|
|
35
|
+
- Windows: ``%LOCALAPPDATA%\\diffrag\\Cache\\<hash>\\``
|
|
36
|
+
"""
|
|
37
|
+
digest = hashlib.sha256(str(repo_path.resolve()).encode()).hexdigest()[:12]
|
|
38
|
+
return Path(platformdirs.user_cache_dir("diffrag")) / digest
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_DEFAULT_EXCLUDED_PATTERNS = [
|
|
42
|
+
".git",
|
|
43
|
+
".venv",
|
|
44
|
+
"__pycache__",
|
|
45
|
+
"node_modules",
|
|
46
|
+
".idea",
|
|
47
|
+
".mypy_cache",
|
|
48
|
+
".ruff_cache",
|
|
49
|
+
"dist",
|
|
50
|
+
"build",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AISettings:
|
|
56
|
+
"""Settings for the language model used to generate reviews.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
base_url: Base URL of the OpenAI-compatible or Ollama endpoint.
|
|
60
|
+
model: Model identifier to use for completion requests.
|
|
61
|
+
api_key: Bearer token sent in the Authorization header (empty = none).
|
|
62
|
+
timeout: HTTP request timeout in seconds.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
base_url: str = "http://localhost:11434"
|
|
66
|
+
model: str = "llama3.2"
|
|
67
|
+
api_key: str = ""
|
|
68
|
+
timeout: float = 120.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class EmbeddingSettings:
|
|
73
|
+
"""Settings for the embedding model used to build the vector index.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
base_url: Base URL of the OpenAI-compatible or Ollama endpoint.
|
|
77
|
+
model: Model identifier to use for embedding requests.
|
|
78
|
+
api_key: Bearer token sent in the Authorization header (empty = none).
|
|
79
|
+
timeout: HTTP request timeout in seconds.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
base_url: str = "http://localhost:11434"
|
|
83
|
+
model: str = "nomic-embed-text"
|
|
84
|
+
api_key: str = ""
|
|
85
|
+
timeout: float = 60.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class IndexingSettings:
|
|
90
|
+
"""Settings that control how the repository is indexed.
|
|
91
|
+
|
|
92
|
+
The database location is always determined automatically by
|
|
93
|
+
:func:`default_db_path` and cannot be configured.
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
chunk_size: Maximum number of characters per text chunk.
|
|
97
|
+
chunk_overlap: Number of characters to overlap between consecutive chunks.
|
|
98
|
+
excluded_patterns: Additional directory or file name segments to skip,
|
|
99
|
+
applied on top of any patterns found in the repository's ``.gitignore``.
|
|
100
|
+
``.git`` is always excluded regardless of this setting.
|
|
101
|
+
max_file_size: Files larger than this (bytes) are skipped.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
chunk_size: int = 1024
|
|
105
|
+
chunk_overlap: int = 128
|
|
106
|
+
excluded_patterns: list[str] = field(default_factory=lambda: list(_DEFAULT_EXCLUDED_PATTERNS))
|
|
107
|
+
max_file_size: int = 1_000_000
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class ReviewSettings:
|
|
112
|
+
"""Settings that control the review behaviour.
|
|
113
|
+
|
|
114
|
+
Attributes:
|
|
115
|
+
base_branch: Default branch to diff against when none is specified.
|
|
116
|
+
similarity_top_k: Number of RAG context chunks retrieved per diff chunk.
|
|
117
|
+
max_prompt_tokens: Approximate token budget per diff chunk before splitting.
|
|
118
|
+
verbosity: Detail level of generated reviews. One of ``"brief"``,
|
|
119
|
+
``"standard"`` (default), or ``"detailed"``.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
base_branch: str = "main"
|
|
123
|
+
similarity_top_k: int = 5
|
|
124
|
+
max_prompt_tokens: int = 6000
|
|
125
|
+
verbosity: str = "standard"
|
|
126
|
+
|
|
127
|
+
def __post_init__(self) -> None:
|
|
128
|
+
if self.verbosity not in ("brief", "standard", "detailed"):
|
|
129
|
+
raise ConfigurationError(
|
|
130
|
+
f"Invalid verbosity {self.verbosity!r}. Choose 'brief', 'standard', or 'detailed'."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class Settings:
|
|
136
|
+
"""Root settings object aggregating all configuration sections.
|
|
137
|
+
|
|
138
|
+
Attributes:
|
|
139
|
+
ai: Language model settings.
|
|
140
|
+
embedding: Embedding model settings.
|
|
141
|
+
indexing: Vector index settings.
|
|
142
|
+
review: Review behaviour settings.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
ai: AISettings = field(default_factory=AISettings)
|
|
146
|
+
embedding: EmbeddingSettings = field(default_factory=EmbeddingSettings)
|
|
147
|
+
indexing: IndexingSettings = field(default_factory=IndexingSettings)
|
|
148
|
+
review: ReviewSettings = field(default_factory=ReviewSettings)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_toml(cls, path: Path) -> "Settings":
|
|
152
|
+
"""Load settings from a TOML file.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
path: Path to the ``.toml`` configuration file.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
A fully populated ``Settings`` instance.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ConfigurationError: If the file cannot be read or contains invalid keys.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
with path.open("rb") as fh:
|
|
165
|
+
data = tomllib.load(fh)
|
|
166
|
+
except OSError as exc:
|
|
167
|
+
raise ConfigurationError(f"Cannot read config file {path}: {exc}") from exc
|
|
168
|
+
except tomllib.TOMLDecodeError as exc:
|
|
169
|
+
raise ConfigurationError(f"Invalid TOML in {path}: {exc}") from exc
|
|
170
|
+
|
|
171
|
+
log.debug("Loaded configuration from %s", path)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
return cls(
|
|
175
|
+
ai=AISettings(**data.get("ai", {})),
|
|
176
|
+
embedding=EmbeddingSettings(**data.get("embedding", {})),
|
|
177
|
+
indexing=IndexingSettings(**data.get("indexing", {})),
|
|
178
|
+
review=ReviewSettings(**data.get("review", {})),
|
|
179
|
+
)
|
|
180
|
+
except TypeError as exc:
|
|
181
|
+
raise ConfigurationError(f"Unexpected key in config {path}: {exc}") from exc
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_defaults(cls) -> "Settings":
|
|
185
|
+
"""Return a ``Settings`` instance with all default values.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
A ``Settings`` instance populated with default values.
|
|
189
|
+
"""
|
|
190
|
+
return cls()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# diffrag configuration
|
|
2
|
+
#
|
|
3
|
+
# Settings are loaded from the first file found:
|
|
4
|
+
# 1. --config <path> (explicit CLI flag)
|
|
5
|
+
# 2. .diffrag.toml (current working directory)
|
|
6
|
+
# 3. user-level config (run "diffrag config-path" to find this)
|
|
7
|
+
# 4. Built-in defaults (no file needed)
|
|
8
|
+
#
|
|
9
|
+
# All keys are optional — remove any section to use built-in defaults.
|
|
10
|
+
|
|
11
|
+
[ai]
|
|
12
|
+
# Base URL of the OpenAI-compatible endpoint (OpenWebUI, Ollama /v1, OpenAI, etc.)
|
|
13
|
+
base_url = "http://localhost:11434"
|
|
14
|
+
# Model identifier to use for review generation
|
|
15
|
+
model = "llama3.2"
|
|
16
|
+
# API key / bearer token (leave empty for unauthenticated local servers)
|
|
17
|
+
api_key = ""
|
|
18
|
+
# Per-read timeout in seconds (requests use streaming; this is the max time
|
|
19
|
+
# between consecutive chunks, not the total generation time)
|
|
20
|
+
timeout = 120.0
|
|
21
|
+
|
|
22
|
+
[embedding]
|
|
23
|
+
# Embedding endpoint — can differ from the completion endpoint
|
|
24
|
+
base_url = "http://localhost:11434"
|
|
25
|
+
model = "nomic-embed-text"
|
|
26
|
+
api_key = ""
|
|
27
|
+
timeout = 60.0
|
|
28
|
+
|
|
29
|
+
[indexing]
|
|
30
|
+
# The index is always stored in the OS user cache directory, keyed by repo.
|
|
31
|
+
# Run "diffrag db-path" to see the exact path for the current repository.
|
|
32
|
+
# Target chunk size in characters
|
|
33
|
+
chunk_size = 1024
|
|
34
|
+
# Overlap between consecutive chunks in characters
|
|
35
|
+
chunk_overlap = 128
|
|
36
|
+
# Patterns in the repository's .gitignore are excluded automatically.
|
|
37
|
+
# The list below provides additional excludes on top of .gitignore.
|
|
38
|
+
excluded_patterns = [
|
|
39
|
+
".git", ".venv", "__pycache__", "node_modules",
|
|
40
|
+
".idea", ".mypy_cache", ".ruff_cache", "dist", "build",
|
|
41
|
+
]
|
|
42
|
+
# Files larger than this (bytes) are not indexed
|
|
43
|
+
max_file_size = 1000000
|
|
44
|
+
|
|
45
|
+
[review]
|
|
46
|
+
# Default branch to diff against when --base is not specified
|
|
47
|
+
base_branch = "main"
|
|
48
|
+
# Number of context chunks retrieved from the index per diff chunk
|
|
49
|
+
similarity_top_k = 5
|
|
50
|
+
# Approximate token budget per diff chunk before hunk-splitting kicks in
|
|
51
|
+
max_prompt_tokens = 6000
|
|
52
|
+
# Detail level of generated reviews: "brief" | "standard" | "detailed"
|
|
53
|
+
verbosity = "standard"
|