alissa-tools-github-devloop 0.1.0__tar.gz → 0.3.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.
- {alissa_tools_github_devloop-0.1.0/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.3.0}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/__main__.py +22 -2
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/alissa.py +33 -4
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/config.py +37 -3
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/ghclient.py +198 -1
- alissa_tools_github_devloop-0.3.0/src/main/alissa/tools/github/devloop/loop.py +1413 -0
- alissa_tools_github_devloop-0.3.0/src/main/alissa/tools/github/devloop/state.py +206 -0
- alissa_tools_github_devloop-0.3.0/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/loop.py +0 -620
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/state.py +0 -113
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/README.md +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/setup.py +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -98,6 +98,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
98
98
|
)
|
|
99
99
|
over.add_argument("--on-missing-hub", choices=[HUB_SKIP, HUB_ADD])
|
|
100
100
|
|
|
101
|
+
fix = over.add_mutually_exclusive_group()
|
|
102
|
+
fix.add_argument(
|
|
103
|
+
"--fix-rounds",
|
|
104
|
+
dest="fix_rounds_enabled",
|
|
105
|
+
action="store_true",
|
|
106
|
+
default=None,
|
|
107
|
+
help="poll open PRs authored by the token for a request_changes "
|
|
108
|
+
"review awaiting the author, and spawn fix sessions (the default)",
|
|
109
|
+
)
|
|
110
|
+
fix.add_argument(
|
|
111
|
+
"--no-fix-rounds",
|
|
112
|
+
dest="fix_rounds_enabled",
|
|
113
|
+
action="store_false",
|
|
114
|
+
help="issues-only polling: never evaluate PRs or spawn fix sessions, "
|
|
115
|
+
"even if the config enables it",
|
|
116
|
+
)
|
|
117
|
+
|
|
101
118
|
dry = over.add_mutually_exclusive_group()
|
|
102
119
|
dry.add_argument(
|
|
103
120
|
"--dry-run",
|
|
@@ -131,6 +148,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
131
148
|
"stale_minutes": args.stale_minutes,
|
|
132
149
|
"on_missing_origin_task": args.on_missing_origin_task,
|
|
133
150
|
"on_missing_hub": args.on_missing_hub,
|
|
151
|
+
"fix_rounds_enabled": args.fix_rounds_enabled,
|
|
134
152
|
"dry_run": args.dry_run,
|
|
135
153
|
}
|
|
136
154
|
|
|
@@ -161,8 +179,10 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
161
179
|
)
|
|
162
180
|
log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
|
|
163
181
|
log.info(
|
|
164
|
-
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min"
|
|
165
|
-
|
|
182
|
+
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
|
|
183
|
+
"fix_rounds=%s",
|
|
184
|
+
config.poll_interval, config.dry_run, config.attempt_cap,
|
|
185
|
+
config.stale_minutes, config.fix_rounds_enabled,
|
|
166
186
|
)
|
|
167
187
|
log.debug("hub_template: %s", config.hub_template)
|
|
168
188
|
log.debug("agent_profile: %s", config.agent_profile)
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
"""Alissa CLI access: enqueue the fresh developer session
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
"""Alissa CLI access: enqueue the fresh developer session, probe the worker,
|
|
2
|
+
and list/kill managed tmux sessions for the reaper sweep. The developer's
|
|
3
|
+
Alissa task is created by the session itself (as a downstream of the origin
|
|
4
|
+
task), so unlike reviewloop there is no task lookup here -- the daemon's only
|
|
5
|
+
Alissa surfaces are the tmux queue, the worker, and the tmux session list."""
|
|
5
6
|
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
9
|
+
import json
|
|
8
10
|
import logging
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
|
|
@@ -66,6 +68,33 @@ class Alissa:
|
|
|
66
68
|
# Cloning a repo can be slow; the poll loop tolerates a long pass.
|
|
67
69
|
run(argv, timeout=600, cwd=workspace_root)
|
|
68
70
|
|
|
71
|
+
def list_sessions(self) -> "list[dict]":
|
|
72
|
+
"""Managed tmux sessions as `alissa tmux ls --json` reports them
|
|
73
|
+
(dicts carrying at least `name` and `status`). Raises CommandError
|
|
74
|
+
when the CLI cannot answer at all (the caller skips that sweep);
|
|
75
|
+
unparseable or non-list JSON degrades to an empty list -- a broken
|
|
76
|
+
listing must read as "nothing to reap", never crash the poll."""
|
|
77
|
+
out = run(["alissa", "tmux", "ls", "--json"], timeout=60)
|
|
78
|
+
if not out.strip():
|
|
79
|
+
return []
|
|
80
|
+
try:
|
|
81
|
+
data = json.loads(out)
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
log.warning(
|
|
84
|
+
"`alissa tmux ls --json` returned unparseable output -- "
|
|
85
|
+
"treating as no sessions this sweep"
|
|
86
|
+
)
|
|
87
|
+
return []
|
|
88
|
+
if not isinstance(data, list):
|
|
89
|
+
return []
|
|
90
|
+
return [entry for entry in data if isinstance(entry, dict)]
|
|
91
|
+
|
|
92
|
+
def kill_session(self, name: str) -> None:
|
|
93
|
+
"""Kill ONE managed session by name -- never the tmux server. Raises
|
|
94
|
+
CommandError on failure (including a CLI old enough to lack
|
|
95
|
+
`tmux kill`); the reaper treats kills as best-effort."""
|
|
96
|
+
run(["alissa", "tmux", "kill", name], timeout=60)
|
|
97
|
+
|
|
69
98
|
def worker_running(self) -> bool:
|
|
70
99
|
"""The queue only drains while `alissa worker` reconciles it."""
|
|
71
100
|
try:
|
|
@@ -58,11 +58,18 @@ CONFIG_KEYS = (
|
|
|
58
58
|
"stale_minutes",
|
|
59
59
|
"on_missing_origin_task",
|
|
60
60
|
"on_missing_hub",
|
|
61
|
+
"fix_rounds_enabled",
|
|
61
62
|
"dry_run",
|
|
62
63
|
)
|
|
63
64
|
|
|
64
65
|
MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
|
|
65
66
|
|
|
67
|
+
# The only placeholders hub_for supplies. Probed at load time: nothing else
|
|
68
|
+
# calls hub_for until the daemon loop is already running, so a typo'd
|
|
69
|
+
# placeholder would otherwise outlive Config.build AND preflight and surface
|
|
70
|
+
# as a raw KeyError mid-poll instead of a config error at startup.
|
|
71
|
+
HUB_TEMPLATE_FIELDS = ("root", "owner", "repo")
|
|
72
|
+
|
|
66
73
|
|
|
67
74
|
def default_state_path(workspace_root: Path) -> Path:
|
|
68
75
|
return Path(workspace_root) / ".devloop" / "state.db"
|
|
@@ -126,6 +133,13 @@ class Config:
|
|
|
126
133
|
|
|
127
134
|
on_missing_origin_task: str = ON_MISSING_WARN
|
|
128
135
|
on_missing_hub: str = HUB_SKIP
|
|
136
|
+
|
|
137
|
+
# The review-response edge (poll open PRs authored by the token for a
|
|
138
|
+
# request_changes verdict awaiting the author, and spawn fix sessions).
|
|
139
|
+
# Default ON: the edge is the daemon's second half, and every side effect
|
|
140
|
+
# still honors dry_run. Turning it off reverts to issues-only polling.
|
|
141
|
+
fix_rounds_enabled: bool = True
|
|
142
|
+
|
|
129
143
|
dry_run: bool = False
|
|
130
144
|
|
|
131
145
|
# Derived at build time: `repos` casefolded for matching. GitHub names are
|
|
@@ -209,6 +223,23 @@ class Config:
|
|
|
209
223
|
f"on_missing_hub must be one of {sorted(_HUB_MODES)}, got {hub_mode!r}"
|
|
210
224
|
)
|
|
211
225
|
|
|
226
|
+
template = raw.get("hub_template", cls.hub_template)
|
|
227
|
+
valid = ", ".join("{" + name + "}" for name in HUB_TEMPLATE_FIELDS)
|
|
228
|
+
try:
|
|
229
|
+
template.format(**{name: name for name in HUB_TEMPLATE_FIELDS})
|
|
230
|
+
except KeyError as exc:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
f"hub_template has an unknown placeholder {{{exc.args[0]}}}: "
|
|
233
|
+
f"{template!r}. Valid placeholders: {valid}"
|
|
234
|
+
) from exc
|
|
235
|
+
except (IndexError, ValueError) as exc:
|
|
236
|
+
# Positional fields ({}, {0}) or unbalanced braces — str.format
|
|
237
|
+
# reports these without naming the template, so name it here.
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"hub_template is not a valid template: {template!r} ({exc}). "
|
|
240
|
+
f"Use named placeholders: {valid}"
|
|
241
|
+
) from exc
|
|
242
|
+
|
|
212
243
|
repos = _string_list(raw.get("repos", cls.repos), "repos")
|
|
213
244
|
if hub_mode == HUB_ADD and not repos:
|
|
214
245
|
# Anyone who can label an issue could otherwise cause an arbitrary
|
|
@@ -233,21 +264,24 @@ class Config:
|
|
|
233
264
|
f"poll_interval must be >= {MIN_POLL_INTERVAL} seconds, got {interval}"
|
|
234
265
|
)
|
|
235
266
|
|
|
236
|
-
state_path = raw.get("state_path")
|
|
267
|
+
state_path = raw.get("state_path", cls.state_path)
|
|
237
268
|
return cls(
|
|
238
269
|
workspace_root=Path(workspace_root),
|
|
239
|
-
hub_template=
|
|
270
|
+
hub_template=template,
|
|
240
271
|
poll_interval=interval,
|
|
241
272
|
label=raw.get("label", cls.label),
|
|
242
273
|
repos=repos,
|
|
243
274
|
agent_profile=raw.get("agent_profile", cls.agent_profile),
|
|
244
|
-
developer_login=raw.get("developer_login"),
|
|
275
|
+
developer_login=raw.get("developer_login", cls.developer_login),
|
|
245
276
|
reviewers=_string_list(raw.get("reviewers", cls.reviewers), "reviewers"),
|
|
246
277
|
state_path=Path(state_path).expanduser() if state_path else None,
|
|
247
278
|
attempt_cap=cap,
|
|
248
279
|
stale_minutes=stale,
|
|
249
280
|
on_missing_origin_task=mode,
|
|
250
281
|
on_missing_hub=hub_mode,
|
|
282
|
+
fix_rounds_enabled=bool(
|
|
283
|
+
raw.get("fix_rounds_enabled", cls.fix_rounds_enabled)
|
|
284
|
+
),
|
|
251
285
|
dry_run=bool(raw.get("dry_run", cls.dry_run)),
|
|
252
286
|
)
|
|
253
287
|
|
|
@@ -36,6 +36,12 @@ TASK_REF_RE = re.compile(r"TASK-(\d+)")
|
|
|
36
36
|
TIMELINE_PAGE_SIZE = 100
|
|
37
37
|
TIMELINE_MAX_PAGES = 5
|
|
38
38
|
|
|
39
|
+
# Review states that count as "a review was submitted". PENDING is a draft
|
|
40
|
+
# review that was never submitted, so it closes nothing. Same set as
|
|
41
|
+
# reviewloop's SUBMITTED_STATES -- the two daemons must count rounds
|
|
42
|
+
# identically or they disagree about which round a PR is in.
|
|
43
|
+
SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
|
|
44
|
+
|
|
39
45
|
|
|
40
46
|
def parse_task_ref(body: str) -> int | None:
|
|
41
47
|
"""Origin Alissa task number from an issue body, or None if absent.
|
|
@@ -74,6 +80,58 @@ class Issue:
|
|
|
74
80
|
return f"{self.owner}/{self.repo}#{self.number}"
|
|
75
81
|
|
|
76
82
|
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class PullRequest:
|
|
85
|
+
owner: str
|
|
86
|
+
repo: str
|
|
87
|
+
number: int
|
|
88
|
+
state: str
|
|
89
|
+
title: str
|
|
90
|
+
author: str
|
|
91
|
+
head_ref: str
|
|
92
|
+
head_sha: str
|
|
93
|
+
draft: bool
|
|
94
|
+
url: str
|
|
95
|
+
# Logins/slugs whose review is CURRENTLY requested (users and teams
|
|
96
|
+
# merged): non-empty means the ball is in a reviewer's court, so the
|
|
97
|
+
# review-response edge is closed. GitHub clears an entry when that
|
|
98
|
+
# reviewer submits and re-adds it on re-request.
|
|
99
|
+
requested_reviewers: tuple[str, ...]
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def full_name(self) -> str:
|
|
103
|
+
return f"{self.owner}/{self.repo}"
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def pr_slug(self) -> str:
|
|
107
|
+
# `#pr16`, not `#16`: PR and issue numbers share GitHub's namespace,
|
|
108
|
+
# and poll_once logs both edges into one Decision list -- the slug is
|
|
109
|
+
# what tells `acme/widgets#7` (issue) from `acme/widgets#pr7` (PR).
|
|
110
|
+
return f"{self.owner}/{self.repo}#pr{self.number}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Review:
|
|
115
|
+
author: str
|
|
116
|
+
state: str
|
|
117
|
+
commit_id: str
|
|
118
|
+
submitted_at: str
|
|
119
|
+
url: str
|
|
120
|
+
body: str = ""
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def is_substantive(self) -> bool:
|
|
124
|
+
"""A real review round, not a side effect of an inline comment.
|
|
125
|
+
|
|
126
|
+
Posting a standalone inline comment on a PR creates its own review
|
|
127
|
+
record with an empty body, so review records outnumber rounds. The
|
|
128
|
+
round-closing review always carries the verdict write-up in its body.
|
|
129
|
+
Same rule as reviewloop's Review.is_substantive -- both daemons (and
|
|
130
|
+
alissa-pr-review's round counter) must agree on what closes a round.
|
|
131
|
+
"""
|
|
132
|
+
return bool(self.body.strip())
|
|
133
|
+
|
|
134
|
+
|
|
77
135
|
class RateLimited(RuntimeError):
|
|
78
136
|
pass
|
|
79
137
|
|
|
@@ -258,6 +316,136 @@ class GitHub:
|
|
|
258
316
|
out.append((parts[-2], parts[-1], number, assignees))
|
|
259
317
|
return out
|
|
260
318
|
|
|
319
|
+
def search_prs(self, repos: tuple[str, ...] = ()) -> list[tuple[str, str, int]]:
|
|
320
|
+
"""Open non-draft PRs AUTHORED by the token identity across the
|
|
321
|
+
watched repos, as `(owner, repo, number)` hits -- the candidate set of
|
|
322
|
+
the review-response edge.
|
|
323
|
+
|
|
324
|
+
`author:@me` resolves server-side from the gh token (the same token
|
|
325
|
+
verify_identity() pins at startup), so the daemon only ever considers
|
|
326
|
+
its own PRs -- the developer identity is the author of every PR its
|
|
327
|
+
spawned sessions push. `draft:false` keeps the daemon's own fresh
|
|
328
|
+
draft PRs (the pre-review norm) from costing a pull_request() re-fetch
|
|
329
|
+
every poll; evaluate_pr() re-checks `draft` anyway, since a PR can
|
|
330
|
+
flip back to draft between search and fetch (same belt-and-braces as
|
|
331
|
+
reviewloop's review_requests + CR1 re-check).
|
|
332
|
+
|
|
333
|
+
Fails closed on an empty allowlist for the same reason search_issues
|
|
334
|
+
does: an unscoped query would return every PR the token ever authored
|
|
335
|
+
anywhere on GitHub, and the caller spawns code-writing sessions
|
|
336
|
+
against what comes back. Empty/whitespace entries fail closed too.
|
|
337
|
+
"""
|
|
338
|
+
repos = tuple(r.strip() for r in repos if r and r.strip())
|
|
339
|
+
if not repos:
|
|
340
|
+
log.warning(
|
|
341
|
+
"search_prs: repo allowlist is empty -- nothing is watched. "
|
|
342
|
+
"An unscoped search would match every PR this token authored "
|
|
343
|
+
"anywhere on GitHub, so this fails closed; configure `repos` "
|
|
344
|
+
"to watch something."
|
|
345
|
+
)
|
|
346
|
+
return []
|
|
347
|
+
|
|
348
|
+
query = "is:pr is:open draft:false author:@me"
|
|
349
|
+
for full_name in repos:
|
|
350
|
+
query += f" repo:{full_name}"
|
|
351
|
+
|
|
352
|
+
payload = self._api(
|
|
353
|
+
"-X",
|
|
354
|
+
"GET",
|
|
355
|
+
"search/issues",
|
|
356
|
+
"-f",
|
|
357
|
+
f"q={query}",
|
|
358
|
+
"-f",
|
|
359
|
+
f"per_page={self.SEARCH_PER_PAGE}",
|
|
360
|
+
)
|
|
361
|
+
items = (payload or {}).get("items", [])
|
|
362
|
+
if len(items) == self.SEARCH_PER_PAGE:
|
|
363
|
+
log.warning(
|
|
364
|
+
"search_prs: got exactly %d items -- results are likely "
|
|
365
|
+
"truncated. Pagination is deliberately not implemented "
|
|
366
|
+
"(same hard ceiling as search_issues); the window only "
|
|
367
|
+
"drains as PRs close or merge.",
|
|
368
|
+
self.SEARCH_PER_PAGE,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
out: list[tuple[str, str, int]] = []
|
|
372
|
+
for item in items:
|
|
373
|
+
# repository_url looks like https://api.github.com/repos/<owner>/<repo>
|
|
374
|
+
parts = item.get("repository_url", "").rstrip("/").split("/")
|
|
375
|
+
if len(parts) < 2:
|
|
376
|
+
log.warning("could not parse repo from %s", item.get("repository_url"))
|
|
377
|
+
continue
|
|
378
|
+
try:
|
|
379
|
+
number = int(item["number"])
|
|
380
|
+
except (KeyError, TypeError, ValueError):
|
|
381
|
+
log.warning("could not parse PR number from %r", item.get("number"))
|
|
382
|
+
continue
|
|
383
|
+
out.append((parts[-2], parts[-1], number))
|
|
384
|
+
return out
|
|
385
|
+
|
|
386
|
+
def pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
|
|
387
|
+
"""The evaluate_pr() re-fetch: draft state, head, author, and the
|
|
388
|
+
pending review requests -- the consistency guard over search
|
|
389
|
+
staleness, like issue() is for the issue edge."""
|
|
390
|
+
data = self._api(f"repos/{owner}/{repo}/pulls/{number}") or {}
|
|
391
|
+
head = data.get("head") or {}
|
|
392
|
+
# Users carry `login`, teams `slug`; both hold the edge closed -- a
|
|
393
|
+
# pending TEAM re-request still means the ball is in a reviewer's
|
|
394
|
+
# court, so it must not read as "awaiting the author".
|
|
395
|
+
requested = tuple(
|
|
396
|
+
name
|
|
397
|
+
for entry in (
|
|
398
|
+
list(data.get("requested_reviewers") or [])
|
|
399
|
+
+ list(data.get("requested_teams") or [])
|
|
400
|
+
)
|
|
401
|
+
if isinstance(entry, dict)
|
|
402
|
+
and (name := (entry.get("login") or entry.get("slug") or ""))
|
|
403
|
+
)
|
|
404
|
+
return PullRequest(
|
|
405
|
+
owner=owner,
|
|
406
|
+
repo=repo,
|
|
407
|
+
number=number,
|
|
408
|
+
state=data.get("state", ""),
|
|
409
|
+
title=data.get("title", ""),
|
|
410
|
+
author=(data.get("user") or {}).get("login", ""),
|
|
411
|
+
head_ref=head.get("ref") or "",
|
|
412
|
+
head_sha=head.get("sha") or "",
|
|
413
|
+
draft=bool(data.get("draft")),
|
|
414
|
+
url=data.get("html_url", ""),
|
|
415
|
+
requested_reviewers=requested,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|
|
419
|
+
"""All review records on a PR, tolerant of malformed entries. The
|
|
420
|
+
substantive/reviewer filtering happens in the loop (it needs config);
|
|
421
|
+
per_page=100 is a documented single-page window like the searches --
|
|
422
|
+
a PR with more than 100 review records is far past the round cap.
|
|
423
|
+
"""
|
|
424
|
+
data = (
|
|
425
|
+
self._api(
|
|
426
|
+
"-X",
|
|
427
|
+
"GET",
|
|
428
|
+
f"repos/{owner}/{repo}/pulls/{number}/reviews",
|
|
429
|
+
"-f",
|
|
430
|
+
"per_page=100",
|
|
431
|
+
)
|
|
432
|
+
or []
|
|
433
|
+
)
|
|
434
|
+
if not isinstance(data, list):
|
|
435
|
+
return []
|
|
436
|
+
return [
|
|
437
|
+
Review(
|
|
438
|
+
author=(r.get("user") or {}).get("login", ""),
|
|
439
|
+
state=r.get("state", ""),
|
|
440
|
+
commit_id=r.get("commit_id") or "",
|
|
441
|
+
submitted_at=r.get("submitted_at") or "",
|
|
442
|
+
url=r.get("html_url", ""),
|
|
443
|
+
body=r.get("body") or "",
|
|
444
|
+
)
|
|
445
|
+
for r in data
|
|
446
|
+
if isinstance(r, dict)
|
|
447
|
+
]
|
|
448
|
+
|
|
261
449
|
def issue(self, owner: str, repo: str, number: int) -> Issue:
|
|
262
450
|
# `or {}`: run_json returns None on empty stdout, and the siblings
|
|
263
451
|
# (search_issues, assign_self) already guard the same way.
|
|
@@ -307,7 +495,10 @@ class GitHub:
|
|
|
307
495
|
comments, labeled/assigned/mentioned events, bot noise -- ahead of
|
|
308
496
|
the cross-reference, so a long pre-label discussion can push it past
|
|
309
497
|
the first page, and a first-page-only probe would mis-read live work
|
|
310
|
-
as dead (and respawn over it). The walk is bounded: it stops
|
|
498
|
+
as dead (and respawn over it). The walk is bounded: it stops as soon
|
|
499
|
+
as a page has yielded any open same-repo PR (every caller's question
|
|
500
|
+
is effectively boolean -- further pages could only lengthen a
|
|
501
|
+
comment's URL list), it stops at the
|
|
311
502
|
first short page (the timeline is exhausted) and hard-caps at
|
|
312
503
|
TIMELINE_MAX_PAGES pages (TIMELINE_MAX_PAGES * TIMELINE_PAGE_SIZE
|
|
313
504
|
events) -- a cross-reference past that ceiling is invisible to the
|
|
@@ -353,6 +544,12 @@ class GitHub:
|
|
|
353
544
|
if not url.startswith(same_repo_pull):
|
|
354
545
|
continue # a mention from another repo says nothing here
|
|
355
546
|
urls.append(url)
|
|
547
|
+
if urls:
|
|
548
|
+
# The caller's question is boolean -- is ANY open same-repo
|
|
549
|
+
# PR linked? -- so once a page yields one, later pages can
|
|
550
|
+
# only add more URLs to a message, never change the
|
|
551
|
+
# decision: stop paying for them.
|
|
552
|
+
break
|
|
356
553
|
if len(events) < TIMELINE_PAGE_SIZE:
|
|
357
554
|
break # short page: the timeline is exhausted
|
|
358
555
|
# One PR can cross-reference the issue more than once (body edit,
|