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.
Files changed (113) hide show
  1. {diffbutler-0.6.0 → diffbutler-0.6.1}/PKG-INFO +30 -6
  2. {diffbutler-0.6.0 → diffbutler-0.6.1}/README.md +29 -5
  3. {diffbutler-0.6.0 → diffbutler-0.6.1}/pyproject.toml +1 -1
  4. {diffbutler-0.6.0 → diffbutler-0.6.1}/pyproject.toml.orig +1 -1
  5. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/default.toml +2 -2
  6. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/settings.py +1 -1
  7. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/application.py +10 -3
  8. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/dashboard.py +7 -1
  9. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/polling.py +2 -4
  10. diffbutler-0.6.1/src/diffbutler/core/pr_lookup.py +83 -0
  11. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/forge.py +8 -0
  12. diffbutler-0.6.1/src/diffbutler/domain/polling.py +67 -0
  13. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/base.py +2 -0
  14. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/diagnostics.py +16 -17
  15. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/adapter.py +4 -0
  16. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/http.py +18 -2
  17. diffbutler-0.6.1/src/diffbutler/forges/github/repositories.py +104 -0
  18. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/dashboard.py +3 -1
  19. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/cli/__init__.py +13 -6
  20. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/dashboard.py +61 -29
  21. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/review_prompt.py +3 -2
  22. diffbutler-0.6.0/src/diffbutler/core/pr_lookup.py +0 -50
  23. diffbutler-0.6.0/src/diffbutler/domain/polling.py +0 -92
  24. diffbutler-0.6.0/src/diffbutler/forges/github/repositories.py +0 -62
  25. {diffbutler-0.6.0 → diffbutler-0.6.1}/LICENSE.txt +0 -0
  26. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/__init__.py +0 -0
  27. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/__main__.py +0 -0
  28. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/__init__.py +0 -0
  29. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/diagnostics.py +0 -0
  30. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/file.py +0 -0
  31. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/instance.py +0 -0
  32. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/setup.py +0 -0
  33. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/config/transport.py +0 -0
  34. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/__init__.py +0 -0
  35. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/context.py +0 -0
  36. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/doctor.py +0 -0
  37. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/lifecycle.py +0 -0
  38. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/managed.py +0 -0
  39. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/native.py +0 -0
  40. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/process.py +0 -0
  41. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/repositories.py +0 -0
  42. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/reset.py +0 -0
  43. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/resources.py +0 -0
  44. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/review.py +0 -0
  45. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/setup.py +0 -0
  46. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/snapshot.py +0 -0
  47. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/sync.py +0 -0
  48. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/worker.py +0 -0
  49. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/core/workspaces.py +0 -0
  50. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/__init__.py +0 -0
  51. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/dashboard.py +0 -0
  52. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/diff.py +0 -0
  53. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/lifecycle.py +0 -0
  54. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/process.py +0 -0
  55. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/redaction.py +0 -0
  56. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/resources.py +0 -0
  57. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/review.py +0 -0
  58. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/domain/sync.py +0 -0
  59. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/__init__.py +0 -0
  60. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/__init__.py +0 -0
  61. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/auth.py +0 -0
  62. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/mapping.py +0 -0
  63. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/github/setup.py +0 -0
  64. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/forges/registry.py +0 -0
  65. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/py.typed +0 -0
  66. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/__init__.py +0 -0
  67. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/base.py +0 -0
  68. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/extraction.py +0 -0
  69. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/native.py +0 -0
  70. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/parsing.py +0 -0
  71. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/presets.toml +0 -0
  72. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/process.py +0 -0
  73. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/prompt.py +0 -0
  74. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/redaction.py +0 -0
  75. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/reviewers/resources.py +0 -0
  76. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/sources/__init__.py +0 -0
  77. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/sources/diagnostics.py +0 -0
  78. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/sources/fetch.py +0 -0
  79. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/sources/git.py +0 -0
  80. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/sources/remotes.py +0 -0
  81. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/__init__.py +0 -0
  82. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/base.py +0 -0
  83. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/database.py +0 -0
  84. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/lifecycle.py +0 -0
  85. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/__init__.py +0 -0
  86. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/env.py +0 -0
  87. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0001_initial.py +0 -0
  88. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0002_monitoring.py +0 -0
  89. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0003_reviews.py +0 -0
  90. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0004_claims.py +0 -0
  91. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0005_results.py +0 -0
  92. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0006_anchors.py +0 -0
  93. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0007_lifecycle.py +0 -0
  94. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0008_processes.py +0 -0
  95. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0009_dashboard.py +0 -0
  96. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0010_run_reasons.py +0 -0
  97. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/0011_explicit_reviews.py +0 -0
  98. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/migrations/versions/__init__.py +0 -0
  99. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/models.py +0 -0
  100. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/process.py +0 -0
  101. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/queue.py +0 -0
  102. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/store/review.py +0 -0
  103. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/__init__.py +0 -0
  104. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/cli/editor.py +0 -0
  105. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/__init__.py +0 -0
  106. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/_fake_agent.py +0 -0
  107. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/_foreground_exec.py +0 -0
  108. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/app.py +0 -0
  109. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/browse.py +0 -0
  110. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/browser.py +0 -0
  111. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/foreground.py +0 -0
  112. {diffbutler-0.6.0 → diffbutler-0.6.1}/src/diffbutler/ui/tui/handoff_probe.py +0 -0
  113. {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.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
- ![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
 
@@ -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
- Authenticated polling slows when API quota is scarce and speeds back up when it
112
- 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.
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 `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.
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
- - `a`: Toggle monitoring all open PRs in configured repositories.
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
+ ![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.
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
- ![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
 
@@ -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
- Authenticated polling slows when API quota is scarce and speeds back up when it
84
- 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.
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 `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.
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
- - `a`: Toggle monitoring all open PRs in configured repositories.
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
+ ![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.
159
+
136
160
  ## Reset
137
161
 
138
162
  Close running DiffButler sessions, then run:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffbutler"
3
- version = "0.6.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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffbutler"
3
- version = "0.6.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"
@@ -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"
@@ -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[
@@ -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(path: Path | None, value: str) -> tuple[ReviewTarget, ...]:
94
- """Resolve without following PRs or requesting any AI work."""
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(service.forge, config.repositories, int(numeric[1]))
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 = "attention"
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
- result: dict[tuple[str, str, str], PollQuota] = {}
38
- for (source, _), observed in self.resources.items():
39
- if not source.authenticated or source.login is None or observed.latest is None:
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, source.login, observed.latest, observed.requests, observed.observed_at
49
+ source.host, login, observed.latest, observed.requests, observed.observed_at
43
50
  )
44
- previous = result.get(sample.key)
45
- if previous is not None:
46
- latest = sample if sample.observed_at >= previous.observed_at else previous
47
- sample = PollQuota(
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 * 0.2:
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)