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.
Files changed (32) hide show
  1. {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
  2. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/__main__.py +9 -0
  3. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/config.py +127 -0
  4. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/ghclient.py +95 -0
  5. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/loop.py +463 -1
  6. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/state.py +65 -0
  7. alissa_tools_github_revloop-0.27.0/src/main/alissa/tools/github/revloop/version +1 -0
  8. {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
  9. {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
  10. {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
  11. {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
  12. alissa_tools_github_revloop-0.25.0/src/main/alissa/tools/github/revloop/version +0 -1
  13. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/LICENSE +0 -0
  14. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/MANIFEST.in +0 -0
  15. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/NOTICE +0 -0
  16. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/README.md +0 -0
  17. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/requirements.txt +0 -0
  18. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/setup.cfg +0 -0
  19. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/setup.py +0 -0
  20. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  21. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  22. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  23. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  24. {alissa_tools_github_revloop-0.25.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  25. {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
  26. {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
  27. {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
  28. {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
  29. {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
  30. {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
  31. {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
  32. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.25.0
3
+ Version: 0.27.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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",