diffbutler 0.6.0__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.6.0 → diffbutler-0.6.1}/PKG-INFO +30 -6
- {diffbutler-0.6.0 → diffbutler-0.6.1}/README.md +29 -5
- {diffbutler-0.6.0 → diffbutler-0.6.1}/pyproject.toml +1 -1
- {diffbutler-0.6.0 → diffbutler-0.6.1}/pyproject.toml.orig +1 -1
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/default.toml +2 -2
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/settings.py +1 -1
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/application.py +10 -3
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/dashboard.py +7 -1
- {diffbutler-0.6.0 → 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.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/forge.py +8 -0
- diffbutler-0.6.1/src/diffbutler/domain/polling.py +67 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/base.py +2 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/diagnostics.py +16 -17
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/adapter.py +4 -0
- {diffbutler-0.6.0 → 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.6.0 → diffbutler-0.6.1}/src/diffbutler/store/dashboard.py +3 -1
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/cli/__init__.py +13 -6
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/dashboard.py +61 -29
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/review_prompt.py +3 -2
- diffbutler-0.6.0/src/diffbutler/core/pr_lookup.py +0 -50
- diffbutler-0.6.0/src/diffbutler/domain/polling.py +0 -92
- diffbutler-0.6.0/src/diffbutler/forges/github/repositories.py +0 -62
- {diffbutler-0.6.0 → diffbutler-0.6.1}/LICENSE.txt +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/__main__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/diagnostics.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/file.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/instance.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/setup.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/transport.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/context.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/doctor.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/lifecycle.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/managed.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/native.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/process.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/repositories.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/reset.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/resources.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/review.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/setup.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/snapshot.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/sync.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/worker.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/workspaces.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/dashboard.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/diff.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/lifecycle.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/process.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/redaction.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/resources.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/review.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/sync.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/auth.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/mapping.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/setup.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/registry.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/py.typed +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/base.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/extraction.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/native.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/parsing.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/presets.toml +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/process.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/prompt.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/redaction.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/resources.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/sources/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/sources/diagnostics.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/sources/fetch.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/sources/git.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/sources/remotes.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/base.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/database.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/lifecycle.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/env.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0001_initial.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0002_monitoring.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0003_reviews.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0004_claims.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0005_results.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0006_anchors.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0007_lifecycle.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0008_processes.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0009_dashboard.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0010_run_reasons.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0011_explicit_reviews.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/models.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/process.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/queue.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/review.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/cli/editor.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/__init__.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/_fake_agent.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/_foreground_exec.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/app.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/browse.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/browser.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/foreground.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/handoff_probe.py +0 -0
- {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/terminal_theme.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diffbutler
|
|
3
|
-
Version: 0.6.
|
|
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
|
|
|
@@ -108,8 +108,8 @@ The dashboard shows active reviews, including followed PRs whose GitHub review
|
|
|
108
108
|
requests have disappeared after you posted feedback. Waiting-for-author reviews
|
|
109
109
|
stay visible across refreshes and restarts. If an approved PR receives new commits,
|
|
110
110
|
it reappears as `done+` at the end of the queue without starting another AI review.
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
113
113
|
|
|
114
114
|
Select a PR with the arrow keys, then work through the review:
|
|
115
115
|
|
|
@@ -128,7 +128,8 @@ Select a PR with the arrow keys, then work through the review:
|
|
|
128
128
|
starts a new run with the current reviewer and replaces the current review details.
|
|
129
129
|
Ask questions and explicitly tell the agent which comments to post, if any.
|
|
130
130
|
3. Exit the agent to return to the dashboard, which refreshes activity from GitHub.
|
|
131
|
-
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.
|
|
132
133
|
4. After approving on GitHub, press `m` to mark the task complete locally. DiffButler
|
|
133
134
|
also detects your approval, closure, or merge and cleans up owned session resources
|
|
134
135
|
while retaining review history.
|
|
@@ -140,7 +141,7 @@ uses the agent's [native permission controls](docs/native-reviews.md#native-perm
|
|
|
140
141
|
and read-only review instructions; source sent to the agent may go to its model provider.
|
|
141
142
|
|
|
142
143
|
- `/`: Filter the queue.
|
|
143
|
-
- `
|
|
144
|
+
- `A`: Toggle monitoring all open PRs in configured repositories.
|
|
144
145
|
- `b`: Browse accessible PRs and follow one.
|
|
145
146
|
- `R`: Find and add a PR by link or number, then review it; choose a repository if
|
|
146
147
|
the number is ambiguous. Explicitly selected PRs bypass queue visibility filters.
|
|
@@ -161,6 +162,29 @@ PRs assigned to you are also hidden by default, even when
|
|
|
161
162
|
you are requested directly or through a team. Set `include_assigned_prs = true` in
|
|
162
163
|
`[ui]` to include them, or select one with `R`.
|
|
163
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.
|
|
187
|
+
|
|
164
188
|
## Reset
|
|
165
189
|
|
|
166
190
|
Close running DiffButler sessions, then run:
|
|
@@ -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
|
|
|
@@ -80,8 +80,8 @@ The dashboard shows active reviews, including followed PRs whose GitHub review
|
|
|
80
80
|
requests have disappeared after you posted feedback. Waiting-for-author reviews
|
|
81
81
|
stay visible across refreshes and restarts. If an approved PR receives new commits,
|
|
82
82
|
it reappears as `done+` at the end of the queue without starting another AI review.
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
85
85
|
|
|
86
86
|
Select a PR with the arrow keys, then work through the review:
|
|
87
87
|
|
|
@@ -100,7 +100,8 @@ Select a PR with the arrow keys, then work through the review:
|
|
|
100
100
|
starts a new run with the current reviewer and replaces the current review details.
|
|
101
101
|
Ask questions and explicitly tell the agent which comments to post, if any.
|
|
102
102
|
3. Exit the agent to return to the dashboard, which refreshes activity from GitHub.
|
|
103
|
-
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.
|
|
104
105
|
4. After approving on GitHub, press `m` to mark the task complete locally. DiffButler
|
|
105
106
|
also detects your approval, closure, or merge and cleans up owned session resources
|
|
106
107
|
while retaining review history.
|
|
@@ -112,7 +113,7 @@ uses the agent's [native permission controls](docs/native-reviews.md#native-perm
|
|
|
112
113
|
and read-only review instructions; source sent to the agent may go to its model provider.
|
|
113
114
|
|
|
114
115
|
- `/`: Filter the queue.
|
|
115
|
-
- `
|
|
116
|
+
- `A`: Toggle monitoring all open PRs in configured repositories.
|
|
116
117
|
- `b`: Browse accessible PRs and follow one.
|
|
117
118
|
- `R`: Find and add a PR by link or number, then review it; choose a repository if
|
|
118
119
|
the number is ambiguous. Explicitly selected PRs bypass queue visibility filters.
|
|
@@ -133,6 +134,29 @@ PRs assigned to you are also hidden by default, even when
|
|
|
133
134
|
you are requested directly or through a team. Set `include_assigned_prs = true` in
|
|
134
135
|
`[ui]` to include them, or select one with `R`.
|
|
135
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.
|
|
159
|
+
|
|
136
160
|
## Reset
|
|
137
161
|
|
|
138
162
|
Close running DiffButler sessions, then run:
|
|
@@ -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"
|
|
@@ -90,8 +90,13 @@ async def browse_configured(
|
|
|
90
90
|
return await service.browse_prs(query, cursor)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
async def review_targets_configured(
|
|
94
|
-
|
|
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
|
+
"""
|
|
95
100
|
value = value.strip()
|
|
96
101
|
numeric = re.fullmatch(r"#?([1-9][0-9]*)", value)
|
|
97
102
|
if not numeric and not value.startswith("https://"):
|
|
@@ -108,7 +113,9 @@ async def review_targets_configured(path: Path | None, value: str) -> tuple[Revi
|
|
|
108
113
|
continue
|
|
109
114
|
async with remote_service(store, settings, index) as service:
|
|
110
115
|
if numeric:
|
|
111
|
-
prs = await numbered_prs(
|
|
116
|
+
prs = await numbered_prs(
|
|
117
|
+
service.forge, config.repositories, int(numeric[1]), deadline=deadline
|
|
118
|
+
)
|
|
112
119
|
else:
|
|
113
120
|
try:
|
|
114
121
|
ref = service.forge.ref(value)
|
|
@@ -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
|
|
|
@@ -274,6 +274,12 @@ async def state_configured(
|
|
|
274
274
|
return row.tracked.pull_request.url
|
|
275
275
|
|
|
276
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
|
+
|
|
277
283
|
async def refresh_configured(path: Path | None, value: str, login: str | None) -> PullRequestRow:
|
|
278
284
|
"""Refresh one PR, preserving a durable pending indication on any failure."""
|
|
279
285
|
async with local_store(path) as (queue, settings):
|
|
@@ -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)
|
|
@@ -130,6 +130,14 @@ class RateLimit:
|
|
|
130
130
|
reset_at: datetime
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class ReadBudget:
|
|
135
|
+
"""Reads a forge can spare from its quota (None if unknown) and seconds per read."""
|
|
136
|
+
|
|
137
|
+
requests: int | None
|
|
138
|
+
seconds_per_request: float
|
|
139
|
+
|
|
140
|
+
|
|
133
141
|
@dataclass(frozen=True)
|
|
134
142
|
class ForgeHealth:
|
|
135
143
|
account: Account
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Pure quota budgeting for read-only polling; timestamps are supplied by the caller."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from diffbutler.domain.forge import RateLimit
|
|
6
|
+
|
|
7
|
+
# Share of each quota left unspent by polling for interactive actions and other clients.
|
|
8
|
+
RESERVE_SHARE = 0.1
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class PollQuota:
|
|
13
|
+
host: str
|
|
14
|
+
login: str # Empty for anonymous access, which no GitHub login can collide with.
|
|
15
|
+
quota: RateLimit
|
|
16
|
+
requests: int
|
|
17
|
+
observed_at: float
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def key(self) -> tuple[str, str, str]:
|
|
21
|
+
return self.host, self.login.casefold(), self.quota.resource
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class QuotaBudget:
|
|
25
|
+
"""Spreads one account/resource's spare quota evenly over the time until reset.
|
|
26
|
+
|
|
27
|
+
Each update recomputes the interval from the latest headers, so consumption by
|
|
28
|
+
other clients is reflected in the next refresh's remaining count.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, minimum: float) -> None:
|
|
32
|
+
self.minimum = minimum
|
|
33
|
+
self.interval = minimum
|
|
34
|
+
self.sample: PollQuota | None = None
|
|
35
|
+
self.average_cost = 0.0
|
|
36
|
+
self.cost = 0.0
|
|
37
|
+
self.wait_for_reset = False
|
|
38
|
+
|
|
39
|
+
def update(self, sample: PollQuota, *, successful: bool, now: float) -> None:
|
|
40
|
+
self.sample = sample
|
|
41
|
+
requests = max(1, sample.requests)
|
|
42
|
+
if not self.average_cost:
|
|
43
|
+
self.average_cost = requests
|
|
44
|
+
elif successful:
|
|
45
|
+
self.average_cost = 0.5 * requests + 0.5 * self.average_cost
|
|
46
|
+
else:
|
|
47
|
+
# An incomplete refresh is not evidence that the queue became cheaper.
|
|
48
|
+
self.average_cost = max(self.average_cost, requests)
|
|
49
|
+
cost = max(requests, self.average_cost)
|
|
50
|
+
self.cost = cost if successful else max(self.cost, cost)
|
|
51
|
+
|
|
52
|
+
quota = sample.quota
|
|
53
|
+
remaining_time = max(0, quota.reset_at.timestamp() - now)
|
|
54
|
+
usable = quota.remaining - RESERVE_SHARE * quota.limit
|
|
55
|
+
self.wait_for_reset = usable < self.cost and remaining_time > 0
|
|
56
|
+
budget_interval = self.cost * remaining_time / usable if usable > 0 else 0
|
|
57
|
+
self.interval = max(self.minimum, budget_interval)
|
|
58
|
+
|
|
59
|
+
def next_start(self, started: float, now: float) -> float:
|
|
60
|
+
assert self.sample is not None
|
|
61
|
+
reset = self.sample.quota.reset_at.timestamp() + 1
|
|
62
|
+
if now >= reset:
|
|
63
|
+
# Wake to get fresh headers, even if the previous interval was very long.
|
|
64
|
+
return started + self.minimum
|
|
65
|
+
if self.wait_for_reset:
|
|
66
|
+
return reset
|
|
67
|
+
return min(started + self.interval, reset)
|
|
@@ -11,6 +11,7 @@ from diffbutler.domain.forge import (
|
|
|
11
11
|
PRQuery,
|
|
12
12
|
PRRef,
|
|
13
13
|
PullRequest,
|
|
14
|
+
ReadBudget,
|
|
14
15
|
)
|
|
15
16
|
from diffbutler.domain.sync import ReviewActivity
|
|
16
17
|
|
|
@@ -32,6 +33,7 @@ class ReadForge(Protocol):
|
|
|
32
33
|
async def whoami(self) -> Account: ...
|
|
33
34
|
async def list_review_requests(self) -> Discovery: ...
|
|
34
35
|
async def list_repositories(self, owner: str) -> tuple[str, ...]: ...
|
|
36
|
+
def read_budget(self) -> ReadBudget: ...
|
|
35
37
|
async def search_pull_requests(self, query: PRQuery, cursor: str | None = None) -> PRPage: ...
|
|
36
38
|
async def get_pull_request(self, ref: PRRef) -> PullRequest: ...
|
|
37
39
|
def ref(self, value: str) -> PRRef: ...
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from collections.abc import Generator
|
|
4
4
|
from contextlib import contextmanager
|
|
5
5
|
from contextvars import ContextVar
|
|
6
|
-
from dataclasses import dataclass, field
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
7
|
|
|
8
8
|
from diffbutler.domain.forge import RateLimit
|
|
9
9
|
from diffbutler.domain.polling import PollQuota
|
|
@@ -34,25 +34,24 @@ class RateLimitObservations:
|
|
|
34
34
|
cooldown_until: float = 0
|
|
35
35
|
|
|
36
36
|
def quotas(self) -> tuple[PollQuota, ...]:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
latest: dict[tuple[str, str, str], PollQuota] = {}
|
|
38
|
+
requests: dict[tuple[str, str, str], int] = {}
|
|
39
|
+
for (source, resource), observed in self.resources.items():
|
|
40
|
+
# Anonymous clients on one host share its per-IP quota.
|
|
41
|
+
login = source.login if source.authenticated else ""
|
|
42
|
+
if login is None:
|
|
43
|
+
continue
|
|
44
|
+
key = source.host, login.casefold(), resource
|
|
45
|
+
requests[key] = requests.get(key, 0) + observed.requests
|
|
46
|
+
if observed.latest is None:
|
|
40
47
|
continue
|
|
41
48
|
sample = PollQuota(
|
|
42
|
-
source.host,
|
|
49
|
+
source.host, login, observed.latest, observed.requests, observed.observed_at
|
|
43
50
|
)
|
|
44
|
-
previous =
|
|
45
|
-
if previous is
|
|
46
|
-
latest = sample
|
|
47
|
-
|
|
48
|
-
latest.host,
|
|
49
|
-
latest.login,
|
|
50
|
-
latest.quota,
|
|
51
|
-
sample.requests + previous.requests,
|
|
52
|
-
latest.observed_at,
|
|
53
|
-
)
|
|
54
|
-
result[sample.key] = sample
|
|
55
|
-
return tuple(result.values())
|
|
51
|
+
previous = latest.get(key)
|
|
52
|
+
if previous is None or sample.observed_at >= previous.observed_at:
|
|
53
|
+
latest[key] = sample
|
|
54
|
+
return tuple(replace(sample, requests=requests[key]) for key, sample in latest.items())
|
|
56
55
|
|
|
57
56
|
|
|
58
57
|
_observations: ContextVar[RateLimitObservations | None] = ContextVar(
|
|
@@ -14,6 +14,7 @@ from diffbutler.domain.forge import (
|
|
|
14
14
|
PRQuery,
|
|
15
15
|
PRRef,
|
|
16
16
|
PullRequest,
|
|
17
|
+
ReadBudget,
|
|
17
18
|
RequestedPR,
|
|
18
19
|
TeamMembership,
|
|
19
20
|
identity_part,
|
|
@@ -152,6 +153,9 @@ class GitHubForge:
|
|
|
152
153
|
async def list_repositories(self, owner: str) -> tuple[str, ...]:
|
|
153
154
|
return await list_repositories(self.http, owner)
|
|
154
155
|
|
|
156
|
+
def read_budget(self) -> ReadBudget:
|
|
157
|
+
return self.http.read_budget()
|
|
158
|
+
|
|
155
159
|
def search_ref(self, value: str) -> PRRef:
|
|
156
160
|
url = self.http.checked_url(value)
|
|
157
161
|
path = url.path.removeprefix(self.http.base_url.path)
|
|
@@ -14,7 +14,8 @@ from typing import ClassVar
|
|
|
14
14
|
|
|
15
15
|
import httpx
|
|
16
16
|
|
|
17
|
-
from diffbutler.domain.forge import RateLimit
|
|
17
|
+
from diffbutler.domain.forge import RateLimit, ReadBudget
|
|
18
|
+
from diffbutler.domain.polling import RESERVE_SHARE
|
|
18
19
|
from diffbutler.forges.base import ForgeError
|
|
19
20
|
from diffbutler.forges.diagnostics import (
|
|
20
21
|
RateLimitSource,
|
|
@@ -53,6 +54,8 @@ class ReadResponse:
|
|
|
53
54
|
class GitHubHTTP:
|
|
54
55
|
DEFAULT_REQUEST_INTERVAL: ClassVar[float] = 1.0
|
|
55
56
|
AUTHENTICATED_REQUEST_INTERVAL: ClassVar[float] = 0.1
|
|
57
|
+
# Below polling's reserve, pacing spreads the remaining requests until reset.
|
|
58
|
+
LOW_QUOTA_SHARE: ClassVar[float] = RESERVE_SHARE
|
|
56
59
|
|
|
57
60
|
def __init__(
|
|
58
61
|
self,
|
|
@@ -106,6 +109,8 @@ class GitHubHTTP:
|
|
|
106
109
|
self.limits: dict[str, RateLimit] = {}
|
|
107
110
|
self.cache: OrderedDict[str, ReadResponse] = OrderedDict()
|
|
108
111
|
self.blocked_until = 0.0
|
|
112
|
+
self.responses = 0
|
|
113
|
+
self.response_seconds = 0.0
|
|
109
114
|
self.lock = asyncio.Lock()
|
|
110
115
|
|
|
111
116
|
async def paced_get(self, url: httpx.URL, headers: dict[str, str]) -> httpx.Response:
|
|
@@ -132,7 +137,7 @@ class GitHubHTTP:
|
|
|
132
137
|
interval = self.request_interval
|
|
133
138
|
if resource == "search" and not self.authenticated:
|
|
134
139
|
interval = max(interval, 6.1)
|
|
135
|
-
if limit and 0 < limit.remaining <= limit.limit *
|
|
140
|
+
if limit and 0 < limit.remaining <= limit.limit * self.LOW_QUOTA_SHARE:
|
|
136
141
|
interval = max(
|
|
137
142
|
interval, (limit.reset_at.timestamp() - previous) / limit.remaining
|
|
138
143
|
)
|
|
@@ -161,6 +166,8 @@ class GitHubHTTP:
|
|
|
161
166
|
except httpx.RequestError:
|
|
162
167
|
logging.getLogger(__name__).exception("GitHub transport failed resource=%s", resource)
|
|
163
168
|
raise
|
|
169
|
+
self.responses += 1
|
|
170
|
+
self.response_seconds += time.monotonic() - began
|
|
164
171
|
self.record_limit(response.headers)
|
|
165
172
|
quota = self.limits.get(resource)
|
|
166
173
|
logging.getLogger(__name__).log(
|
|
@@ -177,6 +184,15 @@ class GitHubHTTP:
|
|
|
177
184
|
)
|
|
178
185
|
return response
|
|
179
186
|
|
|
187
|
+
def read_budget(self) -> ReadBudget:
|
|
188
|
+
"""Core reads available before low-quota pacing starts, and observed seconds per read."""
|
|
189
|
+
limit = self.limits.get("core")
|
|
190
|
+
spare = None
|
|
191
|
+
if limit and limit.reset_at.timestamp() > self.now():
|
|
192
|
+
spare = max(0, limit.remaining - math.ceil(limit.limit * self.LOW_QUOTA_SHARE))
|
|
193
|
+
latency = self.response_seconds / self.responses if self.responses else 0.0
|
|
194
|
+
return ReadBudget(spare, max(self.request_interval, latency))
|
|
195
|
+
|
|
180
196
|
def checked_url(self, value: str | httpx.URL) -> httpx.URL:
|
|
181
197
|
try:
|
|
182
198
|
url = self.base_url.join(value)
|