From 8a9e168454b866522b76a13f1a08fc79656506b3 Mon Sep 17 00:00:00 2001 From: IsraelAraujo70 Date: Thu, 30 Apr 2026 09:09:02 -0300 Subject: [PATCH 1/3] feat(pr-viewer): approve PR button with greyed state when already approved - New "Aprovar" action in the PR header (visible on both Conversa and Arquivos tabs); opens a small popover with optional review comment - Backend computes viewer_has_approved from the latest non-dismissed review submitted by the viewer; when true the header shows a greyed "Aprovado" chip instead of the green button - New approve_pull_request command wraps the addPullRequestReview GraphQL mutation with event=APPROVE Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/commands.rs | 47 ++++++++++- src-tauri/src/lib.rs | 1 + src/components/pr-viewer.tsx | 149 ++++++++++++++++++++++++++++++++--- src/lib/api.ts | 4 + 4 files changed, 186 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0be7aec..592e53f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -478,6 +478,7 @@ pub struct PrDetails { pub checks: Vec, pub pending_review_id: Option, pub pending_review_threads_count: i64, + pub viewer_has_approved: bool, } const PR_DETAILS_QUERY: &str = r#" @@ -894,14 +895,27 @@ pub async fn get_pr_details( } let viewer_login = data.viewer.login.clone(); let mut pending_review_id: Option = None; + let mut viewer_latest_review: Option<(String, String)> = None; for r in pr.reviews.nodes { - let is_viewer_pending = r.state == "PENDING" - && r.author.as_ref().map(|a| a.login == viewer_login).unwrap_or(false); - if is_viewer_pending { + let is_viewer = r + .author + .as_ref() + .map(|a| a.login == viewer_login) + .unwrap_or(false); + if is_viewer && r.state == "PENDING" { pending_review_id = Some(r.id.clone()); continue; } if let Some(submitted_at) = r.submitted_at.clone() { + if is_viewer && r.state != "DISMISSED" { + let is_later = match &viewer_latest_review { + Some((latest_at, _)) => submitted_at > *latest_at, + None => true, + }; + if is_later { + viewer_latest_review = Some((submitted_at.clone(), r.state.clone())); + } + } timeline.push(TimelineEntry::Review { author: r.author.map(user_to_author), body: r.body, @@ -910,6 +924,9 @@ pub async fn get_pr_details( }); } } + let viewer_has_approved = viewer_latest_review + .map(|(_, state)| state == "APPROVED") + .unwrap_or(false); let mut pending_review_threads_count: i64 = 0; for t in pr.review_threads.nodes { let comments: Vec = t @@ -1051,6 +1068,7 @@ pub async fn get_pr_details( checks, pending_review_id, pending_review_threads_count, + viewer_has_approved, }) } @@ -1272,6 +1290,29 @@ pub async fn submit_pr_review( Ok(()) } +const APPROVE_PR_MUTATION: &str = r#" +mutation($input: AddPullRequestReviewInput!) { + addPullRequestReview(input: $input) { + pullRequestReview { id state } + } +} +"#; + +#[tauri::command] +pub async fn approve_pull_request(pr_node_id: String, body: String) -> AppResult<()> { + let token = auth::load_token()?.ok_or(AppError::NotAuthenticated)?; + let client = Client::new(token)?; + let variables = serde_json::json!({ + "input": { + "pullRequestId": pr_node_id, + "event": "APPROVE", + "body": body, + } + }); + let _: serde_json::Value = client.graphql(APPROVE_PR_MUTATION, variables).await?; + Ok(()) +} + #[tauri::command] pub async fn unresolve_review_thread(thread_id: String) -> AppResult<()> { let token = auth::load_token()?.ok_or(AppError::NotAuthenticated)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 516d49b..932a4a2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -66,6 +66,7 @@ pub fn run() { commands::get_pr_details, commands::get_pr_files, commands::merge_pull_request, + commands::approve_pull_request, commands::add_review_thread_reply, commands::resolve_review_thread, commands::unresolve_review_thread, diff --git a/src/components/pr-viewer.tsx b/src/components/pr-viewer.tsx index 43bcb89..11bddd4 100644 --- a/src/components/pr-viewer.tsx +++ b/src/components/pr-viewer.tsx @@ -137,18 +137,31 @@ export function PrViewer({ pr, onBack }: Props) { > - +
+ {state.status === 'ready' && + resolveStatus(state.data) === 'open' && + !state.data.pending_review_id && + (state.data.viewer_has_approved ? ( + + ) : ( + load(true)} + /> + ))} + +
{state.status === 'ready' && ( @@ -957,6 +970,118 @@ function formatDuration( return `${hr}h ${min % 60}m` } +function ApprovedBadge() { + return ( + + + Aprovado + + ) +} + +function ApproveButton({ + prNodeId, + onApproved, +}: { + prNodeId: string + onApproved: () => Promise +}) { + const [open, setOpen] = useState(false) + const [body, setBody] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + function onClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', onClick) + return () => document.removeEventListener('mousedown', onClick) + }, [open]) + + async function approve() { + if (submitting) return + setSubmitting(true) + setError(null) + try { + await api.approvePullRequest(prNodeId, body) + setBody('') + setOpen(false) + await onApproved() + } catch (e) { + setError(String(e)) + } finally { + setSubmitting(false) + } + } + + return ( +
+ + {open && ( +
+