diff --git a/docs/reference/protocols/README.md b/docs/reference/protocols/README.md index 709a5566..a94a7734 100644 --- a/docs/reference/protocols/README.md +++ b/docs/reference/protocols/README.md @@ -18,6 +18,7 @@ Current contracts: - [todo_detail_cold_path_v0](todo-detail-cold-path-v0.md) - [long_horizon_agent_state_protocol_v0](long-horizon-agent-state-protocol-v0.md) - [global_manager_command_v0](global-manager-command-v0.md) +- [pr_review_command_v0](pr-review-command-v0.md) - [event_sourced_state_contract_v0](event-sourced-state-contract-v0.md) - [rollback_packet_v0](rollback-packet-v0.md) - [content_ops_surface_v0](content-ops-surface-v0.md) diff --git a/docs/reference/protocols/global-manager-command-v0.md b/docs/reference/protocols/global-manager-command-v0.md index 4513393a..d64fe4b8 100644 --- a/docs/reference/protocols/global-manager-command-v0.md +++ b/docs/reference/protocols/global-manager-command-v0.md @@ -24,6 +24,7 @@ Recommended first commands: | `/loopx-global-gates` | Show open user/controller gates and what each blocks. | current state | | `/loopx-global-todos` | Show top runnable, blocked, deferred-ready, and review todos. | current state | | `/loopx-global-risks` | Show stale runs, public/private boundary warnings, failing checks, and rollback candidates. | 24 hours | +| `/loopx-pr-review` | Walk the current project's or explicit repository's unmerged GitHub PRs one by one with motivation, scope, checks, risks, and review prompts. | current open PRs | | `/loop-goal-summary ` | Drill into one goal without scanning unrelated projects. | 24 hours | Commands are read-only by default. They can propose follow-up actions, but @@ -46,6 +47,11 @@ Related project-local command: `/loopx ` is covered by command: it starts one project goal, plans ranked todos, writes them in order, and then enters the quota-gated automation flow. +Related repo-review command: `/loopx-pr-review` is covered by +[`pr_review_command_v0`](pr-review-command-v0.md). It is read-only and helps a +human review open PRs in the caller's current project or an explicit +`--repo owner/repo` target; it does not approve, comment, merge, or spend quota. + ## Request Shape ```json diff --git a/docs/reference/protocols/pr-review-command-v0.md b/docs/reference/protocols/pr-review-command-v0.md new file mode 100644 index 00000000..fb66bee7 --- /dev/null +++ b/docs/reference/protocols/pr-review-command-v0.md @@ -0,0 +1,115 @@ +# pr_review_command_v0 + +`pr_review_command_v0` defines the `/loopx-pr-review` command. It helps a +user review unmerged pull requests one by one by turning public GitHub PR +metadata into a guided review queue. + +The reviewed repository is the caller's current GitHub project by default, as +resolved by `gh`, or the explicit `--repo owner/repo` target. LoopX's own +repository may be used for dogfood and public fixtures, but the command is not +LoopX-repo-specific. + +The command is read-only. It does not approve reviews, post PR comments, merge, +push, spend LoopX quota, or mark LoopX todos complete. + +## Command + +| Command | CLI reference | Intent | +| --- | --- | --- | +| `/loopx-pr-review` | `loopx pr-review [--repo owner/repo]` | List open PRs for the current project or explicit repository and build a guided review packet with motivation, change scope, checks, risks, and review prompts. | + +## Source Reads + +Implementations may read compact public PR surfaces: + +- pull request title, number, URL, branch, author, state, and review decision; +- PR body summary; +- changed-file list and diff scale; +- commit headlines; +- status-check rollup; +- merge-state metadata. + +They must not include raw logs, private connector payloads, credentials, local +absolute paths, private source bodies, or hidden CI artifacts. + +## Response Shape + +`loopx_pr_review_command_response_v0`: + +```json +{ + "schema_version": "loopx_pr_review_command_response_v0", + "request": { + "schema_version": "loopx_pr_review_command_request_v0", + "command": "/loopx-pr-review", + "cli_command": "loopx pr-review [--repo owner/repo]", + "repository": "owner/repo", + "limit": 10, + "source": "github_cli", + "privacy_mode": "public_safe_github_metadata", + "dry_run": true + }, + "summary": { + "headline": "5 open PR(s) found; 5 need review attention.", + "open_pr_count": 5, + "review_attention_count": 5, + "draft_count": 0, + "recommended_first_pr": { + "rank": 1, + "number": 773, + "review_depth": "docs_and_smoke_review" + } + }, + "review_sequence": [ + { + "rank": 1, + "number": 773, + "title": "docs: add newcomer command path", + "url": "https://github.com/owner/repo/pull/773", + "review_depth": "docs_and_smoke_review", + "why_now": "Open and awaiting reviewer decision." + } + ], + "pull_requests": [ + { + "number": 773, + "motivation": "Adds a newcomer command path...", + "scale": {"changed_files": 3, "additions": 90, "deletions": 4}, + "areas": {"public_docs": 3}, + "checks": {"summary": "2 successful check(s)."}, + "risk_notes": [], + "review_prompts": [ + "What user or maintainer value does this PR unlock now?" + ] + } + ], + "boundary": { + "raw_logs_recorded": false, + "credential_values_recorded": false, + "absolute_paths_recorded": false + } +} +``` + +## Review Flow + +The packet should let a reviewer move through PRs in order: + +1. Read the motivation and decide whether the PR should exist. +2. Compare the touched areas and key files with that scope. +3. Inspect validation and risk notes before approval or merge handoff. +4. Decide `approve`, `request changes`, `defer`, or `merge after checks`. + +## Acceptance Checks + +A first implementation is acceptable when: + +- `loopx slash-commands` exposes `/loopx-pr-review`; +- `loopx pr-review` returns `loopx_pr_review_command_response_v0`; +- default live reads use the caller's current `gh` repository, while + `--repo owner/repo` can review another GitHub project; +- the response includes review sequence, motivation, changed-file scope, + status checks, risk notes, and review prompts; +- live GitHub reads and fixture-based smokes share the same schema; +- no raw logs, private payloads, credentials, local paths, or private source + bodies are recorded. diff --git a/examples/fixtures/pr-review.public.json b/examples/fixtures/pr-review.public.json new file mode 100644 index 00000000..954ffaff --- /dev/null +++ b/examples/fixtures/pr-review.public.json @@ -0,0 +1,88 @@ +{ + "repository": "huangruiteng/loopx", + "pull_requests": [ + { + "number": 773, + "title": "docs: add newcomer command path", + "url": "https://github.com/huangruiteng/loopx/pull/773", + "author": {"login": "huangruiteng"}, + "baseRefName": "main", + "headRefName": "codex/side-bypass-command-surface-20260627", + "updatedAt": "2026-06-27T12:21:43Z", + "isDraft": false, + "reviewDecision": "REVIEW_REQUIRED", + "mergeStateStatus": "CLEAN", + "body": "Adds a newcomer command path so first-time users see /loopx and one quickstart before the full command catalog. It also adds manager-view commands as second-layer discovery.", + "changedFiles": 3, + "additions": 90, + "deletions": 4, + "files": [ + {"path": "docs/guides/newcomer-command-path.md", "additions": 75, "deletions": 0}, + {"path": "docs/README.md", "additions": 8, "deletions": 2}, + {"path": "docs/guides/getting-started.md", "additions": 7, "deletions": 2} + ], + "commits": [ + {"messageHeadline": "docs: add newcomer command path"}, + {"messageHeadline": "docs: mention global manager slash commands"} + ], + "statusCheckRollup": [ + {"name": "build", "status": "COMPLETED", "conclusion": "SUCCESS"}, + {"name": "docs-smoke", "status": "COMPLETED", "conclusion": "SUCCESS"} + ] + }, + { + "number": 771, + "title": "docs: add public showcase pages and experimental value path", + "url": "https://github.com/huangruiteng/loopx/pull/771", + "author": {"login": "huangruiteng"}, + "baseRefName": "main", + "headRefName": "codex/hardware-canonical-showcases", + "updatedAt": "2026-06-27T12:18:59Z", + "isDraft": false, + "reviewDecision": "REVIEW_REQUIRED", + "mergeStateStatus": "CLEAN", + "body": "Adds public showcase pages, preserves the hardware workflow page as the quality bar, and places the Today Value Path as an experimental section below the first screen.", + "changedFiles": 9, + "additions": 740, + "deletions": 42, + "files": [ + {"path": "README.md", "additions": 20, "deletions": 5}, + {"path": "README.zh-CN.md", "additions": 20, "deletions": 5}, + {"path": "docs/showcases/index.html", "additions": 150, "deletions": 20}, + {"path": "docs/showcases/showcase-catalog.json", "additions": 190, "deletions": 5}, + {"path": "apps/dashboard/src/views/frontstage-page.tsx", "additions": 80, "deletions": 4} + ], + "commits": [ + {"messageHeadline": "docs: require first-screen owner review"}, + {"messageHeadline": "docs: add experimental today value path"} + ], + "statusCheckRollup": [ + {"name": "build", "status": "COMPLETED", "conclusion": "SUCCESS"}, + {"name": "showcase-smoke", "status": "COMPLETED", "conclusion": "SUCCESS"} + ] + }, + { + "number": 775, + "title": "draft: explore PR review dashboard cards", + "url": "https://github.com/huangruiteng/loopx/pull/775", + "author": {"login": "huangruiteng"}, + "baseRefName": "main", + "headRefName": "codex/pr-review-dashboard-cards", + "updatedAt": "2026-06-27T12:30:00Z", + "isDraft": true, + "reviewDecision": "REVIEW_REQUIRED", + "mergeStateStatus": "UNKNOWN", + "body": "Explores a dashboard card view for PR review packets.", + "changedFiles": 1, + "additions": 40, + "deletions": 0, + "files": [ + {"path": "apps/dashboard/src/views/pr-review-card.tsx", "additions": 40, "deletions": 0} + ], + "commits": [ + {"messageHeadline": "draft: explore PR review cards"} + ], + "statusCheckRollup": [] + } + ] +} diff --git a/examples/pr-review-command-smoke.py b/examples/pr-review-command-smoke.py new file mode 100644 index 00000000..111268dc --- /dev/null +++ b/examples/pr-review-command-smoke.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Smoke-test the public-safe `loopx pr-review` command.""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +FIXTURE = REPO_ROOT / "examples" / "fixtures" / "pr-review.public.json" +PRIVATE_PATTERNS = [ + re.compile(r"/" + r"Users/[A-Za-z0-9._-]+/"), + re.compile(r"/" + r"private/"), + re.compile(r"/tmp/"), + re.compile(r"/var/"), + re.compile(r"[A-Za-z]:\\\\Users\\\\"), +] + + +def run_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "loopx.cli", *args], + cwd=REPO_ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + + +def assert_public_safe(payload: dict[str, object]) -> None: + text = json.dumps(payload, ensure_ascii=False) + for pattern in PRIVATE_PATTERNS: + if pattern.search(text): + raise AssertionError(f"pr-review payload leaked private pattern {pattern.pattern!r}") + + +def main() -> int: + payload = json.loads( + run_cli("--format", "json", "pr-review", "--fixture", str(FIXTURE), "--limit", "5").stdout + ) + assert payload["schema_version"] == "loopx_pr_review_command_response_v0", payload + request = payload["request"] + assert request["command"] == "/loopx-pr-review", request + assert request["cli_command"] == "loopx pr-review [--repo owner/repo]", request + assert request["privacy_mode"] == "public_safe_github_metadata", request + assert request["dry_run"] is True, request + assert request["repository"] == "huangruiteng/loopx", request + assert payload["summary"]["open_pr_count"] == 3, payload["summary"] + assert payload["summary"]["review_attention_count"] == 3, payload["summary"] + assert payload["summary"]["draft_count"] == 1, payload["summary"] + sequence = payload["review_sequence"] + assert sequence[0]["number"] == 773, sequence + assert sequence[-1]["number"] == 775, sequence + first = payload["pull_requests"][0] + assert first["number"] == 773, first + assert "newcomer command path" in first["motivation"], first + assert first["checks"]["counts"]["success"] == 2, first["checks"] + assert "public_docs" in first["areas"], first["areas"] + assert payload["boundary"]["absolute_paths_recorded"] is False, payload["boundary"] + assert_public_safe(payload) + + markdown = run_cli("pr-review", "--fixture", str(FIXTURE), "--limit", "1").stdout + assert "# Project PR Review Queue" in markdown, markdown + assert "current gh repository" not in markdown, markdown + assert "## Review Sequence" in markdown, markdown + assert "PR #773" in markdown, markdown + assert "review prompts" in markdown, markdown + + print("pr-review-command-smoke ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/slash-command-catalog-smoke.py b/examples/slash-command-catalog-smoke.py index 95784cd9..f47838e5 100644 --- a/examples/slash-command-catalog-smoke.py +++ b/examples/slash-command-catalog-smoke.py @@ -35,6 +35,7 @@ def main() -> int: "/loopx-global-gates", "/loopx-global-todos", "/loopx-global-risks", + "/loopx-pr-review", ]: assert command in commands, commands assert "/loop-global-summary" in commands["/loopx-global-summary"]["legacy_aliases"] @@ -51,6 +52,8 @@ def main() -> int: assert "# LoopX Slash Commands" in markdown, markdown assert "`/loopx-global-summary`" in markdown, markdown assert "`loopx global-summary`" in markdown, markdown + assert "`/loopx-pr-review`" in markdown, markdown + assert "`loopx pr-review [--repo owner/repo]`" in markdown, markdown assert "`/loopx-summary-all`" not in markdown, markdown top_help = run_cli("--help").stdout diff --git a/loopx/cli.py b/loopx/cli.py index 94a81202..019cf650 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -30,6 +30,7 @@ handle_lark_kanban_command, handle_ml_experiment_command, handle_project_lifecycle_command, + handle_pr_review_command, handle_quota_command, handle_registry_admin_command, handle_review_packet_command, @@ -49,6 +50,7 @@ register_lark_kanban_commands, register_ml_experiment_commands, register_project_lifecycle_commands, + register_pr_review_command, register_quota_command, register_registry_admin_commands, register_slash_commands_command, @@ -150,6 +152,7 @@ def main(argv: list[str] | None = None) -> int: register_status_commands(sub, add_subcommand_format) register_summary_all_command(sub, add_subcommand_format) + register_pr_review_command(sub, add_subcommand_format) register_slash_commands_command(sub, add_subcommand_format) register_dreaming_commands(sub, add_subcommand_format) register_todo_command(sub) @@ -344,6 +347,14 @@ def main(argv: list[str] | None = None) -> int: if summary_all_result is not None: return summary_all_result + pr_review_result = handle_pr_review_command( + args, + output_format=output_format, + print_payload=print_payload, + ) + if pr_review_result is not None: + return pr_review_result + slash_commands_result = handle_slash_commands_command( args, output_format=output_format, diff --git a/loopx/cli_commands/__init__.py b/loopx/cli_commands/__init__.py index 5c9d8070..d5b6cf03 100644 --- a/loopx/cli_commands/__init__.py +++ b/loopx/cli_commands/__init__.py @@ -86,6 +86,7 @@ handle_project_lifecycle_command, register_project_lifecycle_commands, ) +from .pr_review import handle_pr_review_command, register_pr_review_command from .quota import handle_quota_command, register_quota_command from .registry_admin import ( handle_registry_admin_command, @@ -204,6 +205,7 @@ "handle_ml_experiment_command", "handle_new_project_prompt_command", "handle_project_lifecycle_command", + "handle_pr_review_command", "handle_quota_command", "handle_registry_admin_command", "handle_review_packet_command", @@ -245,6 +247,7 @@ "register_lark_kanban_commands", "register_ml_experiment_commands", "register_project_lifecycle_commands", + "register_pr_review_command", "register_quota_command", "register_registry_admin_commands", "register_slash_commands_command", diff --git a/loopx/cli_commands/pr_review.py b/loopx/cli_commands/pr_review.py new file mode 100644 index 00000000..a1dcc322 --- /dev/null +++ b/loopx/cli_commands/pr_review.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import argparse +from collections.abc import Callable +from pathlib import Path + +from ..pr_review import ( + build_pr_review_packet, + fetch_github_pull_requests, + load_pr_fixture, + render_pr_review_markdown, + resolve_current_github_repository, +) + + +PrintPayload = Callable[ + [dict[str, object], str, Callable[[dict[str, object]], str]], + None, +] +FormatSelector = Callable[..., str] + + +def register_pr_review_command( + subparsers: argparse._SubParsersAction, + add_subcommand_format: Callable[[argparse.ArgumentParser], None], +) -> None: + parser = subparsers.add_parser( + "pr-review", + help="Build a public-safe /loopx-pr-review queue for the current project's open pull requests.", + ) + add_subcommand_format(parser) + parser.add_argument( + "--repo", + help="GitHub owner/repo to review. Defaults to the current project's gh repository context.", + ) + parser.add_argument("--limit", type=int, default=10, help="Maximum open PRs to include.") + parser.add_argument( + "--fixture", + help="Read public-safe PR metadata from a JSON fixture instead of live gh output.", + ) + + +def handle_pr_review_command( + args: argparse.Namespace, + *, + output_format: FormatSelector, + print_payload: PrintPayload, +) -> int | None: + if args.command != "pr-review": + return None + try: + repository = args.repo + source = "github_cli" + if args.fixture: + repository_from_fixture, pull_requests = load_pr_fixture(Path(args.fixture).expanduser()) + repository = repository or repository_from_fixture + source = "fixture" + else: + repository = repository or resolve_current_github_repository() + pull_requests = fetch_github_pull_requests(repo=repository, limit=max(1, args.limit)) + payload = build_pr_review_packet( + pull_requests=pull_requests, + repository=repository, + limit=max(1, args.limit), + source=source, + ) + except Exception as exc: + payload = { + "ok": False, + "schema_version": "loopx_pr_review_command_response_v0", + "request": { + "schema_version": "loopx_pr_review_command_request_v0", + "command": "/loopx-pr-review", + "cli_command": "loopx pr-review [--repo owner/repo]", + "repository": args.repo, + "limit": max(1, args.limit), + "source": "fixture" if args.fixture else "github_cli", + "privacy_mode": "public_safe_github_metadata", + "dry_run": True, + }, + "error": str(exc), + } + print_payload(payload, output_format(args), render_pr_review_markdown) + return 0 if payload.get("ok") else 1 diff --git a/loopx/pr_review.py b/loopx/pr_review.py new file mode 100644 index 00000000..08b85be3 --- /dev/null +++ b/loopx/pr_review.py @@ -0,0 +1,618 @@ +from __future__ import annotations + +import json +import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +COMMAND = "/loopx-pr-review" +SCHEMA_VERSION = "loopx_pr_review_command_response_v0" + +BOUNDARY = { + "raw_logs_recorded": False, + "raw_transcripts_recorded": False, + "raw_connector_payloads_recorded": False, + "credential_values_recorded": False, + "absolute_paths_recorded": False, + "private_source_bodies_recorded": False, +} + +SOURCE_SURFACES = [ + "GitHub pull request metadata", + "GitHub pull request body summary", + "GitHub pull request changed-file list", + "GitHub pull request commit headlines", + "GitHub pull request status check rollup", +] + +LOCAL_PATH_PATTERNS = ( + re.compile(r"/(?:Users|home|private|tmp|var)/[^\s`|,)]+"), + re.compile(r"[A-Za-z]:\\\\Users\\\\[^\s`|,)]+"), +) + +RUNTIME_OR_CLI_PREFIXES = ( + "src/", + "lib/", + "pkg/", + "packages/", + "cmd/", + "internal/", + "server/", + "backend/", + "app/", + "apps/", + "loopx/", + "scripts/", + "bin/", + "tools/", +) + +UI_PREFIXES = ( + "apps/dashboard/", + "apps/web/", + "apps/frontend/", + "apps/site/", + "web/", + "frontend/", + "ui/", + "components/", + "pages/", + "views/", + "public/", +) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _redact_text(value: object, *, limit: int = 320) -> str: + text = str(value or "").strip() + for pattern in LOCAL_PATH_PATTERNS: + text = pattern.sub("", text) + text = re.sub(r"\s+", " ", text) + if len(text) > limit: + return text[: max(0, limit - 1)].rstrip() + "..." + return text + + +def _as_dict(value: object) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _as_list(value: object) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _run_gh_json(args: list[str], *, cwd: Path | None = None) -> Any: + proc = subprocess.run( + ["gh", *args], + cwd=cwd, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return json.loads(proc.stdout or "null") + + +def resolve_current_github_repository(*, cwd: Path | None = None) -> str | None: + try: + payload = _run_gh_json(["repo", "view", "--json", "nameWithOwner"], cwd=cwd) + except Exception: + return None + if not isinstance(payload, dict): + return None + return str(payload.get("nameWithOwner") or "") or None + + +def fetch_github_pull_requests( + *, + repo: str | None, + limit: int, + cwd: Path | None = None, +) -> list[dict[str, Any]]: + repo_args = ["--repo", repo] if repo else [] + list_fields = [ + "number", + "title", + "url", + "isDraft", + "reviewDecision", + "mergeStateStatus", + "headRefName", + "baseRefName", + "author", + "updatedAt", + ] + rows = _run_gh_json( + [ + "pr", + "list", + "--state", + "open", + "--limit", + str(max(1, limit)), + "--json", + ",".join(list_fields), + *repo_args, + ], + cwd=cwd, + ) + if not isinstance(rows, list): + return [] + + view_fields = [ + "number", + "title", + "url", + "isDraft", + "reviewDecision", + "mergeStateStatus", + "mergeable", + "headRefName", + "baseRefName", + "author", + "updatedAt", + "body", + "files", + "commits", + "changedFiles", + "additions", + "deletions", + "statusCheckRollup", + ] + detailed: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + number = row.get("number") + if not number: + continue + try: + view = _run_gh_json( + [ + "pr", + "view", + str(number), + "--json", + ",".join(view_fields), + *repo_args, + ], + cwd=cwd, + ) + if isinstance(view, dict): + detailed.append({**row, **view}) + continue + except Exception: + pass + detailed.append(row) + return detailed + + +def load_pr_fixture(path: Path) -> tuple[str | None, list[dict[str, Any]]]: + payload = json.loads(path.read_text(encoding="utf-8")) + if isinstance(payload, list): + return None, [item for item in payload if isinstance(item, dict)] + if not isinstance(payload, dict): + return None, [] + items = payload.get("pull_requests") or payload.get("prs") or [] + return ( + str(payload.get("repository") or "") or None, + [item for item in _as_list(items) if isinstance(item, dict)], + ) + + +def _clean_body_lines(body: object) -> list[str]: + lines: list[str] = [] + for raw in str(body or "").splitlines(): + line = raw.strip() + if not line: + continue + if line.startswith(""): + continue + if line.startswith("#"): + continue + if line.startswith("- [") or line.startswith("* ["): + continue + if line.lower() in {"summary", "motivation", "changes", "testing"}: + continue + lines.append(line.strip("-* ")) + return lines + + +def _motivation(pr: dict[str, Any]) -> str: + lines = _clean_body_lines(pr.get("body")) + if lines: + return _redact_text(" ".join(lines[:2]), limit=380) + title = pr.get("title") or f"PR #{pr.get('number')}" + return _redact_text(f"Review the intent described by the title: {title}", limit=220) + + +def _commit_headlines(pr: dict[str, Any], *, limit: int = 5) -> list[str]: + commits: list[str] = [] + for item in _as_list(pr.get("commits")): + if not isinstance(item, dict): + continue + headline = item.get("messageHeadline") or _as_dict(item.get("commit")).get("messageHeadline") + if headline: + commits.append(_redact_text(headline, limit=140)) + if len(commits) >= limit: + break + return commits + + +def _file_area(path: str) -> str: + name = path.rsplit("/", 1)[-1] + lowered = path.lower() + if path in { + "README.md", + "README.zh-CN.md", + "AGENTS.md", + "CONTRIBUTING.md", + "CHANGELOG.md", + "LICENSE", + "CODE_OF_CONDUCT.md", + "SECURITY.md", + }: + return "public_entry_or_policy" + if path.startswith(".github/workflows/"): + return "ci_or_release" + if path.startswith("docs/"): + return "public_docs" + if path.startswith(("examples/", "fixtures/", "test/", "tests/", "spec/", "smoke/")): + return "test_or_example" + if path.startswith(UI_PREFIXES): + return "app_or_ui_surface" + if path.startswith(RUNTIME_OR_CLI_PREFIXES): + return "product_runtime" + if path.endswith((".toml", ".yaml", ".yml", ".json", ".ini")) or name in { + "package.json", + "pyproject.toml", + "Cargo.toml", + "go.mod", + "Makefile", + }: + return "build_or_config" + if lowered.endswith((".md", ".mdx", ".rst")): + return "public_docs" + return "other" + + +def _files(pr: dict[str, Any]) -> list[dict[str, Any]]: + files: list[dict[str, Any]] = [] + for item in _as_list(pr.get("files")): + if isinstance(item, str): + path = item + additions = None + deletions = None + elif isinstance(item, dict): + path = str(item.get("path") or item.get("filename") or "") + additions = item.get("additions") + deletions = item.get("deletions") + else: + continue + if not path: + continue + files.append( + { + "path": _redact_text(path, limit=180), + "area": _file_area(path), + "additions": additions, + "deletions": deletions, + } + ) + return files + + +def _area_counts(files: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {} + for item in files: + area = str(item.get("area") or "other") + counts[area] = counts.get(area, 0) + 1 + return counts + + +def _check_name(item: dict[str, Any]) -> str: + return str(item.get("name") or item.get("context") or item.get("workflowName") or "check") + + +def _check_state(item: dict[str, Any]) -> str: + conclusion = str(item.get("conclusion") or "").upper() + status = str(item.get("status") or "").upper() + if conclusion in {"SUCCESS", "PASSED", "NEUTRAL", "SKIPPED"}: + return "success" + if conclusion in {"FAILURE", "FAILED", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED"}: + return "failure" + if status in {"COMPLETED"} and conclusion: + return conclusion.lower() + if status in {"QUEUED", "IN_PROGRESS", "PENDING", "WAITING"}: + return "pending" + return "unknown" + + +def _checks(pr: dict[str, Any]) -> dict[str, Any]: + items = [item for item in _as_list(pr.get("statusCheckRollup")) if isinstance(item, dict)] + counts: dict[str, int] = {} + failures: list[str] = [] + pending: list[str] = [] + for item in items: + state = _check_state(item) + counts[state] = counts.get(state, 0) + 1 + if state == "failure": + failures.append(_redact_text(_check_name(item), limit=120)) + elif state == "pending": + pending.append(_redact_text(_check_name(item), limit=120)) + if not items: + return { + "total": 0, + "counts": {}, + "summary": "No status-check rollup was available from the source.", + "failures": [], + "pending": [], + } + if failures: + summary = f"{len(failures)} failing check(s)." + elif pending: + summary = f"{len(pending)} pending check(s)." + else: + summary = f"{counts.get('success', 0)} successful check(s)." + return { + "total": len(items), + "counts": counts, + "summary": summary, + "failures": failures[:5], + "pending": pending[:5], + } + + +def _risk_notes(pr: dict[str, Any], files: list[dict[str, Any]]) -> list[str]: + notes: list[str] = [] + if pr.get("isDraft"): + notes.append("Draft PR: review may be advisory until it is marked ready.") + if str(pr.get("mergeStateStatus") or "").upper() not in {"", "CLEAN", "HAS_HOOKS", "UNKNOWN"}: + notes.append(f"Merge state is {pr.get('mergeStateStatus')}; check conflict or branch-protection details.") + if any(str(item.get("path") or "").startswith(RUNTIME_OR_CLI_PREFIXES) for item in files): + notes.append("Touches runtime, app, CLI, or automation paths; review behavior and compatibility before merge.") + changed = int(pr.get("changedFiles") or len(files) or 0) + additions = int(pr.get("additions") or 0) + deletions = int(pr.get("deletions") or 0) + if changed >= 12 or additions + deletions >= 800: + notes.append("Large review surface; split the review by area before approving.") + checks = _checks(pr) + if checks.get("failures"): + notes.append("Failing status checks block a clean merge decision.") + return notes + + +def _review_depth(files: list[dict[str, Any]]) -> str: + areas = {str(item.get("area") or "") for item in files} + if "product_runtime" in areas: + return "runtime_behavior_review" + if "app_or_ui_surface" in areas or "public_entry_or_policy" in areas: + return "presentation_or_policy_review" + if areas <= {"public_docs", "test_or_example"}: + return "docs_and_smoke_review" + return "standard_review" + + +def _parse_updated_epoch(value: object) -> float: + text = str(value or "").strip() + if not text: + return 0.0 + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() + except ValueError: + return 0.0 + + +def _review_priority(pr: dict[str, Any], files: list[dict[str, Any]]) -> tuple[int, float]: + is_draft = bool(pr.get("isDraft") or pr.get("is_draft")) + review_decision = str(pr.get("reviewDecision") or pr.get("review_decision") or "").upper() + updated_at = pr.get("updatedAt") or pr.get("updated_at") + if is_draft: + bucket = 4 + elif review_decision in {"", "REVIEW_REQUIRED", "CHANGES_REQUESTED", "UNKNOWN"}: + bucket = 0 + elif _review_depth(files) == "runtime_behavior_review": + bucket = 1 + else: + bucket = 2 + return (bucket, -_parse_updated_epoch(updated_at)) + + +def _normalize_pr(pr: dict[str, Any]) -> dict[str, Any]: + files = _files(pr) + checks = _checks(pr) + number = pr.get("number") + url = pr.get("url") or (f"https://github.com/pull/{number}" if number else "") + item: dict[str, Any] = { + "number": number, + "title": _redact_text(pr.get("title"), limit=180), + "url": _redact_text(url, limit=220), + "author": _redact_text(_as_dict(pr.get("author")).get("login") or pr.get("author"), limit=80), + "updated_at": pr.get("updatedAt"), + "base_ref": _redact_text(pr.get("baseRefName"), limit=80), + "head_ref": _redact_text(pr.get("headRefName"), limit=120), + "is_draft": bool(pr.get("isDraft")), + "review_decision": str(pr.get("reviewDecision") or "UNKNOWN"), + "merge_state": str(pr.get("mergeStateStatus") or pr.get("mergeable") or "UNKNOWN"), + "motivation": _motivation(pr), + "scale": { + "changed_files": int(pr.get("changedFiles") or len(files) or 0), + "additions": int(pr.get("additions") or 0), + "deletions": int(pr.get("deletions") or 0), + }, + "areas": _area_counts(files), + "key_files": files[:10], + "commit_headlines": _commit_headlines(pr), + "checks": checks, + "review_depth": _review_depth(files), + "risk_notes": _risk_notes(pr, files), + "review_prompts": [ + "What user or maintainer value does this PR unlock now?", + "Do the touched files match that stated scope?", + "Are validation evidence and risk boundaries strong enough for merge?", + ], + "evidence_commands": [ + f"gh pr view {number} --json title,body,files,commits,statusCheckRollup", + f"gh pr diff {number} --stat", + ] + if number + else [], + } + return item + + +def build_pr_review_packet( + *, + pull_requests: list[dict[str, Any]], + repository: str | None, + limit: int, + source: str, +) -> dict[str, Any]: + normalized = [_normalize_pr(item) for item in pull_requests[: max(1, limit)]] + normalized.sort(key=lambda item: _review_priority(item, item.get("key_files") or [])) + review_sequence = [ + { + "rank": index, + "number": item.get("number"), + "title": item.get("title"), + "url": item.get("url"), + "review_depth": item.get("review_depth"), + "why_now": _review_why_now(item), + } + for index, item in enumerate(normalized, start=1) + ] + review_required = [ + item + for item in normalized + if str(item.get("review_decision") or "").upper() in {"", "REVIEW_REQUIRED", "CHANGES_REQUESTED", "UNKNOWN"} + ] + first = review_sequence[0] if review_sequence else None + headline = ( + f"{len(normalized)} open PR(s) found; {len(review_required)} need review attention." + if normalized + else "No open pull requests found." + ) + return { + "ok": True, + "schema_version": SCHEMA_VERSION, + "request": { + "schema_version": "loopx_pr_review_command_request_v0", + "command": COMMAND, + "cli_command": "loopx pr-review [--repo owner/repo]", + "repository": repository, + "limit": max(1, limit), + "source": source, + "include": ["motivation", "change_scope", "checks", "risk_notes", "review_sequence"], + "privacy_mode": "public_safe_github_metadata", + "dry_run": True, + }, + "generated_at": _now_iso(), + "summary": { + "headline": headline, + "open_pr_count": len(normalized), + "review_attention_count": len(review_required), + "draft_count": sum(1 for item in normalized if item.get("is_draft")), + "source_surfaces": SOURCE_SURFACES, + "recommended_first_pr": first, + }, + "review_sequence": review_sequence, + "pull_requests": normalized, + "actions": [ + { + "action_id": "act_review_next_pr", + "kind": "review", + "requires_user_approval": False, + "requires_primary_agent": False, + "preview": "Start with the first PR in review_sequence, read its motivation, inspect key files, then decide approve/request changes/defer.", + }, + { + "action_id": "act_merge_after_review", + "kind": "merge_or_publish", + "requires_user_approval": False, + "requires_primary_agent": True, + "preview": "Merge only after repository policy, validation, and public/private boundary checks pass.", + }, + ], + "omissions": [ + "Raw logs, private connector payloads, credentials, local paths, and private source bodies were intentionally omitted.", + "The command summarizes public PR metadata and does not post review comments, approve, merge, or spend quota.", + ], + "boundary": BOUNDARY, + } + + +def _review_why_now(item: dict[str, Any]) -> str: + if item.get("is_draft"): + return "Draft PR; skim for early direction but do not treat as merge-ready." + decision = str(item.get("review_decision") or "").upper() + if decision in {"REVIEW_REQUIRED", "UNKNOWN", ""}: + return "Open and awaiting reviewer decision." + if decision == "CHANGES_REQUESTED": + return "Changes were requested; verify whether the latest diff addresses them." + if decision == "APPROVED": + return "Approved but still open; check merge state and final validation." + return "Open PR; confirm current merge readiness." + + +def render_pr_review_markdown(payload: dict[str, Any]) -> str: + if not payload.get("ok"): + return "# Project PR Review Queue\n\n- ok: `False`\n- error: " + _redact_text(payload.get("error")) + + summary = _as_dict(payload.get("summary")) + request = _as_dict(payload.get("request")) + lines = [ + "# Project PR Review Queue", + "", + f"- command: `{request.get('command')}`", + f"- repository: `{request.get('repository') or 'current gh repository'}`", + f"- headline: {summary.get('headline')}", + f"- counts: open=`{summary.get('open_pr_count')}`, review_attention=`{summary.get('review_attention_count')}`, draft=`{summary.get('draft_count')}`", + "", + "## Review Sequence", + ] + sequence = [item for item in _as_list(payload.get("review_sequence")) if isinstance(item, dict)] + if not sequence: + lines.append("- none") + for item in sequence: + lines.append( + f"{item.get('rank')}. [#{item.get('number')} {item.get('title')}]({item.get('url')}) - " + f"{item.get('review_depth')}: {item.get('why_now')}" + ) + + for pr in [item for item in _as_list(payload.get("pull_requests")) if isinstance(item, dict)]: + lines.extend( + [ + "", + f"## PR #{pr.get('number')}: {pr.get('title')}", + "", + f"- url: {pr.get('url')}", + f"- branch: `{pr.get('head_ref')}` -> `{pr.get('base_ref')}`", + f"- status: review=`{pr.get('review_decision')}`, merge=`{pr.get('merge_state')}`, draft=`{pr.get('is_draft')}`", + f"- motivation: {pr.get('motivation')}", + f"- scale: files=`{_as_dict(pr.get('scale')).get('changed_files')}`, +`{_as_dict(pr.get('scale')).get('additions')}`, -`{_as_dict(pr.get('scale')).get('deletions')}`", + f"- areas: `{json.dumps(pr.get('areas') or {}, ensure_ascii=False)}`", + f"- checks: {_as_dict(pr.get('checks')).get('summary')}", + ] + ) + commits = [item for item in _as_list(pr.get("commit_headlines")) if item] + if commits: + lines.append("- commits: " + "; ".join(f"`{item}`" for item in commits)) + key_files = [item for item in _as_list(pr.get("key_files")) if isinstance(item, dict)] + if key_files: + lines.append("- key files:") + for item in key_files[:8]: + lines.append(f" - `{item.get('path')}` ({item.get('area')})") + risks = [item for item in _as_list(pr.get("risk_notes")) if item] + lines.append("- risk notes: " + ("; ".join(str(item) for item in risks) if risks else "none")) + prompts = [item for item in _as_list(pr.get("review_prompts")) if item] + if prompts: + lines.append("- review prompts: " + " / ".join(str(item) for item in prompts)) + + lines.extend(["", "## Boundary", "- Public PR metadata only; raw/private material is omitted."]) + return "\n".join(lines) diff --git a/loopx/slash_commands.py b/loopx/slash_commands.py index e8aa481c..e460f559 100644 --- a/loopx/slash_commands.py +++ b/loopx/slash_commands.py @@ -88,6 +88,13 @@ def build_slash_command_catalog( legacy_aliases=legacy_risks, implementation_status="host_command_defined", ), + _command( + command="/loopx-pr-review", + scope="repo", + intent="List unmerged pull requests for the current project or explicit --repo target and generate a guided review queue with motivation, change scope, checks, risks, and per-PR review prompts.", + mutation_policy="read_only; does not comment, approve, merge, or spend quota", + cli_reference=f"{cli_bin} pr-review [--repo owner/repo]", + ), ] return { "ok": True, @@ -116,6 +123,7 @@ def render_onboarding_slash_command_note(commands: list[dict[str, Any]], *, cli_ f"- `/loopx `: {goal.get('intent', 'start a concrete project goal')}", "- `/loopx-global-summary`: read the global progress digest.", "- `/loopx-global-gates`, `/loopx-global-todos`, `/loopx-global-risks`: inspect manager-level gates, work, and risks.", + "- `/loopx-pr-review`: review the current project's unmerged PRs one by one with motivation, scope, checks, and risks.", f"CLI help: `{cli_bin} slash-commands`.", ] )