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.
- {diffbutler-0.5.3 → diffbutler-0.6.1}/PKG-INFO +47 -16
- {diffbutler-0.5.3 → diffbutler-0.6.1}/README.md +46 -15
- {diffbutler-0.5.3 → diffbutler-0.6.1}/pyproject.toml +3 -3
- {diffbutler-0.5.3 → diffbutler-0.6.1}/pyproject.toml.orig +3 -3
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/default.toml +3 -2
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/settings.py +1 -1
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/setup.py +3 -3
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/application.py +60 -1
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/dashboard.py +18 -4
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/managed.py +11 -2
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/polling.py +2 -4
- diffbutler-0.6.1/src/diffbutler/core/pr_lookup.py +83 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/repositories.py +9 -4
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/setup.py +16 -4
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/sync.py +36 -5
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/forge.py +27 -5
- diffbutler-0.6.1/src/diffbutler/domain/polling.py +67 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/sync.py +1 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/base.py +3 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/diagnostics.py +16 -17
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/adapter.py +25 -2
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/http.py +18 -2
- diffbutler-0.6.1/src/diffbutler/forges/github/repositories.py +104 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/setup.py +2 -2
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/sources/remotes.py +2 -2
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/dashboard.py +3 -1
- diffbutler-0.6.1/src/diffbutler/store/migrations/versions/0011_explicit_reviews.py +20 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/models.py +1 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/queue.py +2 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/cli/__init__.py +13 -6
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/app.py +25 -1
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/dashboard.py +236 -98
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/handoff_probe.py +3 -0
- diffbutler-0.6.1/src/diffbutler/ui/tui/review_prompt.py +123 -0
- diffbutler-0.6.1/src/diffbutler/ui/tui/terminal_theme.py +146 -0
- diffbutler-0.5.3/src/diffbutler/domain/polling.py +0 -92
- {diffbutler-0.5.3 → diffbutler-0.6.1}/LICENSE.txt +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/__main__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/diagnostics.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/file.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/instance.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/config/transport.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/context.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/doctor.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/lifecycle.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/native.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/process.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/reset.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/resources.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/review.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/snapshot.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/worker.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/core/workspaces.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/dashboard.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/diff.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/lifecycle.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/process.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/redaction.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/resources.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/domain/review.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/auth.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/github/mapping.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/forges/registry.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/py.typed +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/base.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/extraction.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/native.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/parsing.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/presets.toml +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/process.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/prompt.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/redaction.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/reviewers/resources.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/sources/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/sources/diagnostics.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/sources/fetch.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/sources/git.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/base.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/database.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/lifecycle.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/env.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0001_initial.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0002_monitoring.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0003_reviews.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0004_claims.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0005_results.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0006_anchors.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0007_lifecycle.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0008_processes.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0009_dashboard.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0010_run_reasons.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/process.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/store/review.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/cli/editor.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/__init__.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/_fake_agent.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/_foreground_exec.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/browse.py +0 -0
- {diffbutler-0.5.3 → diffbutler-0.6.1}/src/diffbutler/ui/tui/browser.py +0 -0
- {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.
|
|
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
|
-

|
|
37
|
+

|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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 `
|
|
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.
|
|
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
|
-
- `
|
|
144
|
+
- `A`: Toggle monitoring all open PRs in configured repositories.
|
|
140
145
|
- `b`: Browse accessible PRs and follow one.
|
|
141
|
-
- `R`:
|
|
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.
|
|
153
|
-
|
|
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
|
+

|
|
172
|
+
|
|
173
|
+
How its status changes:
|
|
174
|
+
|
|
175
|
+

|
|
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
|
+

|
|
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
|
-

|
|
9
|
+

|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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 `
|
|
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.
|
|
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
|
-
- `
|
|
116
|
+
- `A`: Toggle monitoring all open PRs in configured repositories.
|
|
112
117
|
- `b`: Browse accessible PRs and follow one.
|
|
113
|
-
- `R`:
|
|
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.
|
|
125
|
-
|
|
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
|
+

|
|
144
|
+
|
|
145
|
+
How its status changes:
|
|
146
|
+
|
|
147
|
+

|
|
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
|
+

|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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 = "
|
|
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:
|
|
@@ -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
|
-
|
|
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
|
|
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 = "
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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)
|