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