alissa-tools-github-revloop 0.31.5__tar.gz → 0.31.7__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.31.5/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.7}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/alissa.py +188 -15
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/config.py +110 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/ghclient.py +15 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/loop.py +633 -19
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/loop_events.py +188 -3
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/prompts.py +207 -6
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/prreview.py +9 -4
- alissa_tools_github_revloop-0.31.7/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.31.5/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/trust.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -248,12 +248,23 @@ _VERDICT_RE = re.compile(
|
|
|
248
248
|
# own grammar; the reason runs to the end of the line and no further.
|
|
249
249
|
READINESS_AUTO = "auto"
|
|
250
250
|
READINESS_OPERATOR = "operator"
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _envelope_line_re(key: str) -> "re.Pattern[str]":
|
|
254
|
+
"""The tolerant envelope-line grammar for one readiness KEY. Two keys
|
|
255
|
+
share it (issue #148): `Merge-Readiness` on a code PR, `Commit-Readiness`
|
|
256
|
+
on a plan PR -- the same markdown tolerance, the same case-sensitive
|
|
257
|
+
value, the same first-match-wins."""
|
|
258
|
+
return re.compile(
|
|
259
|
+
r"^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?[ \t]*" + re.escape(key)
|
|
260
|
+
+ r"[ \t]*:?[ \t]*(?:\*\*)?"
|
|
261
|
+
r"[ \t]*:?[ \t]*(?:\*\*)?(auto|operator)\b(?:\*\*)?"
|
|
262
|
+
r"(?:[ \t]*[—–-][ \t]*(.*?))?[ \t]*$",
|
|
263
|
+
re.MULTILINE,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
_READINESS_RE = _envelope_line_re("Merge-Readiness")
|
|
257
268
|
|
|
258
269
|
# The operator reason's CLASS (issue #142). An `operator` judgment names WHY
|
|
259
270
|
# the merge is a human's, and until now it did so in prose the merge edge
|
|
@@ -343,11 +354,19 @@ def _classed(value: "str | None", raw_reason: object) -> "tuple[str | None, str,
|
|
|
343
354
|
# construction, a line this regex reads.
|
|
344
355
|
READINESS_TRAILER_LABEL = "Merge-Readiness:"
|
|
345
356
|
READINESS_MISSING = "missing"
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _trailer_line_re(label: str) -> "re.Pattern[str]":
|
|
360
|
+
"""The strict bare-line grammar for one trailer LABEL (see _TRAILER_RE);
|
|
361
|
+
shared by the Merge- and the Commit-Readiness trailers (issue #148)."""
|
|
362
|
+
return re.compile(
|
|
363
|
+
r"^" + re.escape(label)
|
|
364
|
+
+ r"[ \t]*(auto|operator)(?:[ \t]*[—-][ \t]*(.+))?[ \t]*$",
|
|
365
|
+
re.MULTILINE,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
_TRAILER_RE = _trailer_line_re(READINESS_TRAILER_LABEL)
|
|
351
370
|
|
|
352
371
|
|
|
353
372
|
def parse_trailer(body: object) -> "tuple[str | None, str, str | None]":
|
|
@@ -388,6 +407,102 @@ def parse_readiness(blob: object) -> "tuple[str | None, str, str | None]":
|
|
|
388
407
|
return _classed(match.group(1), match.group(2))
|
|
389
408
|
|
|
390
409
|
|
|
410
|
+
# The Commit-Readiness judgment on a PLAN PR (issue #148; genloop design D8,
|
|
411
|
+
# §6.3, §7.1). A plan PR is never a code merge candidate: what its approve
|
|
412
|
+
# decides is whether merging the plan COMMITS its tasks, and orcloop's plan
|
|
413
|
+
# close edge reads that off this trailer the way its merge edge reads
|
|
414
|
+
# Merge-Readiness -- the same grammar with the other key:
|
|
415
|
+
#
|
|
416
|
+
# Commit-Readiness: auto
|
|
417
|
+
# Commit-Readiness: operator — <class>: <one-line reason>
|
|
418
|
+
#
|
|
419
|
+
# Eleven classes in SEVERITY order, the enum orcloop copies verbatim: the
|
|
420
|
+
# first six never auto-commit under any policy, the last five may. A task
|
|
421
|
+
# file's `commitClass` draws on the last ten -- `unanchored` is the
|
|
422
|
+
# refuter's alone. Same rules as the merge enum: exactly one class, the most
|
|
423
|
+
# severe applicable; `auto` never carries one; an operator reason that leads
|
|
424
|
+
# with no recognised token parses and reads as UNCLASSED (the hard hold).
|
|
425
|
+
#
|
|
426
|
+
# One rule is STRICTER than the merge grammar, because the acceptance names
|
|
427
|
+
# it (c4): a class on an `auto` line is REFUSED WHOLE -- the line reads as
|
|
428
|
+
# missing and the fallback holds -- rather than read as a bare `auto` with a
|
|
429
|
+
# stray reason. An `auto` whose trailing text is class-shaped is a reviewer
|
|
430
|
+
# that could not decide between the two forms, and the commit policy must
|
|
431
|
+
# never resolve that doubt toward committing.
|
|
432
|
+
COMMIT_READINESS_KEY = "Commit-Readiness"
|
|
433
|
+
COMMIT_READINESS_CLASSES: "tuple[str, ...]" = (
|
|
434
|
+
"unanchored", # the anchor does not resolve, is closed, or the sources are not its class
|
|
435
|
+
"schema-migration", # a leaf's scope names **/schema.ts, **/migrations/**, a backfill or a migration
|
|
436
|
+
"auth-secrets", # auth, permission gates, tokens, credentials, secrets, deploy env vars
|
|
437
|
+
"pricing-billing", # pricing, credits, tiers, quotas, invoices, refunds
|
|
438
|
+
"customer-promise", # copy or behaviour a customer is told about
|
|
439
|
+
"product-surface", # a new screen, route, tool, command or exported type
|
|
440
|
+
"gate-verification", # leaves that verify an unverified operator's-gate item
|
|
441
|
+
"drift-fix", # leaves that make a lagging repo follow a fleet-wide contract change
|
|
442
|
+
"flaky-test", # leaves that fix, quarantine or pin a test the loop capped out on
|
|
443
|
+
"exhaust-followup", # leaves that carry [triage:later] replies and unpursued [minor]/[nit]
|
|
444
|
+
"doc-correction", # leaves confined to docs, READMEs, API.md prose, design-doc pins
|
|
445
|
+
)
|
|
446
|
+
# The six that stay operator's under every commit policy (design §7.1).
|
|
447
|
+
COMMIT_READINESS_OPERATOR_ONLY: "tuple[str, ...]" = COMMIT_READINESS_CLASSES[:6]
|
|
448
|
+
COMMIT_READINESS_TRAILER_LABEL = COMMIT_READINESS_KEY + ":"
|
|
449
|
+
_COMMIT_READINESS_RE = _envelope_line_re(COMMIT_READINESS_KEY)
|
|
450
|
+
_COMMIT_TRAILER_RE = _trailer_line_re(COMMIT_READINESS_TRAILER_LABEL)
|
|
451
|
+
_COMMIT_READINESS_CLASS_RE = re.compile(
|
|
452
|
+
r"^(" + "|".join(re.escape(klass) for klass in COMMIT_READINESS_CLASSES)
|
|
453
|
+
+ r")[ \t]*:"
|
|
454
|
+
)
|
|
455
|
+
# What "a class on an auto line" looks like: the reason leads with a
|
|
456
|
+
# `<token>:` -- a recognised class, a misspelt one or a merge-enum token
|
|
457
|
+
# alike. Deliberately wider than the enum, for the reason above.
|
|
458
|
+
_CLASS_SHAPED_RE = re.compile(r"^[A-Za-z][A-Za-z0-9-]*[ \t]*:")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def classify_commit_readiness_reason(value: object, reason: object) -> "str | None":
|
|
462
|
+
"""The COMMIT_READINESS_CLASSES token an `operator` reason leads with, or
|
|
463
|
+
None (auto, unclassed, misspelt, wrong case) -- classify_readiness_reason
|
|
464
|
+
over the commit enum."""
|
|
465
|
+
if value != READINESS_OPERATOR or not isinstance(reason, str):
|
|
466
|
+
return None
|
|
467
|
+
match = _COMMIT_READINESS_CLASS_RE.match(reason)
|
|
468
|
+
return match.group(1) if match else None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _commit_classed(
|
|
472
|
+
value: "str | None", raw_reason: object
|
|
473
|
+
) -> "tuple[str | None, str, str | None]":
|
|
474
|
+
"""The `(value, reason, klass)` triple both commit parsers hand back, with
|
|
475
|
+
the class-on-auto refusal applied."""
|
|
476
|
+
reason = clean_readiness_reason(raw_reason)
|
|
477
|
+
if value == READINESS_AUTO and _CLASS_SHAPED_RE.match(reason):
|
|
478
|
+
return (None, "", None)
|
|
479
|
+
return (value, reason, classify_commit_readiness_reason(value, reason))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def parse_commit_trailer(body: object) -> "tuple[str | None, str, str | None]":
|
|
483
|
+
"""`(value, reason, klass)` from the first bare `Commit-Readiness:` line of
|
|
484
|
+
a review body, or `(None, "", None)` -- parse_trailer with the other key,
|
|
485
|
+
plus the class-on-auto refusal."""
|
|
486
|
+
if not isinstance(body, str):
|
|
487
|
+
return (None, "", None)
|
|
488
|
+
match = _COMMIT_TRAILER_RE.search(body.replace("\r\n", "\n"))
|
|
489
|
+
if match is None:
|
|
490
|
+
return (None, "", None)
|
|
491
|
+
return _commit_classed(match.group(1), match.group(2))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def parse_commit_readiness(blob: object) -> "tuple[str | None, str, str | None]":
|
|
495
|
+
"""`(value, reason, klass)` from the first Commit-Readiness line of a
|
|
496
|
+
verdict envelope -- parse_readiness with the other key, plus the
|
|
497
|
+
class-on-auto refusal."""
|
|
498
|
+
if not isinstance(blob, str):
|
|
499
|
+
return (None, "", None)
|
|
500
|
+
match = _COMMIT_READINESS_RE.search(blob)
|
|
501
|
+
if match is None:
|
|
502
|
+
return (None, "", None)
|
|
503
|
+
return _commit_classed(match.group(1), match.group(2))
|
|
504
|
+
|
|
505
|
+
|
|
391
506
|
@dataclass(frozen=True)
|
|
392
507
|
class Task:
|
|
393
508
|
ref: str # TASK-<taskNumber>
|
|
@@ -467,6 +582,26 @@ def pane_path_argv(name: str) -> "list[str]":
|
|
|
467
582
|
]
|
|
468
583
|
|
|
469
584
|
|
|
585
|
+
# What `#{pane_current_command}` reads on a reviewer pane whose `claude` has
|
|
586
|
+
# EXITED (issue #146, devloop #140's set): the shell `alissa tmux new`
|
|
587
|
+
# launched it from, back in the foreground. A live reviewer reads `node`
|
|
588
|
+
# (Claude Code's runtime); a name here means the process the session was
|
|
589
|
+
# spawned for is gone, whatever the session listing says about liveness.
|
|
590
|
+
SHELL_COMMANDS = frozenset((
|
|
591
|
+
"bash", "sh", "zsh", "fish", "dash", "ksh", "ash", "tcsh", "csh",
|
|
592
|
+
))
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def pane_command_argv(name: str) -> "list[str]":
|
|
596
|
+
"""`tmux -S <socket> display-message -p -t =<real>: #{pane_current_command}`:
|
|
597
|
+
the foreground process of the session's active pane, by name."""
|
|
598
|
+
check_session_name(name)
|
|
599
|
+
return [
|
|
600
|
+
"tmux", "-S", tmux_socket(), "display-message", "-p",
|
|
601
|
+
"-t", tmux_target(name), "#{pane_current_command}",
|
|
602
|
+
]
|
|
603
|
+
|
|
604
|
+
|
|
470
605
|
def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
|
|
471
606
|
"""`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
|
|
472
607
|
from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
|
|
@@ -595,10 +730,19 @@ class ManagedSession:
|
|
|
595
730
|
return parse_session_name(self.name)
|
|
596
731
|
|
|
597
732
|
|
|
598
|
-
|
|
599
|
-
|
|
733
|
+
# The CR2 title's lead-in. A code PR's review task reads `Review PR …`; a PLAN
|
|
734
|
+
# PR's reads `Review plan <org>/<repo>#<n> (<plan id>)` (issue #148, genloop
|
|
735
|
+
# design §6.1) -- created downstream of nothing, since a plan has no origin
|
|
736
|
+
# task. Both leads resolve to the same PR, so one pattern serves the daemon's
|
|
737
|
+
# search, its cache check and `alissa-pr-review` alike.
|
|
738
|
+
REVIEW_TASK_TITLE_LEAD = r"^Review\s+(?:PR|plan)\s+"
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def review_task_title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
|
|
742
|
+
"""CR2 title convention: `Review PR <org>/<repo>#<n> (TASK-<origin>)`, or
|
|
743
|
+
`Review plan <org>/<repo>#<n> (<plan id>)` for a plan PR."""
|
|
600
744
|
return re.compile(
|
|
601
|
-
rf"
|
|
745
|
+
rf"{REVIEW_TASK_TITLE_LEAD}{re.escape(owner)}/{re.escape(repo)}#{number}\b",
|
|
602
746
|
re.IGNORECASE,
|
|
603
747
|
)
|
|
604
748
|
|
|
@@ -613,7 +757,7 @@ def is_review_task_for(owner: str, repo: str, number: int, task: "Task") -> bool
|
|
|
613
757
|
would either pin a wrong task forever or re-fetch the corpus every pass
|
|
614
758
|
while disagreeing with itself.
|
|
615
759
|
"""
|
|
616
|
-
return bool(
|
|
760
|
+
return bool(review_task_title_pattern(owner, repo, number).match(task.title)) and task.is_open
|
|
617
761
|
|
|
618
762
|
|
|
619
763
|
def _task_from_row(row: object) -> "Task | None":
|
|
@@ -656,11 +800,18 @@ class VerdictEnvelope:
|
|
|
656
800
|
the narration reads the class back off the emitted line -- so the fact
|
|
657
801
|
has one source, and a second copy here would be a reader-less field
|
|
658
802
|
that could only ever drift from it (PR #143 round 1).
|
|
803
|
+
|
|
804
|
+
`commit_readiness` / `commit_readiness_reason` are the same pair for the
|
|
805
|
+
envelope's `Commit-Readiness` line (issue #148), read off the same
|
|
806
|
+
evidence item; only a PLAN PR's native post reads them, and a code PR's
|
|
807
|
+
envelope simply has none.
|
|
659
808
|
"""
|
|
660
809
|
|
|
661
810
|
verdict: str
|
|
662
811
|
readiness: "str | None" = None
|
|
663
812
|
readiness_reason: str = ""
|
|
813
|
+
commit_readiness: "str | None" = None
|
|
814
|
+
commit_readiness_reason: str = ""
|
|
664
815
|
|
|
665
816
|
|
|
666
817
|
@dataclass(frozen=True)
|
|
@@ -1167,6 +1318,9 @@ class Alissa:
|
|
|
1167
1318
|
readiness, reason, _ = parse_readiness(content)
|
|
1168
1319
|
if readiness is None:
|
|
1169
1320
|
readiness, reason, _ = parse_readiness(title)
|
|
1321
|
+
commit, commit_reason, _ = parse_commit_readiness(content)
|
|
1322
|
+
if commit is None:
|
|
1323
|
+
commit, commit_reason, _ = parse_commit_readiness(title)
|
|
1170
1324
|
found.append(
|
|
1171
1325
|
(Alissa._created_key(item.get("createdAt")),
|
|
1172
1326
|
index,
|
|
@@ -1174,6 +1328,8 @@ class Alissa:
|
|
|
1174
1328
|
verdict=match.group(1).lower(),
|
|
1175
1329
|
readiness=readiness,
|
|
1176
1330
|
readiness_reason=reason,
|
|
1331
|
+
commit_readiness=commit,
|
|
1332
|
+
commit_readiness_reason=commit_reason,
|
|
1177
1333
|
))
|
|
1178
1334
|
)
|
|
1179
1335
|
break
|
|
@@ -1379,6 +1535,23 @@ class Alissa:
|
|
|
1379
1535
|
log.debug("could not read the pane path of %s (%s)", name, exc)
|
|
1380
1536
|
return ""
|
|
1381
1537
|
|
|
1538
|
+
def pane_command(self, name: str) -> str:
|
|
1539
|
+
"""The session's pane current command (`#{pane_current_command}`),
|
|
1540
|
+
or "" when tmux cannot answer. The first-turn death check's one
|
|
1541
|
+
liveness probe (issue #146): a reviewer whose pane reads a shell
|
|
1542
|
+
(`SHELL_COMMANDS`) has lost its `claude`, however alive the tmux
|
|
1543
|
+
session itself is. A READ: runs under dry-run too."""
|
|
1544
|
+
try:
|
|
1545
|
+
argv = pane_command_argv(name)
|
|
1546
|
+
except ValueError as exc:
|
|
1547
|
+
log.warning("pane_command refused: %s", exc)
|
|
1548
|
+
return ""
|
|
1549
|
+
try:
|
|
1550
|
+
return run(argv, timeout=15).strip()
|
|
1551
|
+
except CommandError as exc:
|
|
1552
|
+
log.debug("could not read the pane command of %s (%s)", name, exc)
|
|
1553
|
+
return ""
|
|
1554
|
+
|
|
1382
1555
|
def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
|
|
1383
1556
|
"""Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
|
|
1384
1557
|
-- on ONE managed session's pane. True when tmux accepted them.
|
|
@@ -181,6 +181,8 @@ CONFIG_KEYS = (
|
|
|
181
181
|
"bows_refresh_polls",
|
|
182
182
|
"bow_owners",
|
|
183
183
|
"authors",
|
|
184
|
+
"plan_repos",
|
|
185
|
+
"plan_authors",
|
|
184
186
|
"operators",
|
|
185
187
|
"agent_profile",
|
|
186
188
|
"reviewer_login",
|
|
@@ -231,6 +233,42 @@ REPOS_SOURCE_ENV = "ALISSA_REVIEW_REPOS_SOURCE"
|
|
|
231
233
|
BOWS_REFRESH_POLLS_ENV = "ALISSA_REVIEW_BOWS_REFRESH_POLLS"
|
|
232
234
|
BOW_OWNERS_ENV = "ALISSA_REVIEW_BOW_OWNERS"
|
|
233
235
|
|
|
236
|
+
# The plan directive's two allowlists (issue #148; genloop design §6.1, D12).
|
|
237
|
+
# A PR gets the PLAN directive only when all four signals hold, and two of
|
|
238
|
+
# them are fleet configuration: the repository is one of `plan_repos` and the
|
|
239
|
+
# author one of `plan_authors`. Same `|`/`,` rail as the bows keys, same
|
|
240
|
+
# env > file > default precedence, same blank-falls-through rule.
|
|
241
|
+
#
|
|
242
|
+
# The plans repository is CONFIGURATION, never a literal in the code (the
|
|
243
|
+
# operator's decision on issue #148 -- the design's own name for it is
|
|
244
|
+
# superseded): the default names the repository by NAME under the
|
|
245
|
+
# `<owner>` placeholder, which matches that name under any owner, and a
|
|
246
|
+
# deployment names its own with the variable. A test proves no plans-repo
|
|
247
|
+
# literal survives outside this module.
|
|
248
|
+
#
|
|
249
|
+
# FAIL-CLOSED, and deliberately unlike `authors`: an EMPTY `plan_authors`
|
|
250
|
+
# means NO PR is a plan PR -- every PR, the plans repository's included, is
|
|
251
|
+
# reviewed under the code directive. The plan directive is a different
|
|
252
|
+
# contract (no code rubric, a Commit-Readiness trailer the plan close edge
|
|
253
|
+
# commits on), so it must be switched on by naming the generator's login,
|
|
254
|
+
# never by default. An empty `plan_repos` (the variable set to a lone
|
|
255
|
+
# separator, say) means the same.
|
|
256
|
+
PLAN_REPOS_ENV = "ALISSA_REVIEW_PLAN_REPOS"
|
|
257
|
+
PLAN_AUTHORS_ENV = "ALISSA_REVIEW_PLAN_AUTHORS"
|
|
258
|
+
PLAN_REPO_OWNER_PLACEHOLDER = "<owner>"
|
|
259
|
+
DEFAULT_PLAN_REPO_NAME = "alissa-dark-factory-plans"
|
|
260
|
+
DEFAULT_PLAN_REPOS: "tuple[str, ...]" = (
|
|
261
|
+
f"{PLAN_REPO_OWNER_PLACEHOLDER}/{DEFAULT_PLAN_REPO_NAME}",
|
|
262
|
+
)
|
|
263
|
+
# `owner/repo`, or `<owner>/repo`; GitHub's own character sets.
|
|
264
|
+
_PLAN_REPO_RE = re.compile(
|
|
265
|
+
r"^(?:" + re.escape(PLAN_REPO_OWNER_PLACEHOLDER)
|
|
266
|
+
+ r"|[A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/[A-Za-z0-9._-]{1,100}\Z"
|
|
267
|
+
)
|
|
268
|
+
# A GitHub login, or a GitHub App's `<slug>[bot]` login.
|
|
269
|
+
_GITHUB_LOGIN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,38}(?:\[bot\])?\Z")
|
|
270
|
+
_LIST_SEPARATORS = re.compile(r"[|,]")
|
|
271
|
+
|
|
234
272
|
# The default Studio API base the loop-events client posts to. Mirrors
|
|
235
273
|
# alissa_client.DEFAULT_ENDPOINT (a test pins the two together); defined here
|
|
236
274
|
# too so config stays importable without the client module.
|
|
@@ -377,6 +415,30 @@ def env_bow_owners(environ: "Mapping[str, str] | None" = None) -> "str | None":
|
|
|
377
415
|
return _env_text(BOW_OWNERS_ENV, environ)
|
|
378
416
|
|
|
379
417
|
|
|
418
|
+
def env_plan_list(name: str, environ: "Mapping[str, str] | None" = None) -> "list[str] | None":
|
|
419
|
+
"""One plan allowlist rail (`PLAN_REPOS_ENV` / `PLAN_AUTHORS_ENV`) split on
|
|
420
|
+
`|` and `,`, or None when unset/blank. A value of separators only is an
|
|
421
|
+
EMPTY list, not None: the operator said something, and what they said is
|
|
422
|
+
"nothing" -- which for these keys is the fail-closed answer."""
|
|
423
|
+
raw = _env_text(name, environ)
|
|
424
|
+
if raw is None:
|
|
425
|
+
return None
|
|
426
|
+
return [part.strip() for part in _LIST_SEPARATORS.split(raw) if part.strip()]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _plan_list(value: Any, key: str, what: str, shape: "re.Pattern[str]") -> "tuple[str, ...]":
|
|
430
|
+
"""A plan allowlist as a tuple of validated entries. A malformed entry is
|
|
431
|
+
refused at load, naming it -- a typo in a trust gate must not read as an
|
|
432
|
+
entry that silently matches nothing."""
|
|
433
|
+
entries = _string_list(value, key, what)
|
|
434
|
+
bad = [entry for entry in entries if not shape.match(entry)]
|
|
435
|
+
if bad:
|
|
436
|
+
raise ValueError(
|
|
437
|
+
f"{key} must be a list of {what}, got {', '.join(repr(b) for b in bad)}"
|
|
438
|
+
)
|
|
439
|
+
return entries
|
|
440
|
+
|
|
441
|
+
|
|
380
442
|
def normalize_bow_owners(values: "Any") -> "tuple[str, ...]":
|
|
381
443
|
"""Validate the `bow_owners` layer value into the authority list.
|
|
382
444
|
|
|
@@ -768,6 +830,13 @@ class Config:
|
|
|
768
830
|
# want agent reviews.
|
|
769
831
|
authors: tuple[str, ...] = ()
|
|
770
832
|
|
|
833
|
+
# The plan directive's repository and author allowlists (issue #148); see
|
|
834
|
+
# PLAN_REPOS_ENV above. `plan_repos` entries are `owner/repo` or
|
|
835
|
+
# `<owner>/repo` (any owner); `plan_authors` EMPTY means no PR is a plan
|
|
836
|
+
# PR -- fail closed, the opposite of `authors`.
|
|
837
|
+
plan_repos: tuple[str, ...] = DEFAULT_PLAN_REPOS
|
|
838
|
+
plan_authors: tuple[str, ...] = ()
|
|
839
|
+
|
|
771
840
|
# GitHub logins whose re-entry ack may raise a capped PR's effective cap
|
|
772
841
|
# (loop.parse_reentry_ack). Empty -- the default -- means NO ack is ever
|
|
773
842
|
# honoured: the lever fails closed, because anyone who can comment on a PR
|
|
@@ -1007,6 +1076,29 @@ class Config:
|
|
|
1007
1076
|
return True
|
|
1008
1077
|
return login.lower() in {a.lower() for a in self.authors}
|
|
1009
1078
|
|
|
1079
|
+
def is_plan_repo(self, full_name: str) -> bool:
|
|
1080
|
+
"""Whether `owner/repo` is a plans repository -- signal one of four.
|
|
1081
|
+
|
|
1082
|
+
Case-insensitive, like `watches` under bows: GitHub names are. An
|
|
1083
|
+
entry whose owner is the `<owner>` placeholder matches its repository
|
|
1084
|
+
name under any owner, which is how the default names the plans
|
|
1085
|
+
repository without naming a fleet."""
|
|
1086
|
+
owner, _, name = full_name.partition("/")
|
|
1087
|
+
for entry in self.plan_repos:
|
|
1088
|
+
want_owner, _, want_name = entry.partition("/")
|
|
1089
|
+
if want_name.casefold() != name.casefold():
|
|
1090
|
+
continue
|
|
1091
|
+
if want_owner == PLAN_REPO_OWNER_PLACEHOLDER:
|
|
1092
|
+
return True
|
|
1093
|
+
if want_owner.casefold() == owner.casefold():
|
|
1094
|
+
return True
|
|
1095
|
+
return False
|
|
1096
|
+
|
|
1097
|
+
def is_plan_author(self, login: str) -> bool:
|
|
1098
|
+
"""Whether a PR by this login may be a plan PR -- signal two of four.
|
|
1099
|
+
EMPTY means NOBODY (fail closed; see the field)."""
|
|
1100
|
+
return bool(login) and login.lower() in {a.lower() for a in self.plan_authors}
|
|
1101
|
+
|
|
1010
1102
|
@classmethod
|
|
1011
1103
|
def build(
|
|
1012
1104
|
cls,
|
|
@@ -1095,6 +1187,13 @@ class Config:
|
|
|
1095
1187
|
env_owners = env_bow_owners(environ)
|
|
1096
1188
|
if env_owners is not None:
|
|
1097
1189
|
raw["bow_owners"] = env_owners
|
|
1190
|
+
# The plan allowlists (issue #148), same rail, same reason.
|
|
1191
|
+
env_plan_repos = env_plan_list(PLAN_REPOS_ENV, environ)
|
|
1192
|
+
if env_plan_repos is not None:
|
|
1193
|
+
raw["plan_repos"] = env_plan_repos
|
|
1194
|
+
env_plan_authors = env_plan_list(PLAN_AUTHORS_ENV, environ)
|
|
1195
|
+
if env_plan_authors is not None:
|
|
1196
|
+
raw["plan_authors"] = env_plan_authors
|
|
1098
1197
|
|
|
1099
1198
|
source = raw.get("repos_source", cls.repos_source)
|
|
1100
1199
|
if source not in _REPOS_SOURCES:
|
|
@@ -1145,6 +1244,15 @@ class Config:
|
|
|
1145
1244
|
operators = _string_list(
|
|
1146
1245
|
raw.get("operators", ()), "operators", "GitHub logins"
|
|
1147
1246
|
)
|
|
1247
|
+
plan_repos = _plan_list(
|
|
1248
|
+
raw.get("plan_repos", cls.plan_repos), "plan_repos",
|
|
1249
|
+
f"owner/repo (or {PLAN_REPO_OWNER_PLACEHOLDER}/repo) entries",
|
|
1250
|
+
_PLAN_REPO_RE,
|
|
1251
|
+
)
|
|
1252
|
+
plan_authors = _plan_list(
|
|
1253
|
+
raw.get("plan_authors", cls.plan_authors), "plan_authors",
|
|
1254
|
+
"GitHub logins", _GITHUB_LOGIN_RE,
|
|
1255
|
+
)
|
|
1148
1256
|
if hub_mode == HUB_ADD and not repos and source == REPOS_STATIC:
|
|
1149
1257
|
# Anyone who can request a review could otherwise cause an arbitrary
|
|
1150
1258
|
# repo to be cloned onto this machine and opened as an agent's cwd.
|
|
@@ -1344,6 +1452,8 @@ class Config:
|
|
|
1344
1452
|
bows_refresh_polls=refresh_polls,
|
|
1345
1453
|
bow_owners=bow_owners,
|
|
1346
1454
|
authors=authors,
|
|
1455
|
+
plan_repos=plan_repos,
|
|
1456
|
+
plan_authors=plan_authors,
|
|
1347
1457
|
operators=operators,
|
|
1348
1458
|
agent_profile=raw.get("agent_profile", "claude"),
|
|
1349
1459
|
reviewer_login=raw.get("reviewer_login"),
|
|
@@ -202,6 +202,13 @@ class PullRequest:
|
|
|
202
202
|
# `requested_teams` is a separate field and is deliberately not carried,
|
|
203
203
|
# because nothing in the loop may ever withdraw a team's request.
|
|
204
204
|
requested_reviewers: tuple[str, ...] = ()
|
|
205
|
+
# The head BRANCH name and the PR body, read off the same payload. Two of
|
|
206
|
+
# the four signals that make a PR a PLAN PR (issue #148, genloop design
|
|
207
|
+
# §6.1): a `PLAN-<stamp>-<slug>` head ref and the plan marker on a line of
|
|
208
|
+
# its own in the body. Both are PR-author text -- they select a directive
|
|
209
|
+
# and are never interpolated into one.
|
|
210
|
+
head_ref: str = ""
|
|
211
|
+
body: str = ""
|
|
205
212
|
|
|
206
213
|
@property
|
|
207
214
|
def full_name(self) -> str:
|
|
@@ -311,6 +318,11 @@ class CheckRollup:
|
|
|
311
318
|
# than the one it stands in for (see ACTIONS_FALLBACK_NOTE), so the answer
|
|
312
319
|
# carries which path produced it everywhere the rollup is reported.
|
|
313
320
|
via_actions_fallback: bool = False
|
|
321
|
+
# Every context read, passing ones included -- what a gate keyed on ONE
|
|
322
|
+
# named check reads (the plan-lint gate, issue #148). `failing` and
|
|
323
|
+
# `running` answer "may an approve claim this head?"; only this answers
|
|
324
|
+
# "did the check called X conclude success?".
|
|
325
|
+
contexts: tuple[CheckContext, ...] = ()
|
|
314
326
|
|
|
315
327
|
@property
|
|
316
328
|
def summary(self) -> str:
|
|
@@ -393,6 +405,7 @@ def rollup_of(
|
|
|
393
405
|
running=running,
|
|
394
406
|
total=len(contexts),
|
|
395
407
|
via_actions_fallback=via_actions_fallback,
|
|
408
|
+
contexts=tuple(contexts),
|
|
396
409
|
)
|
|
397
410
|
|
|
398
411
|
|
|
@@ -697,6 +710,8 @@ class GitHub:
|
|
|
697
710
|
for u in (data.get("requested_reviewers") or [])
|
|
698
711
|
if (u or {}).get("login")
|
|
699
712
|
),
|
|
713
|
+
head_ref=str((data.get("head") or {}).get("ref") or ""),
|
|
714
|
+
body=str(data.get("body") or ""),
|
|
700
715
|
)
|
|
701
716
|
|
|
702
717
|
def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|