diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index ea015d877..1da7734e9 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -42,6 +42,27 @@ pub struct GithubCheckSummary { pub pending: i64, pub failed: i64, pub succeeded: i64, + /// Individual runs for the expanded view (name + coarse state). + pub runs: Vec, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubCheckRun { + pub name: String, + /// `pending` | `success` | `failure`. + pub state: String, +} + +/// Review-thread attention state for a PR: how many threads still await a +/// reply from the PR author. REST cannot see GitHub's "resolved" bit (that +/// is GraphQL-only), so a thread counts as open while its latest comment is +/// from someone other than the PR author — replying clears it, which +/// matches the agent workflow the chat panel automates. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubCommentState { + pub open_threads: i64, } /// Fetch live PR details from the GitHub REST API. @@ -155,28 +176,123 @@ pub async fn fetch_github_check_summary( .await .map_err(|error| format!("github response parse failed: {error}"))?; - let runs = body["check_runs"].as_array().cloned().unwrap_or_default(); + let raw_runs = body["check_runs"].as_array().cloned().unwrap_or_default(); let mut pending = 0; let mut failed = 0; let mut succeeded = 0; - for run in &runs { - match run["status"].as_str().unwrap_or_default() { + let mut runs = Vec::with_capacity(raw_runs.len()); + for run in &raw_runs { + let state = match run["status"].as_str().unwrap_or_default() { "completed" => match run["conclusion"].as_str().unwrap_or_default() { - "success" | "neutral" | "skipped" => succeeded += 1, - _ => failed += 1, + "success" | "neutral" | "skipped" => { + succeeded += 1; + "success" + } + _ => { + failed += 1; + "failure" + } }, - _ => pending += 1, - } + _ => { + pending += 1; + "pending" + } + }; + runs.push(GithubCheckRun { + name: run["name"].as_str().unwrap_or("check").to_string(), + state: state.to_string(), + }); } Ok(Some(GithubCheckSummary { - total: runs.len() as i64, + total: raw_runs.len() as i64, pending, failed, succeeded, + runs, })) } +/// Count review threads still awaiting the PR author's reply. See +/// [`GithubCommentState`] for semantics and the REST limitation. +#[tauri::command] +pub async fn fetch_github_pr_comment_state( + owner: String, + repo: String, + number: u64, +) -> Result, String> { + if !is_valid_github_name(&owner) || !is_valid_github_name(&repo) { + return Err("invalid GitHub repository reference".to_string()); + } + + let client = reqwest::Client::builder() + .pool_idle_timeout(Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .build() + .map_err(|error| format!("github client failed: {error}"))?; + + let base = format!("https://api.github.com/repos/{owner}/{repo}"); + let build = |url: String| { + let mut request = client + .get(url) + .timeout(GITHUB_API_TIMEOUT) + .header(ACCEPT, "application/vnd.github+json") + .header(USER_AGENT, "Buzz Desktop link preview") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = ambient_github_token() { + request = request.header(AUTHORIZATION, format!("Bearer {token}")); + } + request + }; + + let pr_response = build(format!("{base}/pulls/{number}")) + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if !pr_response.status().is_success() { + return Ok(None); + } + let pr: serde_json::Value = pr_response + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + let author = pr["user"]["login"].as_str().unwrap_or_default().to_string(); + + let comments_response = build(format!( + "{base}/pulls/{number}/comments?per_page=100&sort=created&direction=asc" + )) + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if !comments_response.status().is_success() { + return Ok(None); + } + let comments: serde_json::Value = comments_response + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + + // Group into threads by root comment id; the latest comment (list is + // created-ascending) decides whether the thread still needs the author. + let mut last_author_by_thread: std::collections::HashMap = + std::collections::HashMap::new(); + for comment in comments.as_array().cloned().unwrap_or_default() { + let id = comment["id"].as_i64().unwrap_or_default(); + let root = comment["in_reply_to_id"].as_i64().unwrap_or(id); + let login = comment["user"]["login"] + .as_str() + .unwrap_or_default() + .to_string(); + last_author_by_thread.insert(root, login); + } + let open_threads = last_author_by_thread + .values() + .filter(|login| !author.is_empty() && **login != author) + .count() as i64; + + Ok(Some(GithubCommentState { open_threads })) +} + fn ambient_github_token() -> Option { ["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| { std::env::var(name) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 579acfadf..fa42960c7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -461,6 +461,7 @@ pub fn run() { fetch_link_preview_title, fetch_github_pull_request, fetch_github_check_summary, + fetch_github_pr_comment_state, discover_acp_providers, install_acp_runtime, discover_managed_agent_prereqs, diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index d854da93a..1ac9f3a56 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { + ChevronDown, CircleCheck, CircleDashed, CircleX, @@ -13,8 +14,10 @@ import { useChatWorkAutomation, } from "@/features/chats/lib/chatWorkAutomation"; import { + type GithubCheckSummary, parseGithubPullRequestRef, useGithubCheckSummaryQuery, + useGithubCommentStateQuery, useGithubPullRequestQuery, } from "@/shared/lib/githubPullRequest"; import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; @@ -26,11 +29,11 @@ const CHIP_CLASS = "flex items-center gap-1.5 rounded-2xl border border-border/70 bg-muted/30 px-3 py-2.5 text-xs"; /** - * Right-hand work drawer for a chat: branch, live PR card, CI monitor, and - * automation toggles once the agent has produced a pull request; an empty - * state before that. When automation is armed, CI failures and new comments - * prompt the chat's agent automatically (deduped per head sha / comment - * count). + * Right-hand work drawer for a chat: branch, live PR card, and a CI monitor + * once the agent has produced a pull request; an empty state before that. + * The monitor expands to the individual check runs and the automation + * toggles — when armed, CI failures and newly-open review threads prompt the + * chat's agent automatically (deduped per head sha / open-thread watermark). */ export function ChatWorkPanel({ chatId, @@ -49,11 +52,14 @@ export function ChatWorkPanel({ const pr = prQuery.data ?? null; const checksQuery = useGithubCheckSummaryQuery(ref, pr?.headSha); const checks = checksQuery.data ?? null; + const commentStateQuery = useGithubCommentStateQuery(ref); + const openThreads = commentStateQuery.data?.openThreads ?? 0; const automation = useChatWorkAutomation(chatId); - const commentTotal = pr ? pr.comments + pr.reviewComments : 0; - // Automation: prompt the agent on CI failure / new comments. Watermarks in - // storage keep this to one nudge per failing sha and per comment increase. + // Automation: prompt the agent on CI failure / newly-open review threads. + // Watermarks in storage keep this to one nudge per failing sha and per + // rise in open threads; the thread watermark re-arms once everything has + // been replied to (count back at zero). React.useEffect(() => { if (!onAutomationPrompt || !prHref || !pr) { return; @@ -70,25 +76,16 @@ export function ChatWorkPanel({ `CI is failing on ${prHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`, ); } - if ( - automation.addressComments && - commentTotal > 0 && - (automation.lastCommentNudgeCount ?? 0) < commentTotal - ) { - updateChatWorkAutomation(chatId, { lastCommentNudgeCount: commentTotal }); + const threadWatermark = automation.lastCommentNudgeCount ?? 0; + if (openThreads === 0 && threadWatermark !== 0) { + updateChatWorkAutomation(chatId, { lastCommentNudgeCount: 0 }); + } else if (automation.addressComments && openThreads > threadWatermark) { + updateChatWorkAutomation(chatId, { lastCommentNudgeCount: openThreads }); onAutomationPrompt( - `There are review comments on ${prHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, + `There are unanswered review comments on ${prHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, ); } - }, [ - automation, - chatId, - checks, - commentTotal, - onAutomationPrompt, - pr, - prHref, - ]); + }, [automation, chatId, checks, onAutomationPrompt, openThreads, pr, prHref]); return (