Skip to content

Commit 5fc6c97

Browse files
Gilad Kochclaude
andcommitted
feat(O2): continuous PR review — PrEventSource seam + org sync CLI
Implements Slice O2: every PR opened on a governed repo gets its verdict with no human action. Architecture (webhook-swap ready): - PrEvent dataclass (frozen, 7 fields) — the seam between poll and webhooks - PrEventSource abstract base; GitHubPollEventSource wraps list_prs today - O6 webhook endpoint maps GitHub payloads to the same PrEvent → same handler, zero rework at swap time (as designed) Per new/updated head SHA: - Trailer links extracted from mirror (failure-safe; sync_org constructs LinkStore like first_results when none injected) - Analysis runs via run_analysis (idempotency cache skips repeats) - check:analyzed activity_events row emitted (failure-safe, absorbs U4) - Optional publish_comment gated by HARD RULE: non-giladax remotes (psf, pallets, …) NEVER receive comments; giladax requires publish_enabled:true in sync_meta.yaml; default OFF everywhere Budget discipline: max_prs_per_repo caps per-pass analysis; list_prs capped at 2 pages; sync_meta.yaml tracks seen SHA set and last_seen_updated_at. CLI: `python3 -m quire.cli org sync [--loop --interval N] [--max-prs N]` Live proof (tokenless, ≤20 API calls): one pass detected + analyzed psf/requests#7586, pallets/itsdangerous#428, #1; verdicts on the org cards; 3 check:analyzed rows in activity_events; publish guard asserted OFF for all residents; registry state committed. Also: github adapter list_prs gains updated_at field (backward-compatible). Tests: 44 new offline tests (no live GitHub, no live Postgres); backend 707 passed, 1 skipped; app vitest (from app/) 59/59 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016cmqJ7aie4Kap4ZsZraMF1
1 parent 7155127 commit 5fc6c97

14 files changed

Lines changed: 2485 additions & 11 deletions

File tree

backend/quire/adapters/github.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def list_prs(
129129
"base_sha": (pr.get("base") or {}).get("sha", ""),
130130
"author": (pr.get("user") or {}).get("login", ""),
131131
"created_at": pr.get("created_at", ""),
132+
"updated_at": pr.get("updated_at", ""),
132133
"html_url": pr.get("html_url", ""),
133134
})
134135
if len(batch) < per_page:

backend/quire/analysis/graph.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,9 @@ def run_analysis(
128128
llm = AnthropicAlignmentLLM()
129129
graph = build_analysis_graph(adapter, llm, store=store, config=config)
130130
result = graph.invoke({"pr_number": pr_number, "force": force})
131-
return result["analysis"]
131+
analysis = result["analysis"]
132+
if analysis is not None:
133+
# Thread the cache-hit flag out of the graph state so callers can
134+
# distinguish a fresh analysis from a cached one (org_sync `skipped`).
135+
analysis.from_cache = result.get("cached", False)
136+
return analysis

backend/quire/cli.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from __future__ import annotations
1414

1515
import pathlib
16+
import time
1617

1718
import typer
1819

@@ -1038,5 +1039,122 @@ def mcp():
10381039
start_mcp_server()
10391040

10401041

1042+
# ── Org sub-group (O2+) ────────────────────────────────────────────────────
1043+
1044+
org_app = typer.Typer(no_args_is_help=True, add_completion=False,
1045+
help="Org platform — governed repos, continuous PR review.")
1046+
app.add_typer(org_app, name="org")
1047+
1048+
1049+
@org_app.command("sync")
1050+
def org_sync(
1051+
loop: bool = typer.Option(False, "--loop", help="poll continuously"),
1052+
interval: int = typer.Option(
1053+
300, "--interval", help="seconds between passes when --loop is active"
1054+
),
1055+
max_prs: int = typer.Option(
1056+
5, "--max-prs", help="max new PRs analyzed per repo per pass"
1057+
),
1058+
db: str = typer.Option("", help="database URL (default sqlite file for alignment store)"),
1059+
):
1060+
"""Analyze new/updated PRs across all active GitHub-governed repos (O2).
1061+
1062+
One pass: for each active GitHub workspace in the org, poll open PRs,
1063+
detect new or updated head SHAs, run the alignment analyzer, and emit a
1064+
check:analyzed activity event per result.
1065+
1066+
Publishing comments on GitHub is OFF by default and must be explicitly
1067+
enabled per workspace (publish_enabled: true in sync_meta.yaml) AND the
1068+
repo must be owned by giladax. Third-party repos (psf/requests, etc.)
1069+
NEVER receive comments from this system.
1070+
1071+
Rate limits: ~3–5 API calls per new PR head SHA; list_prs capped at 2
1072+
pages (≤200 PRs) per repo per pass. GITHUB_TOKEN from .env is used when
1073+
present; tokenless rate limit is 60 req/hr (falls back gracefully).
1074+
"""
1075+
import os
1076+
1077+
from quire.org_store import OrgStore, seed_demo_org
1078+
from quire.org_sync import sync_org
1079+
from quire.store import Store
1080+
1081+
alignment_store = Store(url=db or None)
1082+
1083+
try:
1084+
from quire.db.engine import get_engine
1085+
engine = get_engine()
1086+
org_store = OrgStore(engine=engine)
1087+
seed_demo_org(org_store)
1088+
except Exception as exc:
1089+
typer.secho(
1090+
f"could not connect to org Postgres: {exc} — "
1091+
"check DATABASE_URL and that `quire up` is running",
1092+
fg=typer.colors.RED,
1093+
)
1094+
raise typer.Exit(1)
1095+
1096+
def _run_pass() -> None:
1097+
results = sync_org(
1098+
org_store,
1099+
alignment_store,
1100+
max_prs_per_repo=max_prs,
1101+
)
1102+
for r in results:
1103+
ws = r["workspace"]
1104+
repo = r["repository"]
1105+
if r.get("error"):
1106+
typer.secho(
1107+
f" {ws} ({repo}): ERROR — {r['error']}",
1108+
fg=typer.colors.RED,
1109+
)
1110+
continue
1111+
polled = r["events_polled"]
1112+
analyzed = r["analyzed"]
1113+
typer.echo(f" {ws} ({repo}): {polled} PRs polled, {analyzed} analyzed")
1114+
for res in r["results"]:
1115+
if res.get("error"):
1116+
typer.secho(
1117+
f" PR #{res['pr_number']}: ERROR — {res['error']}",
1118+
fg=typer.colors.YELLOW,
1119+
)
1120+
elif res.get("skipped"):
1121+
typer.echo(f" PR #{res['pr_number']}: already analyzed (cached)")
1122+
else:
1123+
from quire import vocab
1124+
v = vocab.verdict(res.get("verdict"))
1125+
colour = {
1126+
"red": typer.colors.RED,
1127+
"amber": typer.colors.YELLOW,
1128+
"green": typer.colors.GREEN,
1129+
"blue": typer.colors.BLUE,
1130+
}.get(v["ink"], typer.colors.WHITE)
1131+
url_note = (
1132+
f" → published: {res['publish_url']}"
1133+
if res.get("publish_url")
1134+
else ""
1135+
)
1136+
typer.secho(
1137+
f" PR #{res['pr_number']}: {v['label']}{url_note}",
1138+
fg=colour,
1139+
)
1140+
1141+
if loop:
1142+
typer.secho(
1143+
f"org sync --loop: polling every {interval}s (Ctrl-C to stop)",
1144+
fg=typer.colors.CYAN,
1145+
)
1146+
while True:
1147+
typer.secho(f"\n[pass] {_now_str()}", fg=typer.colors.CYAN)
1148+
_run_pass()
1149+
time.sleep(interval)
1150+
else:
1151+
_run_pass()
1152+
1153+
1154+
def _now_str() -> str:
1155+
from datetime import datetime, timezone
1156+
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
1157+
1158+
10411159
if __name__ == "__main__":
10421160
app()

backend/quire/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,3 +405,9 @@ class PRAnalysis(BaseModel):
405405
artifact_snapshot_ids: list[str] = Field(default_factory=list)
406406
comment_markdown: str = ""
407407
created_at: datetime = Field(default_factory=utc_now)
408+
409+
# Transient: set by run_analysis when this result came from the identity
410+
# cache (same head_sha + contract snapshot already analyzed). Not a stored
411+
# fact — a runtime signal so callers (org_sync) can skip re-publishing and
412+
# re-marking the SHA as seen.
413+
from_cache: bool = False

backend/quire/org_store.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"github_remote": "https://github.com/giladax/intent-ai",
3333
"status": "active",
3434
"read_only": False,
35+
"repository": "giladax/intent-ai", # alignment key matches GitHub owner/name (org_sync writes under it)
3536
},
3637
{
3738
"id": "intent-ai-live",

0 commit comments

Comments
 (0)