diffbutler 0.5.2__tar.gz → 0.6.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 (110) hide show
  1. {diffbutler-0.5.2 → diffbutler-0.6.0}/PKG-INFO +18 -11
  2. {diffbutler-0.5.2 → diffbutler-0.6.0}/README.md +17 -10
  3. {diffbutler-0.5.2 → diffbutler-0.6.0}/pyproject.toml +4 -4
  4. {diffbutler-0.5.2 → diffbutler-0.6.0}/pyproject.toml.orig +4 -4
  5. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/config/default.toml +1 -0
  6. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/config/setup.py +3 -3
  7. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/application.py +53 -1
  8. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/dashboard.py +11 -3
  9. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/managed.py +11 -2
  10. diffbutler-0.6.0/src/diffbutler/core/pr_lookup.py +50 -0
  11. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/repositories.py +9 -4
  12. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/setup.py +16 -4
  13. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/sync.py +36 -5
  14. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/forge.py +19 -5
  15. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/sync.py +1 -0
  16. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/forges/base.py +1 -0
  17. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/forges/github/adapter.py +21 -2
  18. diffbutler-0.6.0/src/diffbutler/forges/github/repositories.py +62 -0
  19. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/forges/github/setup.py +2 -2
  20. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/sources/remotes.py +2 -2
  21. diffbutler-0.6.0/src/diffbutler/store/migrations/versions/0011_explicit_reviews.py +20 -0
  22. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/models.py +1 -0
  23. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/queue.py +2 -0
  24. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/tui/app.py +25 -1
  25. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/tui/dashboard.py +187 -81
  26. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/tui/handoff_probe.py +3 -0
  27. diffbutler-0.6.0/src/diffbutler/ui/tui/review_prompt.py +122 -0
  28. diffbutler-0.6.0/src/diffbutler/ui/tui/terminal_theme.py +146 -0
  29. {diffbutler-0.5.2 → diffbutler-0.6.0}/LICENSE.txt +0 -0
  30. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/__init__.py +0 -0
  31. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/__main__.py +0 -0
  32. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/config/__init__.py +0 -0
  33. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/config/diagnostics.py +0 -0
  34. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/config/file.py +0 -0
  35. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/config/instance.py +0 -0
  36. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/config/settings.py +0 -0
  37. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/config/transport.py +0 -0
  38. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/__init__.py +0 -0
  39. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/context.py +0 -0
  40. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/doctor.py +0 -0
  41. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/lifecycle.py +0 -0
  42. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/native.py +0 -0
  43. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/polling.py +0 -0
  44. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/process.py +0 -0
  45. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/reset.py +0 -0
  46. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/resources.py +0 -0
  47. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/review.py +0 -0
  48. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/snapshot.py +0 -0
  49. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/worker.py +0 -0
  50. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/core/workspaces.py +0 -0
  51. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/__init__.py +0 -0
  52. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/dashboard.py +0 -0
  53. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/diff.py +0 -0
  54. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/lifecycle.py +0 -0
  55. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/polling.py +0 -0
  56. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/process.py +0 -0
  57. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/redaction.py +0 -0
  58. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/resources.py +0 -0
  59. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/domain/review.py +0 -0
  60. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/forges/__init__.py +0 -0
  61. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/forges/diagnostics.py +0 -0
  62. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/forges/github/__init__.py +0 -0
  63. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/forges/github/auth.py +0 -0
  64. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/forges/github/http.py +0 -0
  65. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/forges/github/mapping.py +0 -0
  66. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/forges/registry.py +0 -0
  67. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/py.typed +0 -0
  68. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/reviewers/__init__.py +0 -0
  69. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/reviewers/base.py +0 -0
  70. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/reviewers/extraction.py +0 -0
  71. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/reviewers/native.py +0 -0
  72. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/reviewers/parsing.py +0 -0
  73. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/reviewers/presets.toml +0 -0
  74. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/reviewers/process.py +0 -0
  75. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/reviewers/prompt.py +0 -0
  76. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/reviewers/redaction.py +0 -0
  77. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/reviewers/resources.py +0 -0
  78. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/sources/__init__.py +0 -0
  79. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/sources/diagnostics.py +0 -0
  80. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/sources/fetch.py +0 -0
  81. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/sources/git.py +0 -0
  82. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/__init__.py +0 -0
  83. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/base.py +0 -0
  84. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/dashboard.py +0 -0
  85. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/database.py +0 -0
  86. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/lifecycle.py +0 -0
  87. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/__init__.py +0 -0
  88. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/env.py +0 -0
  89. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/0001_initial.py +0 -0
  90. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/0002_monitoring.py +0 -0
  91. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/0003_reviews.py +0 -0
  92. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/0004_claims.py +0 -0
  93. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/0005_results.py +0 -0
  94. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/0006_anchors.py +0 -0
  95. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/0007_lifecycle.py +0 -0
  96. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/0008_processes.py +0 -0
  97. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/0009_dashboard.py +0 -0
  98. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/0010_run_reasons.py +0 -0
  99. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/migrations/versions/__init__.py +0 -0
  100. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/process.py +0 -0
  101. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/store/review.py +0 -0
  102. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/__init__.py +0 -0
  103. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/cli/__init__.py +0 -0
  104. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/cli/editor.py +0 -0
  105. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/tui/__init__.py +0 -0
  106. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/tui/_fake_agent.py +0 -0
  107. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/tui/_foreground_exec.py +0 -0
  108. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/tui/browse.py +0 -0
  109. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/tui/browser.py +0 -0
  110. {diffbutler-0.5.2 → diffbutler-0.6.0}/src/diffbutler/ui/tui/foreground.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffbutler
3
- Version: 0.5.2
3
+ Version: 0.6.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
@@ -91,15 +91,18 @@ From the GitHub repository you want to review, launch:
91
91
  diffbutler
92
92
  ```
93
93
 
94
- First-run setup detects the local repository, looks up your GitHub username using
95
- the selected token, asks for your preferred agent, and saves your choices. If username detection
96
- is unavailable, setup asks for it. Choose the recommended token access for public repositories;
97
- private repositories require it. Setup asks for the token's environment variable name
98
- and validates access. It never saves the token.
94
+ First-run setup detects the local repository and asks whether to monitor just that
95
+ repository or all repositories belonging to the same owner. It looks up your GitHub
96
+ username using the selected token, asks for your preferred agent, and saves your choices.
97
+ If username detection is unavailable, setup asks for it. Choose the recommended token
98
+ access for public repositories; private repositories require it. Setup asks for the
99
+ token's environment variable name and validates access. It never saves the token.
99
100
  New configs silently enable both Codex and Claude when found in `PATH`; setup asks
100
101
  only which detected agent should be the default. Missing agents get commented-out
101
102
  entries you can enable after installation.
102
103
  Later launches reuse your configuration.
104
+ Repository filters accept exact names such as `heikkitoivonen/diffbutler` or
105
+ `heikkitoivonen/*` for all accessible repositories owned by that account.
103
106
 
104
107
  The dashboard shows active reviews, including followed PRs whose GitHub review
105
108
  requests have disappeared after you posted feedback. Waiting-for-author reviews
@@ -131,14 +134,17 @@ Select a PR with the arrow keys, then work through the review:
131
134
  while retaining review history.
132
135
 
133
136
  `Space` performs the selected PR's next action: start a review, discuss findings,
134
- or open the browser. An AI review never counts as your approval. Background analysis
137
+ or open the browser. For `check` and `author`, it resumes the saved agent conversation,
138
+ just like `c`. An AI review never counts as your approval. Background analysis
135
139
  uses the agent's [native permission controls](docs/native-reviews.md#native-permissions-and-authentication)
136
140
  and read-only review instructions; source sent to the agent may go to its model provider.
137
141
 
138
142
  - `/`: Filter the queue.
139
143
  - `a`: Toggle monitoring all open PRs in configured repositories.
140
144
  - `b`: Browse accessible PRs and follow one.
141
- - `R`: Refresh from GitHub.
145
+ - `R`: Find and add a PR by link or number, then review it; choose a repository if
146
+ the number is ambiguous. Explicitly selected PRs bypass queue visibility filters.
147
+ - `Ctrl+R`: Refresh from GitHub.
142
148
  - `k`: Stop the selected PR's review and reset its local tracking and owned resources.
143
149
  It disappears until a forge refresh rediscovers it; review history is retained.
144
150
  - `h`: View completed review history.
@@ -149,10 +155,11 @@ See the [dashboard guide](docs/dashboard.md) for all actions and
149
155
  [configuration guide](docs/configuration.md) for settings.
150
156
 
151
157
  Your own PRs are hidden from the review queue by default, including direct and team
152
- requests. Set `include_own_prs = true` in `[ui]` to review them, such as PRs an agent
153
- created using your account. PRs assigned to you are also hidden by default, even when
158
+ requests. Use `R` to select one explicitly, or set `include_own_prs = true` in `[ui]`
159
+ to show them all, such as PRs an agent created using your account.
160
+ PRs assigned to you are also hidden by default, even when
154
161
  you are requested directly or through a team. Set `include_assigned_prs = true` in
155
- `[ui]` to include them.
162
+ `[ui]` to include them, or select one with `R`.
156
163
 
157
164
  ## Reset
158
165
 
@@ -63,15 +63,18 @@ From the GitHub repository you want to review, launch:
63
63
  diffbutler
64
64
  ```
65
65
 
66
- First-run setup detects the local repository, looks up your GitHub username using
67
- the selected token, asks for your preferred agent, and saves your choices. If username detection
68
- is unavailable, setup asks for it. Choose the recommended token access for public repositories;
69
- private repositories require it. Setup asks for the token's environment variable name
70
- and validates access. It never saves the token.
66
+ First-run setup detects the local repository and asks whether to monitor just that
67
+ repository or all repositories belonging to the same owner. It looks up your GitHub
68
+ username using the selected token, asks for your preferred agent, and saves your choices.
69
+ If username detection is unavailable, setup asks for it. Choose the recommended token
70
+ access for public repositories; private repositories require it. Setup asks for the
71
+ token's environment variable name and validates access. It never saves the token.
71
72
  New configs silently enable both Codex and Claude when found in `PATH`; setup asks
72
73
  only which detected agent should be the default. Missing agents get commented-out
73
74
  entries you can enable after installation.
74
75
  Later launches reuse your configuration.
76
+ Repository filters accept exact names such as `heikkitoivonen/diffbutler` or
77
+ `heikkitoivonen/*` for all accessible repositories owned by that account.
75
78
 
76
79
  The dashboard shows active reviews, including followed PRs whose GitHub review
77
80
  requests have disappeared after you posted feedback. Waiting-for-author reviews
@@ -103,14 +106,17 @@ Select a PR with the arrow keys, then work through the review:
103
106
  while retaining review history.
104
107
 
105
108
  `Space` performs the selected PR's next action: start a review, discuss findings,
106
- or open the browser. An AI review never counts as your approval. Background analysis
109
+ or open the browser. For `check` and `author`, it resumes the saved agent conversation,
110
+ just like `c`. An AI review never counts as your approval. Background analysis
107
111
  uses the agent's [native permission controls](docs/native-reviews.md#native-permissions-and-authentication)
108
112
  and read-only review instructions; source sent to the agent may go to its model provider.
109
113
 
110
114
  - `/`: Filter the queue.
111
115
  - `a`: Toggle monitoring all open PRs in configured repositories.
112
116
  - `b`: Browse accessible PRs and follow one.
113
- - `R`: Refresh from GitHub.
117
+ - `R`: Find and add a PR by link or number, then review it; choose a repository if
118
+ the number is ambiguous. Explicitly selected PRs bypass queue visibility filters.
119
+ - `Ctrl+R`: Refresh from GitHub.
114
120
  - `k`: Stop the selected PR's review and reset its local tracking and owned resources.
115
121
  It disappears until a forge refresh rediscovers it; review history is retained.
116
122
  - `h`: View completed review history.
@@ -121,10 +127,11 @@ See the [dashboard guide](docs/dashboard.md) for all actions and
121
127
  [configuration guide](docs/configuration.md) for settings.
122
128
 
123
129
  Your own PRs are hidden from the review queue by default, including direct and team
124
- requests. Set `include_own_prs = true` in `[ui]` to review them, such as PRs an agent
125
- created using your account. PRs assigned to you are also hidden by default, even when
130
+ requests. Use `R` to select one explicitly, or set `include_own_prs = true` in `[ui]`
131
+ to show them all, such as PRs an agent created using your account.
132
+ PRs assigned to you are also hidden by default, even when
126
133
  you are requested directly or through a team. Set `include_assigned_prs = true` in
127
- `[ui]` to include them.
134
+ `[ui]` to include them, or select one with `R`.
128
135
 
129
136
  ## Reset
130
137
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffbutler"
3
- version = "0.5.2"
3
+ version = "0.6.0"
4
4
  description = "A terminal dashboard for human-directed AI code reviews."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -39,7 +39,7 @@ dependencies = [
39
39
  diffbutler = "diffbutler.ui.cli:main"
40
40
 
41
41
  [build-system]
42
- requires = ["uv_build>=0.12.13,<0.13.0"]
42
+ requires = ["uv_build>=0.12.13,<0.14.0"]
43
43
  build-backend = "uv_build"
44
44
 
45
45
  [dependency-groups]
@@ -56,8 +56,8 @@ dev = [
56
56
  ]
57
57
 
58
58
  [tool.uv]
59
- required-version = ">=0.12.13,<0.13"
60
- exclude-newer = "15 days"
59
+ required-version = ">=0.12.13"
60
+ exclude-newer = "2 days"
61
61
 
62
62
  [tool.ruff]
63
63
  target-version = "py314"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffbutler"
3
- version = "0.5.2"
3
+ version = "0.6.0"
4
4
  description = "A terminal dashboard for human-directed AI code reviews."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -34,7 +34,7 @@ dependencies = [
34
34
  diffbutler = "diffbutler.ui.cli:main"
35
35
 
36
36
  [build-system]
37
- requires = ["uv_build>=0.12.13,<0.13.0"]
37
+ requires = ["uv_build>=0.12.13,<0.14.0"]
38
38
  build-backend = "uv_build"
39
39
 
40
40
  [dependency-groups]
@@ -51,9 +51,9 @@ dev = [
51
51
  ]
52
52
 
53
53
  [tool.uv]
54
- required-version = ">=0.12.13,<0.13"
54
+ required-version = ">=0.12.13"
55
55
  # Give new dependency releases time to settle before routine upgrades.
56
- exclude-newer = "15 days"
56
+ exclude-newer = "2 days"
57
57
 
58
58
  [tool.ruff]
59
59
  target-version = "py314"
@@ -69,6 +69,7 @@ open_in_browser = ["o", "ctrl+o"]
69
69
  # token_env = "GITHUB_TOKEN" # Omit for public access (None); login is then required.
70
70
  # include_team_requests = true
71
71
  # repositories = [] # Empty means review requests from all accessible repositories.
72
+ # Use ["owner/repo"] for one repository or ["owner/*"] for all of an owner's repositories.
72
73
  # login = "your-login"
73
74
 
74
75
  # Remove the top-level reviewers = [] before adding reviewers:
@@ -15,7 +15,7 @@ from diffbutler.config.settings import NATIVE_PRESETS, ConfigError, Forge, Revie
15
15
  def save_setup(
16
16
  path: Path | None,
17
17
  forge: Forge | None,
18
- repository_path: Path | None,
18
+ local_repository: tuple[str, Path] | None,
19
19
  reviewers: Sequence[Reviewer] = (),
20
20
  default_reviewer: str | None = None,
21
21
  ) -> Path:
@@ -40,7 +40,8 @@ def save_setup(
40
40
  if "review" not in document:
41
41
  document["review"] = tomlkit.table()
42
42
  document["review"]["default_reviewer"] = default_reviewer
43
- if repository_path is not None and forge is not None:
43
+ if local_repository is not None and forge is not None:
44
+ repository, repository_path = local_repository
44
45
  repos = document.get("repos")
45
46
  if repos is None:
46
47
  repos = tomlkit.table()
@@ -48,7 +49,6 @@ def save_setup(
48
49
  if not isinstance(repos, MutableMapping):
49
50
  raise ConfigError("Use a [repos] table to save local repository paths")
50
51
  repos = cast(MutableMapping[str, Any], repos)
51
- repository = forge.repositories[0]
52
52
  if repository not in repos:
53
53
  repos[repository] = {"path": str(repository_path)}
54
54
  else:
@@ -1,14 +1,16 @@
1
1
  """Configured shared-core entry points with owned database/forge lifetimes."""
2
2
 
3
3
  import asyncio
4
+ import re
4
5
  from collections.abc import AsyncGenerator
5
6
  from contextlib import asynccontextmanager
6
7
  from pathlib import Path
7
8
  from typing import Literal
8
9
 
9
10
  from diffbutler.config.settings import Settings, load_settings
11
+ from diffbutler.core.pr_lookup import ReviewTarget, numbered_prs, open_pr
10
12
  from diffbutler.core.sync import DiffButler, PullRequestRow, SyncReport, list_queue
11
- from diffbutler.domain.forge import PRPage, PRQuery
13
+ from diffbutler.domain.forge import Account, PRPage, PRQuery, PRRef
12
14
  from diffbutler.forges.base import ForgeError
13
15
  from diffbutler.forges.registry import open_forge
14
16
  from diffbutler.store.dashboard import SQLiteDashboardStore
@@ -88,6 +90,56 @@ async def browse_configured(
88
90
  return await service.browse_prs(query, cursor)
89
91
 
90
92
 
93
+ async def review_targets_configured(path: Path | None, value: str) -> tuple[ReviewTarget, ...]:
94
+ """Resolve without following PRs or requesting any AI work."""
95
+ value = value.strip()
96
+ numeric = re.fullmatch(r"#?([1-9][0-9]*)", value)
97
+ if not numeric and not value.startswith("https://"):
98
+ raise ForgeError("Enter a PR link or a positive PR number")
99
+ found: dict[tuple[Account, PRRef], ReviewTarget] = {}
100
+ async with local_store(path) as (store, settings):
101
+ if not settings.forges:
102
+ raise ForgeError("No forge configured")
103
+ if numeric and not any(config.repositories for config in settings.forges):
104
+ raise ForgeError("No monitored repositories configured; enter a PR link instead")
105
+ matched_url = False
106
+ for index, config in enumerate(settings.forges, 1):
107
+ if numeric and not config.repositories:
108
+ continue
109
+ async with remote_service(store, settings, index) as service:
110
+ if numeric:
111
+ prs = await numbered_prs(service.forge, config.repositories, int(numeric[1]))
112
+ else:
113
+ try:
114
+ ref = service.forge.ref(value)
115
+ except ForgeError:
116
+ continue
117
+ matched_url = True
118
+ pr = await open_pr(service.forge, ref)
119
+ prs = (pr,) if pr else ()
120
+ account = await service.forge.whoami()
121
+ if config.login and account.login.casefold() != config.login.casefold():
122
+ raise ForgeError("Authenticated identity does not match configured account")
123
+ for pr in prs:
124
+ found.setdefault((account, pr.ref), ReviewTarget(index, account, pr))
125
+ if not numeric and not matched_url:
126
+ raise ForgeError("Enter a valid PR link on a configured forge")
127
+ return tuple(found.values())
128
+
129
+
130
+ async def follow_review_target_configured(
131
+ path: Path | None, target: ReviewTarget
132
+ ) -> PullRequestRow:
133
+ """Revalidate the chosen account and open state before tracking a review target."""
134
+ async with (
135
+ local_store(path) as (store, settings),
136
+ remote_service(store, settings, target.forge_index) as service,
137
+ ):
138
+ if await service.forge.whoami() != target.account:
139
+ raise ForgeError("Configured account changed; look up the PR again")
140
+ return await service.follow_pr(target.pull_request.ref, for_review=True)
141
+
142
+
91
143
  async def local_queue(
92
144
  path: Path | None,
93
145
  view: Literal["active", "requested", "following"],
@@ -176,15 +176,23 @@ async def dashboard_rows(
176
176
  for row in rows:
177
177
  if row.tracked.state == "DONE":
178
178
  continue
179
- if view == "triage" and row.tracked.state != "FINDINGS_TRIAGE":
179
+ if (
180
+ not row.tracked.explicit_review
181
+ and view == "triage"
182
+ and row.tracked.state != "FINDINGS_TRIAGE"
183
+ ):
180
184
  continue
181
- if view == "posted" and row.tracked.state != "PENDING_AUTHOR_CHANGES":
185
+ if (
186
+ not row.tracked.explicit_review
187
+ and view == "posted"
188
+ and row.tracked.state != "PENDING_AUTHOR_CHANGES"
189
+ ):
182
190
  continue
183
191
  pr = row.tracked.pull_request
184
192
  searchable = (
185
193
  f"{pr.ref.namespace}/{pr.ref.repository}#{pr.ref.number} {pr.title} {pr.author}"
186
194
  )
187
- if text.lower() not in searchable.lower():
195
+ if not row.tracked.explicit_review and text.lower() not in searchable.lower():
188
196
  continue
189
197
  run = await store.latest_run(row.tracked.task_id) if row.tracked.task_id else None
190
198
  analysis = await reviews.get_result(run.id) if run else None
@@ -296,6 +296,9 @@ class ManagedReviews:
296
296
  findings_threshold=self.settings.review.findings_threshold,
297
297
  )
298
298
  try:
299
+ # Record explicit intent before maintenance can wait on another PR's I/O.
300
+ # Cancellation while waiting must also cancel this durable queued run.
301
+ await self.cleanup()
299
302
  while True:
300
303
  preferences = await SQLiteDashboardStore(self.store.database).preferences()
301
304
  result = await worker.execute_requested(
@@ -313,6 +316,12 @@ class ManagedReviews:
313
316
  except asyncio.CancelledError:
314
317
  await self.reviews.cancel_review(run.id)
315
318
  raise
319
+ except Exception:
320
+ # A maintenance failure before claiming must not leave an orphaned
321
+ # queued attempt after its supervisor has reported failure.
322
+ if (await self.reviews.get_run(run.id)).status == "queued":
323
+ await self.reviews.cancel_review(run.id)
324
+ raise
316
325
 
317
326
  async def findings(self, row: PullRequestRow) -> ReviewResult | None:
318
327
  if row.tracked.task_id is None:
@@ -363,7 +372,7 @@ async def review_configured(
363
372
  load_settings, path, repo=f"{ref.namespace}/{ref.repository}"
364
373
  )
365
374
  managed = ManagedReviews(queue.database, settings, await owned_directories(settings))
366
- await managed.cleanup()
375
+ await managed.recovery.recover_rebooted()
367
376
  return await managed.review(row, reviewer, progress)
368
377
 
369
378
 
@@ -414,7 +423,7 @@ async def handoff_configured(
414
423
  async with local_store(path) as (queue, settings):
415
424
  row = await resolve_local(queue, value, login)
416
425
  managed = ManagedReviews(queue.database, settings, await owned_directories(settings))
417
- await managed.cleanup()
426
+ await managed.recovery.recover_rebooted()
418
427
  launch = await managed.prepare_handoff(row)
419
428
  exited = True
420
429
  try:
@@ -0,0 +1,50 @@
1
+ """Read-only resolution of explicit review targets across repository scopes."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from diffbutler.domain.forge import Account, PRRef, PullRequest, repository_matches
6
+ from diffbutler.forges.base import ForgeError, ReadForge
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ReviewTarget:
11
+ forge_index: int
12
+ account: Account
13
+ pull_request: PullRequest
14
+
15
+
16
+ async def open_pr(forge: ReadForge, ref: PRRef) -> PullRequest | None:
17
+ try:
18
+ pr = await forge.get_pull_request(ref)
19
+ except ForgeError as exc:
20
+ if exc.status == 404:
21
+ return None
22
+ raise
23
+ if pr.ref != ref:
24
+ raise ForgeError("Forge returned a different PR identity")
25
+ return pr if pr.state == "open" else None
26
+
27
+
28
+ async def numbered_prs(
29
+ forge: ReadForge, repositories: list[str], number: int
30
+ ) -> tuple[PullRequest, ...]:
31
+ """Resolve exact numbers directly after expanding owner repository scopes."""
32
+ found: dict[PRRef, PullRequest] = {}
33
+ checked: set[PRRef] = set()
34
+
35
+ async def check(ref: PRRef) -> None:
36
+ if ref not in checked:
37
+ checked.add(ref)
38
+ if pr := await open_pr(forge, ref):
39
+ found[ref] = pr
40
+
41
+ for repository in dict.fromkeys(repositories):
42
+ if not repository.endswith("/*"):
43
+ await check(forge.ref(f"{repository}#{number}"))
44
+ continue
45
+ for name in await forge.list_repositories(repository.removesuffix("/*")):
46
+ ref = forge.ref(f"{name}#{number}")
47
+ if ref.forge != forge.id or not repository_matches(repository, ref):
48
+ raise ForgeError("Forge returned a repository outside the requested owner")
49
+ await check(ref)
50
+ return tuple(found.values())
@@ -23,7 +23,7 @@ from diffbutler.sources.git import GitSourceReader, SourceError
23
23
 
24
24
 
25
25
  @asynccontextmanager
26
- async def file_lock(path: Path) -> AsyncGenerator[None]:
26
+ async def file_lock(path: Path, *, wait: bool = True) -> AsyncGenerator[bool]:
27
27
  """Cancellation never leaves a background thread waiting on an advisory lock."""
28
28
  await settled_io(lambda: path.parent.mkdir(parents=True, exist_ok=True))
29
29
  fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
@@ -36,8 +36,11 @@ async def file_lock(path: Path) -> AsyncGenerator[None]:
36
36
  fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
37
37
  break
38
38
  except BlockingIOError:
39
+ if not wait:
40
+ yield False
41
+ return
39
42
  await asyncio.sleep(0.05)
40
- yield
43
+ yield True
41
44
  finally:
42
45
  os.close(fd)
43
46
 
@@ -218,10 +221,12 @@ class RepositoryCache:
218
221
  await settled_io(lambda path=path: (path / "last-used").touch())
219
222
 
220
223
  async def prune(self) -> None:
221
- """Evict whole inactive repositories; never expire a live session's pins."""
224
+ """Evict inactive repositories, deferring maintenance while the cache is busy."""
222
225
  if not self.path.exists():
223
226
  return
224
- async with file_lock(self.lock_path):
227
+ async with file_lock(self.lock_path, wait=False) as acquired:
228
+ if not acquired:
229
+ return
225
230
  directories = await open_directories(self.path)
226
231
  for resource in await self._resources(directories):
227
232
  path = self.path / resource.name
@@ -19,7 +19,7 @@ from diffbutler.config.settings import (
19
19
  load_settings,
20
20
  )
21
21
  from diffbutler.config.setup import save_setup
22
- from diffbutler.domain.forge import PRQuery, identity_part
22
+ from diffbutler.domain.forge import identity_part, repository_parts
23
23
  from diffbutler.forges.base import ForgeError
24
24
  from diffbutler.forges.github.setup import detect_login, repository_is_public, validate_access
25
25
  from diffbutler.sources.remotes import discover_repository
@@ -100,9 +100,21 @@ async def startup_settings(
100
100
  else await question("Repository to monitor (owner/name)")
101
101
  )
102
102
  try:
103
- PRQuery(repository=repository)
103
+ repository_parts(repository)
104
104
  except ValueError:
105
105
  raise ConfigError("Repository must be owner/name") from None
106
+ scope = repository
107
+ if detected is not None and matched:
108
+ owner = repository.split("/")[0]
109
+ choice = (
110
+ await question(
111
+ f"Repositories to monitor (repo for just {repository}, all for {owner}/*)", "repo"
112
+ )
113
+ ).lower()
114
+ if choice not in {"repo", "all"}:
115
+ raise ConfigError("Choose repo or all repositories")
116
+ if choice == "all":
117
+ scope = f"{owner}/*"
106
118
  base_url = "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
107
119
  public = await check(
108
120
  "Checking GitHub repository visibility", repository_is_public(base_url, repository)
@@ -154,7 +166,7 @@ async def startup_settings(
154
166
  token_env=token_env,
155
167
  login=login,
156
168
  include_team_requests=not public,
157
- repositories=[repository],
169
+ repositories=[scope],
158
170
  )
159
171
  reviewers, default = await setup_reviewer(
160
172
  settings, directory, question, progress, discover=new_config
@@ -163,7 +175,7 @@ async def startup_settings(
163
175
  save_setup,
164
176
  path,
165
177
  forge,
166
- detected.path if detected and matched else None,
178
+ (repository, detected.path) if detected and matched else None,
167
179
  reviewers,
168
180
  default,
169
181
  )
@@ -5,7 +5,7 @@ from typing import Literal
5
5
 
6
6
  from pydantic import BaseModel, ConfigDict
7
7
 
8
- from diffbutler.domain.forge import Account, Discovery, PRPage, PRQuery, PRRef
8
+ from diffbutler.domain.forge import Account, Discovery, PRPage, PRQuery, PRRef, repository_matches
9
9
  from diffbutler.domain.sync import ReviewActivity, TrackedPR, reconcile
10
10
  from diffbutler.forges.base import ForgeError, ReadForge
11
11
  from diffbutler.store.base import QueueStore, SyncConflict
@@ -80,8 +80,7 @@ class DiffButler:
80
80
  if (
81
81
  pr.state == "open"
82
82
  and pr.ref.forge == account.forge
83
- and f"{pr.ref.namespace}/{pr.ref.repository}".lower()
84
- == repository.lower()
83
+ and repository_matches(repository, pr.ref)
85
84
  ):
86
85
  discovered[pr.ref] = pr
87
86
  cursor = page.next_cursor
@@ -167,7 +166,7 @@ class DiffButler:
167
166
  )
168
167
  raise AssertionError("unreachable")
169
168
 
170
- async def follow_pr(self, ref: PRRef) -> PullRequestRow:
169
+ async def follow_pr(self, ref: PRRef, *, for_review: bool = False) -> PullRequestRow:
171
170
  account = await self._account()
172
171
  stale: TrackedPR | None = None
173
172
  for attempt in range(3):
@@ -180,6 +179,8 @@ class DiffButler:
180
179
  pr = await self.forge.get_pull_request(ref)
181
180
  if pr.ref != ref:
182
181
  raise ForgeError("Forge returned a different PR identity")
182
+ if for_review and pr.state != "open":
183
+ raise ForgeError("PR is closed or merged; choose an open PR")
183
184
  # Following succeeds even if activity access fails; return the warning to the UI.
184
185
  try:
185
186
  activity = await self.forge.get_review_activity(ref)
@@ -188,7 +189,7 @@ class DiffButler:
188
189
  if activity.account != account or await self._account() != account:
189
190
  raise ForgeError("Account changed during follow; no changes saved")
190
191
  item = reconcile(previous, pr, activity, previous.sources if previous else ())
191
- item = replace(item, following=True)
192
+ item = replace(item, following=True, explicit_review=item.explicit_review or for_review)
192
193
  try:
193
194
  await self.store.apply(
194
195
  account, snapshot.version, (item,), refreshed=frozenset({ref})
@@ -200,6 +201,33 @@ class DiffButler:
200
201
  continue
201
202
  persisted = await self.store.snapshot(account)
202
203
  result = next(row for row in persisted.items if row.pull_request.ref == ref)
204
+ if for_review and result.state == "DONE":
205
+ # An explicit review request may reopen an approved, still-open PR.
206
+ # Persist completion first so the old cycle and its cleanup survive.
207
+ if result.pull_request.state != "open":
208
+ raise ForgeError("PR is closed or merged; choose an open PR")
209
+ reopened = replace(
210
+ result,
211
+ task_id=None,
212
+ cycle=result.cycle + 1,
213
+ state="PENDING_AI_REVIEW",
214
+ completion_reason=None,
215
+ completion_event=None,
216
+ opened_at=None,
217
+ posted_at=None,
218
+ review_requested_at=None,
219
+ notice="Review explicitly requested",
220
+ )
221
+ try:
222
+ await self.store.apply(account, persisted.version, (reopened,))
223
+ except SyncConflict:
224
+ if attempt == 2:
225
+ raise ForgeError(
226
+ "Queue changed concurrently; retry review request"
227
+ ) from None
228
+ continue
229
+ persisted = await self.store.snapshot(account)
230
+ result = next(row for row in persisted.items if row.pull_request.ref == ref)
203
231
  return PullRequestRow(account=account, tracked=result, warnings=activity.warnings)
204
232
  raise AssertionError("unreachable")
205
233
 
@@ -222,6 +250,9 @@ async def list_queue(
222
250
  if login is not None and account.login.lower() != login.lower():
223
251
  continue
224
252
  for item in (await store.snapshot(account)).items:
253
+ if item.explicit_review and item.state != "DONE":
254
+ rows.append(PullRequestRow(account=account, tracked=item))
255
+ continue
225
256
  if (
226
257
  not include_own_prs
227
258
  and item.pull_request.author.casefold() == account.login.casefold()
@@ -12,6 +12,17 @@ def identity_part(value: str) -> str:
12
12
  return value
13
13
 
14
14
 
15
+ def repository_parts(value: str, *, allow_wildcard: bool = False) -> tuple[str, str]:
16
+ parts = value.split("/")
17
+ if len(parts) != 2:
18
+ raise ValueError("repository must be namespace/name")
19
+ owner, name = parts
20
+ identity_part(owner)
21
+ if not (allow_wildcard and name == "*"):
22
+ identity_part(name)
23
+ return owner, name
24
+
25
+
15
26
  @dataclass(frozen=True)
16
27
  class Account:
17
28
  forge: str
@@ -33,6 +44,13 @@ class PRRef:
33
44
  raise ValueError("PR number must be positive")
34
45
 
35
46
 
47
+ def repository_matches(scope: str, ref: PRRef) -> bool:
48
+ owner, name = repository_parts(scope, allow_wildcard=True)
49
+ return owner.lower() == ref.namespace.lower() and (
50
+ name == "*" or name.lower() == ref.repository.lower()
51
+ )
52
+
53
+
36
54
  @dataclass(frozen=True)
37
55
  class PullRequest:
38
56
  ref: PRRef
@@ -67,11 +85,7 @@ class PRQuery:
67
85
 
68
86
  def __post_init__(self) -> None:
69
87
  if self.repository is not None:
70
- parts = self.repository.split("/")
71
- if len(parts) != 2:
72
- raise ValueError("repository must be namespace/name")
73
- for part in parts:
74
- identity_part(part)
88
+ repository_parts(self.repository, allow_wildcard=True)
75
89
  if self.author is not None:
76
90
  identity_part(self.author)
77
91
  if self.state not in {"open", "closed", "merged", "all"}:
@@ -72,6 +72,7 @@ class TrackedPR:
72
72
  notice: str = ""
73
73
  posted_at: datetime | None = None
74
74
  review_requested_at: datetime | None = None
75
+ explicit_review: bool = False
75
76
 
76
77
 
77
78
  @dataclass(frozen=True)
@@ -31,6 +31,7 @@ class ReadForge(Protocol):
31
31
  def id(self) -> str: ...
32
32
  async def whoami(self) -> Account: ...
33
33
  async def list_review_requests(self) -> Discovery: ...
34
+ async def list_repositories(self, owner: str) -> tuple[str, ...]: ...
34
35
  async def search_pull_requests(self, query: PRQuery, cursor: str | None = None) -> PRPage: ...
35
36
  async def get_pull_request(self, ref: PRRef) -> PullRequest: ...
36
37
  def ref(self, value: str) -> PRRef: ...