diffbutler 0.5.0__py3-none-any.whl
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/__init__.py +1 -0
- diffbutler/__main__.py +6 -0
- diffbutler/config/__init__.py +1 -0
- diffbutler/config/default.toml +91 -0
- diffbutler/config/diagnostics.py +90 -0
- diffbutler/config/file.py +30 -0
- diffbutler/config/instance.py +55 -0
- diffbutler/config/settings.py +297 -0
- diffbutler/config/setup.py +92 -0
- diffbutler/config/transport.py +65 -0
- diffbutler/core/__init__.py +1 -0
- diffbutler/core/application.py +103 -0
- diffbutler/core/context.py +54 -0
- diffbutler/core/dashboard.py +393 -0
- diffbutler/core/doctor.py +200 -0
- diffbutler/core/lifecycle.py +70 -0
- diffbutler/core/managed.py +441 -0
- diffbutler/core/native.py +301 -0
- diffbutler/core/polling.py +118 -0
- diffbutler/core/process.py +64 -0
- diffbutler/core/repositories.py +260 -0
- diffbutler/core/reset.py +34 -0
- diffbutler/core/resources.py +50 -0
- diffbutler/core/review.py +331 -0
- diffbutler/core/setup.py +232 -0
- diffbutler/core/snapshot.py +70 -0
- diffbutler/core/sync.py +259 -0
- diffbutler/core/worker.py +205 -0
- diffbutler/core/workspaces.py +139 -0
- diffbutler/domain/__init__.py +1 -0
- diffbutler/domain/dashboard.py +63 -0
- diffbutler/domain/diff.py +285 -0
- diffbutler/domain/forge.py +123 -0
- diffbutler/domain/lifecycle.py +51 -0
- diffbutler/domain/polling.py +92 -0
- diffbutler/domain/process.py +29 -0
- diffbutler/domain/redaction.py +61 -0
- diffbutler/domain/resources.py +50 -0
- diffbutler/domain/review.py +167 -0
- diffbutler/domain/sync.py +196 -0
- diffbutler/forges/__init__.py +1 -0
- diffbutler/forges/base.py +39 -0
- diffbutler/forges/diagnostics.py +104 -0
- diffbutler/forges/github/__init__.py +1 -0
- diffbutler/forges/github/adapter.py +352 -0
- diffbutler/forges/github/auth.py +49 -0
- diffbutler/forges/github/http.py +326 -0
- diffbutler/forges/github/mapping.py +157 -0
- diffbutler/forges/github/setup.py +86 -0
- diffbutler/forges/registry.py +23 -0
- diffbutler/py.typed +0 -0
- diffbutler/reviewers/__init__.py +1 -0
- diffbutler/reviewers/base.py +101 -0
- diffbutler/reviewers/extraction.py +198 -0
- diffbutler/reviewers/native.py +238 -0
- diffbutler/reviewers/parsing.py +247 -0
- diffbutler/reviewers/presets.toml +12 -0
- diffbutler/reviewers/process.py +78 -0
- diffbutler/reviewers/prompt.py +148 -0
- diffbutler/reviewers/redaction.py +30 -0
- diffbutler/reviewers/resources.py +162 -0
- diffbutler/sources/__init__.py +1 -0
- diffbutler/sources/diagnostics.py +100 -0
- diffbutler/sources/fetch.py +285 -0
- diffbutler/sources/git.py +461 -0
- diffbutler/sources/remotes.py +82 -0
- diffbutler/store/__init__.py +1 -0
- diffbutler/store/base.py +88 -0
- diffbutler/store/dashboard.py +201 -0
- diffbutler/store/database.py +172 -0
- diffbutler/store/lifecycle.py +247 -0
- diffbutler/store/migrations/__init__.py +1 -0
- diffbutler/store/migrations/env.py +14 -0
- diffbutler/store/migrations/versions/0001_initial.py +147 -0
- diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
- diffbutler/store/migrations/versions/0003_reviews.py +72 -0
- diffbutler/store/migrations/versions/0004_claims.py +22 -0
- diffbutler/store/migrations/versions/0005_results.py +27 -0
- diffbutler/store/migrations/versions/0006_anchors.py +17 -0
- diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
- diffbutler/store/migrations/versions/0008_processes.py +30 -0
- diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
- diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
- diffbutler/store/migrations/versions/__init__.py +1 -0
- diffbutler/store/models.py +290 -0
- diffbutler/store/process.py +75 -0
- diffbutler/store/queue.py +289 -0
- diffbutler/store/review.py +583 -0
- diffbutler/ui/__init__.py +1 -0
- diffbutler/ui/cli/__init__.py +532 -0
- diffbutler/ui/cli/editor.py +33 -0
- diffbutler/ui/tui/__init__.py +1 -0
- diffbutler/ui/tui/_fake_agent.py +47 -0
- diffbutler/ui/tui/_foreground_exec.py +23 -0
- diffbutler/ui/tui/app.py +34 -0
- diffbutler/ui/tui/browse.py +119 -0
- diffbutler/ui/tui/browser.py +42 -0
- diffbutler/ui/tui/dashboard.py +926 -0
- diffbutler/ui/tui/foreground.py +103 -0
- diffbutler/ui/tui/handoff_probe.py +184 -0
- diffbutler-0.5.0.dist-info/METADATA +170 -0
- diffbutler-0.5.0.dist-info/RECORD +105 -0
- diffbutler-0.5.0.dist-info/WHEEL +4 -0
- diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
- diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Durable monitoring values and pure review-cycle reconciliation."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from diffbutler.domain.forge import Account, PullRequest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class RequestEvent:
|
|
12
|
+
id: str
|
|
13
|
+
created_at: datetime
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def key(self) -> str:
|
|
17
|
+
return f"{self.created_at.astimezone(UTC).isoformat(timespec='microseconds')}:{self.id}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ReviewEvent:
|
|
22
|
+
id: str
|
|
23
|
+
created_at: datetime
|
|
24
|
+
author: str
|
|
25
|
+
state: str
|
|
26
|
+
head_sha: str
|
|
27
|
+
body: str = ""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def key(self) -> str:
|
|
31
|
+
return f"{self.created_at.astimezone(UTC).isoformat(timespec='microseconds')}:{self.id}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class CommentEvent:
|
|
36
|
+
id: str
|
|
37
|
+
created_at: datetime
|
|
38
|
+
author: str
|
|
39
|
+
body: str
|
|
40
|
+
head_sha: str = ""
|
|
41
|
+
review_id: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ReviewActivity:
|
|
46
|
+
"""Requests addressed to the authenticated account or its visible teams."""
|
|
47
|
+
|
|
48
|
+
account: Account
|
|
49
|
+
requests: tuple[RequestEvent, ...] = ()
|
|
50
|
+
reviews: tuple[ReviewEvent, ...] = ()
|
|
51
|
+
complete: bool = True
|
|
52
|
+
warnings: tuple[str, ...] = ()
|
|
53
|
+
comments: tuple[CommentEvent, ...] = ()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class TrackedPR:
|
|
58
|
+
pull_request: PullRequest
|
|
59
|
+
following: bool = True
|
|
60
|
+
sources: tuple[str, ...] = ()
|
|
61
|
+
cycle: int = 1
|
|
62
|
+
task_id: UUID | None = None
|
|
63
|
+
state: str = "PENDING_AI_REVIEW"
|
|
64
|
+
request_key: str = ""
|
|
65
|
+
review_key: str = ""
|
|
66
|
+
completion_reason: str | None = None
|
|
67
|
+
completion_event: str | None = None
|
|
68
|
+
activity: ReviewActivity | None = None
|
|
69
|
+
refresh_pending: bool = False
|
|
70
|
+
manual_after: str = ""
|
|
71
|
+
opened_at: datetime | None = None
|
|
72
|
+
notice: str = ""
|
|
73
|
+
posted_at: datetime | None = None
|
|
74
|
+
review_requested_at: datetime | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class QueueSnapshot:
|
|
79
|
+
version: int
|
|
80
|
+
items: tuple[TrackedPR, ...]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reconcile(
|
|
84
|
+
previous: TrackedPR | None,
|
|
85
|
+
pr: PullRequest,
|
|
86
|
+
activity: ReviewActivity,
|
|
87
|
+
sources: tuple[str, ...],
|
|
88
|
+
) -> TrackedPR:
|
|
89
|
+
"""Observe only complete activity; never dispatch AI or trust posting claims."""
|
|
90
|
+
current = previous or TrackedPR(pr)
|
|
91
|
+
request = max(activity.requests, key=lambda event: event.key, default=None)
|
|
92
|
+
own_reviews = [
|
|
93
|
+
event
|
|
94
|
+
for event in activity.reviews
|
|
95
|
+
if event.author.lower() == activity.account.login.lower()
|
|
96
|
+
and event.state in {"APPROVED", "CHANGES_REQUESTED", "DISMISSED", "COMMENTED"}
|
|
97
|
+
]
|
|
98
|
+
review = max(own_reviews, key=lambda event: event.key, default=None)
|
|
99
|
+
request_key = request.key if request and activity.complete else current.request_key
|
|
100
|
+
review_key = review.key if review and activity.complete else current.review_key
|
|
101
|
+
# Watermarks never move backwards when access or remote history changes.
|
|
102
|
+
request_key = max(current.request_key, request_key)
|
|
103
|
+
review_key = max(current.review_key, review_key)
|
|
104
|
+
head_changed = previous is not None and pr.head_sha != previous.pull_request.head_sha
|
|
105
|
+
requested_again = (
|
|
106
|
+
previous is not None
|
|
107
|
+
and request_key > previous.request_key
|
|
108
|
+
and request is not None
|
|
109
|
+
and (
|
|
110
|
+
current.review_requested_at is None or request.created_at > current.review_requested_at
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
changed = head_changed or requested_again
|
|
114
|
+
result = replace(
|
|
115
|
+
current,
|
|
116
|
+
pull_request=pr,
|
|
117
|
+
sources=sources,
|
|
118
|
+
request_key=request_key,
|
|
119
|
+
review_key=review_key,
|
|
120
|
+
activity=activity if activity.complete else current.activity,
|
|
121
|
+
refresh_pending=not activity.complete,
|
|
122
|
+
)
|
|
123
|
+
if changed and pr.state == "open":
|
|
124
|
+
changed_after_approval = (
|
|
125
|
+
head_changed
|
|
126
|
+
and not requested_again
|
|
127
|
+
and (
|
|
128
|
+
current.state == "DONE_PLUS"
|
|
129
|
+
or (
|
|
130
|
+
current.state == "DONE"
|
|
131
|
+
and current.completion_reason in {"own_approval", "local_approval"}
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
result = replace(
|
|
136
|
+
result,
|
|
137
|
+
state="DONE_PLUS" if changed_after_approval else "PENDING_AI_REVIEW",
|
|
138
|
+
completion_reason=None,
|
|
139
|
+
completion_event=None,
|
|
140
|
+
cycle=current.cycle + (current.state == "DONE"),
|
|
141
|
+
task_id=None if current.state == "DONE" else current.task_id,
|
|
142
|
+
notice="Review requested again" if requested_again else "New commits detected",
|
|
143
|
+
posted_at=None if current.state == "DONE" else current.posted_at,
|
|
144
|
+
)
|
|
145
|
+
feedback = (
|
|
146
|
+
previous is not None
|
|
147
|
+
and current.activity is not None
|
|
148
|
+
and not changed
|
|
149
|
+
and activity.complete
|
|
150
|
+
and review is not None
|
|
151
|
+
and review.state in {"COMMENTED", "CHANGES_REQUESTED"}
|
|
152
|
+
and review.head_sha == pr.head_sha
|
|
153
|
+
and review.key > max(current.review_key, current.manual_after)
|
|
154
|
+
and (request is None or review.created_at > request.created_at)
|
|
155
|
+
)
|
|
156
|
+
if result.state != "DONE" and feedback:
|
|
157
|
+
count = (
|
|
158
|
+
sum(comment.review_id == review.id for comment in activity.comments) if review else 0
|
|
159
|
+
)
|
|
160
|
+
result = replace(
|
|
161
|
+
result,
|
|
162
|
+
state="PENDING_AUTHOR_CHANGES",
|
|
163
|
+
notice=f"Posted review detected · {count} comments",
|
|
164
|
+
posted_at=review.created_at if review else current.posted_at,
|
|
165
|
+
)
|
|
166
|
+
elif not changed and activity.complete and current.activity is not None:
|
|
167
|
+
known = {comment.id for comment in current.activity.comments}
|
|
168
|
+
count = sum(comment.id not in known for comment in activity.comments)
|
|
169
|
+
if count:
|
|
170
|
+
result = replace(result, notice=f"New discussion activity · {count} comments")
|
|
171
|
+
else:
|
|
172
|
+
known_reviews = {event.id for event in current.activity.reviews}
|
|
173
|
+
new_reviews = sum(event.id not in known_reviews for event in activity.reviews)
|
|
174
|
+
if new_reviews:
|
|
175
|
+
result = replace(result, notice=f"New review activity · {new_reviews} reviews")
|
|
176
|
+
decisive = max(
|
|
177
|
+
(event for event in own_reviews if event.state != "COMMENTED"),
|
|
178
|
+
key=lambda event: event.key,
|
|
179
|
+
default=None,
|
|
180
|
+
)
|
|
181
|
+
approval = (
|
|
182
|
+
activity.complete
|
|
183
|
+
and decisive is not None
|
|
184
|
+
and decisive.state == "APPROVED"
|
|
185
|
+
and decisive.head_sha == pr.head_sha
|
|
186
|
+
and (previous is None or decisive.key > previous.review_key)
|
|
187
|
+
and (request is None or decisive.created_at > request.created_at)
|
|
188
|
+
)
|
|
189
|
+
if result.state != "DONE" and (pr.state != "open" or approval):
|
|
190
|
+
result = replace(
|
|
191
|
+
result,
|
|
192
|
+
state="DONE",
|
|
193
|
+
completion_reason=pr.state if pr.state != "open" else "own_approval",
|
|
194
|
+
completion_event=decisive.key if approval and decisive else pr.state,
|
|
195
|
+
)
|
|
196
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Forge adapters and shared diff machinery."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Read-only forge seam. Review execution and publishing are separate capabilities."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from diffbutler.domain.diff import DiffIndex as DiffIndex
|
|
6
|
+
from diffbutler.domain.forge import (
|
|
7
|
+
Account,
|
|
8
|
+
Discovery,
|
|
9
|
+
ForgeHealth,
|
|
10
|
+
PRPage,
|
|
11
|
+
PRQuery,
|
|
12
|
+
PRRef,
|
|
13
|
+
PullRequest,
|
|
14
|
+
)
|
|
15
|
+
from diffbutler.domain.sync import ReviewActivity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ForgeError(Exception):
|
|
19
|
+
"""Safe diagnostic, never an HTTP body, URL with credentials, or auth output."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self, message: str, *, status: int | None = None, retry_after: float | None = None
|
|
23
|
+
) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.status = status
|
|
26
|
+
self.retry_after = retry_after
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ReadForge(Protocol):
|
|
30
|
+
@property
|
|
31
|
+
def id(self) -> str: ...
|
|
32
|
+
async def whoami(self) -> Account: ...
|
|
33
|
+
async def list_review_requests(self) -> Discovery: ...
|
|
34
|
+
async def search_pull_requests(self, query: PRQuery, cursor: str | None = None) -> PRPage: ...
|
|
35
|
+
async def get_pull_request(self, ref: PRRef) -> PullRequest: ...
|
|
36
|
+
def ref(self, value: str) -> PRRef: ...
|
|
37
|
+
async def get_review_activity(self, ref: PRRef) -> ReviewActivity: ...
|
|
38
|
+
async def health(self) -> ForgeHealth: ...
|
|
39
|
+
async def close(self) -> None: ...
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Task-scoped rate-limit observations for sync diagnostics."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from diffbutler.domain.forge import RateLimit
|
|
9
|
+
from diffbutler.domain.polling import PollQuota
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(eq=False)
|
|
13
|
+
class RateLimitSource:
|
|
14
|
+
"""Client identity, resolved by whoami; never contains credentials."""
|
|
15
|
+
|
|
16
|
+
host: str
|
|
17
|
+
authenticated: bool
|
|
18
|
+
login: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ResourceObservations:
|
|
23
|
+
requests: int = 0
|
|
24
|
+
latest: RateLimit | None = None
|
|
25
|
+
observed_at: float = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RateLimitObservations:
|
|
30
|
+
minimum_core: RateLimit | None = None
|
|
31
|
+
resources: dict[tuple[RateLimitSource, str], ResourceObservations] = field(
|
|
32
|
+
default_factory=dict[tuple[RateLimitSource, str], ResourceObservations]
|
|
33
|
+
)
|
|
34
|
+
cooldown_until: float = 0
|
|
35
|
+
|
|
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:
|
|
40
|
+
continue
|
|
41
|
+
sample = PollQuota(
|
|
42
|
+
source.host, source.login, observed.latest, observed.requests, observed.observed_at
|
|
43
|
+
)
|
|
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())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_observations: ContextVar[RateLimitObservations | None] = ContextVar(
|
|
59
|
+
"forge_rate_limit_observations", default=None
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def collect_rate_limits() -> Generator[RateLimitObservations]:
|
|
65
|
+
observations = RateLimitObservations()
|
|
66
|
+
token = _observations.set(observations)
|
|
67
|
+
try:
|
|
68
|
+
yield observations
|
|
69
|
+
finally:
|
|
70
|
+
_observations.reset(token)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def observe_request(source: RateLimitSource, resource: str) -> None:
|
|
74
|
+
observations = _observations.get()
|
|
75
|
+
if observations is not None:
|
|
76
|
+
observed = observations.resources.setdefault((source, resource), ResourceObservations())
|
|
77
|
+
observed.requests += 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def observe_cooldown(until: float) -> None:
|
|
81
|
+
observations = _observations.get()
|
|
82
|
+
if observations is not None:
|
|
83
|
+
observations.cooldown_until = max(observations.cooldown_until, until)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def observe_rate_limit(
|
|
87
|
+
limit: RateLimit, source: RateLimitSource | None = None, *, observed_at: float = 0
|
|
88
|
+
) -> None:
|
|
89
|
+
observations = _observations.get()
|
|
90
|
+
if observations is not None and source is not None:
|
|
91
|
+
observed = observations.resources.setdefault(
|
|
92
|
+
(source, limit.resource), ResourceObservations()
|
|
93
|
+
)
|
|
94
|
+
observed.latest = limit
|
|
95
|
+
observed.observed_at = observed_at
|
|
96
|
+
if (
|
|
97
|
+
observations is not None
|
|
98
|
+
and limit.resource == "core"
|
|
99
|
+
and (
|
|
100
|
+
observations.minimum_core is None
|
|
101
|
+
or limit.remaining < observations.minimum_core.remaining
|
|
102
|
+
)
|
|
103
|
+
):
|
|
104
|
+
observations.minimum_core = limit
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GitHub REST read adapter (GitHub.com and Enterprise)."""
|