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.
Files changed (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. 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)."""