feat: migrate push-approved check from file-based to server-based - #58
Conversation
Add /rpc/push.check endpoint to the redpen server that checks all tracked sessions for an "approved" verdict. Update the git push hook to query the server first, falling back to the legacy file signal for environments where the server isn't running. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Migrates the git push review gate from consuming a per-repo signal file to querying the local Red Pen HTTP server for an approved review state, with the legacy file check kept as a fallback.
Changes:
- Update the pre-push hook to call
/rpc/push.checkwith a short timeout before falling back to.redpen/signals/push-approved. - Add a new
/rpc/push.checkendpoint toredpen-serverto report whether an approved verdict exists. - Update the
/review-codeskill documentation to describe server-based approval as primary and the signal file as fallback.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| plugin/skills/review-code/SKILL.md | Documents server-tracked approval as the primary push gate signal (legacy file as fallback). |
| plugin/hooks/scripts/check-git-push.sh | Adds server RPC query for push approval before checking the legacy signal file. |
| crates/redpen-server/src/lib.rs | Adds request/response types and a new /rpc/push.check handler/route. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| port="${REDPEN_PORT:-8789}" | ||
|
|
||
| # Primary: check if the Red Pen server reports push-approved | ||
| server_response=$(curl -sf --max-time 2 "http://127.0.0.1:${port}/rpc/push.check" \ | ||
| -X POST -H "Content-Type: application/json" \ | ||
| -d "{\"repo_root\": \"$repo_root\"}" 2>/dev/null || echo "") | ||
|
|
There was a problem hiding this comment.
The hook hardcodes a default port (8789) for the server, but redpen-server binds to an ephemeral port (TcpListener::bind("127.0.0.1:0")) and writes it to ~/.config/redpen/server.json. As-is, the curl will almost always miss the running server and silently fall back to the legacy file gate.
Consider reading the port from the discovery file (same logic as crates/redpen-cli/src/server_client.rs::server_url()), or delegating this check to redpen CLI so the hook stays in sync with server discovery.
| port="${REDPEN_PORT:-8789}" | |
| # Primary: check if the Red Pen server reports push-approved | |
| server_response=$(curl -sf --max-time 2 "http://127.0.0.1:${port}/rpc/push.check" \ | |
| -X POST -H "Content-Type: application/json" \ | |
| -d "{\"repo_root\": \"$repo_root\"}" 2>/dev/null || echo "") | |
| # Determine Red Pen server port: | |
| # 1. Honor REDPEN_PORT if explicitly set. | |
| # 2. Otherwise, read the discovery file written by redpen-server. | |
| if [ -n "${REDPEN_PORT:-}" ]; then | |
| port="$REDPEN_PORT" | |
| else | |
| config_dir="${XDG_CONFIG_HOME:-$HOME/.config}" | |
| discovery_file="$config_dir/redpen/server.json" | |
| if [ -f "$discovery_file" ]; then | |
| port="$(jq -r '.port // empty' "$discovery_file" 2>/dev/null || echo "")" | |
| else | |
| port="" | |
| fi | |
| fi | |
| server_response="" | |
| if [ -n "$port" ]; then | |
| # Primary: check if the Red Pen server reports push-approved | |
| server_response=$(curl -sf --max-time 2 "http://127.0.0.1:${port}/rpc/push.check" \ | |
| -X POST -H "Content-Type: application/json" \ | |
| -d "{\"repo_root\": \"$repo_root\"}" 2>/dev/null || echo "") | |
| fi |
| server_response=$(curl -sf --max-time 2 "http://127.0.0.1:${port}/rpc/push.check" \ | ||
| -X POST -H "Content-Type: application/json" \ | ||
| -d "{\"repo_root\": \"$repo_root\"}" 2>/dev/null || echo "") |
There was a problem hiding this comment.
-d "{\"repo_root\": \"$repo_root\"}" does not JSON-escape $repo_root. Repo paths containing quotes/backslashes/newlines will produce invalid JSON (and can change the request payload).
Build the JSON with a proper escaper (e.g., jq -nc --arg repo_root "$repo_root" '{repo_root:$repo_root}') and pass it to curl.
| server_response=$(curl -sf --max-time 2 "http://127.0.0.1:${port}/rpc/push.check" \ | |
| -X POST -H "Content-Type: application/json" \ | |
| -d "{\"repo_root\": \"$repo_root\"}" 2>/dev/null || echo "") | |
| json_payload=$(jq -nc --arg repo_root "$repo_root" '{repo_root:$repo_root}') | |
| server_response=$(curl -sf --max-time 2 "http://127.0.0.1:${port}/rpc/push.check" \ | |
| -X POST -H "Content-Type: application/json" \ | |
| -d "$json_payload" 2>/dev/null || echo "") |
| async fn rpc_push_check( | ||
| AxumState(state): AxumState<ServerState>, | ||
| Json(_req): Json<PushCheckRequest>, | ||
| ) -> impl IntoResponse { | ||
| // Check all tracked sessions for a recent approval via the persisted state. | ||
| let ids = state.sessions.session_ids().await; | ||
| for session_id in &ids { | ||
| if let Ok(Some(status)) = state.bridge.review_session_status(session_id) { | ||
| if status.verdict.as_deref() == Some("approved") { | ||
| return Json(PushCheckResponse { approved: true }); | ||
| } |
There was a problem hiding this comment.
PushCheckRequest.repo_root is accepted (and the hook sends it), but the handler ignores it (Json(_req)). That means any approved session tracked by the server will allow pushes for all repos, which is weaker than the legacy per-repo signal file.
Use repo_root to scope approvals (e.g., only treat a session as applicable if its persisted ReviewSessionState.file is under the requested repo_root, or explicitly store repo_root with the session when it’s created).
| /// Check whether any session has an "approved" verdict (used by the git push hook). | ||
| async fn rpc_push_check( | ||
| AxumState(state): AxumState<ServerState>, | ||
| Json(_req): Json<PushCheckRequest>, | ||
| ) -> impl IntoResponse { | ||
| // Check all tracked sessions for a recent approval via the persisted state. | ||
| let ids = state.sessions.session_ids().await; | ||
| for session_id in &ids { | ||
| if let Ok(Some(status)) = state.bridge.review_session_status(session_id) { | ||
| if status.verdict.as_deref() == Some("approved") { | ||
| return Json(PushCheckResponse { approved: true }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Json(PushCheckResponse { approved: false }) | ||
| } |
There was a problem hiding this comment.
The legacy gate was one-time use (the signal file is consumed). rpc_push_check returns approved: true for any previously-approved session and never “consumes” that approval or checks recency, so a single approval can effectively allow unlimited future pushes.
If the intent is still “approve the next push”, consider adding consumption/TTL semantics (e.g., record an approval timestamp and require it to be within a short window, or add a separate endpoint to atomically consume an approval token).
| /// Get all tracked session IDs. | ||
| pub async fn session_ids(&self) -> Vec<String> { | ||
| self.session_files.lock().await.keys().cloned().collect() | ||
| } |
There was a problem hiding this comment.
session_ids() exposes all keys from session_files, but session_files entries are never removed. With rpc_push_check looping over all session IDs on every push, this can become an unbounded O(n) scan over time.
Consider pruning completed/old sessions from session_files (or tracking only “active/recent” sessions for push checks) to keep the check bounded.
| @@ -453,6 +487,7 @@ pub fn build_router(bridge: Arc<dyn AppBridge>, sessions: Arc<ReviewSessions>) - | |||
| .route("/rpc/review", post(rpc_review)) | |||
| .route("/rpc/review.pr", post(rpc_review_pr)) | |||
| .route("/rpc/session.annotations", post(rpc_session_annotations)) | |||
| .route("/rpc/push.check", post(rpc_push_check)) | |||
There was a problem hiding this comment.
There are extensive tests for other RPC endpoints in this module, but no test coverage for the new /rpc/push.check behavior. Adding a test would help prevent regressions (e.g., when multiple sessions exist, or when one session is approved).
A minimal test could set up a MockBridge that returns an approved verdict for a known session ID and verify the endpoint returns {approved: true}.
Closes #47
Migrates the push-approved signal from a file-based check to querying the Red Pen server.
Changes:
check-git-push.sh: Now queries/rpc/push.checkon the Red Pen server (2s timeout) as primary check. Falls back to legacy file signal when server isn't running.crates/redpen-server/src/lib.rs: New/rpc/push.checkendpoint that iterates tracked sessions and checks for an approved verdict via the bridge.plugin/skills/review-code/SKILL.md: Updated to note server-based approval is primary, file signal is fallback.🤖 Generated with Claude Code