alissa-tools-github-revloop 0.25.0__tar.gz → 0.27.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_revloop-0.25.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.27.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/__main__.py +9 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/config.py +127 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/ghclient.py +95 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/loop.py +463 -1
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/state.py +65 -0
- alissa_tools_github_revloop-0.27.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +6 -4
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/page.py +64 -11
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/sources.py +229 -16
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.25.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -101,6 +101,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
101
101
|
)
|
|
102
102
|
over.add_argument("--poll-interval", type=int, metavar="SECONDS")
|
|
103
103
|
over.add_argument("--round-cap", type=int, metavar="N", help="CR9 round cap")
|
|
104
|
+
over.add_argument(
|
|
105
|
+
"--stability-rounds",
|
|
106
|
+
type=int,
|
|
107
|
+
metavar="N",
|
|
108
|
+
help="PRODUCT-STABILITY GUARD: hold the loop once the shipped-product "
|
|
109
|
+
"diff has been empty for N consecutive request_changes rounds (0 "
|
|
110
|
+
"disables the guard entirely)",
|
|
111
|
+
)
|
|
104
112
|
over.add_argument("--hub-template", metavar="TEMPLATE")
|
|
105
113
|
over.add_argument("--agent-profile", metavar="NAME")
|
|
106
114
|
over.add_argument("--reviewer-login", metavar="LOGIN")
|
|
@@ -218,6 +226,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
218
226
|
"operators": tuple(args.operators) if args.operators else None,
|
|
219
227
|
"poll_interval": args.poll_interval,
|
|
220
228
|
"round_cap": args.round_cap,
|
|
229
|
+
"stability_rounds": args.stability_rounds,
|
|
221
230
|
"hub_template": args.hub_template,
|
|
222
231
|
"agent_profile": args.agent_profile,
|
|
223
232
|
"reviewer_login": args.reviewer_login,
|
|
@@ -21,6 +21,7 @@ daemons over different workspaces on the same machine, each pointed with
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
+
import fnmatch
|
|
24
25
|
import json
|
|
25
26
|
import os
|
|
26
27
|
import re
|
|
@@ -127,6 +128,8 @@ CONFIG_KEYS = (
|
|
|
127
128
|
"hub_template",
|
|
128
129
|
"poll_interval",
|
|
129
130
|
"round_cap",
|
|
131
|
+
"stability_rounds",
|
|
132
|
+
"stability_nonshipped_globs",
|
|
130
133
|
"repos",
|
|
131
134
|
"authors",
|
|
132
135
|
"operators",
|
|
@@ -276,6 +279,105 @@ DEFAULT_CHECKS_SPAWN_WAIT_SECONDS = 15 * 60
|
|
|
276
279
|
DEFAULT_REVIEW_TASK_MISS_TTL_POLLS = 10
|
|
277
280
|
|
|
278
281
|
|
|
282
|
+
# -- the product-stability guard (issue #105) ---------------------------------
|
|
283
|
+
#
|
|
284
|
+
# How many consecutive `request_changes` rounds must move NOTHING outside
|
|
285
|
+
# `stability_nonshipped_globs` before the loop stops spending rounds on a PR
|
|
286
|
+
# whose shipped product has converged. Measured on the live fleet: of the PRs
|
|
287
|
+
# sampled on 2026-08-28/29, none that was still unapproved at round 5 ever
|
|
288
|
+
# needed a shipped-code change again -- studio #847 ran eleven further rounds
|
|
289
|
+
# (~7 h, ~22 agent sessions) moving only `tests/**`.
|
|
290
|
+
#
|
|
291
|
+
# 3 rather than 1 because ONE empty round is ordinary: a round that only asked
|
|
292
|
+
# for a comment to be reworded is a normal round, and holding on it would turn
|
|
293
|
+
# the guard into a second cap. Three consecutive ones is a pattern.
|
|
294
|
+
#
|
|
295
|
+
# 0 disables the guard, and disables it COMPLETELY: no compare call, no
|
|
296
|
+
# directive block, no hold, every decision bit-identical to the world before
|
|
297
|
+
# this key existed. That is the escape hatch a deployment that dislikes the
|
|
298
|
+
# guard uses, and it is why the gate is skipped whole rather than degraded into
|
|
299
|
+
# a no-op.
|
|
300
|
+
DEFAULT_STABILITY_ROUNDS = 3
|
|
301
|
+
|
|
302
|
+
# Path globs whose movement is NOT product movement. Deliberately conservative:
|
|
303
|
+
# a file that is not obviously a test, a doc or a generated artifact counts as
|
|
304
|
+
# shipped, because the guard's two failure directions are not symmetric --
|
|
305
|
+
# calling shipped code non-shipped can hold a PR that still needs work, while
|
|
306
|
+
# calling a test file shipped only costs another round.
|
|
307
|
+
DEFAULT_STABILITY_NONSHIPPED_GLOBS = (
|
|
308
|
+
"tests/**",
|
|
309
|
+
"test/**",
|
|
310
|
+
"**/*.test.*",
|
|
311
|
+
"**/*.spec.*",
|
|
312
|
+
"**/*.md",
|
|
313
|
+
"docs/**",
|
|
314
|
+
"**/__snapshots__/**",
|
|
315
|
+
"**/_generated/**",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def glob_matches(path: str, pattern: str) -> bool:
|
|
320
|
+
"""Does `path` match `pattern`, with `**` crossing directory separators?
|
|
321
|
+
|
|
322
|
+
Neither obvious implementation does what these globs mean:
|
|
323
|
+
|
|
324
|
+
* `pathlib.PurePosixPath.match` treats `**` as a single component, so
|
|
325
|
+
`**/*.test.*` misses `src/a/b/x.test.ts` -- the exact nesting the guard
|
|
326
|
+
has to recognise, and the miss is silent (the file reads as shipped and
|
|
327
|
+
the PR simply never stabilises);
|
|
328
|
+
* bare `fnmatch` goes the other way -- its `*` crosses `/` too, so `docs/*`
|
|
329
|
+
would swallow `docs/a/b/c` -- and, worse for the defaults here, `**/*.md`
|
|
330
|
+
then REQUIRES a `/` and does not match a top-level `README.md`.
|
|
331
|
+
|
|
332
|
+
So the pattern is matched SEGMENT by segment: `**` matches zero or more
|
|
333
|
+
whole segments (which is what makes `**/*.md` cover both `README.md` and
|
|
334
|
+
`docs/a/b.md`), and every other segment is an ordinary `fnmatch` pattern
|
|
335
|
+
that cannot cross a separator. A trailing `**` matches the whole remainder,
|
|
336
|
+
including none of it.
|
|
337
|
+
|
|
338
|
+
Case-SENSITIVE (`fnmatchcase`): `fnmatch.fnmatch` normalises by platform, so
|
|
339
|
+
the same config would classify `Docs/x.MD` one way on a developer's macOS
|
|
340
|
+
checkout and another in the Linux container. Repo paths are case-sensitive
|
|
341
|
+
on the side that matters -- GitHub's.
|
|
342
|
+
"""
|
|
343
|
+
return _match_segments(
|
|
344
|
+
tuple(part for part in str(path).split("/") if part != ""),
|
|
345
|
+
tuple(str(pattern).split("/")),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _match_segments(
|
|
350
|
+
path_parts: "tuple[str, ...]", pat_parts: "tuple[str, ...]"
|
|
351
|
+
) -> bool:
|
|
352
|
+
"""The recursion behind `glob_matches`. Branching is bounded by the pattern:
|
|
353
|
+
only a `**` branches, the defaults carry at most one, and a pattern is
|
|
354
|
+
operator-written config rather than repo-controlled text."""
|
|
355
|
+
if not pat_parts:
|
|
356
|
+
return not path_parts
|
|
357
|
+
head, rest = pat_parts[0], pat_parts[1:]
|
|
358
|
+
if head == "**":
|
|
359
|
+
if not rest:
|
|
360
|
+
return True # the whole remainder, including nothing
|
|
361
|
+
return any(
|
|
362
|
+
_match_segments(path_parts[i:], rest) for i in range(len(path_parts) + 1)
|
|
363
|
+
)
|
|
364
|
+
if not path_parts:
|
|
365
|
+
return False
|
|
366
|
+
if not fnmatch.fnmatchcase(path_parts[0], head):
|
|
367
|
+
return False
|
|
368
|
+
return _match_segments(path_parts[1:], rest)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def is_nonshipped(path: str, globs: "tuple[str, ...]") -> bool:
|
|
372
|
+
"""Whether a changed path is one the stability guard ignores.
|
|
373
|
+
|
|
374
|
+
An EMPTY glob tuple means nothing is non-shipped, so every changed file is
|
|
375
|
+
product movement and the guard can never hold -- the same fail-toward-rounds
|
|
376
|
+
direction every other unknown in the guard takes.
|
|
377
|
+
"""
|
|
378
|
+
return any(glob_matches(path, pattern) for pattern in globs)
|
|
379
|
+
|
|
380
|
+
|
|
279
381
|
def default_state_path(workspace_root: Path) -> Path:
|
|
280
382
|
return Path(workspace_root) / ".revloop" / "state.db"
|
|
281
383
|
|
|
@@ -289,6 +391,15 @@ class Config:
|
|
|
289
391
|
poll_interval: int = 60
|
|
290
392
|
round_cap: int = 10 # CR9 default
|
|
291
393
|
|
|
394
|
+
# How many consecutive request_changes rounds with an EMPTY shipped-product
|
|
395
|
+
# diff make the loop stop spawning plain rounds; see
|
|
396
|
+
# DEFAULT_STABILITY_ROUNDS. 0 disables the guard entirely.
|
|
397
|
+
stability_rounds: int = DEFAULT_STABILITY_ROUNDS
|
|
398
|
+
|
|
399
|
+
# The path globs that count as non-shipped when the guard measures that
|
|
400
|
+
# diff; see DEFAULT_STABILITY_NONSHIPPED_GLOBS and `glob_matches`.
|
|
401
|
+
stability_nonshipped_globs: tuple[str, ...] = DEFAULT_STABILITY_NONSHIPPED_GLOBS
|
|
402
|
+
|
|
292
403
|
# Empty tuple means "every repo that requests a review from me".
|
|
293
404
|
repos: tuple[str, ...] = ()
|
|
294
405
|
|
|
@@ -524,6 +635,20 @@ class Config:
|
|
|
524
635
|
if cap < 1:
|
|
525
636
|
raise ValueError(f"round_cap must be >= 1, got {cap}")
|
|
526
637
|
|
|
638
|
+
stability = int(raw.get("stability_rounds", cls.stability_rounds))
|
|
639
|
+
if stability < 0:
|
|
640
|
+
# 0 is legal and means "off", unlike round_cap's floor of 1: the
|
|
641
|
+
# guard is an ADDITION to the loop, so switching it off has to be
|
|
642
|
+
# expressible, and the value that expresses it is the one asking
|
|
643
|
+
# for zero stable rounds.
|
|
644
|
+
raise ValueError(f"stability_rounds must be >= 0, got {stability}")
|
|
645
|
+
|
|
646
|
+
nonshipped = _string_list(
|
|
647
|
+
raw.get("stability_nonshipped_globs", cls.stability_nonshipped_globs),
|
|
648
|
+
"stability_nonshipped_globs",
|
|
649
|
+
"path globs",
|
|
650
|
+
)
|
|
651
|
+
|
|
527
652
|
interval = int(raw.get("poll_interval", 60))
|
|
528
653
|
if interval < MIN_POLL_INTERVAL:
|
|
529
654
|
raise ValueError(
|
|
@@ -637,6 +762,8 @@ class Config:
|
|
|
637
762
|
hub_template=raw.get("hub_template", cls.hub_template),
|
|
638
763
|
poll_interval=interval,
|
|
639
764
|
round_cap=cap,
|
|
765
|
+
stability_rounds=stability,
|
|
766
|
+
stability_nonshipped_globs=nonshipped,
|
|
640
767
|
repos=repos,
|
|
641
768
|
authors=authors,
|
|
642
769
|
operators=operators,
|
|
@@ -42,6 +42,33 @@ SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
|
|
|
42
42
|
PER_PAGE = 100
|
|
43
43
|
COMMENT_PAGE_LIMIT = 20
|
|
44
44
|
|
|
45
|
+
# The compare endpoint's HARD cap on the `files` array, and it is a cap, not a
|
|
46
|
+
# page: `files` is not paginated at all. Measured against api.github.com on
|
|
47
|
+
# python/cpython v3.11.0...v3.12.0 (PR #106 round 1, blocker):
|
|
48
|
+
#
|
|
49
|
+
# per_page=300 page=1 -> {"commits": 300, "files": 300}
|
|
50
|
+
# per_page=300 page=2 -> {"commits": 300, "files": 0}
|
|
51
|
+
# per_page=100 page=1 -> {"commits": 100, "files": 300}
|
|
52
|
+
# per_page=1 page=1 -> {"commits": 1, "files": 300}
|
|
53
|
+
#
|
|
54
|
+
# So `per_page` sizes the COMMITS array and nothing else, and page 2 carries no
|
|
55
|
+
# files because there is no second page of them. A reader that paged until a
|
|
56
|
+
# short page would take that empty page 2 as "the listing ended" and hand back
|
|
57
|
+
# exactly 300 paths as a complete diff -- and `files` comes back in PATH ORDER,
|
|
58
|
+
# so what survives is the alphabetically first 300, which is where `docs/**` and
|
|
59
|
+
# `**/*.md` live and where `src/**` does not. Truncation therefore does not
|
|
60
|
+
# sample the diff, it systematically keeps the non-shipped files and drops the
|
|
61
|
+
# shipped ones -- inverting the one invariant the stability guard rests on.
|
|
62
|
+
#
|
|
63
|
+
# Hence: ONE request, and a full 300 means "cannot prove absence" (see
|
|
64
|
+
# `compare_files`).
|
|
65
|
+
COMPARE_FILE_PAGE = 300
|
|
66
|
+
|
|
67
|
+
# `per_page` only sizes the commits array, which no caller here reads, so it is
|
|
68
|
+
# asked for the smallest legal value rather than for a page that matches the
|
|
69
|
+
# file cap. 300 commits per evaluation was payload nobody looked at.
|
|
70
|
+
COMPARE_COMMITS_PER_PAGE = 1
|
|
71
|
+
|
|
45
72
|
# GitHub's OWN cap on the pull-request commits endpoint: it "lists a maximum of
|
|
46
73
|
# 250 commits" and refers callers with more to the repository commits endpoint.
|
|
47
74
|
# That, not a page count of ours, is where absence stops being provable -- a
|
|
@@ -1196,6 +1223,74 @@ class GitHub:
|
|
|
1196
1223
|
)
|
|
1197
1224
|
return out
|
|
1198
1225
|
|
|
1226
|
+
def compare_files(
|
|
1227
|
+
self, owner: str, repo: str, base_sha: str, head_sha: str
|
|
1228
|
+
) -> list[str]:
|
|
1229
|
+
"""Every path that differs between two commits, in the order GitHub
|
|
1230
|
+
returns them (path order).
|
|
1231
|
+
|
|
1232
|
+
Read on ONE path: the product-stability guard, which asks whether the
|
|
1233
|
+
diff between the head a round judged N rounds ago and the head now
|
|
1234
|
+
contains anything outside the non-shipped globs. ABSENCE is therefore
|
|
1235
|
+
the load-bearing answer, and this endpoint cannot support it past
|
|
1236
|
+
COMPARE_FILE_PAGE files -- the array is capped, not paged, so a
|
|
1237
|
+
comparison at the cap is indistinguishable from one just under it. At
|
|
1238
|
+
the cap the read is REFUSED (`TruncatedListing`) rather than answered
|
|
1239
|
+
short; the caller turns that into an inert guard and another round.
|
|
1240
|
+
|
|
1241
|
+
ONE request. An earlier version paged until a short page, which on this
|
|
1242
|
+
endpoint always arrived on page 2 with zero files -- see
|
|
1243
|
+
COMPARE_FILE_PAGE for the measurement and for why the truncation is
|
|
1244
|
+
biased rather than random.
|
|
1245
|
+
|
|
1246
|
+
A RENAME contributes BOTH names. GitHub reports it as one entry whose
|
|
1247
|
+
`filename` is the new path and whose `previous_filename` is the old one,
|
|
1248
|
+
and only counting the new one loses exactly the case the guard must not
|
|
1249
|
+
miss: `src/thing.ts` renamed to `tests/thing.test.ts` moved shipped
|
|
1250
|
+
code, and reading only the destination makes it look like a test-only
|
|
1251
|
+
change.
|
|
1252
|
+
|
|
1253
|
+
403 is NOT caught here (`forbidden_is_rate_limit=False` lets it through
|
|
1254
|
+
as a CommandError): whether an unreadable comparison is fatal or merely
|
|
1255
|
+
makes a guard inert is the caller's decision, not this client's -- and
|
|
1256
|
+
this credential is the same PAT that cannot read check-runs, so it is a
|
|
1257
|
+
live case rather than a theoretical one.
|
|
1258
|
+
"""
|
|
1259
|
+
payload = (
|
|
1260
|
+
self._api(
|
|
1261
|
+
"-X",
|
|
1262
|
+
"GET",
|
|
1263
|
+
f"repos/{owner}/{repo}/compare/{base_sha}...{head_sha}",
|
|
1264
|
+
"-f",
|
|
1265
|
+
f"per_page={COMPARE_COMMITS_PER_PAGE}",
|
|
1266
|
+
forbidden_is_rate_limit=False,
|
|
1267
|
+
)
|
|
1268
|
+
or {}
|
|
1269
|
+
)
|
|
1270
|
+
files = payload.get("files") or []
|
|
1271
|
+
if len(files) >= COMPARE_FILE_PAGE:
|
|
1272
|
+
log.warning(
|
|
1273
|
+
"%s/%s comparison %s...%s reported %d files, the API's "
|
|
1274
|
+
"per-comparison cap — the file list is not complete, so "
|
|
1275
|
+
"'nothing shipped moved' cannot be read from it",
|
|
1276
|
+
owner, repo, base_sha[:8], head_sha[:8], len(files),
|
|
1277
|
+
)
|
|
1278
|
+
raise TruncatedListing(
|
|
1279
|
+
f"{owner}/{repo} comparison {base_sha[:8]}...{head_sha[:8]} "
|
|
1280
|
+
f"reported {len(files)} files, the API's per-comparison cap; "
|
|
1281
|
+
f"absence cannot be proven from it"
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
out: list[str] = []
|
|
1285
|
+
seen: set[str] = set()
|
|
1286
|
+
for entry in files:
|
|
1287
|
+
for key in ("filename", "previous_filename"):
|
|
1288
|
+
name = str(entry.get(key) or "")
|
|
1289
|
+
if name and name not in seen:
|
|
1290
|
+
seen.add(name)
|
|
1291
|
+
out.append(name)
|
|
1292
|
+
return out
|
|
1293
|
+
|
|
1199
1294
|
def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> None:
|
|
1200
1295
|
self._api(
|
|
1201
1296
|
"-X",
|