|
13 | 13 | from __future__ import annotations |
14 | 14 |
|
15 | 15 | import pathlib |
| 16 | +import time |
16 | 17 |
|
17 | 18 | import typer |
18 | 19 |
|
@@ -1038,5 +1039,122 @@ def mcp(): |
1038 | 1039 | start_mcp_server() |
1039 | 1040 |
|
1040 | 1041 |
|
| 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 | + |
1041 | 1159 | if __name__ == "__main__": |
1042 | 1160 | app() |
0 commit comments