diffbutler 0.5.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.
- diffbutler/__init__.py +1 -0
- diffbutler/__main__.py +6 -0
- diffbutler/config/__init__.py +1 -0
- diffbutler/config/default.toml +91 -0
- diffbutler/config/diagnostics.py +90 -0
- diffbutler/config/file.py +30 -0
- diffbutler/config/instance.py +55 -0
- diffbutler/config/settings.py +297 -0
- diffbutler/config/setup.py +92 -0
- diffbutler/config/transport.py +65 -0
- diffbutler/core/__init__.py +1 -0
- diffbutler/core/application.py +103 -0
- diffbutler/core/context.py +54 -0
- diffbutler/core/dashboard.py +393 -0
- diffbutler/core/doctor.py +200 -0
- diffbutler/core/lifecycle.py +70 -0
- diffbutler/core/managed.py +441 -0
- diffbutler/core/native.py +301 -0
- diffbutler/core/polling.py +118 -0
- diffbutler/core/process.py +64 -0
- diffbutler/core/repositories.py +260 -0
- diffbutler/core/reset.py +34 -0
- diffbutler/core/resources.py +50 -0
- diffbutler/core/review.py +331 -0
- diffbutler/core/setup.py +232 -0
- diffbutler/core/snapshot.py +70 -0
- diffbutler/core/sync.py +259 -0
- diffbutler/core/worker.py +205 -0
- diffbutler/core/workspaces.py +139 -0
- diffbutler/domain/__init__.py +1 -0
- diffbutler/domain/dashboard.py +63 -0
- diffbutler/domain/diff.py +285 -0
- diffbutler/domain/forge.py +123 -0
- diffbutler/domain/lifecycle.py +51 -0
- diffbutler/domain/polling.py +92 -0
- diffbutler/domain/process.py +29 -0
- diffbutler/domain/redaction.py +61 -0
- diffbutler/domain/resources.py +50 -0
- diffbutler/domain/review.py +167 -0
- diffbutler/domain/sync.py +196 -0
- diffbutler/forges/__init__.py +1 -0
- diffbutler/forges/base.py +39 -0
- diffbutler/forges/diagnostics.py +104 -0
- diffbutler/forges/github/__init__.py +1 -0
- diffbutler/forges/github/adapter.py +352 -0
- diffbutler/forges/github/auth.py +49 -0
- diffbutler/forges/github/http.py +326 -0
- diffbutler/forges/github/mapping.py +157 -0
- diffbutler/forges/github/setup.py +86 -0
- diffbutler/forges/registry.py +23 -0
- diffbutler/py.typed +0 -0
- diffbutler/reviewers/__init__.py +1 -0
- diffbutler/reviewers/base.py +101 -0
- diffbutler/reviewers/extraction.py +198 -0
- diffbutler/reviewers/native.py +238 -0
- diffbutler/reviewers/parsing.py +247 -0
- diffbutler/reviewers/presets.toml +12 -0
- diffbutler/reviewers/process.py +78 -0
- diffbutler/reviewers/prompt.py +148 -0
- diffbutler/reviewers/redaction.py +30 -0
- diffbutler/reviewers/resources.py +162 -0
- diffbutler/sources/__init__.py +1 -0
- diffbutler/sources/diagnostics.py +100 -0
- diffbutler/sources/fetch.py +285 -0
- diffbutler/sources/git.py +461 -0
- diffbutler/sources/remotes.py +82 -0
- diffbutler/store/__init__.py +1 -0
- diffbutler/store/base.py +88 -0
- diffbutler/store/dashboard.py +201 -0
- diffbutler/store/database.py +172 -0
- diffbutler/store/lifecycle.py +247 -0
- diffbutler/store/migrations/__init__.py +1 -0
- diffbutler/store/migrations/env.py +14 -0
- diffbutler/store/migrations/versions/0001_initial.py +147 -0
- diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
- diffbutler/store/migrations/versions/0003_reviews.py +72 -0
- diffbutler/store/migrations/versions/0004_claims.py +22 -0
- diffbutler/store/migrations/versions/0005_results.py +27 -0
- diffbutler/store/migrations/versions/0006_anchors.py +17 -0
- diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
- diffbutler/store/migrations/versions/0008_processes.py +30 -0
- diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
- diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
- diffbutler/store/migrations/versions/__init__.py +1 -0
- diffbutler/store/models.py +290 -0
- diffbutler/store/process.py +75 -0
- diffbutler/store/queue.py +289 -0
- diffbutler/store/review.py +583 -0
- diffbutler/ui/__init__.py +1 -0
- diffbutler/ui/cli/__init__.py +532 -0
- diffbutler/ui/cli/editor.py +33 -0
- diffbutler/ui/tui/__init__.py +1 -0
- diffbutler/ui/tui/_fake_agent.py +47 -0
- diffbutler/ui/tui/_foreground_exec.py +23 -0
- diffbutler/ui/tui/app.py +34 -0
- diffbutler/ui/tui/browse.py +119 -0
- diffbutler/ui/tui/browser.py +42 -0
- diffbutler/ui/tui/dashboard.py +926 -0
- diffbutler/ui/tui/foreground.py +103 -0
- diffbutler/ui/tui/handoff_probe.py +184 -0
- diffbutler-0.5.0.dist-info/METADATA +170 -0
- diffbutler-0.5.0.dist-info/RECORD +105 -0
- diffbutler-0.5.0.dist-info/WHEEL +4 -0
- diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
- diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
diffbutler/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DiffButler: human-directed AI code reviews."""
|
diffbutler/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration loading and validation."""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# DiffButler configuration. Omitted options use their built-in defaults.
|
|
2
|
+
# No forge or reviewer is configured until you add one below.
|
|
3
|
+
forges = []
|
|
4
|
+
reviewers = []
|
|
5
|
+
|
|
6
|
+
[general]
|
|
7
|
+
# Minimum: 60 seconds with authenticated accounts, 600 with any public account.
|
|
8
|
+
# poll_interval_s = 600 # Override the minimum; low authenticated quota slows polling.
|
|
9
|
+
max_concurrent_reviews = 2
|
|
10
|
+
# Omit workspace_root to follow $XDG_DATA_HOME/diffbutler/reviews
|
|
11
|
+
# (default ~/.local/share/diffbutler/reviews).
|
|
12
|
+
# workspace_root = "~/diffbutler-reviews"
|
|
13
|
+
# Shared bare repositories; default $XDG_DATA_HOME/diffbutler/repos.
|
|
14
|
+
# repository_root = "~/diffbutler-repos"
|
|
15
|
+
# Evict unused repositories after 30 days; active session pins never expire.
|
|
16
|
+
# repository_retention_days = 30
|
|
17
|
+
# Optional reconstructible snapshots; conversations remain in workspace_root.
|
|
18
|
+
# Use a new private directory, separate from the other managed roots.
|
|
19
|
+
# snapshot_root = "/tmp/diffbutler-snapshots-yourname"
|
|
20
|
+
|
|
21
|
+
[db]
|
|
22
|
+
auto_migrate = true
|
|
23
|
+
# Omit url to follow $XDG_DATA_HOME/diffbutler/diffbutler.db
|
|
24
|
+
# (default ~/.local/share/diffbutler/diffbutler.db).
|
|
25
|
+
# url = "sqlite+aiosqlite:///~/my-data/diffbutler.db"
|
|
26
|
+
|
|
27
|
+
[review]
|
|
28
|
+
findings_threshold = "minor" # blocking | important | minor | nit | question
|
|
29
|
+
min_confidence = 0.5
|
|
30
|
+
exclude_globs = ["**/*.lock", "**/vendor/**", "**/*.min.js", "**/generated/**"]
|
|
31
|
+
mode = "source_only"
|
|
32
|
+
checkout = "worktree" # Native reviews currently use pinned source snapshots.
|
|
33
|
+
# default_reviewer = "codex"
|
|
34
|
+
# prompt = "~/review-instructions.md"
|
|
35
|
+
|
|
36
|
+
[ui]
|
|
37
|
+
include_own_prs = false # Set true to review PRs authored by your account.
|
|
38
|
+
include_assigned_prs = false # Set true to review PRs assigned to your account.
|
|
39
|
+
columns = ["number", "title", "author", "state"]
|
|
40
|
+
sort_by = "attention"
|
|
41
|
+
sort_desc = true
|
|
42
|
+
preview_pane = true
|
|
43
|
+
default_filter = "active"
|
|
44
|
+
# browser_command = ["firefox", "--new-tab"]
|
|
45
|
+
|
|
46
|
+
[ui.attention]
|
|
47
|
+
blocking = 100
|
|
48
|
+
important = 40
|
|
49
|
+
age_pressure_per_day = 5
|
|
50
|
+
size_penalty_per_100_lines = 2
|
|
51
|
+
|
|
52
|
+
[keys]
|
|
53
|
+
open_in_browser = ["o", "ctrl+o"]
|
|
54
|
+
|
|
55
|
+
[repos]
|
|
56
|
+
# Add per-repository settings, for example:
|
|
57
|
+
# [repos."acme/api"]
|
|
58
|
+
# enabled = true
|
|
59
|
+
# path = "~/src/acme/api"
|
|
60
|
+
# git_transport = "auto" # auto | https | ssh; remembers the last successful fetch.
|
|
61
|
+
# default_reviewer = "codex"
|
|
62
|
+
# prompt = "~/review-instructions.md"
|
|
63
|
+
# checkout = "mediated"
|
|
64
|
+
|
|
65
|
+
# Remove the top-level forges = [] before adding forge accounts:
|
|
66
|
+
# [[forges]]
|
|
67
|
+
# kind = "github"
|
|
68
|
+
# base_url = "https://api.github.com"
|
|
69
|
+
# token_env = "GITHUB_TOKEN" # Omit for public access (None); login is then required.
|
|
70
|
+
# include_team_requests = true
|
|
71
|
+
# repositories = [] # Empty means review requests from all accessible repositories.
|
|
72
|
+
# login = "your-login"
|
|
73
|
+
|
|
74
|
+
# Remove the top-level reviewers = [] before adding reviewers:
|
|
75
|
+
# [[reviewers]]
|
|
76
|
+
# id = "codex"
|
|
77
|
+
# kind = "cli"
|
|
78
|
+
# command = ["codex"]
|
|
79
|
+
# preset = "codex"
|
|
80
|
+
# timeout_s = 900
|
|
81
|
+
# parse = "json_envelope" # The Codex preset uses its fixed JSONL protocol.
|
|
82
|
+
# json_path = "result"
|
|
83
|
+
# stdin = "prompt"
|
|
84
|
+
|
|
85
|
+
# Claude Code can be configured alongside Codex; select with --reviewer claude
|
|
86
|
+
# or press n in the dashboard, or set review.default_reviewer = "claude" above.
|
|
87
|
+
# [[reviewers]]
|
|
88
|
+
# id = "claude"
|
|
89
|
+
# kind = "cli"
|
|
90
|
+
# command = ["claude"]
|
|
91
|
+
# preset = "claude"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Private, bounded application diagnostics without agent or forge content."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
from logging.handlers import RotatingFileHandler
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
|
|
13
|
+
from diffbutler.config.settings import ConfigError
|
|
14
|
+
from diffbutler.sources.git import SourceError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def state_path() -> Path:
|
|
18
|
+
return (
|
|
19
|
+
Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state").expanduser()
|
|
20
|
+
/ "diffbutler"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def log_path() -> Path:
|
|
25
|
+
return state_path() / "logs" / "diffbutler.log"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
type ExceptionInfo = (
|
|
29
|
+
tuple[type[BaseException], BaseException, TracebackType | None] | tuple[None, None, None]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DiagnosticFormatter(logging.Formatter):
|
|
34
|
+
converter = time.gmtime
|
|
35
|
+
|
|
36
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
37
|
+
record = copy.copy(record)
|
|
38
|
+
record.exc_text = None
|
|
39
|
+
return super().format(record)
|
|
40
|
+
|
|
41
|
+
def formatException(
|
|
42
|
+
self,
|
|
43
|
+
ei: ExceptionInfo,
|
|
44
|
+
) -> str:
|
|
45
|
+
# Exception messages, source lines, and locals can contain remote content or secrets.
|
|
46
|
+
# These exceptions guarantee safe diagnostics without raw command output.
|
|
47
|
+
lines = ["Traceback (most recent call last):"]
|
|
48
|
+
for frame in traceback.extract_tb(ei[2]):
|
|
49
|
+
lines.append(f" {Path(frame.filename).name}:{frame.lineno} in {frame.name}")
|
|
50
|
+
lines.append(type(ei[1]).__name__)
|
|
51
|
+
if isinstance(ei[1], (ConfigError, SourceError)):
|
|
52
|
+
lines.append(str(ei[1]))
|
|
53
|
+
return "\n".join(lines)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DiagnosticHandler(RotatingFileHandler):
|
|
57
|
+
def handleError(self, record: logging.LogRecord) -> None:
|
|
58
|
+
# A full disk must not print logging failures into an interactive agent's terminal.
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def stop_logging() -> None:
|
|
63
|
+
logger = logging.getLogger("diffbutler")
|
|
64
|
+
if not any(isinstance(handler, logging.NullHandler) for handler in logger.handlers):
|
|
65
|
+
logger.addHandler(logging.NullHandler())
|
|
66
|
+
for handler in tuple(logger.handlers):
|
|
67
|
+
if isinstance(handler, DiagnosticHandler):
|
|
68
|
+
logger.removeHandler(handler)
|
|
69
|
+
handler.close()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def configure_logging(*, level: int = logging.INFO) -> Path:
|
|
73
|
+
path = log_path()
|
|
74
|
+
stop_logging()
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
76
|
+
path.parent.chmod(0o700)
|
|
77
|
+
fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600)
|
|
78
|
+
os.close(fd)
|
|
79
|
+
path.chmod(0o600)
|
|
80
|
+
handler = DiagnosticHandler(path, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8")
|
|
81
|
+
handler.setFormatter(
|
|
82
|
+
DiagnosticFormatter("%(asctime)sZ %(levelname)s pid=%(process)d %(name)s %(message)s")
|
|
83
|
+
)
|
|
84
|
+
logger = logging.getLogger("diffbutler")
|
|
85
|
+
logger.setLevel(level)
|
|
86
|
+
logger.propagate = False
|
|
87
|
+
logger.addHandler(handler)
|
|
88
|
+
if level <= logging.DEBUG:
|
|
89
|
+
logger.debug("Debug diagnostics enabled version=%s", version("diffbutler"))
|
|
90
|
+
return path
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Read the user's configuration verbatim, creating defaults only when absent."""
|
|
2
|
+
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from diffbutler.config.settings import ConfigError, config_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def show_config_file(path: Path | None = None) -> tuple[Path, str]:
|
|
10
|
+
selected = (path or config_path()).expanduser().absolute()
|
|
11
|
+
try:
|
|
12
|
+
try:
|
|
13
|
+
content = selected.read_text(encoding="utf-8")
|
|
14
|
+
except FileNotFoundError:
|
|
15
|
+
defaults = (
|
|
16
|
+
files("diffbutler.config").joinpath("default.toml").read_text(encoding="utf-8")
|
|
17
|
+
)
|
|
18
|
+
selected.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
try:
|
|
20
|
+
# Exclusive creation preserves files created since the initial read.
|
|
21
|
+
with selected.open("x", encoding="utf-8") as stream:
|
|
22
|
+
stream.write(defaults)
|
|
23
|
+
except FileExistsError:
|
|
24
|
+
pass
|
|
25
|
+
content = selected.read_text(encoding="utf-8")
|
|
26
|
+
except OSError, UnicodeError:
|
|
27
|
+
raise ConfigError(
|
|
28
|
+
f"config: could not read or create configuration file: {selected}"
|
|
29
|
+
) from None
|
|
30
|
+
return selected, content
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""One CLI/TUI process per standard data home, using a kernel-held advisory lock."""
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from diffbutler.config.settings import ConfigError, data_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def instance_lock_path() -> Path:
|
|
14
|
+
# Outside the reset target: unlinking a held flock file permits a second inode/owner.
|
|
15
|
+
return data_path().parent / ".diffbutler-instance.lock"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@contextmanager
|
|
19
|
+
def single_instance() -> Generator[None]:
|
|
20
|
+
"""Keep a stable inode; a leftover PID is diagnostic only, never ownership proof."""
|
|
21
|
+
path = instance_lock_path()
|
|
22
|
+
fd: int | None = None
|
|
23
|
+
try:
|
|
24
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
|
|
26
|
+
info = os.fstat(fd)
|
|
27
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1:
|
|
28
|
+
raise ConfigError("Invalid DiffButler instance lock; check its ownership and file type")
|
|
29
|
+
try:
|
|
30
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
31
|
+
except BlockingIOError:
|
|
32
|
+
pid = os.pread(fd, 32, 0).strip()
|
|
33
|
+
owner = f" (PID {int(pid)})" if pid and pid.isdigit() else ""
|
|
34
|
+
raise ConfigError(
|
|
35
|
+
f"DiffButler is already running{owner}; close it before starting another command "
|
|
36
|
+
"or resetting. Do not delete the instance lock file."
|
|
37
|
+
) from None
|
|
38
|
+
os.fchmod(fd, 0o600)
|
|
39
|
+
os.ftruncate(fd, 0)
|
|
40
|
+
os.write(fd, f"{os.getpid()}\n".encode("ascii"))
|
|
41
|
+
except OSError:
|
|
42
|
+
if fd is not None:
|
|
43
|
+
os.close(fd)
|
|
44
|
+
raise ConfigError(
|
|
45
|
+
"Could not acquire the DiffButler instance lock; check permissions"
|
|
46
|
+
) from None
|
|
47
|
+
except BaseException:
|
|
48
|
+
if fd is not None:
|
|
49
|
+
os.close(fd)
|
|
50
|
+
raise
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
# Closing releases the kernel lock. Never unlink, including after a clean exit.
|
|
55
|
+
os.close(fd)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Validated local configuration; loading never starts reviews or contacts a forge."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal, Self, cast
|
|
7
|
+
|
|
8
|
+
from pydantic import (
|
|
9
|
+
BaseModel,
|
|
10
|
+
ConfigDict,
|
|
11
|
+
Field,
|
|
12
|
+
PrivateAttr,
|
|
13
|
+
ValidationError,
|
|
14
|
+
field_validator,
|
|
15
|
+
model_validator,
|
|
16
|
+
)
|
|
17
|
+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
|
|
18
|
+
from pydantic_settings.exceptions import SettingsError
|
|
19
|
+
from pydantic_settings.sources import InitSettingsSource
|
|
20
|
+
|
|
21
|
+
from diffbutler.domain.forge import PRQuery
|
|
22
|
+
|
|
23
|
+
NATIVE_PRESETS: tuple[Literal["codex", "claude"], ...] = ("codex", "claude")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigError(ValueError):
|
|
27
|
+
"""A user-facing configuration error with no input values or secrets."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Section(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class General(Section):
|
|
35
|
+
workspace_root: Path = Field(default_factory=lambda: data_path() / "reviews")
|
|
36
|
+
repository_root: Path = Field(default_factory=lambda: data_path() / "repos")
|
|
37
|
+
repository_retention_days: int = Field(default=30, ge=0)
|
|
38
|
+
snapshot_root: Path | None = None
|
|
39
|
+
poll_interval_s: int | None = Field(default=None, gt=0)
|
|
40
|
+
max_concurrent_reviews: int = Field(default=2, gt=0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def data_path() -> Path:
|
|
44
|
+
return (
|
|
45
|
+
Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))).expanduser()
|
|
46
|
+
/ "diffbutler"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Database(Section):
|
|
51
|
+
url: str = Field(default_factory=lambda: f"sqlite+aiosqlite:///{data_path() / 'diffbutler.db'}")
|
|
52
|
+
auto_migrate: bool = True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Forge(Section):
|
|
56
|
+
kind: Literal["github", "gitlab"]
|
|
57
|
+
base_url: str
|
|
58
|
+
token_env: str | None = Field(default=None, pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
59
|
+
include_team_requests: bool = True
|
|
60
|
+
login: str | None = None
|
|
61
|
+
repositories: list[str] = Field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
@field_validator("repositories")
|
|
64
|
+
@classmethod
|
|
65
|
+
def valid_repositories(cls, values: list[str]) -> list[str]:
|
|
66
|
+
for value in values:
|
|
67
|
+
PRQuery(repository=value)
|
|
68
|
+
return values
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Review(Section):
|
|
72
|
+
default_reviewer: str | None = None
|
|
73
|
+
findings_threshold: Literal["blocking", "important", "minor", "nit", "question"] = "minor"
|
|
74
|
+
min_confidence: float = Field(default=0.5, ge=0, le=1)
|
|
75
|
+
# Accepted for compatibility with existing configuration; no longer enforced.
|
|
76
|
+
max_diff_bytes: int | None = Field(default=None, exclude=True)
|
|
77
|
+
exclude_globs: list[str] = Field(
|
|
78
|
+
default_factory=lambda: ["**/*.lock", "**/vendor/**", "**/*.min.js", "**/generated/**"]
|
|
79
|
+
)
|
|
80
|
+
mode: Literal["source_only"] = "source_only"
|
|
81
|
+
checkout: Literal["worktree", "mediated"] = "worktree"
|
|
82
|
+
prompt: Path | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Reviewer(Section):
|
|
86
|
+
id: str = Field(min_length=1)
|
|
87
|
+
kind: Literal["cli"] = "cli"
|
|
88
|
+
command: list[str] = Field(min_length=1)
|
|
89
|
+
preset: Literal["codex", "claude"] | None = None
|
|
90
|
+
# Accepted for compatibility with existing configuration; no longer enforced.
|
|
91
|
+
max_output_bytes: int | None = Field(default=None, exclude=True)
|
|
92
|
+
timeout_s: int = Field(default=900, gt=0)
|
|
93
|
+
parse: Literal["json_envelope", "jsonl_stream"] = "json_envelope"
|
|
94
|
+
json_path: str | None = None
|
|
95
|
+
stdin: Literal["prompt"] | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Attention(Section):
|
|
99
|
+
blocking: int = 100
|
|
100
|
+
important: int = 40
|
|
101
|
+
age_pressure_per_day: int = 5
|
|
102
|
+
size_penalty_per_100_lines: int = 2
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class UI(Section):
|
|
106
|
+
include_own_prs: bool = False
|
|
107
|
+
include_assigned_prs: bool = False
|
|
108
|
+
browser_command: list[str] | None = Field(default=None, min_length=1)
|
|
109
|
+
|
|
110
|
+
@model_validator(mode="after")
|
|
111
|
+
def known_columns(self) -> Self:
|
|
112
|
+
from diffbutler.domain.dashboard import COLUMN_IDS
|
|
113
|
+
|
|
114
|
+
if not self.columns or len(set(self.columns)) != len(self.columns):
|
|
115
|
+
raise ValueError("ui.columns must be nonempty and unique")
|
|
116
|
+
if any(column not in COLUMN_IDS for column in [*self.columns, self.sort_by]):
|
|
117
|
+
raise ValueError("Unknown column; valid columns: " + ", ".join(COLUMN_IDS))
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
columns: list[str] = Field(
|
|
121
|
+
default_factory=lambda: [
|
|
122
|
+
"number",
|
|
123
|
+
"title",
|
|
124
|
+
"author",
|
|
125
|
+
"state",
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
sort_by: str = "attention"
|
|
129
|
+
sort_desc: bool = True
|
|
130
|
+
preview_pane: bool = True
|
|
131
|
+
default_filter: Literal[
|
|
132
|
+
"active", "requested", "following", "all", "triage", "posted", "snoozed", "history"
|
|
133
|
+
] = "active"
|
|
134
|
+
attention: Attention = Field(default_factory=Attention)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Repository(Section):
|
|
138
|
+
enabled: bool = True
|
|
139
|
+
path: Path | None = None
|
|
140
|
+
default_reviewer: str | None = None
|
|
141
|
+
prompt: Path | None = None
|
|
142
|
+
checkout: Literal["worktree", "mediated"] | None = None
|
|
143
|
+
git_transport: Literal["auto", "https", "ssh"] = "auto"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Settings(BaseSettings):
|
|
147
|
+
_config_file: Path | None = PrivateAttr(default=None)
|
|
148
|
+
model_config = SettingsConfigDict(
|
|
149
|
+
env_prefix="DIFFBUTLER_", env_nested_delimiter="__", extra="forbid"
|
|
150
|
+
)
|
|
151
|
+
general: General = Field(default_factory=General)
|
|
152
|
+
db: Database = Field(default_factory=Database)
|
|
153
|
+
forges: list[Forge] = Field(default_factory=list[Forge])
|
|
154
|
+
review: Review = Field(default_factory=Review)
|
|
155
|
+
reviewers: list[Reviewer] = Field(default_factory=list[Reviewer])
|
|
156
|
+
ui: UI = Field(default_factory=UI)
|
|
157
|
+
keys: dict[str, list[str]] = Field(default_factory=lambda: {"open_in_browser": ["o", "ctrl+o"]})
|
|
158
|
+
repos: dict[str, Repository] = Field(default_factory=dict)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def config_file(self) -> Path | None:
|
|
162
|
+
return self._config_file
|
|
163
|
+
|
|
164
|
+
@config_file.setter
|
|
165
|
+
def config_file(self, path: Path) -> None:
|
|
166
|
+
self._config_file = path
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def effective_poll_interval_s(self) -> int:
|
|
170
|
+
if self.general.poll_interval_s is not None:
|
|
171
|
+
return self.general.poll_interval_s
|
|
172
|
+
# One poll refreshes every account; retain the public cadence in mixed setups.
|
|
173
|
+
authenticated = self.forges and all(forge.token_env is not None for forge in self.forges)
|
|
174
|
+
return 60 if authenticated else 600
|
|
175
|
+
|
|
176
|
+
@field_validator("keys", mode="before")
|
|
177
|
+
@classmethod
|
|
178
|
+
def key_lists(cls, value: object) -> object:
|
|
179
|
+
if isinstance(value, dict):
|
|
180
|
+
return {
|
|
181
|
+
key: [keys] if isinstance(keys, str) else keys
|
|
182
|
+
for key, keys in cast(dict[str, object], value).items()
|
|
183
|
+
}
|
|
184
|
+
return value
|
|
185
|
+
|
|
186
|
+
@model_validator(mode="after")
|
|
187
|
+
def reviewer_references(self) -> Self:
|
|
188
|
+
ids = [reviewer.id for reviewer in self.reviewers]
|
|
189
|
+
if len(ids) != len(set(ids)):
|
|
190
|
+
raise ValueError("reviewers.id must be unique")
|
|
191
|
+
for name in [
|
|
192
|
+
self.review.default_reviewer,
|
|
193
|
+
*(repo.default_reviewer for repo in self.repos.values()),
|
|
194
|
+
]:
|
|
195
|
+
if name is not None and name not in ids:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
"review.default_reviewer / repos.default_reviewer "
|
|
198
|
+
"must name a configured reviewers.id"
|
|
199
|
+
)
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def config_path() -> Path:
|
|
204
|
+
return (
|
|
205
|
+
Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))).expanduser()
|
|
206
|
+
/ "diffbutler/config.toml"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def load_settings(
|
|
211
|
+
path: Path | None = None,
|
|
212
|
+
*,
|
|
213
|
+
repo: str | None = None,
|
|
214
|
+
overrides: dict[str, Any] | None = None,
|
|
215
|
+
missing_ok: bool = False,
|
|
216
|
+
) -> Settings:
|
|
217
|
+
"""CLI > environment > selected repo review options > TOML > defaults.
|
|
218
|
+
|
|
219
|
+
Only the implicit default config may be missing unless startup opts in with
|
|
220
|
+
missing_ok. No dotenv or repository code is loaded. Relative configured paths
|
|
221
|
+
are relative to the config directory.
|
|
222
|
+
"""
|
|
223
|
+
selected = (path or config_path()).expanduser().absolute()
|
|
224
|
+
try:
|
|
225
|
+
with selected.open("rb") as stream:
|
|
226
|
+
document = tomllib.load(stream)
|
|
227
|
+
except FileNotFoundError:
|
|
228
|
+
if path is not None and not missing_ok:
|
|
229
|
+
raise ConfigError("config: explicitly selected file does not exist") from None
|
|
230
|
+
document = {}
|
|
231
|
+
except tomllib.TOMLDecodeError as exc:
|
|
232
|
+
raise ConfigError(
|
|
233
|
+
f"config: invalid TOML at line {exc.lineno}, column {exc.colno}"
|
|
234
|
+
) from None
|
|
235
|
+
except OSError:
|
|
236
|
+
raise ConfigError("config: cannot read configuration file") from None
|
|
237
|
+
|
|
238
|
+
repo_values: dict[str, Any] = {}
|
|
239
|
+
repositories = document.get("repos", {})
|
|
240
|
+
if repo is not None and isinstance(repositories, dict):
|
|
241
|
+
entry = cast(dict[str, Any], repositories).get(repo, {})
|
|
242
|
+
if isinstance(entry, dict):
|
|
243
|
+
repo_values = {
|
|
244
|
+
"review": {
|
|
245
|
+
key: value
|
|
246
|
+
for key, value in cast(dict[str, Any], entry).items()
|
|
247
|
+
if key in {"default_reviewer", "prompt", "checkout"}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
class LoadedSettings(Settings):
|
|
252
|
+
@classmethod
|
|
253
|
+
def settings_customise_sources(
|
|
254
|
+
cls,
|
|
255
|
+
settings_cls: type[BaseSettings],
|
|
256
|
+
init_settings: PydanticBaseSettingsSource,
|
|
257
|
+
env_settings: PydanticBaseSettingsSource,
|
|
258
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
259
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
260
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
261
|
+
return (
|
|
262
|
+
init_settings,
|
|
263
|
+
env_settings,
|
|
264
|
+
InitSettingsSource(settings_cls, repo_values),
|
|
265
|
+
InitSettingsSource(settings_cls, document),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
result = LoadedSettings(**(overrides or {}))
|
|
270
|
+
result.config_file = selected
|
|
271
|
+
except ValidationError as exc:
|
|
272
|
+
errors = [
|
|
273
|
+
f"{'.'.join(map(str, error['loc'])) or 'config'}: {error['msg']}"
|
|
274
|
+
for error in exc.errors(include_input=False)
|
|
275
|
+
]
|
|
276
|
+
raise ConfigError("; ".join(errors)) from None
|
|
277
|
+
except SettingsError:
|
|
278
|
+
raise ConfigError(
|
|
279
|
+
"environment: invalid DIFFBUTLER_* value; use JSON for lists and objects"
|
|
280
|
+
) from None
|
|
281
|
+
|
|
282
|
+
def resolve(value: Path) -> Path:
|
|
283
|
+
value = value.expanduser()
|
|
284
|
+
return value if value.is_absolute() else selected.parent / value
|
|
285
|
+
|
|
286
|
+
result.general.workspace_root = resolve(result.general.workspace_root)
|
|
287
|
+
result.general.repository_root = resolve(result.general.repository_root)
|
|
288
|
+
if result.general.snapshot_root is not None:
|
|
289
|
+
result.general.snapshot_root = resolve(result.general.snapshot_root)
|
|
290
|
+
if result.review.prompt is not None:
|
|
291
|
+
result.review.prompt = resolve(result.review.prompt)
|
|
292
|
+
for repository in result.repos.values():
|
|
293
|
+
if repository.path is not None:
|
|
294
|
+
repository.path = resolve(repository.path)
|
|
295
|
+
if repository.prompt is not None:
|
|
296
|
+
repository.prompt = resolve(repository.prompt)
|
|
297
|
+
return result
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Persist first-run choices while preserving unrelated TOML and comments."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections.abc import MutableMapping, Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import tomlkit
|
|
10
|
+
from tomlkit.exceptions import TOMLKitError
|
|
11
|
+
|
|
12
|
+
from diffbutler.config.settings import NATIVE_PRESETS, ConfigError, Forge, Reviewer, config_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def save_setup(
|
|
16
|
+
path: Path | None,
|
|
17
|
+
forge: Forge | None,
|
|
18
|
+
repository_path: Path | None,
|
|
19
|
+
reviewers: Sequence[Reviewer] = (),
|
|
20
|
+
default_reviewer: str | None = None,
|
|
21
|
+
) -> Path:
|
|
22
|
+
selected = (path or config_path()).expanduser().resolve()
|
|
23
|
+
try:
|
|
24
|
+
original = selected.read_text(encoding="utf-8") if selected.exists() else None
|
|
25
|
+
document = cast(MutableMapping[str, Any], tomlkit.parse(original or ""))
|
|
26
|
+
if forge is not None:
|
|
27
|
+
if document.get("forges"):
|
|
28
|
+
raise ConfigError("Forge configuration changed during setup; restart DiffButler")
|
|
29
|
+
document["forges"] = [forge.model_dump(exclude_none=True)]
|
|
30
|
+
if reviewers:
|
|
31
|
+
entries = document.get("reviewers", [])
|
|
32
|
+
for reviewer in reviewers:
|
|
33
|
+
if any(entry["id"] == reviewer.id for entry in entries):
|
|
34
|
+
raise ConfigError(
|
|
35
|
+
"Reviewer configuration changed during setup; restart DiffButler"
|
|
36
|
+
)
|
|
37
|
+
entries.append(reviewer.model_dump(exclude_none=True))
|
|
38
|
+
document["reviewers"] = entries
|
|
39
|
+
if default_reviewer is not None:
|
|
40
|
+
if "review" not in document:
|
|
41
|
+
document["review"] = tomlkit.table()
|
|
42
|
+
document["review"]["default_reviewer"] = default_reviewer
|
|
43
|
+
if repository_path is not None and forge is not None:
|
|
44
|
+
repos = document.get("repos")
|
|
45
|
+
if repos is None:
|
|
46
|
+
repos = tomlkit.table()
|
|
47
|
+
document["repos"] = repos
|
|
48
|
+
if not isinstance(repos, MutableMapping):
|
|
49
|
+
raise ConfigError("Use a [repos] table to save local repository paths")
|
|
50
|
+
repos = cast(MutableMapping[str, Any], repos)
|
|
51
|
+
repository = forge.repositories[0]
|
|
52
|
+
if repository not in repos:
|
|
53
|
+
repos[repository] = {"path": str(repository_path)}
|
|
54
|
+
else:
|
|
55
|
+
entry = cast(MutableMapping[str, Any], repos[repository])
|
|
56
|
+
if "path" not in entry:
|
|
57
|
+
entry["path"] = str(repository_path)
|
|
58
|
+
content = tomlkit.dumps(document)
|
|
59
|
+
if original is None:
|
|
60
|
+
for preset in NATIVE_PRESETS:
|
|
61
|
+
if any(entry.get("preset") == preset for entry in document.get("reviewers", [])):
|
|
62
|
+
continue
|
|
63
|
+
example = tomlkit.dumps(
|
|
64
|
+
{"reviewers": [{"id": preset, "preset": preset, "command": [preset]}]}
|
|
65
|
+
)
|
|
66
|
+
content += (
|
|
67
|
+
f"\n# {preset} was not found in PATH; install it and uncomment to enable.\n"
|
|
68
|
+
+ "\n".join(f"# {line}" for line in example.splitlines())
|
|
69
|
+
+ "\n"
|
|
70
|
+
)
|
|
71
|
+
selected.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
temporary: Path | None = None
|
|
73
|
+
try:
|
|
74
|
+
with tempfile.NamedTemporaryFile(
|
|
75
|
+
mode="w", encoding="utf-8", dir=selected.parent, delete=False
|
|
76
|
+
) as stream:
|
|
77
|
+
temporary = Path(stream.name)
|
|
78
|
+
stream.write(content)
|
|
79
|
+
if original is not None:
|
|
80
|
+
if selected.read_text(encoding="utf-8") != original:
|
|
81
|
+
raise ConfigError("Configuration changed during setup; restart DiffButler")
|
|
82
|
+
temporary.chmod(selected.stat().st_mode & 0o777)
|
|
83
|
+
os.replace(temporary, selected)
|
|
84
|
+
else:
|
|
85
|
+
# Atomic publication without overwriting a concurrently created file.
|
|
86
|
+
os.link(temporary, selected)
|
|
87
|
+
finally:
|
|
88
|
+
if temporary is not None:
|
|
89
|
+
temporary.unlink(missing_ok=True)
|
|
90
|
+
except OSError, UnicodeError, TOMLKitError:
|
|
91
|
+
raise ConfigError(f"Could not save startup configuration: {selected}") from None
|
|
92
|
+
return selected
|