alissa-tools-github-revloop 0.31.6__tar.gz → 0.31.8__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.6/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.8}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/alissa.py +151 -15
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/config.py +110 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/ghclient.py +15 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/loop.py +455 -16
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/loop_events.py +49 -1
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/prreview.py +9 -4
- alissa_tools_github_revloop-0.31.8/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.31.6/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/trust.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/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>
|
|
@@ -615,10 +730,19 @@ class ManagedSession:
|
|
|
615
730
|
return parse_session_name(self.name)
|
|
616
731
|
|
|
617
732
|
|
|
618
|
-
|
|
619
|
-
|
|
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."""
|
|
620
744
|
return re.compile(
|
|
621
|
-
rf"
|
|
745
|
+
rf"{REVIEW_TASK_TITLE_LEAD}{re.escape(owner)}/{re.escape(repo)}#{number}\b",
|
|
622
746
|
re.IGNORECASE,
|
|
623
747
|
)
|
|
624
748
|
|
|
@@ -633,7 +757,7 @@ def is_review_task_for(owner: str, repo: str, number: int, task: "Task") -> bool
|
|
|
633
757
|
would either pin a wrong task forever or re-fetch the corpus every pass
|
|
634
758
|
while disagreeing with itself.
|
|
635
759
|
"""
|
|
636
|
-
return bool(
|
|
760
|
+
return bool(review_task_title_pattern(owner, repo, number).match(task.title)) and task.is_open
|
|
637
761
|
|
|
638
762
|
|
|
639
763
|
def _task_from_row(row: object) -> "Task | None":
|
|
@@ -676,11 +800,18 @@ class VerdictEnvelope:
|
|
|
676
800
|
the narration reads the class back off the emitted line -- so the fact
|
|
677
801
|
has one source, and a second copy here would be a reader-less field
|
|
678
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.
|
|
679
808
|
"""
|
|
680
809
|
|
|
681
810
|
verdict: str
|
|
682
811
|
readiness: "str | None" = None
|
|
683
812
|
readiness_reason: str = ""
|
|
813
|
+
commit_readiness: "str | None" = None
|
|
814
|
+
commit_readiness_reason: str = ""
|
|
684
815
|
|
|
685
816
|
|
|
686
817
|
@dataclass(frozen=True)
|
|
@@ -1187,6 +1318,9 @@ class Alissa:
|
|
|
1187
1318
|
readiness, reason, _ = parse_readiness(content)
|
|
1188
1319
|
if readiness is None:
|
|
1189
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)
|
|
1190
1324
|
found.append(
|
|
1191
1325
|
(Alissa._created_key(item.get("createdAt")),
|
|
1192
1326
|
index,
|
|
@@ -1194,6 +1328,8 @@ class Alissa:
|
|
|
1194
1328
|
verdict=match.group(1).lower(),
|
|
1195
1329
|
readiness=readiness,
|
|
1196
1330
|
readiness_reason=reason,
|
|
1331
|
+
commit_readiness=commit,
|
|
1332
|
+
commit_readiness_reason=commit_reason,
|
|
1197
1333
|
))
|
|
1198
1334
|
)
|
|
1199
1335
|
break
|
|
@@ -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]:
|
|
@@ -26,6 +26,7 @@ from typing import Callable
|
|
|
26
26
|
|
|
27
27
|
from . import prompts as prompts_mod
|
|
28
28
|
from .alissa import (
|
|
29
|
+
COMMIT_READINESS_TRAILER_LABEL,
|
|
29
30
|
MANAGED_PREFIX,
|
|
30
31
|
SHELL_COMMANDS,
|
|
31
32
|
READINESS_AUTO,
|
|
@@ -41,6 +42,7 @@ from .alissa import (
|
|
|
41
42
|
Task,
|
|
42
43
|
TaskDetail,
|
|
43
44
|
is_review_task_for,
|
|
45
|
+
parse_commit_trailer,
|
|
44
46
|
parse_trailer,
|
|
45
47
|
session_repo_slug,
|
|
46
48
|
)
|
|
@@ -495,6 +497,30 @@ def readiness_trailer(envelope: "VerdictEnvelope | None") -> str:
|
|
|
495
497
|
return f"{READINESS_TRAILER_LABEL} {READINESS_OPERATOR} — {READINESS_MISSING_REASON}"
|
|
496
498
|
|
|
497
499
|
|
|
500
|
+
# The Commit-Readiness trailer (issue #148; genloop design D8, §6.3): what a
|
|
501
|
+
# PLAN PR's native APPROVE carries instead of Merge-Readiness. Same emitter
|
|
502
|
+
# rules as readiness_trailer -- APPROVE events only, the envelope's reason
|
|
503
|
+
# copied whole (class prefix included), and an envelope with no parseable
|
|
504
|
+
# line fails CLOSED to an UNCLASSED `operator` whose reason says why: the
|
|
505
|
+
# design's fallback, verbatim. A plan PR never carries Merge-Readiness: it is
|
|
506
|
+
# not a code merge candidate, and a stray `auto` there is a line orcloop's
|
|
507
|
+
# merge edge must never read on a plan.
|
|
508
|
+
COMMIT_READINESS_MISSING_REASON = "envelope carries no Commit-Readiness line"
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def commit_readiness_trailer(envelope: "VerdictEnvelope | None") -> str:
|
|
512
|
+
"""The line a plan PR's native APPROVE carries for `envelope`'s
|
|
513
|
+
Commit-Readiness judgment -- readiness_trailer over the other key."""
|
|
514
|
+
label = COMMIT_READINESS_TRAILER_LABEL
|
|
515
|
+
if envelope is not None and envelope.commit_readiness == READINESS_AUTO:
|
|
516
|
+
return f"{label} {READINESS_AUTO}"
|
|
517
|
+
if envelope is not None and envelope.commit_readiness == READINESS_OPERATOR:
|
|
518
|
+
if envelope.commit_readiness_reason:
|
|
519
|
+
return f"{label} {READINESS_OPERATOR} — {envelope.commit_readiness_reason}"
|
|
520
|
+
return f"{label} {READINESS_OPERATOR}"
|
|
521
|
+
return f"{label} {READINESS_OPERATOR} — {COMMIT_READINESS_MISSING_REASON}"
|
|
522
|
+
|
|
523
|
+
|
|
498
524
|
# Appended to the body when the round's review task is known.
|
|
499
525
|
_VERDICT_TASK_NOTE = ", and the CR6 verdict envelope is on `{task_ref}`"
|
|
500
526
|
|
|
@@ -1117,6 +1143,302 @@ ROUND_K_DIRECTIVE = (
|
|
|
1117
1143
|
+ _RELEASE_SLOT
|
|
1118
1144
|
)
|
|
1119
1145
|
|
|
1146
|
+
# -- the plan directive (issue #148; genloop design D8, D18, §6, §7.1) --------
|
|
1147
|
+
#
|
|
1148
|
+
# A PLAN PR is a generated plan -- one new directory under `plans/` holding
|
|
1149
|
+
# `plan.md` and one task file per task -- opened by the generator seat in the
|
|
1150
|
+
# plans repository. Merging it COMMITS its tasks, so its reviewer is the
|
|
1151
|
+
# refuter that makes a merged plan trustworthy, and the code rubric would
|
|
1152
|
+
# review that prose as if it were code. So a plan PR gets its own two round
|
|
1153
|
+
# directives, beside the code ones, and its own trailer.
|
|
1154
|
+
#
|
|
1155
|
+
# All FOUR signals, or it is not a plan PR (design §6.1): the repository is
|
|
1156
|
+
# in `plan_repos`, the author in `plan_authors` (both fleet configuration,
|
|
1157
|
+
# config.py -- `plan_authors` empty means nobody), the head ref matches the
|
|
1158
|
+
# generator's branch grammar, and the body carries the plan marker on a line
|
|
1159
|
+
# of its own. Any other PR in the plans repository -- a `TASK-` lane on its
|
|
1160
|
+
# linter or README -- and any PR by the generator's login elsewhere is
|
|
1161
|
+
# reviewed under the code directive: a plan author never pushes code, and if
|
|
1162
|
+
# it did, the code rubric is the safer reading.
|
|
1163
|
+
PLAN_HEAD_REF_RE = re.compile(r"^PLAN-\d{8}T\d{6}Z-[a-z0-9-]+\Z")
|
|
1164
|
+
PLAN_MARKER = "<!-- alissa-genloop:plan v1 -->"
|
|
1165
|
+
|
|
1166
|
+
# The plan's id (the directory name, `<yyyy-mm-dd>-<slug>`, design §5.1), as
|
|
1167
|
+
# the generator writes it into the PR body (`Alissa-Plan: <id>`, §2.14). It is
|
|
1168
|
+
# PR-author text, so it reaches the directive only through this grammar --
|
|
1169
|
+
# an id that does not match is not quoted at all, and the session is told to
|
|
1170
|
+
# read the id from `plan.md` instead.
|
|
1171
|
+
PLAN_ID_RE = re.compile(
|
|
1172
|
+
r"^Alissa-Plan:[ \t]*(\d{4}-\d{2}-\d{2}-[a-z0-9-]{3,48})[ \t]*$", re.MULTILINE
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
# The check run the round waits for (design D18). Its name is a string, and
|
|
1176
|
+
# that is the whole of the dependency on the lint lane: without the workflow
|
|
1177
|
+
# the run never appears and the round holds, which is fail-closed.
|
|
1178
|
+
PLAN_LINT_CHECK = "plan-lint"
|
|
1179
|
+
|
|
1180
|
+
# The ledger ping that marks round `<n>` of a PR as a PLAN round, so the loop
|
|
1181
|
+
# events derived from the ledger can carry `data.kind: "plan"` (issue #148
|
|
1182
|
+
# c6). Recorded when the plan gate first sees the round -- before any hold --
|
|
1183
|
+
# so the held round's `checks.held` is marked too. loop_events keeps a pinned
|
|
1184
|
+
# copy of the prefix (it cannot import this module).
|
|
1185
|
+
PLAN_ROUND_PREFIX = "plan-round:"
|
|
1186
|
+
ROUND_KIND_PLAN = "plan"
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def plan_round_kind(round_: int) -> str:
|
|
1190
|
+
"""The ping kind that marks round `round_` of a PR as a plan round."""
|
|
1191
|
+
return f"{PLAN_ROUND_PREFIX}{int(round_)}"
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def plan_signals(config: Config, pr: PullRequest) -> "dict[str, bool]":
|
|
1195
|
+
"""The four signals of design §6.1, by name, for the one predicate below
|
|
1196
|
+
and for the log line that says which one a near-miss lacked."""
|
|
1197
|
+
body = pr.body.replace("\r\n", "\n")
|
|
1198
|
+
return {
|
|
1199
|
+
"repo": config.is_plan_repo(pr.full_name),
|
|
1200
|
+
"author": config.is_plan_author(pr.author),
|
|
1201
|
+
"head_ref": bool(PLAN_HEAD_REF_RE.match(pr.head_ref)),
|
|
1202
|
+
"marker": any(line.strip() == PLAN_MARKER for line in body.split("\n")),
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def is_plan_pr(config: Config, pr: PullRequest) -> bool:
|
|
1207
|
+
"""All four signals hold -- the ONLY way a PR gets the plan directive."""
|
|
1208
|
+
return all(plan_signals(config, pr).values())
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def plan_id_of(pr: PullRequest) -> "str | None":
|
|
1212
|
+
"""The plan id the PR body declares, or None when it declares none in the
|
|
1213
|
+
grammar (see PLAN_ID_RE)."""
|
|
1214
|
+
match = PLAN_ID_RE.search(pr.body.replace("\r\n", "\n"))
|
|
1215
|
+
return match.group(1) if match else None
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def plan_review_task_title(pr: PullRequest) -> str:
|
|
1219
|
+
"""The CR2 title of a plan PR's review task (design §6.1)."""
|
|
1220
|
+
return f"Review plan {pr.slug} ({plan_id_of(pr) or '<plan id>'})"
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
# Who the reviewer is and which protocol it runs. The skill is QUOTED BY PATH,
|
|
1224
|
+
# never inlined: `alissa-code-review:references/plan-directive.md` is the
|
|
1225
|
+
# skill's own statement of the plan rubric (a sibling change in the skills
|
|
1226
|
+
# repository), and this directive states the parts the daemon must be able
|
|
1227
|
+
# to rely on whatever version of the skill the container carries.
|
|
1228
|
+
_PLAN_PROTOCOL = (
|
|
1229
|
+
"Load the alissa-code-review skill and run its protocol unchanged — fresh "
|
|
1230
|
+
"instance, CR1–CR9, one review task, severity-tagged comments via gh pr "
|
|
1231
|
+
"review, one verdict envelope, the task moved to pending_validation — but "
|
|
1232
|
+
"walk the PLAN rubric of "
|
|
1233
|
+
"`alissa-code-review:references/plan-directive.md` instead of the code "
|
|
1234
|
+
"rubric: this PR is a plan (one new directory under `plans/` holding "
|
|
1235
|
+
"`plan.md` and one `tasks/<id>.md` per task), not code. The review task is "
|
|
1236
|
+
"titled `Review plan <org>/<repo>#<n> (<plan id>)` and is created "
|
|
1237
|
+
"downstream of nothing — a plan has no origin task; the rest of CR2 is "
|
|
1238
|
+
"unchanged. "
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
# Nothing mechanical is the reviewer's (design D18): every deterministic
|
|
1242
|
+
# predicate is the plan-lint job's, and the daemon has already held the round
|
|
1243
|
+
# until that job's run on this head concluded success. v0.1's automatic
|
|
1244
|
+
# request_changes rules are gone from the directive -- each became a lint
|
|
1245
|
+
# rule, a close-edge rule or a rubric question -- and this clause is where
|
|
1246
|
+
# the directive says so rather than re-stating any of them as a rule.
|
|
1247
|
+
_PLAN_LINT_OWNS_THE_MECHANICS = (
|
|
1248
|
+
"NOTHING MECHANICAL IS YOURS. The daemon held this round until the "
|
|
1249
|
+
"`" + PLAN_LINT_CHECK + "` check run on this head concluded success, so "
|
|
1250
|
+
"every deterministic predicate — the frontmatter schema, an acyclic graph, "
|
|
1251
|
+
"edges that resolve, a scope on every task with no broad glob, one to "
|
|
1252
|
+
"eight tasks, an open anchor, a commitClass on every task, the generated "
|
|
1253
|
+
"graph and summary, a diff confined to one new plan directory — is the "
|
|
1254
|
+
"lint's and has passed. Do not re-check them and never request changes "
|
|
1255
|
+
"on one; read the `" + PLAN_LINT_CHECK + "` run like any other check under "
|
|
1256
|
+
"the CI GATE below. Your findings are judgment, and only the six "
|
|
1257
|
+
"questions below. "
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
# The refuter's checkout is the plans repository; the target code a plan
|
|
1261
|
+
# names lives elsewhere and is read remotely (design D19).
|
|
1262
|
+
_PLAN_READ_TARGETS = (
|
|
1263
|
+
"READ THE TARGET CODE REMOTELY. Your checkout is the plans repository — "
|
|
1264
|
+
"this PR's own. Every task names where it lands (`repo`, `scope`): read "
|
|
1265
|
+
"that code, its issues and its pull requests under the reviewer login, "
|
|
1266
|
+
"which can read every fed repository, with `gh api "
|
|
1267
|
+
"repos/<repo>/contents/<path>`, `gh search code`, `gh issue list --repo "
|
|
1268
|
+
"<repo>` and `gh pr list --repo <repo>`. NEVER clone a target repository. "
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
# The rubric: six questions, in this order, byte-pinned (design §6.2).
|
|
1272
|
+
PLAN_RUBRIC = (
|
|
1273
|
+
"THE RUBRIC — answer all six questions, in this order, each with one line "
|
|
1274
|
+
"in your verdict envelope and a finding wherever the answer is no: "
|
|
1275
|
+
"(1) Worth doing? Does the plan serve its anchor in a way the operator "
|
|
1276
|
+
"would recognise, and does *Why now* hold against the program's "
|
|
1277
|
+
"milestones and the direction? "
|
|
1278
|
+
"(2) Correctly split into tasks? Is each task one PR's worth for one "
|
|
1279
|
+
"worker with one definition of done; is nothing a milestone in disguise; "
|
|
1280
|
+
"is nothing split so fine that the pieces cannot be reviewed alone? "
|
|
1281
|
+
"(3) Scopes honest and narrow? Does each `scope` name the files the task "
|
|
1282
|
+
"will actually touch — the shared hotspots included (`convex/schema.ts`, "
|
|
1283
|
+
"`packages/client/src/**`, `API.md`) — and nothing it will not? The lint "
|
|
1284
|
+
"refuses the broad glob; you refuse the narrow lie. "
|
|
1285
|
+
"(4) Dependencies right? Are the edges the real order; is anything "
|
|
1286
|
+
"missing that would make two tasks collide on a scope; is anything hard "
|
|
1287
|
+
"that should be soft? The lint proves the graph resolves and is acyclic; "
|
|
1288
|
+
"you judge whether it is true. "
|
|
1289
|
+
"(5) Duplicates an open task? The same intent under another title — `gh "
|
|
1290
|
+
"issue list --repo <repo> --label alissa:develop --state all`, the "
|
|
1291
|
+
"merged-PR titles of the window and `GET /v1/tasks?status=…` for the fleet "
|
|
1292
|
+
"actor, read for every task's `repo`. "
|
|
1293
|
+
"(6) Consistent with the accepted design docs and the operator's "
|
|
1294
|
+
"direction? Does any task contradict a decision in the target repo's "
|
|
1295
|
+
"`docs/design/` (the `D<n>` rows, read with `gh api`) or a priority in the "
|
|
1296
|
+
"steering record; does it touch an `offLimits` topic or an unexpired "
|
|
1297
|
+
"`notNow[]` entry by another name (`GET /v1/factory/steering`, `GET "
|
|
1298
|
+
"/v1/factory/proposal-rules`)? "
|
|
1299
|
+
)
|
|
1300
|
+
|
|
1301
|
+
# Severity (design §6.2): the skill's tags and triage, minus the design-lane
|
|
1302
|
+
# cap -- a plan is this seat's shipped product.
|
|
1303
|
+
_PLAN_SEVERITY = (
|
|
1304
|
+
"Severity tags and the triage loop are the skill's (CR5, CR8, CR9), with "
|
|
1305
|
+
"one exception: the design-lane rule that caps findings on prose at "
|
|
1306
|
+
"`[minor]` does NOT apply — a plan is the shipped product of this review, "
|
|
1307
|
+
"and a wrong scope or a wrong dependency is a `[major]`. "
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
# The Commit-Readiness line (design §6.3, §7.1): the plan's merge decision,
|
|
1311
|
+
# in the Merge-Readiness grammar with the other key. The checklist and the
|
|
1312
|
+
# eleven-class table are the design's, condensed; alissa.py holds the enum
|
|
1313
|
+
# (COMMIT_READINESS_CLASSES) and test_readiness pins the two together.
|
|
1314
|
+
_COMMIT_READINESS_LINE = (
|
|
1315
|
+
"Your verdict envelope MUST carry `- **Commit-Readiness:** auto` or `- "
|
|
1316
|
+
"**Commit-Readiness:** operator — <class>: <one-line reason>` as the "
|
|
1317
|
+
"first line under `## Summary`, AND your OWN native review (every `gh pr "
|
|
1318
|
+
"review` form and the reviews-API POST alike) MUST END with the bare line "
|
|
1319
|
+
"`Commit-Readiness: auto` or `Commit-Readiness: operator — <class>: "
|
|
1320
|
+
"<one-line reason>` as its last non-empty line — plain text at the start "
|
|
1321
|
+
"of the line, not in backticks, not bold — byte-equal in value, class and "
|
|
1322
|
+
"reason to the envelope's line; the value is case-sensitive. Write NO "
|
|
1323
|
+
"Merge-Readiness line: a plan PR is never a code merge candidate. Decide "
|
|
1324
|
+
"the line by walking this checklist in order after the verdict; the first "
|
|
1325
|
+
"item that fires is the reason: "
|
|
1326
|
+
"(1) the verdict is request_changes → `operator — verdict is "
|
|
1327
|
+
"request_changes`; "
|
|
1328
|
+
"(2) the anchor is a milestone, criterion or objective and any leaf's "
|
|
1329
|
+
"shape is outside the five auto classes → the operator-only class that "
|
|
1330
|
+
"fits, `product-surface` when none is more specific; "
|
|
1331
|
+
"(3) any leaf's scope names `**/schema.ts`, `**/migrations/**`, a backfill "
|
|
1332
|
+
"or a migration → `schema-migration`; "
|
|
1333
|
+
"(4) auth, permission gates, secrets, credentials, tokens → "
|
|
1334
|
+
"`auth-secrets`; "
|
|
1335
|
+
"(5) pricing, credits, tiers, quotas, invoices → `pricing-billing`; "
|
|
1336
|
+
"(6) copy or behaviour a customer is told about (landing pages, offer "
|
|
1337
|
+
"prompts, WhatsApp wording, public pages) → `customer-promise`; "
|
|
1338
|
+
"(7) a waived `[major]`, an open `[blocker]` / `[major]`, or a cap-out → "
|
|
1339
|
+
"`operator — review outcome: …` with no class; "
|
|
1340
|
+
"(8) the plan's class (the most severe commitClass of its tasks) is "
|
|
1341
|
+
"higher than yours → the plan's class; "
|
|
1342
|
+
"(9) a judgment residual, stated → no class. "
|
|
1343
|
+
"Otherwise `auto`, which asserts the plan is one of the five auto classes "
|
|
1344
|
+
"and nothing above fired. `<class>` is exactly ONE token from this closed "
|
|
1345
|
+
"enum, lowercase and hyphenated, followed by a colon and the reason; the "
|
|
1346
|
+
"rows are in severity order, most severe first, and the first six never "
|
|
1347
|
+
"auto-commit: "
|
|
1348
|
+
"`unanchored` (the anchor does not resolve, is closed, or the sources do "
|
|
1349
|
+
"not belong to the class named); "
|
|
1350
|
+
"`schema-migration` (a leaf's scope names `**/schema.ts`, "
|
|
1351
|
+
"`**/migrations/**`, or its description says backfill, migration, "
|
|
1352
|
+
"rename, removal); "
|
|
1353
|
+
"`auth-secrets` (auth, permission gates, tokens, credentials, secrets, env "
|
|
1354
|
+
"vars a deploy must carry); "
|
|
1355
|
+
"`pricing-billing` (pricing, credits, tiers, quotas, invoices, refunds); "
|
|
1356
|
+
"`customer-promise` (copy or behaviour a customer is told about: landing "
|
|
1357
|
+
"pages, offer prompts, WhatsApp wording, public pages, emails); "
|
|
1358
|
+
"`product-surface` (a new screen, route, tool, command or exported type — "
|
|
1359
|
+
"new product, however small — and the default for a milestone- or "
|
|
1360
|
+
"objective-anchored plan whose leaves fit no row above); "
|
|
1361
|
+
"then the five that may: "
|
|
1362
|
+
"`gate-verification` (leaves that verify an unverified operator's-gate "
|
|
1363
|
+
"item with a test, a replay or a recorded smoke); "
|
|
1364
|
+
"`drift-fix` (leaves that make a lagging repo follow a fleet-wide "
|
|
1365
|
+
"contract change); "
|
|
1366
|
+
"`flaky-test` (leaves that fix, quarantine with a named owner, or pin a "
|
|
1367
|
+
"test the loop capped out on); "
|
|
1368
|
+
"`exhaust-followup` (leaves that carry `[triage:later]` replies and "
|
|
1369
|
+
"unpursued `[minor]` / `[nit]` findings on merged code); "
|
|
1370
|
+
"`doc-correction` (leaves confined to `docs/**`, READMEs, `API.md` prose "
|
|
1371
|
+
"and design-doc pins where the code did not move). "
|
|
1372
|
+
"Exactly one class, the most severe applicable row; your class is never "
|
|
1373
|
+
"lower than the plan's class. `auto` never carries a class — an `auto` "
|
|
1374
|
+
"line with a class is refused whole and read as no line at all — and is "
|
|
1375
|
+
"written only when the plan's class is one of the last five rows and "
|
|
1376
|
+
"nothing above fired: before writing it, check that every leaf's scope "
|
|
1377
|
+
"stays out of the hold paths, that no leaf adds a route, a tool, a table "
|
|
1378
|
+
"or a public type, and that every leaf's definition of done is a PR bar "
|
|
1379
|
+
"the loop already knows. When no row fits, write no class: an unclassed "
|
|
1380
|
+
"operator line is the correct hard hold, never a failure. No backticks in "
|
|
1381
|
+
"the reason. The daemon copies the envelope's line onto a native review "
|
|
1382
|
+
"only when it posts the verdict itself, and an envelope without the line "
|
|
1383
|
+
"posts as `operator — " + COMMIT_READINESS_MISSING_REASON + "`, with no "
|
|
1384
|
+
"class. "
|
|
1385
|
+
)
|
|
1386
|
+
|
|
1387
|
+
PLAN_ROUND_1_DIRECTIVE = (
|
|
1388
|
+
"You are a PLAN REVIEWER — the refuter of a generated plan — not an "
|
|
1389
|
+
"implementer and not its generator. {assignment} "
|
|
1390
|
+
+ _PLAN_PROTOCOL
|
|
1391
|
+
+ _PLAN_LINT_OWNS_THE_MECHANICS
|
|
1392
|
+
+ _PLAN_READ_TARGETS
|
|
1393
|
+
+ PLAN_RUBRIC
|
|
1394
|
+
+ _PLAN_SEVERITY
|
|
1395
|
+
+ _SHELL_RULE
|
|
1396
|
+
+ _REVIEWER_RULE
|
|
1397
|
+
+ _RECORD_THE_CAP
|
|
1398
|
+
+ _COMMIT_READINESS_LINE
|
|
1399
|
+
+ "{credential}"
|
|
1400
|
+
+ _CHECKS_BEFORE_VERDICT
|
|
1401
|
+
+ "{checks}"
|
|
1402
|
+
+ "{stability}"
|
|
1403
|
+
+ _CLOSE_THE_ROUND +
|
|
1404
|
+
"NEVER push commits, merge, or change PR state. "
|
|
1405
|
+
"Do NOT create further ali-* sessions. "
|
|
1406
|
+
+ _RELEASE_SLOT
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
PLAN_ROUND_K_DIRECTIVE = (
|
|
1410
|
+
"You are a PLAN REVIEWER — the refuter of a generated plan — not an "
|
|
1411
|
+
"implementer and not its generator — round {round} of a review loop (cap "
|
|
1412
|
+
"{cap}). {assignment} "
|
|
1413
|
+
+ _PLAN_PROTOCOL
|
|
1414
|
+
+ "Follow the skill's round-k section: verify the generator's triage "
|
|
1415
|
+
"reply to every prior finding on its thread, verify the amended plan "
|
|
1416
|
+
"directory against each, walk the full rubric again on the new head, "
|
|
1417
|
+
"record a round-{round} verdict envelope. "
|
|
1418
|
+
+ _PLAN_LINT_OWNS_THE_MECHANICS
|
|
1419
|
+
+ _PLAN_READ_TARGETS
|
|
1420
|
+
+ PLAN_RUBRIC
|
|
1421
|
+
+ _PLAN_SEVERITY
|
|
1422
|
+
+ _SHELL_RULE
|
|
1423
|
+
+ _REVIEWER_RULE
|
|
1424
|
+
+ _RECORD_THE_CAP
|
|
1425
|
+
+ _COMMIT_READINESS_LINE
|
|
1426
|
+
+ "{credential}"
|
|
1427
|
+
+ _CHECKS_BEFORE_VERDICT
|
|
1428
|
+
+ "{checks}"
|
|
1429
|
+
+ "{stability}"
|
|
1430
|
+
+ _CLOSE_THE_ROUND +
|
|
1431
|
+
"NEVER push commits, merge, or change PR state. "
|
|
1432
|
+
"Do NOT create further ali-* sessions. "
|
|
1433
|
+
+ _RELEASE_SLOT
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
# What the plan-lint gate tells the log and the console while it holds.
|
|
1437
|
+
PLAN_LINT_HELD = (
|
|
1438
|
+
"round {round} waits for {check} — the `{check}` run at {sha} is {state}; "
|
|
1439
|
+
"a plan round never starts before the lint is green"
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1120
1442
|
# The operator re-entry ack (issue #42). A capped PR is unreviewable until an
|
|
1121
1443
|
# operator says otherwise, and the cap-out message used to OFFER "re-enter with
|
|
1122
1444
|
# a fresh cap" without any mechanism existing: the only lever was raising
|
|
@@ -2119,6 +2441,10 @@ class ReviewWatcher:
|
|
|
2119
2441
|
# that resets each pass never reaches its bound and the diagnostic would
|
|
2120
2442
|
# report an eternal hold production never takes).
|
|
2121
2443
|
self._dry_run_check_waits: dict[tuple[str, int, int, str], float] = {}
|
|
2444
|
+
# (repo slug, number, head) the plan-lint gate has already announced
|
|
2445
|
+
# at INFO (issue #148). Process-lifetime and log-only: the hold itself
|
|
2446
|
+
# is re-decided from the rollup every poll.
|
|
2447
|
+
self._plan_lint_logged: set[tuple[str, int, str]] = set()
|
|
2122
2448
|
# The task corpus THIS poll pass already fetched, or None until some
|
|
2123
2449
|
# PR in it misses the review-task cache. `alissa task list` returns
|
|
2124
2450
|
# every non-terminal task this actor owns (hundreds of rows, ~250 KB)
|
|
@@ -3394,6 +3720,11 @@ class ReviewWatcher:
|
|
|
3394
3720
|
rollup = self.github.check_rollup(pr.owner, pr.repo, pr.head_sha)
|
|
3395
3721
|
sha, short = pr.head_sha, pr.head_sha[:8]
|
|
3396
3722
|
|
|
3723
|
+
if is_plan_pr(self.config, pr):
|
|
3724
|
+
held = self._gate_spawn_on_plan_lint(pr, round_, rollup)
|
|
3725
|
+
if held is not None:
|
|
3726
|
+
return SpawnChecks(hold=held)
|
|
3727
|
+
|
|
3397
3728
|
if rollup.state == CHECKS_RED:
|
|
3398
3729
|
failing = directive_data([
|
|
3399
3730
|
CHECKS_AT_SPAWN_FAILING.format(
|
|
@@ -3476,6 +3807,61 @@ class ReviewWatcher:
|
|
|
3476
3807
|
)
|
|
3477
3808
|
)
|
|
3478
3809
|
|
|
3810
|
+
def _gate_spawn_on_plan_lint(
|
|
3811
|
+
self, pr: PullRequest, round_: int, rollup: CheckRollup
|
|
3812
|
+
) -> "Decision | None":
|
|
3813
|
+
"""Hold a PLAN round until the `plan-lint` run on its head concluded
|
|
3814
|
+
success (issue #148; design D18, §6.1). None lets the round through
|
|
3815
|
+
to the ordinary CI gate.
|
|
3816
|
+
|
|
3817
|
+
UNBOUNDED, unlike the CI wait above it: that wait bounds a CI system
|
|
3818
|
+
that never reports, because a review must never be cancelled by one;
|
|
3819
|
+
a plan round owes nothing to a plan the lint has not passed. A
|
|
3820
|
+
`failure` is not the reviewer's to explain -- the generator reads it
|
|
3821
|
+
on its next pass and pushes a new head, which re-arms this gate. A
|
|
3822
|
+
MISSING run holds too (the lint lane is a soft dependency: without
|
|
3823
|
+
the workflow nothing ever passes, which is fail-closed), and so do an
|
|
3824
|
+
unreadable rollup and a `skipped` / `neutral` conclusion: only
|
|
3825
|
+
`success` proves the lint ran and passed.
|
|
3826
|
+
|
|
3827
|
+
The hold is the ordinary pre-spawn hold -- a QUEUED with
|
|
3828
|
+
`checks_held`, the `spawn_checks_holds` row that derives the
|
|
3829
|
+
`checks.held` loop event once per (PR, round, head) -- and the round
|
|
3830
|
+
is marked as a plan round in the ledger first, so that event carries
|
|
3831
|
+
`data.kind: "plan"`. One INFO line per (PR, head); repeats are DEBUG,
|
|
3832
|
+
because a lint failure can hold for as long as the generator takes.
|
|
3833
|
+
"""
|
|
3834
|
+
self._note_plan_round(pr, round_)
|
|
3835
|
+
runs = [c for c in rollup.contexts if c.name == PLAN_LINT_CHECK]
|
|
3836
|
+
if rollup.state == CHECKS_UNKNOWN:
|
|
3837
|
+
state = f"unreadable ({rollup.unreadable or 'no reason recorded'})"
|
|
3838
|
+
elif not runs:
|
|
3839
|
+
state = "missing"
|
|
3840
|
+
elif any(c.running for c in runs):
|
|
3841
|
+
state = "still running"
|
|
3842
|
+
elif all(c.conclusion == "success" for c in runs):
|
|
3843
|
+
return None
|
|
3844
|
+
else:
|
|
3845
|
+
state = ", ".join(sorted({c.conclusion for c in runs if c.conclusion}))
|
|
3846
|
+
|
|
3847
|
+
self._checks_wait_since(pr, round_)
|
|
3848
|
+
reason = PLAN_LINT_HELD.format(
|
|
3849
|
+
round=round_, check=PLAN_LINT_CHECK, sha=pr.head_sha[:8], state=state
|
|
3850
|
+
)
|
|
3851
|
+
seen = (pr.full_name, pr.number, pr.head_sha)
|
|
3852
|
+
level = logging.DEBUG if seen in self._plan_lint_logged else logging.INFO
|
|
3853
|
+
self._plan_lint_logged.add(seen)
|
|
3854
|
+
log.log(level, "%s: %s", pr.slug, reason)
|
|
3855
|
+
return Decision(Action.QUEUED, reason, round_, checks_held=True)
|
|
3856
|
+
|
|
3857
|
+
def _note_plan_round(self, pr: PullRequest, round_: int) -> None:
|
|
3858
|
+
"""Mark round `round_` of this PR as a PLAN round in the ledger, once
|
|
3859
|
+
(the ping is idempotent per kind). Never under --dry-run, which
|
|
3860
|
+
writes no durable state."""
|
|
3861
|
+
if self.config.dry_run:
|
|
3862
|
+
return
|
|
3863
|
+
self.state.record_ping(pr.full_name, pr.number, plan_round_kind(round_))
|
|
3864
|
+
|
|
3479
3865
|
def _checks_wait_since(self, pr: PullRequest, round_: int) -> float:
|
|
3480
3866
|
"""When this round's pre-spawn CI wait began -- the stamp its bound is
|
|
3481
3867
|
measured from. Durable in production, per-process in dry-run (which
|
|
@@ -3796,7 +4182,17 @@ class ReviewWatcher:
|
|
|
3796
4182
|
# checks gate turned into a REQUEST_CHANGES or COMMENT carries none,
|
|
3797
4183
|
# because `auto` on anything but an APPROVE is exactly what the
|
|
3798
4184
|
# consumer must never see (issue #130).
|
|
3799
|
-
|
|
4185
|
+
# A PLAN PR's approve carries Commit-Readiness and nothing else
|
|
4186
|
+
# (issue #148): the plan close edge commits on it, and a plan is
|
|
4187
|
+
# never a code merge candidate.
|
|
4188
|
+
plan = is_plan_pr(self.config, pr)
|
|
4189
|
+
label = COMMIT_READINESS_TRAILER_LABEL if plan else READINESS_TRAILER_LABEL
|
|
4190
|
+
if event != EVENT_APPROVE:
|
|
4191
|
+
trailer = ""
|
|
4192
|
+
elif plan:
|
|
4193
|
+
trailer = commit_readiness_trailer(envelope)
|
|
4194
|
+
else:
|
|
4195
|
+
trailer = readiness_trailer(envelope)
|
|
3800
4196
|
body = gate.lead + NATIVE_VERDICT_BODY.format(
|
|
3801
4197
|
round=round_,
|
|
3802
4198
|
verdict=verdict,
|
|
@@ -3849,10 +4245,15 @@ class ReviewWatcher:
|
|
|
3849
4245
|
# read BACK off the emitted line with the shared grammar (issue
|
|
3850
4246
|
# #142), so what the narration names is what the consumer will parse
|
|
3851
4247
|
# -- an unclassed operator says `class=unclassed`, the hard hold.
|
|
3852
|
-
readiness_value = trailer[len(
|
|
3853
|
-
posted_value, _, posted_class =
|
|
4248
|
+
readiness_value = trailer[len(label):].strip()
|
|
4249
|
+
posted_value, _, posted_class = (
|
|
4250
|
+
parse_commit_trailer(trailer) if plan else parse_trailer(trailer)
|
|
4251
|
+
)
|
|
3854
4252
|
class_term = readiness_class_term(posted_value, posted_class)
|
|
3855
|
-
|
|
4253
|
+
readiness_name = "commit-readiness" if plan else "readiness"
|
|
4254
|
+
readiness_note = (
|
|
4255
|
+
f" {readiness_name}={readiness_value}{class_term}" if trailer else ""
|
|
4256
|
+
)
|
|
3856
4257
|
log.info(
|
|
3857
4258
|
"%s round %d closed: native %s review submitted as %s (%s)%s%s",
|
|
3858
4259
|
pr.slug, round_, event, self.github.login, url or "no url",
|
|
@@ -3862,7 +4263,11 @@ class ReviewWatcher:
|
|
|
3862
4263
|
pr,
|
|
3863
4264
|
f"- {_now()} — round {round_} — native `{event}` review submitted "
|
|
3864
4265
|
f"as `{self.github.login}` (verdict of record){gate_note}"
|
|
3865
|
-
+ (
|
|
4266
|
+
+ (
|
|
4267
|
+
f" — {'commit' if plan else 'merge'}-readiness: "
|
|
4268
|
+
f"`{readiness_value}`{class_term}"
|
|
4269
|
+
if trailer else ""
|
|
4270
|
+
),
|
|
3866
4271
|
)
|
|
3867
4272
|
if event == EVENT_COMMENT:
|
|
3868
4273
|
# A degraded verdict takes the PR out of the loop (the review
|
|
@@ -4576,20 +4981,29 @@ class ReviewWatcher:
|
|
|
4576
4981
|
if self.state.readiness_observed(pr.full_name, pr.number, pr.head_sha) is not None:
|
|
4577
4982
|
return
|
|
4578
4983
|
|
|
4579
|
-
|
|
4984
|
+
# A plan PR's approve is read for its Commit-Readiness trailer, the
|
|
4985
|
+
# one the plan close edge commits on (issue #148); the flag, the row
|
|
4986
|
+
# and the narration are otherwise the same.
|
|
4987
|
+
plan = is_plan_pr(self.config, pr)
|
|
4988
|
+
if plan:
|
|
4989
|
+
value, reason, klass = parse_commit_trailer(newest.body)
|
|
4990
|
+
label, name, edge = COMMIT_READINESS_TRAILER_LABEL, "commit-readiness", "plan close"
|
|
4991
|
+
else:
|
|
4992
|
+
value, reason, klass = parse_trailer(newest.body)
|
|
4993
|
+
label, name, edge = READINESS_TRAILER_LABEL, "readiness", "merge"
|
|
4580
4994
|
head7 = pr.head_sha[:7]
|
|
4581
4995
|
if value is None:
|
|
4582
4996
|
readiness = READINESS_MISSING
|
|
4583
4997
|
log.warning(
|
|
4584
|
-
"%s approve at %s by %s carries no
|
|
4585
|
-
"
|
|
4998
|
+
"%s approve at %s by %s carries no %s trailer — the "
|
|
4999
|
+
"%s edge will hold it; the session must end its review body "
|
|
4586
5000
|
"with the line (see directive)",
|
|
4587
|
-
pr.slug, head7, self.github.login,
|
|
5001
|
+
pr.slug, head7, self.github.login, label.rstrip(":"), edge,
|
|
4588
5002
|
)
|
|
4589
5003
|
line = (
|
|
4590
5004
|
f"- {_now()} — round {round_} — session-posted `APPROVE` review by "
|
|
4591
|
-
f"`{self.github.login}` at `{head7}` —
|
|
4592
|
-
f"`{
|
|
5005
|
+
f"`{self.github.login}` at `{head7}` — {name}=missing (no "
|
|
5006
|
+
f"`{label}` trailer on the review body; the {edge} "
|
|
4593
5007
|
f"edge will hold it)"
|
|
4594
5008
|
)
|
|
4595
5009
|
else:
|
|
@@ -4599,12 +5013,12 @@ class ReviewWatcher:
|
|
|
4599
5013
|
# not, nothing for `auto`.
|
|
4600
5014
|
term = (f"{value} — {reason}" if reason else value) + readiness_class_term(value, klass)
|
|
4601
5015
|
log.info(
|
|
4602
|
-
"%s approve at %s by %s carries
|
|
4603
|
-
pr.slug, head7, self.github.login, term,
|
|
5016
|
+
"%s approve at %s by %s carries %s=%s",
|
|
5017
|
+
pr.slug, head7, self.github.login, name, term,
|
|
4604
5018
|
)
|
|
4605
5019
|
line = (
|
|
4606
5020
|
f"- {_now()} — round {round_} — session-posted `APPROVE` review by "
|
|
4607
|
-
f"`{self.github.login}` at `{head7}` —
|
|
5021
|
+
f"`{self.github.login}` at `{head7}` — {name}={term}"
|
|
4608
5022
|
)
|
|
4609
5023
|
|
|
4610
5024
|
posted_at = _epoch(newest.submitted_at)
|
|
@@ -5375,7 +5789,29 @@ class ReviewWatcher:
|
|
|
5375
5789
|
# `task is None` here means spawn_anyway/warn_and_spawn: the skip mode
|
|
5376
5790
|
# was decided in _refused_before_start, above the CI gate, so a round
|
|
5377
5791
|
# that will never start buys no rollup.
|
|
5378
|
-
|
|
5792
|
+
plan = is_plan_pr(self.config, pr)
|
|
5793
|
+
if plan and task is None:
|
|
5794
|
+
# The ordinary round 1 of a plan PR, not a CR2 gap: a plan has no
|
|
5795
|
+
# origin task and no implementer to have made one, so its first
|
|
5796
|
+
# reviewer creates the review task (design §6.1).
|
|
5797
|
+
log.info(
|
|
5798
|
+
"%s is a plan PR with no review task yet — its reviewer "
|
|
5799
|
+
"creates %r downstream of nothing",
|
|
5800
|
+
pr.slug, plan_review_task_title(pr),
|
|
5801
|
+
)
|
|
5802
|
+
assignment = (
|
|
5803
|
+
f"Review the plan PR {pr.url} . There is no Alissa review task "
|
|
5804
|
+
f"for it yet — create it per CR2, titled "
|
|
5805
|
+
f"`{plan_review_task_title(pr)}`, downstream of nothing (a plan "
|
|
5806
|
+
f"has no origin task), before recording your verdict."
|
|
5807
|
+
+ (
|
|
5808
|
+
""
|
|
5809
|
+
if plan_id_of(pr)
|
|
5810
|
+
else " The PR body declares no plan id: take it from the "
|
|
5811
|
+
"`id` field of the plan's `plan.md` frontmatter."
|
|
5812
|
+
)
|
|
5813
|
+
)
|
|
5814
|
+
elif task is None:
|
|
5379
5815
|
log.warning(
|
|
5380
5816
|
"%s has no open Alissa review task (CR2) — spawning against the PR "
|
|
5381
5817
|
"URL; the reviewer must create or locate one before recording a verdict",
|
|
@@ -5390,7 +5826,10 @@ class ReviewWatcher:
|
|
|
5390
5826
|
assignment = f"You've been assigned Alissa review task {task.ref}."
|
|
5391
5827
|
|
|
5392
5828
|
name = session_name(pr, round_)
|
|
5393
|
-
|
|
5829
|
+
if plan:
|
|
5830
|
+
template = PLAN_ROUND_1_DIRECTIVE if round_ == 1 else PLAN_ROUND_K_DIRECTIVE
|
|
5831
|
+
else:
|
|
5832
|
+
template = ROUND_1_DIRECTIVE if round_ == 1 else ROUND_K_DIRECTIVE
|
|
5394
5833
|
directive = template.format(
|
|
5395
5834
|
assignment=assignment,
|
|
5396
5835
|
round=round_,
|
|
@@ -68,6 +68,14 @@ refused. The suffix is the design's R1 key: Studio's reducer flips a
|
|
|
68
68
|
credential only when the suffix matches the stored row, so a container still
|
|
69
69
|
running an OLD token after a same-mode replace cannot flip the new, good
|
|
70
70
|
credential. `reason` is a fixed sentence, never the pane's text.
|
|
71
|
+
|
|
72
|
+
A PLAN round (issue #148; genloop design §6, lane L4) is marked the same
|
|
73
|
+
ledger-derived way: when the plan gate first sees round `<n>` of a plan PR it
|
|
74
|
+
records one `plan-round:<n>` ping, and every ROUND event of that (repo, PR,
|
|
75
|
+
round) -- `round.spawned`, `round.verdict`, `round.abandoned`,
|
|
76
|
+
`round.capped` and both gates' `checks.held` -- carries `data.kind: "plan"`.
|
|
77
|
+
A code round carries no `kind` at all, so every pre-existing payload is
|
|
78
|
+
unchanged byte for byte.
|
|
71
79
|
"""
|
|
72
80
|
|
|
73
81
|
from __future__ import annotations
|
|
@@ -113,6 +121,18 @@ _PROMPT_PAGE_RE = re.compile(
|
|
|
113
121
|
)
|
|
114
122
|
|
|
115
123
|
|
|
124
|
+
# The plan-round marker (issue #148): `plan-round:<round>`, recorded by
|
|
125
|
+
# loop._note_plan_round (a test pins the two prefixes together).
|
|
126
|
+
PLAN_ROUND_PREFIX = "plan-round:"
|
|
127
|
+
_PLAN_ROUND_RE = re.compile(r"^plan-round:(?P<round>\d+)$")
|
|
128
|
+
ROUND_KIND_PLAN = "plan"
|
|
129
|
+
# The events a round kind is stamped on: the per-round kinds, and nothing
|
|
130
|
+
# that is about a session or the whole PR.
|
|
131
|
+
ROUND_EVENT_KINDS = frozenset({
|
|
132
|
+
"round.spawned", "round.verdict", "round.abandoned", "round.capped",
|
|
133
|
+
"checks.held",
|
|
134
|
+
})
|
|
135
|
+
|
|
116
136
|
# The `auth.rejected` row (issue #146): one per SESSION,
|
|
117
137
|
# `auth-rejected:<session>#<round>:<source>:<matched>:<envName>:<suffix>`.
|
|
118
138
|
# The session leads so "already recorded for this session" is one prefix read
|
|
@@ -547,6 +567,32 @@ def _reap_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
|
|
|
547
567
|
]
|
|
548
568
|
|
|
549
569
|
|
|
570
|
+
def _plan_rounds(pings: "list[dict]") -> "set[tuple[str, int, int]]":
|
|
571
|
+
"""Every (repo, PR, round) the ledger marks as a plan round."""
|
|
572
|
+
out = set()
|
|
573
|
+
for row in pings:
|
|
574
|
+
match = _PLAN_ROUND_RE.match(str(row["kind"]))
|
|
575
|
+
if match is not None:
|
|
576
|
+
out.add((row["repo"], int(row["number"]), int(match.group("round"))))
|
|
577
|
+
return out
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _mark_plan_rounds(
|
|
581
|
+
stamped: "list[tuple[int, dict]]", plan_rounds: "set[tuple[str, int, int]]"
|
|
582
|
+
) -> None:
|
|
583
|
+
"""Stamp `data.kind: "plan"` on the round events of plan rounds, in
|
|
584
|
+
place. An event with no round (a `round.capped` no spawn row explains)
|
|
585
|
+
cannot be attributed and is left unmarked."""
|
|
586
|
+
if not plan_rounds:
|
|
587
|
+
return
|
|
588
|
+
for _, event in stamped:
|
|
589
|
+
if event["kind"] not in ROUND_EVENT_KINDS or "round" not in event:
|
|
590
|
+
continue
|
|
591
|
+
key = (event.get("repo"), event.get("prNumber"), event["round"])
|
|
592
|
+
if key in plan_rounds:
|
|
593
|
+
event["data"] = {**event.get("data", {}), "kind": ROUND_KIND_PLAN}
|
|
594
|
+
|
|
595
|
+
|
|
550
596
|
def derive_events(state: State, *, since: int = 0) -> "list[dict]":
|
|
551
597
|
"""Every loop event the ledger implies whose stamp is >= `since`,
|
|
552
598
|
oldest first.
|
|
@@ -565,10 +611,12 @@ def derive_events(state: State, *, since: int = 0) -> "list[dict]":
|
|
|
565
611
|
stamped += _spawn_events(spawns)
|
|
566
612
|
stamped += _verdict_events(state.read_verdict_posts())
|
|
567
613
|
stamped += _capped_events(state.read_escalations(), spawns)
|
|
568
|
-
|
|
614
|
+
pings = state.read_pings()
|
|
615
|
+
stamped += _ping_events(pings, notices)
|
|
569
616
|
stamped += _spawn_hold_events(state.read_spawn_checks_holds())
|
|
570
617
|
stamped += _grant_events(state.read_grants())
|
|
571
618
|
stamped += _reap_events(state.read_reaps())
|
|
619
|
+
_mark_plan_rounds(stamped, _plan_rounds(pings))
|
|
572
620
|
|
|
573
621
|
stamped = [(at, event) for at, event in stamped if at >= since]
|
|
574
622
|
stamped.sort(key=lambda pair: pair[0])
|
|
@@ -34,7 +34,12 @@ import re
|
|
|
34
34
|
import sys
|
|
35
35
|
import time
|
|
36
36
|
|
|
37
|
-
from .alissa import
|
|
37
|
+
from .alissa import (
|
|
38
|
+
Alissa,
|
|
39
|
+
VERDICT_APPROVE,
|
|
40
|
+
VERDICT_REQUEST_CHANGES,
|
|
41
|
+
review_task_title_pattern,
|
|
42
|
+
)
|
|
38
43
|
from .config import env_task_list_bow_id
|
|
39
44
|
from .ghclient import GitHub
|
|
40
45
|
from .proc import CommandError, run, run_json
|
|
@@ -80,9 +85,9 @@ def _review_task_ref(alissa: Alissa, owner: str, repo: str, number: int) -> str
|
|
|
80
85
|
`is_open` set or absent altogether, and in the first case narrowing by it
|
|
81
86
|
here would re-impose the filter this function exists to avoid.
|
|
82
87
|
"""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
)
|
|
88
|
+
# The daemon's own title grammar, so a plan PR's `Review plan …` task
|
|
89
|
+
# (issue #148) resolves here exactly as it does in the poll loop.
|
|
90
|
+
pattern = review_task_title_pattern(owner, repo, number)
|
|
86
91
|
matches = [
|
|
87
92
|
t for t in alissa.list_tasks(narrow_status=False) if pattern.match(t.title)
|
|
88
93
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.31.8
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.31.6
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|