diffbutler 0.6.1__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. {diffbutler-0.6.1 → diffbutler-0.7.0}/PKG-INFO +3 -2
  2. {diffbutler-0.6.1 → diffbutler-0.7.0}/README.md +1 -0
  3. {diffbutler-0.6.1 → diffbutler-0.7.0}/pyproject.toml +4 -3
  4. {diffbutler-0.6.1 → diffbutler-0.7.0}/pyproject.toml.orig +4 -3
  5. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/default.toml +0 -3
  6. diffbutler-0.7.0/src/diffbutler/config/editor.py +535 -0
  7. diffbutler-0.7.0/src/diffbutler/config/file.py +70 -0
  8. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/settings.py +100 -17
  9. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/setup.py +16 -27
  10. diffbutler-0.7.0/src/diffbutler/config/transport.py +58 -0
  11. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/context.py +3 -1
  12. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/dashboard.py +13 -5
  13. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/managed.py +1 -0
  14. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/native.py +29 -5
  15. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/polling.py +22 -0
  16. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/workspaces.py +54 -39
  17. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/polling.py +7 -2
  18. diffbutler-0.7.0/src/diffbutler/domain/redaction.py +90 -0
  19. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/sync.py +42 -3
  20. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/base.py +25 -0
  21. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/adapter.py +19 -12
  22. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/native.py +22 -4
  23. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/presets.toml +4 -2
  24. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/sources/git.py +62 -32
  25. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/database.py +66 -10
  26. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/lifecycle.py +2 -0
  27. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/queue.py +69 -12
  28. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/review.py +4 -0
  29. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/cli/__init__.py +15 -6
  30. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/_foreground_exec.py +2 -1
  31. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/dashboard.py +55 -4
  32. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/foreground.py +6 -1
  33. diffbutler-0.7.0/src/diffbutler/ui/tui/settings.py +337 -0
  34. diffbutler-0.6.1/src/diffbutler/config/file.py +0 -30
  35. diffbutler-0.6.1/src/diffbutler/config/transport.py +0 -65
  36. diffbutler-0.6.1/src/diffbutler/domain/redaction.py +0 -61
  37. {diffbutler-0.6.1 → diffbutler-0.7.0}/LICENSE.txt +0 -0
  38. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/__init__.py +0 -0
  39. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/__main__.py +0 -0
  40. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/__init__.py +0 -0
  41. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/diagnostics.py +0 -0
  42. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/config/instance.py +0 -0
  43. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/__init__.py +0 -0
  44. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/application.py +0 -0
  45. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/doctor.py +0 -0
  46. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/lifecycle.py +0 -0
  47. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/pr_lookup.py +0 -0
  48. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/process.py +0 -0
  49. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/repositories.py +0 -0
  50. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/reset.py +0 -0
  51. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/resources.py +0 -0
  52. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/review.py +0 -0
  53. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/setup.py +0 -0
  54. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/snapshot.py +0 -0
  55. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/sync.py +0 -0
  56. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/core/worker.py +0 -0
  57. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/__init__.py +0 -0
  58. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/dashboard.py +0 -0
  59. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/diff.py +0 -0
  60. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/forge.py +0 -0
  61. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/lifecycle.py +0 -0
  62. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/process.py +0 -0
  63. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/resources.py +0 -0
  64. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/domain/review.py +0 -0
  65. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/__init__.py +0 -0
  66. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/diagnostics.py +0 -0
  67. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/__init__.py +0 -0
  68. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/auth.py +0 -0
  69. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/http.py +0 -0
  70. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/mapping.py +0 -0
  71. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/repositories.py +0 -0
  72. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/github/setup.py +0 -0
  73. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/forges/registry.py +0 -0
  74. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/py.typed +0 -0
  75. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/__init__.py +0 -0
  76. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/base.py +0 -0
  77. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/extraction.py +0 -0
  78. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/parsing.py +0 -0
  79. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/process.py +0 -0
  80. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/prompt.py +0 -0
  81. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/redaction.py +0 -0
  82. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/reviewers/resources.py +0 -0
  83. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/sources/__init__.py +0 -0
  84. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/sources/diagnostics.py +0 -0
  85. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/sources/fetch.py +0 -0
  86. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/sources/remotes.py +0 -0
  87. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/__init__.py +0 -0
  88. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/base.py +0 -0
  89. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/dashboard.py +0 -0
  90. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/__init__.py +0 -0
  91. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/env.py +0 -0
  92. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0001_initial.py +0 -0
  93. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0002_monitoring.py +0 -0
  94. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0003_reviews.py +0 -0
  95. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0004_claims.py +0 -0
  96. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0005_results.py +0 -0
  97. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0006_anchors.py +0 -0
  98. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0007_lifecycle.py +0 -0
  99. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0008_processes.py +0 -0
  100. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0009_dashboard.py +0 -0
  101. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0010_run_reasons.py +0 -0
  102. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/0011_explicit_reviews.py +0 -0
  103. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/migrations/versions/__init__.py +0 -0
  104. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/models.py +0 -0
  105. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/store/process.py +0 -0
  106. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/__init__.py +0 -0
  107. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/cli/editor.py +0 -0
  108. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/__init__.py +0 -0
  109. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/_fake_agent.py +0 -0
  110. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/app.py +0 -0
  111. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/browse.py +0 -0
  112. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/browser.py +0 -0
  113. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/handoff_probe.py +0 -0
  114. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/review_prompt.py +0 -0
  115. {diffbutler-0.6.1 → diffbutler-0.7.0}/src/diffbutler/ui/tui/terminal_theme.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffbutler
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: A terminal dashboard for human-directed AI code reviews.
5
5
  Keywords: code-review,github,terminal,ai
6
6
  License-Expression: MIT
7
7
  License-File: LICENSE.txt
8
- Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Development Status :: 4 - Beta
9
9
  Classifier: Environment :: Console
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -149,6 +149,7 @@ and read-only review instructions; source sent to the agent may go to its model
149
149
  - `k`: Stop the selected PR's review and reset its local tracking and owned resources.
150
150
  It disappears until a forge refresh rediscovers it; review history is retained.
151
151
  - `h`: View completed review history.
152
+ - `,`: Edit settings; saved changes apply without restarting.
152
153
  - `?`: Show all keyboard shortcuts.
153
154
  - `q`: Quit.
154
155
 
@@ -121,6 +121,7 @@ and read-only review instructions; source sent to the agent may go to its model
121
121
  - `k`: Stop the selected PR's review and reset its local tracking and owned resources.
122
122
  It disappears until a forge refresh rediscovers it; review history is retained.
123
123
  - `h`: View completed review history.
124
+ - `,`: Edit settings; saved changes apply without restarting.
124
125
  - `?`: Show all keyboard shortcuts.
125
126
  - `q`: Quit.
126
127
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffbutler"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  description = "A terminal dashboard for human-directed AI code reviews."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -13,7 +13,7 @@ keywords = [
13
13
  "ai",
14
14
  ]
15
15
  classifiers = [
16
- "Development Status :: 3 - Alpha",
16
+ "Development Status :: 4 - Beta",
17
17
  "Environment :: Console",
18
18
  "Intended Audience :: Developers",
19
19
  "Programming Language :: Python :: 3 :: Only",
@@ -53,11 +53,12 @@ dev = [
53
53
  "pytest-cov>=7.1.0",
54
54
  "pytest-xdist>=3.8.0",
55
55
  "ruff>=0.16.5",
56
+ "zizmor>=1.32.0",
56
57
  ]
57
58
 
58
59
  [tool.uv]
59
60
  required-version = ">=0.12.13"
60
- exclude-newer = "2 days"
61
+ exclude-newer = "8 days"
61
62
 
62
63
  [tool.ruff]
63
64
  target-version = "py314"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffbutler"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  description = "A terminal dashboard for human-directed AI code reviews."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -8,7 +8,7 @@ license = "MIT"
8
8
  license-files = ["LICENSE.txt"]
9
9
  keywords = ["code-review", "github", "terminal", "ai"]
10
10
  classifiers = [
11
- "Development Status :: 3 - Alpha",
11
+ "Development Status :: 4 - Beta",
12
12
  "Environment :: Console",
13
13
  "Intended Audience :: Developers",
14
14
  "Programming Language :: Python :: 3 :: Only",
@@ -48,12 +48,13 @@ dev = [
48
48
  "pytest-cov>=7.1.0",
49
49
  "pytest-xdist>=3.8.0",
50
50
  "ruff>=0.16.5",
51
+ "zizmor>=1.32.0",
51
52
  ]
52
53
 
53
54
  [tool.uv]
54
55
  required-version = ">=0.12.13"
55
56
  # Give new dependency releases time to settle before routine upgrades.
56
- exclude-newer = "2 days"
57
+ exclude-newer = "8 days"
57
58
 
58
59
  [tool.ruff]
59
60
  target-version = "py314"
@@ -27,9 +27,7 @@ auto_migrate = true
27
27
  [review]
28
28
  findings_threshold = "minor" # blocking | important | minor | nit | question
29
29
  min_confidence = 0.5
30
- exclude_globs = ["**/*.lock", "**/vendor/**", "**/*.min.js", "**/generated/**"]
31
30
  mode = "source_only"
32
- checkout = "worktree" # Native reviews currently use pinned source snapshots.
33
31
  # default_reviewer = "codex"
34
32
  # prompt = "~/review-instructions.md"
35
33
 
@@ -60,7 +58,6 @@ open_in_browser = ["o", "ctrl+o"]
60
58
  # git_transport = "auto" # auto | https | ssh; remembers the last successful fetch.
61
59
  # default_reviewer = "codex"
62
60
  # prompt = "~/review-instructions.md"
63
- # checkout = "mediated"
64
61
 
65
62
  # Remove the top-level forges = [] before adding forge accounts:
66
63
  # [[forges]]
@@ -0,0 +1,535 @@
1
+ """Edit configuration values in place, preserving comments and concurrent file changes.
2
+
3
+ The editor works on what the file says, not on effective settings: an unset value
4
+ shows its built-in default and is only written once changed. Saving re-reads the
5
+ file under the config lock and applies just the edited values, so background
6
+ updates such as a remembered Git transport survive. A value edited both here and
7
+ in the file since opening is reported as a conflict instead of being overwritten.
8
+ """
9
+
10
+ import copy
11
+ import os
12
+ import shlex
13
+ import tomllib
14
+ import types
15
+ from collections.abc import Mapping, MutableMapping, MutableSequence
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any, Literal, Union, cast, get_args, get_origin
19
+
20
+ import tomlkit
21
+ from pydantic import BaseModel
22
+ from tomlkit.exceptions import TOMLKitError
23
+ from tomlkit.items import AoT, InlineTable
24
+
25
+ from diffbutler.config.file import config_lock, publish_config
26
+ from diffbutler.config.settings import (
27
+ UI,
28
+ ConfigError,
29
+ Database,
30
+ Forge,
31
+ General,
32
+ Repository,
33
+ Review,
34
+ Reviewer,
35
+ config_path,
36
+ validate_document,
37
+ )
38
+ from diffbutler.domain.dashboard import COLUMN_IDS
39
+
40
+ type Value = bool | int | float | str | list[str] | None
41
+ FieldKind = Literal["bool", "choice", "int", "float", "text", "words"]
42
+ SectionKind = Literal["table", "list", "map"]
43
+
44
+ HELP: dict[str, str] = {
45
+ "general.workspace_root": "Review home for session workspaces and conversations. Existing "
46
+ "data is not moved; DiffButler uses the new location from its next operation.",
47
+ "general.repository_root": "Shared bare Git repositories. Existing data is not moved; "
48
+ "DiffButler uses the new location from its next operation.",
49
+ "general.repository_retention_days": "Evict unused shared repositories after this many days.",
50
+ "general.snapshot_root": "Optional private directory for reconstructible source snapshots, "
51
+ "separate from the other managed roots.",
52
+ "general.poll_interval_s": "Seconds between forge refreshes. Empty uses 60 with "
53
+ "authenticated accounts and 600 with any public account; low quota slows polling further.",
54
+ "general.max_concurrent_reviews": "Background AI reviews allowed to run at once.",
55
+ "db.url": "SQLite database URL. Existing data is not moved; DiffButler uses the new "
56
+ "database from its next operation.",
57
+ "db.auto_migrate": "Back up and migrate an older database schema automatically.",
58
+ "review.default_reviewer": "Reviewer ID used when none is chosen; must name a reviewer.",
59
+ "review.findings_threshold": "Lowest severity shown as a finding.",
60
+ "review.min_confidence": "Lowest reviewer confidence (0 to 1) shown as a finding.",
61
+ "review.mode": "Background reviews only read source.",
62
+ "review.prompt": "Optional file with extra review instructions.",
63
+ "forges.kind": "Forge type; only GitHub is implemented.",
64
+ "forges.base_url": "API URL, for example https://api.github.com.",
65
+ "forges.token_env": "Name of the environment variable holding the token, never the token. "
66
+ "Empty uses public access, which requires a login.",
67
+ "forges.include_team_requests": "Include review requests made to your teams.",
68
+ "forges.login": "Your username on this forge.",
69
+ "forges.repositories": "Monitored repositories as owner/repo or owner/*, space-separated. "
70
+ "Empty searches review requests in all accessible repositories.",
71
+ "reviewers.id": "Unique name used to select this reviewer.",
72
+ "reviewers.kind": "Reviewer type.",
73
+ "reviewers.command": "Executable and arguments, space-separated with shell-style quoting; "
74
+ "no shell runs.",
75
+ "reviewers.preset": "Native Codex or Claude Code integration.",
76
+ "reviewers.timeout_s": "Seconds before a background review is stopped.",
77
+ "reviewers.parse": "Output format of custom reviewers.",
78
+ "reviewers.json_path": "Field holding the review in JSON output.",
79
+ "reviewers.stdin": "Send the prompt on standard input.",
80
+ "ui.include_own_prs": "Show PRs authored by your account in the queue.",
81
+ "ui.include_assigned_prs": "Show PRs assigned to your account in the queue.",
82
+ "ui.browser_command": "Browser command and arguments; the PR URL is appended. "
83
+ "Empty uses the system browser.",
84
+ "ui.columns": "Queue columns, space-separated: " + " ".join(COLUMN_IDS) + ".",
85
+ "ui.sort_by": "Default sort column; a sort chosen in the dashboard takes precedence.",
86
+ "ui.sort_desc": "Sort in descending order by default.",
87
+ "ui.preview_pane": "Show details of the selected PR below the queue.",
88
+ "ui.default_filter": "View shown when the dashboard starts.",
89
+ "ui.attention.blocking": "Attention score added for blocking findings.",
90
+ "ui.attention.important": "Attention score added for important findings.",
91
+ "ui.attention.age_pressure_per_day": "Attention score added per day of PR age.",
92
+ "ui.attention.size_penalty_per_100_lines": "Attention score removed per 100 changed lines.",
93
+ "repos.enabled": "Repository AI opt-in metadata; it never starts a review.",
94
+ "repos.path": "Local checkout used as a source for this repository.",
95
+ "repos.default_reviewer": "Reviewer ID for this repository; overrides the review default.",
96
+ "repos.prompt": "Review instructions file for this repository.",
97
+ "repos.git_transport": "Preferred Git transport; a successful fetch remembers it.",
98
+ "keys.keys": "Keys for this dashboard action, space-separated, for example: o ctrl+o.",
99
+ }
100
+ CHOICES: dict[str, tuple[str, ...]] = {"ui.sort_by": COLUMN_IDS}
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class FieldSpec:
105
+ label: str # Dotted path within its table, e.g. "attention.blocking".
106
+ kind: FieldKind
107
+ optional: bool
108
+ required: bool
109
+ default: Value
110
+ choices: tuple[str, ...] = ()
111
+ help: str = ""
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class SectionSpec:
116
+ name: str
117
+ title: str
118
+ kind: SectionKind
119
+ fields: tuple[FieldSpec, ...]
120
+ entry_title: str = ""
121
+
122
+
123
+ @dataclass
124
+ class Entry:
125
+ """One forge, reviewer, repository or key binding; origin locates it in the file."""
126
+
127
+ values: dict[str, Value]
128
+ origin: int | str | None = None
129
+ name: str = ""
130
+
131
+
132
+ @dataclass
133
+ class Draft:
134
+ tables: dict[str, dict[str, Value]] = field(default_factory=dict[str, dict[str, Value]])
135
+ entries: dict[str, list[Entry]] = field(default_factory=dict[str, list[Entry]])
136
+
137
+
138
+ def plain(value: object) -> Value:
139
+ if isinstance(value, Path):
140
+ return str(value)
141
+ if isinstance(value, list):
142
+ return [str(item) for item in cast(list[object], value)]
143
+ return cast(Value, value)
144
+
145
+
146
+ def field_specs(section: str, model: type[BaseModel], prefix: str = "") -> list[FieldSpec]:
147
+ specs: list[FieldSpec] = []
148
+ for name, info in model.model_fields.items():
149
+ if info.exclude:
150
+ continue # Legacy settings that are accepted but ignored.
151
+ label = prefix + name
152
+ annotation: Any = info.annotation
153
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
154
+ specs.extend(field_specs(section, annotation, label + "."))
155
+ continue
156
+ optional = False
157
+ if get_origin(annotation) in {Union, types.UnionType}:
158
+ arguments = [item for item in get_args(annotation) if item is not type(None)]
159
+ optional = len(arguments) < len(get_args(annotation))
160
+ (annotation,) = arguments
161
+ choices: tuple[str, ...] = CHOICES.get(f"{section}.{label}", ())
162
+ if get_origin(annotation) is Literal:
163
+ kind: FieldKind = "choice"
164
+ choices = tuple(get_args(annotation))
165
+ elif choices:
166
+ kind = "choice"
167
+ elif annotation is bool:
168
+ kind = "bool"
169
+ elif annotation is int:
170
+ kind = "int"
171
+ elif annotation is float:
172
+ kind = "float"
173
+ elif annotation in {str, Path}:
174
+ kind = "text"
175
+ elif get_origin(annotation) is list and get_args(annotation) == (str,):
176
+ kind = "words"
177
+ else:
178
+ raise TypeError(f"No settings editor for {section}.{label}")
179
+ required = info.is_required()
180
+ specs.append(
181
+ FieldSpec(
182
+ label,
183
+ kind,
184
+ optional,
185
+ required,
186
+ None if required else plain(info.get_default(call_default_factory=True)),
187
+ choices,
188
+ HELP.get(f"{section}.{label}", ""),
189
+ )
190
+ )
191
+ return specs
192
+
193
+
194
+ SECTIONS: tuple[SectionSpec, ...] = (
195
+ SectionSpec("general", "General", "table", tuple(field_specs("general", General))),
196
+ SectionSpec("db", "Database", "table", tuple(field_specs("db", Database))),
197
+ SectionSpec("review", "Review", "table", tuple(field_specs("review", Review))),
198
+ SectionSpec(
199
+ "reviewers", "Reviewers", "list", tuple(field_specs("reviewers", Reviewer)), "Reviewer"
200
+ ),
201
+ SectionSpec("forges", "Forges", "list", tuple(field_specs("forges", Forge)), "Forge"),
202
+ SectionSpec(
203
+ "repos",
204
+ "Repositories",
205
+ "map",
206
+ tuple(field_specs("repos", Repository)),
207
+ "Repository",
208
+ ),
209
+ SectionSpec("ui", "Interface", "table", tuple(field_specs("ui", UI))),
210
+ SectionSpec(
211
+ "keys",
212
+ "Keys",
213
+ "map",
214
+ (FieldSpec("keys", "words", False, True, None, help=HELP["keys.keys"]),),
215
+ ),
216
+ )
217
+ SECTION = {section.name: section for section in SECTIONS}
218
+
219
+
220
+ EMPTY_LIST = "[]" # Distinguishes an empty list from an unset one in text input.
221
+
222
+
223
+ def format_value(spec: FieldSpec, value: Value) -> str:
224
+ if value is None:
225
+ return ""
226
+ if spec.kind == "words":
227
+ if value == []:
228
+ return EMPTY_LIST
229
+ return shlex.join([value] if isinstance(value, str) else cast(list[str], value))
230
+ if isinstance(value, bool):
231
+ return "true" if value else "false"
232
+ return str(value)
233
+
234
+
235
+ def parse_value(spec: FieldSpec, text: str) -> Value:
236
+ """Convert text input; empty means unset, so the built-in default applies."""
237
+ text = text.strip()
238
+ if not text:
239
+ return None
240
+ if spec.kind == "words" and text == EMPTY_LIST:
241
+ return []
242
+ try:
243
+ match spec.kind:
244
+ case "int":
245
+ return int(text)
246
+ case "float":
247
+ return float(text)
248
+ case "words":
249
+ return shlex.split(text)
250
+ case _:
251
+ return text
252
+ except ValueError:
253
+ expected = {"int": "a whole number", "float": "a number", "words": "matching quotes"}
254
+ raise ConfigError(f"{spec.label}: enter {expected[spec.kind]}") from None
255
+
256
+
257
+ def overridden(label: str, environ: Mapping[str, str] | None = None) -> bool:
258
+ """Whether a DIFFBUTLER_* variable overrides a dotted setting or one of its parents."""
259
+ names = {
260
+ name.upper() for name in (os.environ if environ is None else environ) if name.isascii()
261
+ }
262
+ parts = label.upper().split(".")
263
+ return any(
264
+ "DIFFBUTLER_" + "__".join(parts[:length]) in names for length in range(1, len(parts) + 1)
265
+ )
266
+
267
+
268
+ def lookup(table: object, label: str) -> Value:
269
+ for part in label.split("."):
270
+ if not isinstance(table, Mapping):
271
+ return None
272
+ table = cast(Mapping[str, object], table).get(part)
273
+ return plain(table)
274
+
275
+
276
+ def project(document: Mapping[str, Any]) -> Draft:
277
+ draft = Draft()
278
+ for section in SECTIONS:
279
+ content: object = document.get(section.name)
280
+ if section.kind == "table":
281
+ draft.tables[section.name] = {
282
+ spec.label: lookup(content, spec.label) for spec in section.fields
283
+ }
284
+ elif section.kind == "list":
285
+ draft.entries[section.name] = [
286
+ Entry({spec.label: lookup(entry, spec.label) for spec in section.fields}, index)
287
+ for index, entry in enumerate(cast(list[object], content or []))
288
+ ]
289
+ else:
290
+ draft.entries[section.name] = [
291
+ Entry(
292
+ {"keys": plain(entry)}
293
+ if section.name == "keys"
294
+ else {spec.label: lookup(entry, spec.label) for spec in section.fields},
295
+ name,
296
+ name,
297
+ )
298
+ for name, entry in cast(dict[str, object], content or {}).items()
299
+ ]
300
+ return draft
301
+
302
+
303
+ def assign(table: MutableMapping[str, Any], label: str, value: Value) -> None:
304
+ *parents, key = label.split(".")
305
+ for part in parents:
306
+ if part not in table:
307
+ if value is None:
308
+ return
309
+ table[part] = (
310
+ tomlkit.inline_table() if isinstance(table, InlineTable) else tomlkit.table()
311
+ )
312
+ table = cast(MutableMapping[str, Any], table[part])
313
+ if value is None:
314
+ table.pop(key, None)
315
+ else:
316
+ table[key] = value
317
+
318
+
319
+ def new_table(values: Mapping[str, Value], *, inline: bool = False) -> Any:
320
+ table = tomlkit.inline_table() if inline else tomlkit.table()
321
+ for label, value in values.items():
322
+ assign(table, label, value)
323
+ if not inline:
324
+ table.add(tomlkit.nl())
325
+ return table
326
+
327
+
328
+ def conflict(where: str) -> ConfigError:
329
+ return ConfigError(
330
+ f"{where} changed in the config file while Settings was open; "
331
+ "close Settings and open it again to edit the new values"
332
+ )
333
+
334
+
335
+ def entry_label(section: SectionSpec, entry: Entry) -> str:
336
+ if isinstance(entry.origin, int):
337
+ return f"{section.entry_title} {entry.origin + 1}"
338
+ return f"{section.entry_title or 'Key'} {entry.name or entry.origin}"
339
+
340
+
341
+ def apply(document: MutableMapping[str, Any], base: Draft, current: Draft, draft: Draft) -> bool:
342
+ """Apply the values edited between base and draft onto the re-read document."""
343
+ changed = False
344
+ for section in SECTIONS:
345
+ name = section.name
346
+ if section.kind == "table":
347
+ for label, value in draft.tables[name].items():
348
+ old = base.tables[name][label]
349
+ if value == old:
350
+ continue
351
+ if current.tables[name][label] != old:
352
+ raise conflict(f"{name}.{label}")
353
+ if name not in document:
354
+ if value is None:
355
+ continue
356
+ document[name] = tomlkit.table()
357
+ assign(cast(MutableMapping[str, Any], document[name]), label, value)
358
+ changed = True
359
+ elif section.kind == "list":
360
+ changed |= apply_list(document, section, base.entries[name], current, draft)
361
+ else:
362
+ changed |= apply_map(document, section, base.entries[name], current, draft)
363
+ return changed
364
+
365
+
366
+ def apply_list(
367
+ document: MutableMapping[str, Any],
368
+ section: SectionSpec,
369
+ base: list[Entry],
370
+ current: Draft,
371
+ draft: Draft,
372
+ ) -> bool:
373
+ entries = draft.entries[section.name]
374
+ if entries == base:
375
+ return False
376
+ if [entry.values for entry in current.entries[section.name]] != [
377
+ entry.values for entry in base
378
+ ]:
379
+ raise conflict(section.title)
380
+ kept = sorted(entry.origin for entry in entries if isinstance(entry.origin, int))
381
+ array = cast(MutableSequence[object] | None, document.get(section.name))
382
+ if array is None or (not array and not isinstance(array, AoT)):
383
+ # Replace the default empty inline array with tables at the end of the file.
384
+ document.pop(section.name, None)
385
+ document[section.name] = tomlkit.aot()
386
+ array = cast(MutableSequence[object], document[section.name])
387
+ for index in reversed(range(len(array))):
388
+ if index not in kept:
389
+ del array[index]
390
+ for entry in entries:
391
+ if isinstance(entry.origin, int):
392
+ table = cast(MutableMapping[str, Any], array[kept.index(entry.origin)])
393
+ for label, value in entry.values.items():
394
+ if value != base[entry.origin].values[label]:
395
+ assign(table, label, value)
396
+ else:
397
+ # Keep the file's style; a standard table inside an inline array is invalid.
398
+ array.append(new_table(entry.values, inline=not isinstance(array, AoT)))
399
+ if not array:
400
+ document.pop(section.name)
401
+ return True
402
+
403
+
404
+ def apply_map(
405
+ document: MutableMapping[str, Any],
406
+ section: SectionSpec,
407
+ base: list[Entry],
408
+ current: Draft,
409
+ draft: Draft,
410
+ ) -> bool:
411
+ entries = draft.entries[section.name]
412
+ if entries == base:
413
+ return False
414
+ names = [entry.name.strip() for entry in entries]
415
+ for entry, name in zip(entries, names, strict=True):
416
+ if not name:
417
+ raise ConfigError(f"{entry_label(section, entry)}: enter a name")
418
+ if len(set(names)) != len(names):
419
+ raise ConfigError(f"{section.title}: names must be unique")
420
+ originals = {cast(str, entry.origin): entry.values for entry in base}
421
+ present = {cast(str, entry.origin): entry.values for entry in current.entries[section.name]}
422
+ renamed = {
423
+ cast(str, entry.origin): name
424
+ for entry, name in zip(entries, names, strict=True)
425
+ if entry.origin is not None and name != entry.origin
426
+ }
427
+ edited = {
428
+ cast(str, entry.origin)
429
+ for entry in entries
430
+ if entry.origin is not None and entry.values != originals[cast(str, entry.origin)]
431
+ }
432
+ removed = set(originals) - {entry.origin for entry in entries}
433
+ title = section.entry_title or "Key"
434
+ # Removing or renaming replaces the whole entry; an edit touches only its changed values.
435
+ for name in removed | set(renamed):
436
+ if present.get(name) != originals[name]:
437
+ raise conflict(f"{title} {name}")
438
+ for entry in entries:
439
+ origin = cast(str, entry.origin)
440
+ if origin in edited and origin not in renamed:
441
+ for label, value in entry.values.items():
442
+ old = originals[origin][label]
443
+ if value != old and (origin not in present or present[origin][label] != old):
444
+ raise conflict(f"{title} {origin}")
445
+ vacated = removed | set(renamed)
446
+ for entry, name in zip(entries, names, strict=True):
447
+ if name != entry.origin and name in present and name not in vacated:
448
+ raise conflict(f"{title} {name}")
449
+ if section.name not in document:
450
+ document[section.name] = tomlkit.table(is_super_table=True)
451
+ table = cast(MutableMapping[str, Any], document[section.name])
452
+ moved = {origin: table.pop(origin) for origin in renamed}
453
+ for name in removed:
454
+ del table[name]
455
+ for entry, name in zip(entries, names, strict=True):
456
+ if entry.origin is None:
457
+ table[name] = (
458
+ entry.values["keys"]
459
+ if section.name == "keys"
460
+ else new_table(entry.values, inline=isinstance(table, InlineTable))
461
+ )
462
+ continue
463
+ origin = cast(str, entry.origin)
464
+ if origin in moved:
465
+ table[name] = moved[origin]
466
+ if origin not in edited:
467
+ continue
468
+ if section.name == "keys":
469
+ table[name] = entry.values["keys"]
470
+ continue
471
+ item = cast(MutableMapping[str, Any], table[name])
472
+ for label, value in entry.values.items():
473
+ if value != originals[origin][label]:
474
+ assign(item, label, value)
475
+ return True
476
+
477
+
478
+ class ConfigEditor:
479
+ def __init__(self, path: Path, base: Draft) -> None:
480
+ self.path = path
481
+ self.base = base
482
+
483
+ @staticmethod
484
+ def read(path: Path) -> tuple[str | None, tomlkit.TOMLDocument]:
485
+ try:
486
+ original = path.read_text(encoding="utf-8")
487
+ except FileNotFoundError:
488
+ original = None
489
+ except OSError, UnicodeError:
490
+ raise ConfigError(f"Could not read configuration file: {path}") from None
491
+ try:
492
+ document = tomlkit.parse(original or "")
493
+ except TOMLKitError:
494
+ raise ConfigError(
495
+ "Configuration file is not valid TOML; fix it with diffbutler config --edit"
496
+ ) from None
497
+ try:
498
+ validate_document(document.unwrap())
499
+ except ConfigError as exc:
500
+ raise ConfigError(
501
+ f"Configuration file is invalid ({exc}); fix it with diffbutler config --edit"
502
+ ) from None
503
+ return original, document
504
+
505
+ @classmethod
506
+ def open(cls, path: Path | None) -> ConfigEditor:
507
+ selected = (path or config_path()).expanduser().resolve()
508
+ _, document = cls.read(selected)
509
+ return cls(selected, project(document))
510
+
511
+ def draft(self) -> Draft:
512
+ return copy.deepcopy(self.base)
513
+
514
+ def save(self, draft: Draft) -> bool:
515
+ """Write the edited values; False when nothing was edited."""
516
+ if draft == self.base:
517
+ return False
518
+ try:
519
+ self.path.parent.mkdir(parents=True, exist_ok=True)
520
+ with config_lock(self.path):
521
+ original, document = self.read(self.path)
522
+ if not apply(document, self.base, project(document), draft):
523
+ return False
524
+ content = tomlkit.dumps(document)
525
+ try:
526
+ written = tomllib.loads(content)
527
+ except tomllib.TOMLDecodeError:
528
+ raise ConfigError("Settings could not be saved as valid TOML") from None
529
+ validate_document(written)
530
+ if not publish_config(self.path, original, content):
531
+ raise conflict("The configuration file")
532
+ self.base = project(document)
533
+ except OSError, UnicodeError, TOMLKitError:
534
+ raise ConfigError(f"Could not save configuration file: {self.path}") from None
535
+ return True