diffbutler 0.5.3__tar.gz → 0.6.1__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 (111) hide show
  1. {diffbutler-0.5.3 → diffbutler-0.6.1}/PKG-INFO +47 -16
  2. {diffbutler-0.5.3 → diffbutler-0.6.1}/README.md +46 -15
  3. {diffbutler-0.5.3 → diffbutler-0.6.1}/pyproject.toml +3 -3
  4. {diffbutler-0.5.3 → diffbutler-0.6.1}/pyproject.toml.orig +3 -3
  5. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/default.toml +3 -2
  6. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/settings.py +1 -1
  7. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/setup.py +3 -3
  8. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/application.py +60 -1
  9. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/dashboard.py +18 -4
  10. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/managed.py +11 -2
  11. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/polling.py +2 -4
  12. diffbutler-0.6.1/src/diffbutler/core/pr_lookup.py +83 -0
  13. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/repositories.py +9 -4
  14. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/setup.py +16 -4
  15. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/sync.py +36 -5
  16. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/forge.py +27 -5
  17. diffbutler-0.6.1/src/diffbutler/domain/polling.py +67 -0
  18. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/sync.py +1 -0
  19. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/base.py +3 -0
  20. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/diagnostics.py +16 -17
  21. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/adapter.py +25 -2
  22. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/http.py +18 -2
  23. diffbutler-0.6.1/src/diffbutler/forges/github/repositories.py +104 -0
  24. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/setup.py +2 -2
  25. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/sources/remotes.py +2 -2
  26. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/dashboard.py +3 -1
  27. diffbutler-0.6.1/src/diffbutler/store/migrations/versions/0011_explicit_reviews.py +20 -0
  28. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/models.py +1 -0
  29. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/queue.py +2 -0
  30. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/cli/__init__.py +13 -6
  31. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/app.py +25 -1
  32. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/dashboard.py +236 -98
  33. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/handoff_probe.py +3 -0
  34. diffbutler-0.6.1/src/diffbutler/ui/tui/review_prompt.py +123 -0
  35. diffbutler-0.6.1/src/diffbutler/ui/tui/terminal_theme.py +146 -0
  36. diffbutler-0.5.3/src/diffbutler/domain/polling.py +0 -92
  37. {diffbutler-0.5.3 → diffbutler-0.6.1}/LICENSE.txt +0 -0
  38. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/__init__.py +0 -0
  39. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/__main__.py +0 -0
  40. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/__init__.py +0 -0
  41. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/diagnostics.py +0 -0
  42. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/file.py +0 -0
  43. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/instance.py +0 -0
  44. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/transport.py +0 -0
  45. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/__init__.py +0 -0
  46. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/context.py +0 -0
  47. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/doctor.py +0 -0
  48. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/lifecycle.py +0 -0
  49. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/native.py +0 -0
  50. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/process.py +0 -0
  51. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/reset.py +0 -0
  52. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/resources.py +0 -0
  53. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/review.py +0 -0
  54. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/snapshot.py +0 -0
  55. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/worker.py +0 -0
  56. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/workspaces.py +0 -0
  57. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/__init__.py +0 -0
  58. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/dashboard.py +0 -0
  59. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/diff.py +0 -0
  60. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/lifecycle.py +0 -0
  61. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/process.py +0 -0
  62. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/redaction.py +0 -0
  63. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/resources.py +0 -0
  64. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/review.py +0 -0
  65. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/__init__.py +0 -0
  66. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/__init__.py +0 -0
  67. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/auth.py +0 -0
  68. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/mapping.py +0 -0
  69. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/registry.py +0 -0
  70. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/py.typed +0 -0
  71. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/__init__.py +0 -0
  72. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/base.py +0 -0
  73. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/extraction.py +0 -0
  74. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/native.py +0 -0
  75. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/parsing.py +0 -0
  76. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/presets.toml +0 -0
  77. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/process.py +0 -0
  78. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/prompt.py +0 -0
  79. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/redaction.py +0 -0
  80. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/resources.py +0 -0
  81. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/sources/__init__.py +0 -0
  82. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/sources/diagnostics.py +0 -0
  83. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/sources/fetch.py +0 -0
  84. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/sources/git.py +0 -0
  85. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/__init__.py +0 -0
  86. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/base.py +0 -0
  87. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/database.py +0 -0
  88. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/lifecycle.py +0 -0
  89. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/__init__.py +0 -0
  90. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/env.py +0 -0
  91. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0001_initial.py +0 -0
  92. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0002_monitoring.py +0 -0
  93. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0003_reviews.py +0 -0
  94. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0004_claims.py +0 -0
  95. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0005_results.py +0 -0
  96. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0006_anchors.py +0 -0
  97. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0007_lifecycle.py +0 -0
  98. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0008_processes.py +0 -0
  99. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0009_dashboard.py +0 -0
  100. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0010_run_reasons.py +0 -0
  101. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/__init__.py +0 -0
  102. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/process.py +0 -0
  103. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/review.py +0 -0
  104. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/__init__.py +0 -0
  105. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/cli/editor.py +0 -0
  106. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/__init__.py +0 -0
  107. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/_fake_agent.py +0 -0
  108. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/_foreground_exec.py +0 -0
  109. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/browse.py +0 -0
  110. {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/browser.py +0 -0
  111. {diffbutler-0.5.3 → diffbutler-0.6.1}/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.3
3
+ Version: 0.6.1
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
@@ -34,7 +34,7 @@ Keep track of what each pull request needs next: AI review, your review, or auth
34
34
  changes. Start an AI review, discuss its findings in the agent's own session, and
35
35
  finish your review in the browser.
36
36
 
37
- ![DiffButler dashboard showing pull requests and the next review action](diffbutler.png)
37
+ ![DiffButler dashboard showing pull requests and the next review action](https://raw.githubusercontent.com/heikkitoivonen/diffbutler/main/diffbutler.png)
38
38
 
39
39
  ## Install
40
40
 
@@ -91,22 +91,25 @@ 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
106
109
  stay visible across refreshes and restarts. If an approved PR receives new commits,
107
110
  it reappears as `done+` at the end of the queue without starting another AI review.
108
- Authenticated polling slows when API quota is scarce and speeds back up when it
109
- recovers, accounting for refresh cost and time until the quota resets.
111
+ Polling spreads API quota evenly until it resets, leaving 10% in reserve: it slows
112
+ when quota is scarce or refreshes are costly and speeds back up when quota recovers.
110
113
 
111
114
  Select a PR with the arrow keys, then work through the review:
112
115
 
@@ -125,20 +128,24 @@ Select a PR with the arrow keys, then work through the review:
125
128
  starts a new run with the current reviewer and replaces the current review details.
126
129
  Ask questions and explicitly tell the agent which comments to post, if any.
127
130
  3. Exit the agent to return to the dashboard, which refreshes activity from GitHub.
128
- Press `w` if you are waiting for author changes, or `o` to review in your browser.
131
+ Press `o` to open the PR in your browser at any time; it does not change the state.
132
+ Press `w` if you are waiting for author changes, or `a` once you are ready to approve.
129
133
  4. After approving on GitHub, press `m` to mark the task complete locally. DiffButler
130
134
  also detects your approval, closure, or merge and cleans up owned session resources
131
135
  while retaining review history.
132
136
 
133
137
  `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
138
+ or open the browser. For `check` and `author`, it resumes the saved agent conversation,
139
+ just like `c`. An AI review never counts as your approval. Background analysis
135
140
  uses the agent's [native permission controls](docs/native-reviews.md#native-permissions-and-authentication)
136
141
  and read-only review instructions; source sent to the agent may go to its model provider.
137
142
 
138
143
  - `/`: Filter the queue.
139
- - `a`: Toggle monitoring all open PRs in configured repositories.
144
+ - `A`: Toggle monitoring all open PRs in configured repositories.
140
145
  - `b`: Browse accessible PRs and follow one.
141
- - `R`: Refresh from GitHub.
146
+ - `R`: Find and add a PR by link or number, then review it; choose a repository if
147
+ the number is ambiguous. Explicitly selected PRs bypass queue visibility filters.
148
+ - `Ctrl+R`: Refresh from GitHub.
142
149
  - `k`: Stop the selected PR's review and reset its local tracking and owned resources.
143
150
  It disappears until a forge refresh rediscovers it; review history is retained.
144
151
  - `h`: View completed review history.
@@ -149,10 +156,34 @@ See the [dashboard guide](docs/dashboard.md) for all actions and
149
156
  [configuration guide](docs/configuration.md) for settings.
150
157
 
151
158
  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
159
+ requests. Use `R` to select one explicitly, or set `include_own_prs = true` in `[ui]`
160
+ to show them all, such as PRs an agent created using your account.
161
+ PRs assigned to you are also hidden by default, even when
154
162
  you are requested directly or through a team. Set `include_assigned_prs = true` in
155
- `[ui]` to include them.
163
+ `[ui]` to include them, or select one with `R`.
164
+
165
+ ### How PRs move through the board
166
+
167
+ Solid arrows are actions you take; dashed arrows happen automatically.
168
+
169
+ How a PR arrives:
170
+
171
+ ![Flow diagram: review requests and all-PR monitoring reach the board through polling, browsing adds followed PRs, and your own or assigned PRs are hidden unless enabled; R adds a PR directly and starts a review. New PRs start as pending unless already approved, closed, or merged.](https://raw.githubusercontent.com/heikkitoivonen/diffbutler/main/docs/diagrams/pr-arrival.svg)
172
+
173
+ How its status changes:
174
+
175
+ ![State diagram: r queues a review, the worker runs it, and the result is approve or check; w, a, and m move a PR between author, approve, and done; Ctrl+C or a failed agent returns it to pending; GitHub commits, your submitted reviews, and closes or merges move it automatically; new commits after approval show done+.](https://raw.githubusercontent.com/heikkitoivonen/diffbutler/main/docs/diagrams/pr-status.svg)
176
+
177
+ `r`, `a`, `w`, and `m` work from any open status, not only along the arrows shown,
178
+ though `r` refuses while a review is already queued or running. New commits on a
179
+ `done+` PR keep it `done+` rather than returning it to `pending`.
180
+
181
+ How it leaves:
182
+
183
+ ![Flow diagram: m, your approval, or a close or merge moves a PR to done and history; k removes it until a refresh finds it still requested or monitored; becoming an assignee hides it unless include_assigned_prs is set or it was selected with R.](https://raw.githubusercontent.com/heikkitoivonen/diffbutler/main/docs/diagrams/pr-exit.svg)
184
+
185
+ A PR stays on the board when its review request is withdrawn; press `k` to remove it.
186
+ A PR you only followed stays away after `k` until you follow it again.
156
187
 
157
188
  ## Reset
158
189
 
@@ -6,7 +6,7 @@ Keep track of what each pull request needs next: AI review, your review, or auth
6
6
  changes. Start an AI review, discuss its findings in the agent's own session, and
7
7
  finish your review in the browser.
8
8
 
9
- ![DiffButler dashboard showing pull requests and the next review action](diffbutler.png)
9
+ ![DiffButler dashboard showing pull requests and the next review action](https://raw.githubusercontent.com/heikkitoivonen/diffbutler/main/diffbutler.png)
10
10
 
11
11
  ## Install
12
12
 
@@ -63,22 +63,25 @@ 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
78
81
  stay visible across refreshes and restarts. If an approved PR receives new commits,
79
82
  it reappears as `done+` at the end of the queue without starting another AI review.
80
- Authenticated polling slows when API quota is scarce and speeds back up when it
81
- recovers, accounting for refresh cost and time until the quota resets.
83
+ Polling spreads API quota evenly until it resets, leaving 10% in reserve: it slows
84
+ when quota is scarce or refreshes are costly and speeds back up when quota recovers.
82
85
 
83
86
  Select a PR with the arrow keys, then work through the review:
84
87
 
@@ -97,20 +100,24 @@ Select a PR with the arrow keys, then work through the review:
97
100
  starts a new run with the current reviewer and replaces the current review details.
98
101
  Ask questions and explicitly tell the agent which comments to post, if any.
99
102
  3. Exit the agent to return to the dashboard, which refreshes activity from GitHub.
100
- Press `w` if you are waiting for author changes, or `o` to review in your browser.
103
+ Press `o` to open the PR in your browser at any time; it does not change the state.
104
+ Press `w` if you are waiting for author changes, or `a` once you are ready to approve.
101
105
  4. After approving on GitHub, press `m` to mark the task complete locally. DiffButler
102
106
  also detects your approval, closure, or merge and cleans up owned session resources
103
107
  while retaining review history.
104
108
 
105
109
  `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
110
+ or open the browser. For `check` and `author`, it resumes the saved agent conversation,
111
+ just like `c`. An AI review never counts as your approval. Background analysis
107
112
  uses the agent's [native permission controls](docs/native-reviews.md#native-permissions-and-authentication)
108
113
  and read-only review instructions; source sent to the agent may go to its model provider.
109
114
 
110
115
  - `/`: Filter the queue.
111
- - `a`: Toggle monitoring all open PRs in configured repositories.
116
+ - `A`: Toggle monitoring all open PRs in configured repositories.
112
117
  - `b`: Browse accessible PRs and follow one.
113
- - `R`: Refresh from GitHub.
118
+ - `R`: Find and add a PR by link or number, then review it; choose a repository if
119
+ the number is ambiguous. Explicitly selected PRs bypass queue visibility filters.
120
+ - `Ctrl+R`: Refresh from GitHub.
114
121
  - `k`: Stop the selected PR's review and reset its local tracking and owned resources.
115
122
  It disappears until a forge refresh rediscovers it; review history is retained.
116
123
  - `h`: View completed review history.
@@ -121,10 +128,34 @@ See the [dashboard guide](docs/dashboard.md) for all actions and
121
128
  [configuration guide](docs/configuration.md) for settings.
122
129
 
123
130
  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
131
+ requests. Use `R` to select one explicitly, or set `include_own_prs = true` in `[ui]`
132
+ to show them all, such as PRs an agent created using your account.
133
+ PRs assigned to you are also hidden by default, even when
126
134
  you are requested directly or through a team. Set `include_assigned_prs = true` in
127
- `[ui]` to include them.
135
+ `[ui]` to include them, or select one with `R`.
136
+
137
+ ### How PRs move through the board
138
+
139
+ Solid arrows are actions you take; dashed arrows happen automatically.
140
+
141
+ How a PR arrives:
142
+
143
+ ![Flow diagram: review requests and all-PR monitoring reach the board through polling, browsing adds followed PRs, and your own or assigned PRs are hidden unless enabled; R adds a PR directly and starts a review. New PRs start as pending unless already approved, closed, or merged.](https://raw.githubusercontent.com/heikkitoivonen/diffbutler/main/docs/diagrams/pr-arrival.svg)
144
+
145
+ How its status changes:
146
+
147
+ ![State diagram: r queues a review, the worker runs it, and the result is approve or check; w, a, and m move a PR between author, approve, and done; Ctrl+C or a failed agent returns it to pending; GitHub commits, your submitted reviews, and closes or merges move it automatically; new commits after approval show done+.](https://raw.githubusercontent.com/heikkitoivonen/diffbutler/main/docs/diagrams/pr-status.svg)
148
+
149
+ `r`, `a`, `w`, and `m` work from any open status, not only along the arrows shown,
150
+ though `r` refuses while a review is already queued or running. New commits on a
151
+ `done+` PR keep it `done+` rather than returning it to `pending`.
152
+
153
+ How it leaves:
154
+
155
+ ![Flow diagram: m, your approval, or a close or merge moves a PR to done and history; k removes it until a refresh finds it still requested or monitored; becoming an assignee hides it unless include_assigned_prs is set or it was selected with R.](https://raw.githubusercontent.com/heikkitoivonen/diffbutler/main/docs/diagrams/pr-exit.svg)
156
+
157
+ A PR stays on the board when its review request is withdrawn; press `k` to remove it.
158
+ A PR you only followed stays away after `k` until you follow it again.
128
159
 
129
160
  ## Reset
130
161
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffbutler"
3
- version = "0.5.3"
3
+ version = "0.6.1"
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,7 +56,7 @@ dev = [
56
56
  ]
57
57
 
58
58
  [tool.uv]
59
- required-version = ">=0.12.13,<0.13"
59
+ required-version = ">=0.12.13"
60
60
  exclude-newer = "2 days"
61
61
 
62
62
  [tool.ruff]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffbutler"
3
- version = "0.5.3"
3
+ version = "0.6.1"
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,7 +51,7 @@ 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
56
  exclude-newer = "2 days"
57
57
 
@@ -5,7 +5,7 @@ reviewers = []
5
5
 
6
6
  [general]
7
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.
8
+ # poll_interval_s = 600 # Override the minimum; low quota slows polling.
9
9
  max_concurrent_reviews = 2
10
10
  # Omit workspace_root to follow $XDG_DATA_HOME/diffbutler/reviews
11
11
  # (default ~/.local/share/diffbutler/reviews).
@@ -37,7 +37,7 @@ checkout = "worktree" # Native reviews currently use pinned source snapshots.
37
37
  include_own_prs = false # Set true to review PRs authored by your account.
38
38
  include_assigned_prs = false # Set true to review PRs assigned to your account.
39
39
  columns = ["number", "title", "author", "state"]
40
- sort_by = "attention"
40
+ sort_by = "state"
41
41
  sort_desc = true
42
42
  preview_pane = true
43
43
  default_filter = "active"
@@ -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:
@@ -125,7 +125,7 @@ class UI(Section):
125
125
  "state",
126
126
  ]
127
127
  )
128
- sort_by: str = "attention"
128
+ sort_by: str = "state"
129
129
  sort_desc: bool = True
130
130
  preview_pane: bool = True
131
131
  default_filter: Literal[
@@ -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,63 @@ async def browse_configured(
88
90
  return await service.browse_prs(query, cursor)
89
91
 
90
92
 
93
+ async def review_targets_configured(
94
+ path: Path | None, value: str, *, deadline: float | None = None
95
+ ) -> tuple[ReviewTarget, ...]:
96
+ """Resolve without following PRs or requesting any AI work.
97
+
98
+ deadline is the event-loop time by which number lookups must finish.
99
+ """
100
+ value = value.strip()
101
+ numeric = re.fullmatch(r"#?([1-9][0-9]*)", value)
102
+ if not numeric and not value.startswith("https://"):
103
+ raise ForgeError("Enter a PR link or a positive PR number")
104
+ found: dict[tuple[Account, PRRef], ReviewTarget] = {}
105
+ async with local_store(path) as (store, settings):
106
+ if not settings.forges:
107
+ raise ForgeError("No forge configured")
108
+ if numeric and not any(config.repositories for config in settings.forges):
109
+ raise ForgeError("No monitored repositories configured; enter a PR link instead")
110
+ matched_url = False
111
+ for index, config in enumerate(settings.forges, 1):
112
+ if numeric and not config.repositories:
113
+ continue
114
+ async with remote_service(store, settings, index) as service:
115
+ if numeric:
116
+ prs = await numbered_prs(
117
+ service.forge, config.repositories, int(numeric[1]), deadline=deadline
118
+ )
119
+ else:
120
+ try:
121
+ ref = service.forge.ref(value)
122
+ except ForgeError:
123
+ continue
124
+ matched_url = True
125
+ pr = await open_pr(service.forge, ref)
126
+ prs = (pr,) if pr else ()
127
+ account = await service.forge.whoami()
128
+ if config.login and account.login.casefold() != config.login.casefold():
129
+ raise ForgeError("Authenticated identity does not match configured account")
130
+ for pr in prs:
131
+ found.setdefault((account, pr.ref), ReviewTarget(index, account, pr))
132
+ if not numeric and not matched_url:
133
+ raise ForgeError("Enter a valid PR link on a configured forge")
134
+ return tuple(found.values())
135
+
136
+
137
+ async def follow_review_target_configured(
138
+ path: Path | None, target: ReviewTarget
139
+ ) -> PullRequestRow:
140
+ """Revalidate the chosen account and open state before tracking a review target."""
141
+ async with (
142
+ local_store(path) as (store, settings),
143
+ remote_service(store, settings, target.forge_index) as service,
144
+ ):
145
+ if await service.forge.whoami() != target.account:
146
+ raise ForgeError("Configured account changed; look up the PR again")
147
+ return await service.follow_pr(target.pull_request.ref, for_review=True)
148
+
149
+
91
150
  async def local_queue(
92
151
  path: Path | None,
93
152
  view: Literal["active", "requested", "following"],
@@ -54,7 +54,7 @@ class DashboardRow(BaseModel):
54
54
 
55
55
 
56
56
  class SortSpec(BaseModel):
57
- column: str = "attention"
57
+ column: str = "state"
58
58
  descending: bool = True
59
59
 
60
60
 
@@ -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
@@ -266,6 +274,12 @@ async def state_configured(
266
274
  return row.tracked.pull_request.url
267
275
 
268
276
 
277
+ async def url_configured(path: Path | None, value: str, login: str | None) -> str:
278
+ """Resolve a tracked PR's URL without changing its state."""
279
+ async with local_store(path) as (queue, _):
280
+ return (await resolve_local(queue, value, login)).tracked.pull_request.url
281
+
282
+
269
283
  async def refresh_configured(path: Path | None, value: str, login: str | None) -> PullRequestRow:
270
284
  """Refresh one PR, preserving a durable pending indication on any failure."""
271
285
  async with local_store(path) as (queue, settings):
@@ -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:
@@ -78,17 +78,15 @@ class Poller:
78
78
  if previous != (budget.interval, budget.wait_for_reset):
79
79
  logging.getLogger(__name__).info(
80
80
  "Polling adjusted host=%s login=%s resource=%s interval_s=%.1f "
81
- "remaining=%d limit=%d reset_in_s=%.1f cost=%.1f "
82
- "external_calls_per_s=%.3f wait_for_reset=%s",
81
+ "remaining=%d limit=%d reset_in_s=%.1f cost=%.1f wait_for_reset=%s",
83
82
  sample.host,
84
- sample.login,
83
+ sample.login or "(anonymous)",
85
84
  sample.quota.resource,
86
85
  budget.interval,
87
86
  sample.quota.remaining,
88
87
  sample.quota.limit,
89
88
  sample.quota.reset_at.timestamp() - self.now(),
90
89
  budget.cost,
91
- budget.external_rate,
92
90
  budget.wait_for_reset,
93
91
  )
94
92
  self.changed.set()
@@ -0,0 +1,83 @@
1
+ """Read-only resolution of explicit review targets across repository scopes."""
2
+
3
+ import asyncio
4
+ import math
5
+ from dataclasses import dataclass
6
+
7
+ from diffbutler.domain.forge import Account, PRRef, PullRequest, repository_matches
8
+ from diffbutler.forges.base import ForgeError, ReadForge
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ReviewTarget:
13
+ forge_index: int
14
+ account: Account
15
+ pull_request: PullRequest
16
+
17
+
18
+ async def open_pr(forge: ReadForge, ref: PRRef, *, listed: bool = False) -> PullRequest | None:
19
+ """Open PR at ref, or None when it is missing or, for listed repositories, unreadable."""
20
+ try:
21
+ pr = await forge.get_pull_request(ref)
22
+ except ForgeError as exc:
23
+ # A listed repository can deny PR reads (disabled, blocked, or metadata-only
24
+ # access); rate limits carry retry_after and still fail the lookup.
25
+ if exc.status == 404 or (listed and exc.status in {403, 451} and exc.retry_after is None):
26
+ return None
27
+ raise
28
+ if pr.ref != ref:
29
+ raise ForgeError("Forge returned a different PR identity")
30
+ return pr if pr.state == "open" else None
31
+
32
+
33
+ # Reads kept in reserve after a scan: the account check, then following the chosen
34
+ # PR (account checks, the PR, and its first activity pages) on a fresh client.
35
+ FOLLOW_UP_READS = 10
36
+
37
+
38
+ def check_budget(forge: ReadForge, number: int, reads: int, deadline: float | None) -> None:
39
+ """Fail before reads that would reach low-quota pacing or outlast the deadline."""
40
+ budget = forge.read_budget()
41
+ if budget.requests is not None and reads + FOLLOW_UP_READS > budget.requests:
42
+ raise ForgeError(
43
+ f"Checking PR #{number} in {reads} more repositories needs more requests "
44
+ f"than the forge's rate limit can spare ({budget.requests}); "
45
+ "enter the full PR link or retry after the limit resets"
46
+ )
47
+ if deadline is not None:
48
+ needed = (reads + FOLLOW_UP_READS) * budget.seconds_per_request
49
+ if needed > deadline - asyncio.get_running_loop().time():
50
+ raise ForgeError(
51
+ f"Checking PR #{number} in {reads} more repositories and following it "
52
+ f"would take about {math.ceil(needed)} seconds, longer than the lookup "
53
+ "allows; enter the full PR link"
54
+ )
55
+
56
+
57
+ async def numbered_prs(
58
+ forge: ReadForge, repositories: list[str], number: int, *, deadline: float | None = None
59
+ ) -> tuple[PullRequest, ...]:
60
+ """Resolve exact numbers directly after expanding owner repository scopes.
61
+
62
+ deadline is an event-loop time. When owner scopes were listed, each read first
63
+ checks that the remaining reads fit the forge's spare quota and the deadline.
64
+ """
65
+ refs: dict[PRRef, bool] = {}
66
+ for repository in dict.fromkeys(repositories):
67
+ if not repository.endswith("/*"):
68
+ refs[forge.ref(f"{repository}#{number}")] = False
69
+ continue
70
+ for name in await forge.list_repositories(repository.removesuffix("/*")):
71
+ ref = forge.ref(f"{name}#{number}")
72
+ if ref.forge != forge.id or not repository_matches(repository, ref):
73
+ raise ForgeError("Forge returned a repository outside the requested owner")
74
+ refs.setdefault(ref, True)
75
+ budgeted = any(refs.values())
76
+ found: list[PullRequest] = []
77
+ for index, (ref, listed) in enumerate(refs.items()):
78
+ if budgeted:
79
+ # Rechecking before every read also covers retries that used extra quota.
80
+ check_budget(forge, number, len(refs) - index, deadline)
81
+ if pr := await open_pr(forge, ref, listed=listed):
82
+ found.append(pr)
83
+ return tuple(found)