From 1ee49bce2f5d72ead161a292190765dabb121f75 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 12:40:59 +0100 Subject: [PATCH] CI monitor and automation toggles in the chat work panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The work drawer becomes a PR monitor: a CI chip shows running (with progress), failing (red, count), or passing (green) from the head commit's check runs, alongside the PR's comment count — both polled while the panel is mounted. Two persisted per-chat checkboxes arm automation: "Auto-fix CI failures" prompts the chat's agent to investigate and fix once a head sha's checks settle red (one nudge per sha), and "Address comments & resolve" prompts it to work through comments and replies, reply, and resolve addressed threads whenever the comment count rises (watermarked so it never repeats). Adds fetch_github_check_summary and head sha/comment counts to the PR fetch. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/commands/link_preview.rs | 91 +++++++++ desktop/src-tauri/src/lib.rs | 1 + .../features/chats/lib/chatWorkAutomation.ts | 90 +++++++++ desktop/src/features/chats/ui/ChatDetail.tsx | 7 +- .../src/features/chats/ui/ChatWorkPanel.tsx | 186 ++++++++++++++++-- desktop/src/shared/lib/githubPullRequest.ts | 44 +++++ desktop/src/testing/e2eBridge.ts | 5 + desktop/tests/e2e/chats-first-message.spec.ts | 6 + 8 files changed, 418 insertions(+), 12 deletions(-) create mode 100644 desktop/src/features/chats/lib/chatWorkAutomation.ts diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 086c7016b..ea015d877 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -25,6 +25,23 @@ pub struct GithubPullRequestInfo { pub changed_files: i64, /// Source branch of the PR (`head.ref`). pub head_ref: String, + /// Head commit sha — used to query check runs. + pub head_sha: String, + /// Issue-level comment count. + pub comments: i64, + /// Review (inline) comment count. + pub review_comments: i64, +} + +/// Aggregate check-run state for a commit, for the chat work panel's CI +/// monitor. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubCheckSummary { + pub total: i64, + pub pending: i64, + pub failed: i64, + pub succeeded: i64, } /// Fetch live PR details from the GitHub REST API. @@ -83,6 +100,80 @@ pub async fn fetch_github_pull_request( deletions: body["deletions"].as_i64().unwrap_or(0), changed_files: body["changed_files"].as_i64().unwrap_or(0), head_ref: body["head"]["ref"].as_str().unwrap_or_default().to_string(), + head_sha: body["head"]["sha"].as_str().unwrap_or_default().to_string(), + comments: body["comments"].as_i64().unwrap_or(0), + review_comments: body["review_comments"].as_i64().unwrap_or(0), + })) +} + +/// Fetch the check-run summary for a commit. Same auth/fallback behavior as +/// [`fetch_github_pull_request`]: `Ok(None)` on any non-success response. +#[tauri::command] +pub async fn fetch_github_check_summary( + owner: String, + repo: String, + sha: String, +) -> Result, String> { + if !is_valid_github_name(&owner) + || !is_valid_github_name(&repo) + || !sha.chars().all(|c| c.is_ascii_hexdigit()) + || sha.is_empty() + || sha.len() > 64 + { + return Err("invalid GitHub check 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 url = format!( + "https://api.github.com/repos/{owner}/{repo}/commits/{sha}/check-runs?per_page=100" + ); + 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}")); + } + + let response = request + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if !response.status().is_success() { + return Ok(None); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + + let 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() { + "completed" => match run["conclusion"].as_str().unwrap_or_default() { + "success" | "neutral" | "skipped" => succeeded += 1, + _ => failed += 1, + }, + _ => pending += 1, + } + } + + Ok(Some(GithubCheckSummary { + total: runs.len() as i64, + pending, + failed, + succeeded, })) } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 93c5fb3fd..579acfadf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -460,6 +460,7 @@ pub fn run() { get_media_proxy_port, fetch_link_preview_title, fetch_github_pull_request, + fetch_github_check_summary, discover_acp_providers, install_acp_runtime, discover_managed_agent_prereqs, diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts new file mode 100644 index 000000000..a8e76af32 --- /dev/null +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -0,0 +1,90 @@ +import * as React from "react"; + +// Per-chat automation preferences for the work panel, plus watermarks that +// keep the auto-prompts from repeating (one CI nudge per failing head sha, +// one comment nudge per count increase). Local state: the prompts are sent +// from this client into the chat, so they never need to sync. +const STORAGE_PREFIX = "buzz:chat-work-automation:v1"; +const STORAGE_EVENT = "buzz:chat-work-automation-changed"; + +export type ChatWorkAutomation = { + autoFixCi: boolean; + addressComments: boolean; + /** Head sha of the last CI failure the agent was asked to fix. */ + lastCiNudgeSha: string | null; + /** Comment total at the last address-comments nudge. */ + lastCommentNudgeCount: number | null; +}; + +const DEFAULTS: ChatWorkAutomation = { + autoFixCi: false, + addressComments: false, + lastCiNudgeSha: null, + lastCommentNudgeCount: null, +}; + +function storageKey(chatId: string) { + return `${STORAGE_PREFIX}:${chatId}`; +} + +export function readChatWorkAutomation(chatId: string): ChatWorkAutomation { + if (typeof window === "undefined") { + return DEFAULTS; + } + try { + const raw = window.localStorage.getItem(storageKey(chatId)); + if (!raw) { + return DEFAULTS; + } + const parsed = JSON.parse(raw) as Partial; + return { + autoFixCi: Boolean(parsed.autoFixCi), + addressComments: Boolean(parsed.addressComments), + lastCiNudgeSha: + typeof parsed.lastCiNudgeSha === "string" + ? parsed.lastCiNudgeSha + : null, + lastCommentNudgeCount: + typeof parsed.lastCommentNudgeCount === "number" + ? parsed.lastCommentNudgeCount + : null, + }; + } catch { + return DEFAULTS; + } +} + +export function updateChatWorkAutomation( + chatId: string, + patch: Partial, +) { + if (typeof window === "undefined") { + return; + } + try { + const next = { ...readChatWorkAutomation(chatId), ...patch }; + window.localStorage.setItem(storageKey(chatId), JSON.stringify(next)); + window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); + } catch { + // Preferences are a convenience layer; ignore unavailable storage. + } +} + +export function useChatWorkAutomation(chatId: string): ChatWorkAutomation { + const [state, setState] = React.useState(() => + readChatWorkAutomation(chatId), + ); + + React.useEffect(() => { + const refresh = () => setState(readChatWorkAutomation(chatId)); + refresh(); + window.addEventListener(STORAGE_EVENT, refresh); + window.addEventListener("storage", refresh); + return () => { + window.removeEventListener(STORAGE_EVENT, refresh); + window.removeEventListener("storage", refresh); + }; + }, [chatId]); + + return state; +} diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index ae668f034..00ce76f7c 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -624,7 +624,12 @@ export function ChatDetail({ /> - + void onSend(content, [])} + open={showWorkPanel} + prHref={workPanelHref} + /> ); diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 924292ac3..d854da93a 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -1,30 +1,94 @@ -import { GitBranch } from "lucide-react"; +import * as React from "react"; +import { + CircleCheck, + CircleDashed, + CircleX, + GitBranch, + LoaderCircle, + MessageSquareText, +} from "lucide-react"; +import { + updateChatWorkAutomation, + useChatWorkAutomation, +} from "@/features/chats/lib/chatWorkAutomation"; import { parseGithubPullRequestRef, + useGithubCheckSummaryQuery, useGithubPullRequestQuery, } from "@/shared/lib/githubPullRequest"; import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; import { cn } from "@/shared/lib/cn"; +import { Checkbox } from "@/shared/ui/checkbox"; import { GithubPullRequestCard } from "@/shared/ui/link-preview-attachment"; +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: the PR's source branch and the live PR - * card once the agent has produced a pull request, or an empty state until - * then. The drawer eases open/closed on its width so the conversation column - * and composer slide to make room. + * 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). */ export function ChatWorkPanel({ + chatId, + onAutomationPrompt, open = true, prHref, }: { + chatId: string; + onAutomationPrompt?: (content: string) => void; open?: boolean; prHref?: string | null; }) { const preview = prHref ? parseSupportedLinkPreview(prHref) : null; const ref = prHref ? parseGithubPullRequestRef(prHref) : null; - const query = useGithubPullRequestQuery(ref); - const branch = query.data?.headRef?.trim(); + const prQuery = useGithubPullRequestQuery(ref); + const pr = prQuery.data ?? null; + const checksQuery = useGithubCheckSummaryQuery(ref, pr?.headSha); + const checks = checksQuery.data ?? null; + 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. + React.useEffect(() => { + if (!onAutomationPrompt || !prHref || !pr) { + return; + } + if ( + automation.autoFixCi && + checks && + checks.failed > 0 && + checks.pending === 0 && + automation.lastCiNudgeSha !== pr.headSha + ) { + updateChatWorkAutomation(chatId, { lastCiNudgeSha: pr.headSha }); + onAutomationPrompt( + `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 }); + 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.`, + ); + } + }, [ + automation, + chatId, + checks, + commentTotal, + onAutomationPrompt, + pr, + prHref, + ]); return ( ); } + +function CiStatus({ + checks, +}: { + checks: { + total: number; + pending: number; + failed: number; + succeeded: number; + } | null; +}) { + if (!checks || checks.total === 0) { + return ( + <> + + No checks + + ); + } + if (checks.pending > 0) { + return ( + <> + + + CI running ({checks.total - checks.pending}/{checks.total}) + + + ); + } + if (checks.failed > 0) { + return ( + <> + + + CI failing ({checks.failed}) + + + ); + } + return ( + <> + + + CI passing + + + ); +} diff --git a/desktop/src/shared/lib/githubPullRequest.ts b/desktop/src/shared/lib/githubPullRequest.ts index 0ff7e47e6..f0a92ed8c 100644 --- a/desktop/src/shared/lib/githubPullRequest.ts +++ b/desktop/src/shared/lib/githubPullRequest.ts @@ -13,6 +13,19 @@ export type GithubPullRequestInfo = { changedFiles: number; /** Source branch of the PR (`head.ref`). */ headRef: string; + /** Head commit sha — used to query check runs. */ + headSha: string; + /** Issue-level comment count. */ + comments: number; + /** Review (inline) comment count. */ + reviewComments: number; +}; + +export type GithubCheckSummary = { + total: number; + pending: number; + failed: number; + succeeded: number; }; export type GithubPullRequestRef = { @@ -69,6 +82,37 @@ export function useGithubPullRequestQuery(ref: GithubPullRequestRef | null) { }, )) ?? null, staleTime: 60_000, + // The work panel doubles as a PR monitor — keep state and comment + // counts fresh while mounted. + refetchInterval: 60_000, + retry: 1, + }); +} + +export function useGithubCheckSummaryQuery( + ref: GithubPullRequestRef | null, + sha: string | null | undefined, +) { + return useQuery({ + enabled: ref !== null && Boolean(sha), + queryKey: [ + "github-check-summary", + ref?.owner ?? "", + ref?.repo ?? "", + sha ?? "", + ], + queryFn: async () => + (await invokeTauri( + "fetch_github_check_summary", + { + owner: ref?.owner ?? "", + repo: ref?.repo ?? "", + sha: sha ?? "", + }, + )) ?? null, + staleTime: 30_000, + // CI flips fast while runs execute; poll while the panel is mounted. + refetchInterval: 45_000, retry: 1, }); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index fbba4c282..724adc656 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8888,6 +8888,8 @@ export function maybeInstallE2eTauriMocks() { ); case "get_media_proxy_port": return MOCK_MEDIA_PROXY_PORT; + case "fetch_github_check_summary": + return { total: 4, pending: 0, failed: 0, succeeded: 4 }; case "fetch_github_pull_request": { // Deterministic PR details so the rich GitHub card renders in mocks. const prPayload = payload as { number?: number }; @@ -8900,6 +8902,9 @@ export function maybeInstallE2eTauriMocks() { deletions: 96, changedFiles: 24, headRef: "kennylopez-chatmode", + headSha: "deadbeefcafe0000000000000000000000000000", + comments: 2, + reviewComments: 1, number: prPayload.number ?? 0, }; } diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 00bb848cc..746e9ebd4 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -155,6 +155,12 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { workPanel.locator("[data-link-preview='github-pull-request']"), ).toBeVisible(); + // CI monitor and automation toggles render alongside the card. + await expect(page.getByTestId("chat-ci-monitor")).toContainText("CI passing"); + await expect(page.getByTestId("chat-ci-monitor")).toContainText("3 comments"); + await expect(page.getByTestId("automation-auto-fix-ci")).toBeVisible(); + await expect(page.getByTestId("automation-address-comments")).toBeVisible(); + // The header's PR button toggles the panel. await page.getByTestId("toggle-work-panel").click(); await expect(workPanel).not.toBeVisible();