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.
Files changed (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. 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,6 @@
1
+ """Support ``python -m diffbutler``."""
2
+
3
+ from diffbutler.ui.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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