feat(robomp): added incoming PR review feature with classify and submit
- Added `review_pr` task that checks out PR head in a detached worktree, classifies rank/type/area, and posts a batched GitHub review as `event=COMMENT`. - Added four new host tools: `fetch_pr`, `classify_pr`, `pr_review_comment`, and `submit_pr_review`; review tools self-gate on `review_mode`, push/open-PR tools refuse when `review_mode` is set. - Added sqlite staging table `pr_review_comments` with `stage_review_comment`, `list_staged_review_comments`, and `clear_staged_review_comments` DAOs. - Routed `pull_request.opened/reopened/ready_for_review` to `review_pr` and extended `pull_request.closed` cleanup to any tracked PR regardless of author.
This commit is contained in:
@@ -33,6 +33,9 @@ from robomp.git_ops import (
|
||||
from robomp.git_ops import (
|
||||
clone as git_clone,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
fetch_pr_head as git_fetch_pr_head,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
fetch_prune as git_fetch_prune,
|
||||
)
|
||||
@@ -120,6 +123,35 @@ def _optional_str_list(value: Any, field: str) -> list[str] | None:
|
||||
return list(value)
|
||||
|
||||
|
||||
def _require_review_comments(value: Any) -> list[dict[str, Any]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise HTTPException(400, "missing/invalid 'comments'")
|
||||
comments: list[dict[str, Any]] = []
|
||||
for idx, item in enumerate(value):
|
||||
if not isinstance(item, dict):
|
||||
raise HTTPException(400, f"comments[{idx}] must be an object")
|
||||
path = _require_str(item.get("path"), f"comments[{idx}].path")
|
||||
line = _require_int(item.get("line"), f"comments[{idx}].line")
|
||||
body = _require_str(item.get("body"), f"comments[{idx}].body")
|
||||
side = str(item.get("side") or "RIGHT")
|
||||
if side not in ("RIGHT", "LEFT"):
|
||||
raise HTTPException(400, f"comments[{idx}].side must be RIGHT or LEFT")
|
||||
comment: dict[str, Any] = {"path": path, "line": line, "side": side, "body": body}
|
||||
start_line = item.get("start_line")
|
||||
if start_line is not None:
|
||||
comment["start_line"] = _require_int(start_line, f"comments[{idx}].start_line")
|
||||
start_side = item.get("start_side")
|
||||
if start_side is not None:
|
||||
start_side_str = _require_str(start_side, f"comments[{idx}].start_side")
|
||||
if start_side_str not in ("RIGHT", "LEFT"):
|
||||
raise HTTPException(400, f"comments[{idx}].start_side must be RIGHT or LEFT")
|
||||
comment["start_side"] = start_side_str
|
||||
comments.append(comment)
|
||||
return comments
|
||||
|
||||
|
||||
def _pool_dir(cfg: Settings, repo: str) -> Path:
|
||||
if "/" not in repo or repo.startswith("/") or ".." in repo.split("/"):
|
||||
raise HTTPException(400, f"invalid repo {repo!r}")
|
||||
@@ -346,6 +378,16 @@ def create_proxy_app(settings: Settings) -> FastAPI:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(info))
|
||||
|
||||
@app.get("/gh/v1/pr_files")
|
||||
async def list_pr_files(request: Request, repo: str, pr_number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
items = await github.list_pr_files(repo, pr_number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(item) for item in items]})
|
||||
|
||||
@app.get("/gh/v1/issues")
|
||||
async def list_issues(request: Request, repo: str, state: str = "open", limit: int = 30) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
@@ -467,6 +509,27 @@ def create_proxy_app(settings: Settings) -> FastAPI:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"labels": list(applied)})
|
||||
|
||||
@app.post("/gh/v1/submit_pr_review")
|
||||
async def submit_pr_review(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
pr_number = _require_int(data.get("pr_number"), "pr_number")
|
||||
body = _require_str(data.get("body"), "body")
|
||||
event = str(data.get("event") or "COMMENT")
|
||||
comments = _require_review_comments(data.get("comments"))
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
review = await github.submit_pr_review(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
body=body,
|
||||
event=event,
|
||||
comments=comments,
|
||||
)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(review))
|
||||
|
||||
@app.post("/gh/v1/add_assignees")
|
||||
async def add_assignees(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
@@ -568,6 +631,18 @@ def create_proxy_app(settings: Settings) -> FastAPI:
|
||||
await _run_git_op(git_fetch_ref, target, ref, token=_resolve_token(settings))
|
||||
return JSONResponse({"pool_dir": str(target)})
|
||||
|
||||
@app.post("/gh/v1/git/fetch_pr_head")
|
||||
async def git_fetch_pr_head_endpoint(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
pr_number = _require_int(data.get("pr_number"), "pr_number")
|
||||
target = _pool_dir(settings, repo)
|
||||
try:
|
||||
await _run_git_op(git_fetch_pr_head, target, pr_number, token=_resolve_token(settings))
|
||||
except GitCommandError as exc:
|
||||
return _git_error_response(exc)
|
||||
return JSONResponse({"pool_dir": str(target)})
|
||||
|
||||
@app.post("/gh/v1/git/push")
|
||||
async def git_push_endpoint(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
|
||||
Reference in New Issue
Block a user