compforge-repokit 0.15.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.
- compforge_repokit-0.15.0.dist-info/METADATA +114 -0
- compforge_repokit-0.15.0.dist-info/RECORD +37 -0
- compforge_repokit-0.15.0.dist-info/WHEEL +4 -0
- repokit/__init__.py +40 -0
- repokit/_git.py +177 -0
- repokit/_inspect.py +70 -0
- repokit/_language.py +23 -0
- repokit/_layout.py +187 -0
- repokit/_process.py +135 -0
- repokit/_staging.py +59 -0
- repokit/budget.py +40 -0
- repokit/dependencies.py +378 -0
- repokit/forge/__init__.py +13 -0
- repokit/forge/_http_state.py +92 -0
- repokit/forge/_page.py +34 -0
- repokit/forge/_rest.py +233 -0
- repokit/forge/creation.py +88 -0
- repokit/forge/github.py +440 -0
- repokit/forge/gitlab.py +407 -0
- repokit/forge/model.py +380 -0
- repokit/forge/observation/__init__.py +9 -0
- repokit/forge/observation/context.py +255 -0
- repokit/forge/observation/model.py +40 -0
- repokit/forge/observation/service.py +283 -0
- repokit/forge/observation/store.py +136 -0
- repokit/git.py +296 -0
- repokit/git_index.py +275 -0
- repokit/git_state.py +447 -0
- repokit/languages.json +1115 -0
- repokit/model.py +59 -0
- repokit/py.typed +0 -0
- repokit/remote.py +41 -0
- repokit/repository.py +84 -0
- repokit/snapshot.py +275 -0
- repokit/stash.py +66 -0
- repokit/tree.py +191 -0
- repokit/validation.py +508 -0
repokit/forge/model.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Provider-neutral repository collaboration objects and operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any, Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
from harness_common import Repository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_pr_number(s: str) -> int | None:
|
|
15
|
+
"""Extract a PR/MR number from a URL or a bare ref. Knows both forges' URL shapes
|
|
16
|
+
(GitHub `/pull/N`, GitLab `/merge_requests/N`) so callers (CLI scripts) don't carry
|
|
17
|
+
provider URL knowledge. Accepts a bare `N`, `#N`, or `!N` too."""
|
|
18
|
+
m = re.search(r"/(?:pull|merge_requests)/(\d+)", s) or re.fullmatch(r"[!#]?(\d+)", s.strip())
|
|
19
|
+
return int(m.group(1)) if m else None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ForgeError(Exception):
|
|
23
|
+
"""Any forge transport/API failure."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Page(Generic[T]):
|
|
31
|
+
"""One page; next_cursor is an opaque continuation, not an inventory guarantee."""
|
|
32
|
+
|
|
33
|
+
items: tuple[T, ...]
|
|
34
|
+
next_cursor: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ForgeOutcomeUnknown(ForgeError):
|
|
38
|
+
"""A request may have taken effect; inspect remote state before retrying."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ForgeAuthError(ForgeError):
|
|
42
|
+
"""No usable token or insufficient permission (excluding rate limits)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ForgeRateLimited(ForgeError):
|
|
46
|
+
"""Requests must wait until retry_at (UTC epoch seconds); never an auth failure."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, resource: str, retry_at: float, *, secondary: bool = False):
|
|
49
|
+
self.resource = resource
|
|
50
|
+
self.retry_at = retry_at
|
|
51
|
+
self.secondary = secondary
|
|
52
|
+
super().__init__(f"forge rate limited: resource={resource}, retry_at={retry_at:.0f}")
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
return f"forge rate limited: resource={self.resource}, retry_at={self.retry_at:.0f}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ForgeNotFound(ForgeError):
|
|
59
|
+
"""404 from the API."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class PullRequestIdentity:
|
|
64
|
+
"""Stable cross-repo identity for a PR/MR.
|
|
65
|
+
|
|
66
|
+
`source` is the origin host used as the `forges` config key; `repository`
|
|
67
|
+
is the provider-neutral path under that host.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
source: str
|
|
71
|
+
repository: str
|
|
72
|
+
number: int
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict[str, Any]:
|
|
75
|
+
return {
|
|
76
|
+
"source": self.source,
|
|
77
|
+
"repository": self.repository,
|
|
78
|
+
"number": self.number,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class PullRequest:
|
|
84
|
+
"""A code-review proposal returned by a forge — neutral across forges.
|
|
85
|
+
|
|
86
|
+
`number` is the per-repo sequential id (the number in the URL: GitLab `iid`,
|
|
87
|
+
GitHub PR number). `state` is normalized to 'open' | 'merged' | 'closed' by the
|
|
88
|
+
adapter (GitHub's open/closed + a `merged` flag collapses to these three). No
|
|
89
|
+
`provider` field — that's a repo-level fact (see module docstring); display labels
|
|
90
|
+
are built with `pr_label(provider, number)` at render time. 'inactive' (merged/closed)
|
|
91
|
+
is derived, never stored.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
number: int
|
|
95
|
+
title: str = ""
|
|
96
|
+
state: str = "" # neutral: "open" | "merged" | "closed"
|
|
97
|
+
source_branch: str = ""
|
|
98
|
+
target_branch: str = ""
|
|
99
|
+
web_url: str = ""
|
|
100
|
+
sha: str = "" # source-branch tip the monitor ancestry-checks against HEAD
|
|
101
|
+
updated_at: str | None = None
|
|
102
|
+
source_repository: str = ""
|
|
103
|
+
target_repository: str = ""
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def inactive(self) -> bool:
|
|
107
|
+
return self.state in ("merged", "closed")
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def is_open(self) -> bool:
|
|
111
|
+
"""Whether the forge reports the proposal as open."""
|
|
112
|
+
return self.state == "open"
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_dict(cls, d: dict[str, Any]) -> PullRequest:
|
|
116
|
+
return cls(
|
|
117
|
+
number=int(d["number"]),
|
|
118
|
+
title=d.get("title", ""),
|
|
119
|
+
state=d.get("state", ""),
|
|
120
|
+
source_branch=d.get("source_branch", ""),
|
|
121
|
+
target_branch=d.get("target_branch", ""),
|
|
122
|
+
web_url=d.get("web_url", ""),
|
|
123
|
+
sha=d.get("sha", ""),
|
|
124
|
+
updated_at=d.get("updated_at"),
|
|
125
|
+
source_repository=d.get("source_repository", ""),
|
|
126
|
+
target_repository=d.get("target_repository", ""),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class CommentResolution(StrEnum):
|
|
131
|
+
"""Resolution state of the provider interaction backing a top-level comment."""
|
|
132
|
+
|
|
133
|
+
UNSUPPORTED = "unsupported"
|
|
134
|
+
UNRESOLVED = "unresolved"
|
|
135
|
+
RESOLVED = "resolved"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class Comment:
|
|
140
|
+
"""One top-level PR/MR comment, optionally backed by a replyable review interaction.
|
|
141
|
+
|
|
142
|
+
`replies` keeps provider threading inside the Forge adapter: callers consume one neutral
|
|
143
|
+
Comment whether GitHub supplied a review-comment thread or GitLab supplied a discussion.
|
|
144
|
+
`reply_ref` / `resolve_ref` are opaque adapter handles and must only be passed back to the
|
|
145
|
+
Forge that produced them.
|
|
146
|
+
|
|
147
|
+
Carries no label/finding vocabulary: `ccr:fp=` and `ccr:label=` remain review-layer body
|
|
148
|
+
conventions. The forge is the durable source of truth, so comment refs are never persisted
|
|
149
|
+
locally.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
author: str = ""
|
|
153
|
+
body: str = ""
|
|
154
|
+
id: str = "" # opaque, adapter-scoped; "" when the adapter can't supply one
|
|
155
|
+
path: str = "" # diff anchor (new side); "" for plain conversation comments
|
|
156
|
+
line: int | None = None
|
|
157
|
+
created_at: str = ""
|
|
158
|
+
replies: list[Comment] = field(default_factory=list)
|
|
159
|
+
reply_ref: str = ""
|
|
160
|
+
resolve_ref: str = ""
|
|
161
|
+
resolution: CommentResolution = CommentResolution.UNSUPPORTED
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def replyable(self) -> bool:
|
|
165
|
+
return bool(self.reply_ref)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class Release:
|
|
170
|
+
"""A published release returned by a forge — neutral across forges.
|
|
171
|
+
|
|
172
|
+
`tag` is the git tag the release points to; `target` is the commit-ish the tag was cut
|
|
173
|
+
at (a branch name or sha). Like `PullRequest` it carries no provider — the tag is created
|
|
174
|
+
server-side by the forge, so a release never requires a local `git push --tags`.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
tag: str
|
|
178
|
+
name: str = ""
|
|
179
|
+
target: str = "" # target_commitish (GitHub) / commit the tag resolved to (GitLab)
|
|
180
|
+
web_url: str = ""
|
|
181
|
+
created_at: str | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class MergeReadiness(StrEnum):
|
|
185
|
+
"""Why a PR/MR can't be merged yet — the neutral form of GitLab's `detailed_merge_status`
|
|
186
|
+
/ GitHub's `mergeable_state`. Forges surface one blocking reason at a time; plus READY and
|
|
187
|
+
UNKNOWN.
|
|
188
|
+
|
|
189
|
+
UNKNOWN is the *safe* value, and first-class on purpose: forges compute mergeability
|
|
190
|
+
ASYNCHRONOUSLY, so a just-pushed PR reads as "still checking" — that must never collapse to
|
|
191
|
+
READY or to a real blocker.
|
|
192
|
+
|
|
193
|
+
Fetched on demand via `Forge.merge_readiness` (a primitive, like `description`/`comments`),
|
|
194
|
+
deliberately NOT a `PullRequest` field: it's a derived verdict over the source×target tips
|
|
195
|
+
that goes stale the moment either branch moves (a merge into target can block YOUR PR with
|
|
196
|
+
your branch unchanged), so consumers should re-read it at the relevant decision boundary.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
READY = "ready"
|
|
200
|
+
CONFLICT = "conflict"
|
|
201
|
+
DISCUSSIONS_UNRESOLVED = "discussions_unresolved"
|
|
202
|
+
CI_BLOCKED = "ci_blocked"
|
|
203
|
+
NEEDS_APPROVAL = "needs_approval"
|
|
204
|
+
DRAFT = "draft"
|
|
205
|
+
UNKNOWN = "unknown"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Forge(abc.ABC):
|
|
209
|
+
"""Operations provided by a code-review host — defined in the domain's terms, NOT
|
|
210
|
+
mirroring any one SDK/REST surface. GitLab/GitHub adapters implement this as peers.
|
|
211
|
+
|
|
212
|
+
Methods return neutral PullRequest / Comment objects. Callers own workflow policy.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
provider: str = ""
|
|
216
|
+
|
|
217
|
+
def list_repositories(
|
|
218
|
+
self, *, page_size: int | None = None, cursor: str | None = None
|
|
219
|
+
) -> Page[Repository]:
|
|
220
|
+
"""Read one page of repositories the authenticated account owns or participates in.
|
|
221
|
+
|
|
222
|
+
Default page_size is 50 (maximum 100). Resume with the same account and page size.
|
|
223
|
+
An inventory is not an atomic snapshot and does not cover every authored PR.
|
|
224
|
+
"""
|
|
225
|
+
raise ForgeError("repository listing is not supported")
|
|
226
|
+
|
|
227
|
+
def repository(self) -> Repository:
|
|
228
|
+
"""Read repository identity and optional direct same-Forge fork parent.
|
|
229
|
+
|
|
230
|
+
Missing provenance is not a failed lookup; transport errors propagate.
|
|
231
|
+
"""
|
|
232
|
+
raise ForgeError("repository metadata is not supported")
|
|
233
|
+
|
|
234
|
+
def for_repository(self, path: str) -> Forge:
|
|
235
|
+
"""Scope operations to a repository on this Forge instance.
|
|
236
|
+
|
|
237
|
+
PR numbers, queries and updates belong to path. No parent-selection policy.
|
|
238
|
+
"""
|
|
239
|
+
raise ForgeError("repository scoping is not supported")
|
|
240
|
+
|
|
241
|
+
@abc.abstractmethod
|
|
242
|
+
def create(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
source_repository: Repository,
|
|
246
|
+
source_branch: str,
|
|
247
|
+
target_repository: Repository,
|
|
248
|
+
target_branch: str,
|
|
249
|
+
title: str,
|
|
250
|
+
body: str = "",
|
|
251
|
+
) -> PullRequest:
|
|
252
|
+
"""Create from an explicit repository/branch pair on each side.
|
|
253
|
+
|
|
254
|
+
@spec Both repositories belong to this Forge instance; reject mismatch before I/O.
|
|
255
|
+
Repository scope on the client never overrides either explicit repository.
|
|
256
|
+
@link component://docs/pr.md
|
|
257
|
+
"""
|
|
258
|
+
...
|
|
259
|
+
|
|
260
|
+
def merge(self, number: int, *, expected_sha: str) -> PullRequest:
|
|
261
|
+
"""Merge only the reviewed source tip; authorization belongs to the caller."""
|
|
262
|
+
raise ForgeError("merge is not supported")
|
|
263
|
+
|
|
264
|
+
@abc.abstractmethod
|
|
265
|
+
def get(self, number: int) -> PullRequest: ...
|
|
266
|
+
|
|
267
|
+
@abc.abstractmethod
|
|
268
|
+
def description(self, number: int) -> str:
|
|
269
|
+
"""The PR/MR body text. A separate primitive rather than a `PullRequest` field:
|
|
270
|
+
bodies can be large, so they are fetched
|
|
271
|
+
only at the moment a caller syncs the description (see commit_flow)."""
|
|
272
|
+
|
|
273
|
+
@abc.abstractmethod
|
|
274
|
+
def update(self, number: int, **fields: str) -> PullRequest: ...
|
|
275
|
+
|
|
276
|
+
@abc.abstractmethod
|
|
277
|
+
def close(self, number: int) -> PullRequest:
|
|
278
|
+
"""Close the PR/MR without merging. A distinct primitive, not `update(state=...)`: the
|
|
279
|
+
two forges spell it incompatibly (GitLab `state_event=close`, GitHub `state=closed`), so
|
|
280
|
+
the neutral verb hides that split instead of leaking either spelling into callers."""
|
|
281
|
+
|
|
282
|
+
@abc.abstractmethod
|
|
283
|
+
def prs_for_branch(
|
|
284
|
+
self, branch: str, *, source_repository: Repository | None = None
|
|
285
|
+
) -> list[PullRequest]:
|
|
286
|
+
"""All PRs whose source is `branch`, newest first (an old finished one + a new
|
|
287
|
+
open one can coexist after a branch is reused — callers pick).
|
|
288
|
+
|
|
289
|
+
Query this client's repository, filtering by the explicit source repository
|
|
290
|
+
(defaults to this repository). Target-branch selection belongs to the caller.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
@abc.abstractmethod
|
|
294
|
+
def recent(self, limit: int) -> list[PullRequest]:
|
|
295
|
+
"""The `limit` most-recently-created PRs in the repo, newest first."""
|
|
296
|
+
|
|
297
|
+
def open_pull_requests(self) -> list[PullRequest]:
|
|
298
|
+
"""Complete open PR inventory sourced from this repository, newest first.
|
|
299
|
+
|
|
300
|
+
A failed page raises; partial inventories cannot establish absence. Foreign
|
|
301
|
+
source repositories are excluded, just as in prs_for_branch's default scope.
|
|
302
|
+
"""
|
|
303
|
+
raise ForgeError(f"{self.provider}: open PR inventory is not supported")
|
|
304
|
+
|
|
305
|
+
def request_counts(self) -> dict[str, int]:
|
|
306
|
+
"""Transport calls made by this client, including conditional validations."""
|
|
307
|
+
return {"requests": 0, "not_modified": 0}
|
|
308
|
+
|
|
309
|
+
@abc.abstractmethod
|
|
310
|
+
def default_branch(self) -> str:
|
|
311
|
+
"""The repo's **forge-configured** default branch, e.g. 'main' / 'master' (one
|
|
312
|
+
repo-level GET). This is the remote source of truth, fresher and more reliable than
|
|
313
|
+
the local `refs/remotes/origin/HEAD` cache (which `git fetch` never updates). It is
|
|
314
|
+
NOT necessarily the branch a team merges into — a repo may default to `main` yet treat
|
|
315
|
+
`release` as trunk — so callers still layer a per-repo config override on top."""
|
|
316
|
+
|
|
317
|
+
@abc.abstractmethod
|
|
318
|
+
def create_release(self, *, tag: str, target: str, name: str = "", notes: str = "") -> Release:
|
|
319
|
+
"""Publish a release `name` at `tag`, creating the tag at `target` (a branch name or
|
|
320
|
+
sha) on the server; this needs no local checkout or Git push.
|
|
321
|
+
GitHub uses target_commitish; GitLab uses ref.
|
|
322
|
+
A write primitive; version/increment policy lives in the release orchestrator, not here."""
|
|
323
|
+
|
|
324
|
+
@abc.abstractmethod
|
|
325
|
+
def latest_release(self) -> Release | None:
|
|
326
|
+
"""The most recent published release, or None when the repo has none yet (its first
|
|
327
|
+
release). Read primitive — the orchestrator uses it to check the new version is an
|
|
328
|
+
increment; callers use the tag as the baseline for semantic release notes."""
|
|
329
|
+
|
|
330
|
+
@abc.abstractmethod
|
|
331
|
+
def comments(self, number: int) -> list[Comment]:
|
|
332
|
+
"""Top-level human comments on PR/MR `number`, with review replies nested.
|
|
333
|
+
|
|
334
|
+
The adapter merges the provider's plain-comment and review surfaces. A replyable
|
|
335
|
+
review interaction is one Comment with `replies` and opaque action refs; callers do
|
|
336
|
+
not group flat provider notes or understand GitHub threads versus GitLab discussions.
|
|
337
|
+
System/bot activity notes are excluded.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
@abc.abstractmethod
|
|
341
|
+
def comment(
|
|
342
|
+
self,
|
|
343
|
+
number: int,
|
|
344
|
+
body: str,
|
|
345
|
+
*,
|
|
346
|
+
replyable: bool = False,
|
|
347
|
+
path: str = "",
|
|
348
|
+
line: int | None = None,
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Post one comment, optionally requiring a replyable provider interaction.
|
|
351
|
+
|
|
352
|
+
The default is a standalone GitHub issue comment / GitLab MR note. With
|
|
353
|
+
`replyable=True`, `path`/`line` optionally request a new-side diff anchor; adapters
|
|
354
|
+
raise when that shape is unsupported rather than silently publishing a standalone
|
|
355
|
+
comment. The caller owns any fallback policy.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
def reply(self, number: int, target: Comment, body: str) -> None:
|
|
359
|
+
"""Reply to a Comment returned by this Forge's `comments()`.
|
|
360
|
+
|
|
361
|
+
Adapters consume `target.reply_ref`. A standalone comment raises rather than silently
|
|
362
|
+
creating a detached response.
|
|
363
|
+
"""
|
|
364
|
+
raise ForgeError(f"{self.provider or 'forge'}: replies not supported")
|
|
365
|
+
|
|
366
|
+
def resolve_comment(self, number: int, target: Comment) -> None:
|
|
367
|
+
"""Resolve the review interaction backing `target` after its finding is handled.
|
|
368
|
+
|
|
369
|
+
Resolution is a separate, best-effort
|
|
370
|
+
forge transition. Adapters consume `target.resolve_ref`; unsupported comments raise so
|
|
371
|
+
callers keep the verdict while reporting that the merge blocker may remain.
|
|
372
|
+
"""
|
|
373
|
+
raise ForgeError(f"{self.provider or 'forge'}: resolving comments not supported")
|
|
374
|
+
|
|
375
|
+
def merge_readiness(self, number: int) -> MergeReadiness:
|
|
376
|
+
"""Why MR/PR `number` can't merge yet (CONFLICT / DISCUSSIONS_UNRESOLVED / …), or READY.
|
|
377
|
+
A fetch primitive (peer of `description`), NOT a `PullRequest` field — see `MergeReadiness`.
|
|
378
|
+
Concrete-with-default rather than abstract: an adapter that hasn't implemented it (today:
|
|
379
|
+
GitHub) inherits the safe UNKNOWN instead of being forced to lie; GitLab overrides."""
|
|
380
|
+
return MergeReadiness.UNKNOWN
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Persistent PR lifecycle queries and live local branch associations."""
|
|
2
|
+
|
|
3
|
+
from .context import PullRequestContext as PullRequestContext
|
|
4
|
+
from .model import BranchPullRequests as BranchPullRequests
|
|
5
|
+
from .model import PullRequestKey as PullRequestKey
|
|
6
|
+
from .model import PullRequestStatus as PullRequestStatus
|
|
7
|
+
from .model import PullRequestStatusUnavailable as PullRequestStatusUnavailable
|
|
8
|
+
from .service import PullRequestObserver as PullRequestObserver
|
|
9
|
+
from .store import ObservationStorageError as ObservationStorageError
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""PR association queries joined to live local branch and checkout topology."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from harness_common import Repository
|
|
9
|
+
|
|
10
|
+
from ... import git_state
|
|
11
|
+
from ...budget import operation
|
|
12
|
+
from ...remote import remote
|
|
13
|
+
from ..model import ForgeError, PullRequest
|
|
14
|
+
from .model import BranchPullRequests, PullRequestKey
|
|
15
|
+
from .service import PullRequestObserver, epoch, iso, key_from_json, key_json, repository_json
|
|
16
|
+
from .store import JSON, digest
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PullRequestContext:
|
|
20
|
+
"""One local repository and explicit remote query scope; no workflow selection policy.
|
|
21
|
+
|
|
22
|
+
@spec An association is scoped by source repository, branch and target repositories.
|
|
23
|
+
@spec Multiple matching proposals remain ambiguous; detached checkouts have no branch.
|
|
24
|
+
@spec Git topology is read live and never persisted as authority to remove resources.
|
|
25
|
+
@link component://docs/pr.md
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
observer: PullRequestObserver,
|
|
31
|
+
checkout: str | Path,
|
|
32
|
+
*,
|
|
33
|
+
source: Repository,
|
|
34
|
+
targets: tuple[Repository, ...],
|
|
35
|
+
):
|
|
36
|
+
if not targets or any(repo.forge.name != observer.forge for repo in (source, *targets)):
|
|
37
|
+
raise ValueError("source and targets must belong to the observer Forge")
|
|
38
|
+
self.observer, self.checkout = observer, str(checkout)
|
|
39
|
+
self.source, self.targets = source, tuple(dict.fromkeys(targets))
|
|
40
|
+
self._remote = remote(self.checkout)
|
|
41
|
+
|
|
42
|
+
def _association_path(self, branch: str) -> Path:
|
|
43
|
+
observer = self.observer
|
|
44
|
+
identity = [
|
|
45
|
+
observer.connection,
|
|
46
|
+
repository_json(self.source),
|
|
47
|
+
branch,
|
|
48
|
+
[repository_json(repo) for repo in self.targets],
|
|
49
|
+
]
|
|
50
|
+
return observer.store.root / "associations" / f"{digest(identity)}.json"
|
|
51
|
+
|
|
52
|
+
def _keys(self, branch: str, *, verify: bool) -> tuple[PullRequestKey, ...]:
|
|
53
|
+
observer = self.observer
|
|
54
|
+
path = self._association_path(branch)
|
|
55
|
+
with observer.store.locked(path) as owned:
|
|
56
|
+
record = observer.store.read(path)
|
|
57
|
+
if record and (
|
|
58
|
+
record.get("version") != 1
|
|
59
|
+
or not record.get("error")
|
|
60
|
+
and not isinstance(record.get("keys"), list)
|
|
61
|
+
):
|
|
62
|
+
raise ForgeError("invalid PR association record")
|
|
63
|
+
try:
|
|
64
|
+
refresh_at = epoch(record.get("nextRefreshAt")) if record else 0
|
|
65
|
+
cached = (
|
|
66
|
+
tuple(key_from_json(row) for row in cast(list[JSON], record["keys"]))
|
|
67
|
+
if record and not record.get("error")
|
|
68
|
+
else ()
|
|
69
|
+
)
|
|
70
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
71
|
+
raise ForgeError(f"invalid PR association record for {branch}: {exc}") from exc
|
|
72
|
+
if not verify and record and refresh_at > observer.now():
|
|
73
|
+
if record.get("error"):
|
|
74
|
+
raise ForgeError(str(record["error"]))
|
|
75
|
+
return cached
|
|
76
|
+
keys: list[PullRequestKey] = []
|
|
77
|
+
try:
|
|
78
|
+
for target in self.targets:
|
|
79
|
+
for pr in observer.reader.for_repository(target.path).prs_for_branch(
|
|
80
|
+
branch, source_repository=self.source
|
|
81
|
+
):
|
|
82
|
+
if (
|
|
83
|
+
pr.source_repository != self.source.path
|
|
84
|
+
or pr.target_repository != target.path
|
|
85
|
+
or pr.source_branch != branch
|
|
86
|
+
):
|
|
87
|
+
raise ForgeError("Forge returned a different branch association")
|
|
88
|
+
keys.append(PullRequestKey(target, pr.number))
|
|
89
|
+
except (ForgeError, OSError) as exc:
|
|
90
|
+
if isinstance(exc, InterruptedError):
|
|
91
|
+
raise
|
|
92
|
+
observer.store.write(
|
|
93
|
+
path,
|
|
94
|
+
{
|
|
95
|
+
"version": 1,
|
|
96
|
+
"error": str(exc),
|
|
97
|
+
"nextRefreshAt": iso(observer.now() + observer.retry_interval),
|
|
98
|
+
},
|
|
99
|
+
owned,
|
|
100
|
+
)
|
|
101
|
+
raise
|
|
102
|
+
unique = tuple(dict.fromkeys(keys))
|
|
103
|
+
observer.store.write(
|
|
104
|
+
path,
|
|
105
|
+
{
|
|
106
|
+
"version": 1,
|
|
107
|
+
"keys": [key_json(key) for key in unique],
|
|
108
|
+
"nextRefreshAt": iso(observer.now() + observer.open_interval),
|
|
109
|
+
},
|
|
110
|
+
owned,
|
|
111
|
+
)
|
|
112
|
+
return unique
|
|
113
|
+
|
|
114
|
+
def _branch(self, branch: str, *, verify: bool = False) -> BranchPullRequests:
|
|
115
|
+
with operation(timeout=self.observer.timeout, cancel=self.observer._stop):
|
|
116
|
+
heads = dict(git_state.list_local_branches(self.checkout))
|
|
117
|
+
if branch not in heads:
|
|
118
|
+
raise ValueError(f"local branch does not exist: {branch}")
|
|
119
|
+
head = heads[branch]
|
|
120
|
+
checkouts = tuple(
|
|
121
|
+
entry for entry in git_state.list_checkouts(self.checkout) if entry.branch == branch
|
|
122
|
+
)
|
|
123
|
+
candidates: list[PullRequest] = []
|
|
124
|
+
errors: list[str] = []
|
|
125
|
+
try:
|
|
126
|
+
keys = self._keys(branch, verify=verify)
|
|
127
|
+
for key in keys:
|
|
128
|
+
try:
|
|
129
|
+
pr = self.observer._snapshot(key, verify=verify)
|
|
130
|
+
# Metadata belongs to the successful observation, not to a branch name.
|
|
131
|
+
if not pr.source_repository or not pr.source_branch:
|
|
132
|
+
raise ForgeError("observed PR source is unknown")
|
|
133
|
+
if pr.source_branch != branch or pr.source_repository != self.source.path:
|
|
134
|
+
continue
|
|
135
|
+
candidates.append(pr)
|
|
136
|
+
except (ForgeError, OSError) as exc:
|
|
137
|
+
if isinstance(exc, InterruptedError):
|
|
138
|
+
raise
|
|
139
|
+
errors.append(str(exc))
|
|
140
|
+
except (ForgeError, OSError) as exc:
|
|
141
|
+
if isinstance(exc, InterruptedError):
|
|
142
|
+
raise
|
|
143
|
+
errors.append(str(exc))
|
|
144
|
+
opens = [pr for pr in candidates if pr.is_open]
|
|
145
|
+
matching = list(opens)
|
|
146
|
+
if not opens:
|
|
147
|
+
for pr in candidates:
|
|
148
|
+
try:
|
|
149
|
+
if pr.sha and git_state.is_ancestor(self.checkout, pr.sha, head):
|
|
150
|
+
matching.append(pr)
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
if isinstance(exc, InterruptedError):
|
|
153
|
+
raise
|
|
154
|
+
errors.append(f"PR {pr.number} ancestry unavailable: {exc}")
|
|
155
|
+
return BranchPullRequests(
|
|
156
|
+
self.source,
|
|
157
|
+
branch,
|
|
158
|
+
head,
|
|
159
|
+
tuple(candidates),
|
|
160
|
+
checkouts,
|
|
161
|
+
matching[0] if len(matching) == 1 and not errors else None,
|
|
162
|
+
len(matching) > 1,
|
|
163
|
+
"; ".join(errors) or None,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def get_branch(self, branch: str) -> BranchPullRequests:
|
|
167
|
+
return self._branch(branch)
|
|
168
|
+
|
|
169
|
+
def get_checkout(self) -> BranchPullRequests | None:
|
|
170
|
+
branch = git_state.get_current_branch(self.checkout)
|
|
171
|
+
return self.get_branch(branch) if branch else None
|
|
172
|
+
|
|
173
|
+
def local_branches(self, *, limit: int = 20) -> tuple[BranchPullRequests, ...]:
|
|
174
|
+
"""Bound cold discovery; oldest due associations progress independently of retries.
|
|
175
|
+
|
|
176
|
+
@spec Per-branch read failures preserve other results; cancellation aborts the round.
|
|
177
|
+
@case tests/test_pr_observation.py
|
|
178
|
+
"""
|
|
179
|
+
if limit < 1:
|
|
180
|
+
raise ValueError("limit must be positive")
|
|
181
|
+
branches = git_state.list_local_branches(self.checkout)
|
|
182
|
+
checkouts = git_state.list_checkouts(self.checkout)
|
|
183
|
+
due = {}
|
|
184
|
+
unavailable: dict[str, str] = {}
|
|
185
|
+
for branch, _ in branches:
|
|
186
|
+
try:
|
|
187
|
+
record = self.observer.store.read(self._association_path(branch))
|
|
188
|
+
due[branch] = epoch(record.get("nextRefreshAt")) if record else 0
|
|
189
|
+
except (OSError, ValueError) as exc:
|
|
190
|
+
unavailable[branch] = str(exc)
|
|
191
|
+
due[branch] = float("inf")
|
|
192
|
+
selected = {
|
|
193
|
+
branch
|
|
194
|
+
for branch, at in sorted(due.items(), key=lambda item: (item[1], item[0]))
|
|
195
|
+
if at <= self.observer.now()
|
|
196
|
+
}
|
|
197
|
+
selected = set(sorted(selected, key=lambda branch: (due[branch], branch))[:limit])
|
|
198
|
+
results = []
|
|
199
|
+
with operation(timeout=self.observer.timeout, cancel=self.observer._stop):
|
|
200
|
+
from ...budget import current_budget
|
|
201
|
+
|
|
202
|
+
for branch, head in branches:
|
|
203
|
+
error = unavailable.get(branch)
|
|
204
|
+
try:
|
|
205
|
+
current_budget(self.observer.timeout).remaining()
|
|
206
|
+
if error:
|
|
207
|
+
pass
|
|
208
|
+
elif branch not in selected and due[branch] <= self.observer.now():
|
|
209
|
+
error = "association discovery pending"
|
|
210
|
+
else:
|
|
211
|
+
results.append(self.get_branch(branch))
|
|
212
|
+
continue
|
|
213
|
+
except TimeoutError:
|
|
214
|
+
error = "association round timed out"
|
|
215
|
+
except (ForgeError, OSError, ValueError) as exc:
|
|
216
|
+
if isinstance(exc, InterruptedError):
|
|
217
|
+
raise
|
|
218
|
+
error = str(exc)
|
|
219
|
+
results.append(
|
|
220
|
+
BranchPullRequests(
|
|
221
|
+
self.source,
|
|
222
|
+
branch,
|
|
223
|
+
head,
|
|
224
|
+
(),
|
|
225
|
+
tuple(c for c in checkouts if c.branch == branch),
|
|
226
|
+
None,
|
|
227
|
+
error=error,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
return tuple(results)
|
|
231
|
+
|
|
232
|
+
def for_pull_request(self, key: PullRequestKey) -> tuple[BranchPullRequests, ...]:
|
|
233
|
+
pr = self.observer._snapshot(key)
|
|
234
|
+
if key.repository not in self.targets or pr.source_repository != self.source.path:
|
|
235
|
+
return ()
|
|
236
|
+
if pr.source_branch not in dict(git_state.list_local_branches(self.checkout)):
|
|
237
|
+
return ()
|
|
238
|
+
branch = self.get_branch(pr.source_branch)
|
|
239
|
+
return (
|
|
240
|
+
(branch,)
|
|
241
|
+
if any(
|
|
242
|
+
p.number == key.number and p.target_repository == key.repository.path
|
|
243
|
+
for p in branch.candidates
|
|
244
|
+
)
|
|
245
|
+
else ()
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def verify(self, previous: BranchPullRequests) -> BranchPullRequests:
|
|
249
|
+
"""Recheck a decision's association and identity before the caller mutates resources."""
|
|
250
|
+
if remote(self.checkout) != self._remote:
|
|
251
|
+
raise ForgeError("source repository remote changed during PR decision")
|
|
252
|
+
current = self._branch(previous.branch, verify=True)
|
|
253
|
+
if current.head != previous.head or current.checkouts != previous.checkouts:
|
|
254
|
+
raise ForgeError("local branch or checkout changed during PR decision")
|
|
255
|
+
return current
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Public identities and query results, independent of local storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from harness_common import Repository
|
|
9
|
+
|
|
10
|
+
from ...git_state import CheckoutEntry
|
|
11
|
+
from ..model import ForgeError, PullRequest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PullRequestKey:
|
|
16
|
+
repository: Repository
|
|
17
|
+
number: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class PullRequestStatus:
|
|
22
|
+
key: PullRequestKey
|
|
23
|
+
state: Literal["open", "closed", "merged"]
|
|
24
|
+
observed_at: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PullRequestStatusUnavailable(ForgeError):
|
|
28
|
+
"""No current lifecycle observation; an old snapshot is not a successful query."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class BranchPullRequests:
|
|
33
|
+
repository: Repository
|
|
34
|
+
branch: str
|
|
35
|
+
head: str
|
|
36
|
+
candidates: tuple[PullRequest, ...]
|
|
37
|
+
checkouts: tuple[CheckoutEntry, ...]
|
|
38
|
+
selected: PullRequest | None
|
|
39
|
+
ambiguous: bool = False
|
|
40
|
+
error: str | None = None
|