mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
CI monitor and automation toggles in the chat work panel
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e24f4a8c8a
commit
1ee49bce2f
@@ -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<Option<GithubCheckSummary>, 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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ChatWorkAutomation>;
|
||||
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<ChatWorkAutomation>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
@@ -624,7 +624,12 @@ export function ChatDetail({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ChatWorkPanel open={showWorkPanel} prHref={workPanelHref} />
|
||||
<ChatWorkPanel
|
||||
chatId={chat.id}
|
||||
onAutomationPrompt={(content) => void onSend(content, [])}
|
||||
open={showWorkPanel}
|
||||
prHref={workPanelHref}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<aside
|
||||
@@ -38,11 +102,12 @@ export function ChatWorkPanel({
|
||||
{/* Fixed-width inner wrapper so content never reflows mid-slide. */}
|
||||
<div className="w-96 overflow-y-auto py-4 pl-1 pr-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Same attachment styling as the generic link chips. */}
|
||||
<div className="flex items-center gap-1.5 rounded-2xl border border-border/70 bg-muted/30 px-3 py-2.5 text-xs">
|
||||
<div className={CHIP_CLASS}>
|
||||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{branch ? (
|
||||
<span className="min-w-0 truncate font-mono">{branch}</span>
|
||||
{pr?.headRef?.trim() ? (
|
||||
<span className="min-w-0 truncate font-mono">
|
||||
{pr.headRef.trim()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No current branch</span>
|
||||
)}
|
||||
@@ -50,8 +115,107 @@ export function ChatWorkPanel({
|
||||
{preview ? (
|
||||
<GithubPullRequestCard className="w-full" preview={preview} />
|
||||
) : null}
|
||||
{preview ? (
|
||||
<div className={CHIP_CLASS} data-testid="chat-ci-monitor">
|
||||
<CiStatus checks={checks} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mx-0.5 text-muted-foreground/50"
|
||||
>
|
||||
·
|
||||
</span>
|
||||
<MessageSquareText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
{commentTotal} comment{commentTotal === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{preview ? (
|
||||
<div className={cn(CHIP_CLASS, "flex-col items-stretch gap-2.5")}>
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
htmlFor="automation-auto-fix-ci"
|
||||
>
|
||||
<Checkbox
|
||||
checked={automation.autoFixCi}
|
||||
data-testid="automation-auto-fix-ci"
|
||||
id="automation-auto-fix-ci"
|
||||
onCheckedChange={(checked) =>
|
||||
updateChatWorkAutomation(chatId, {
|
||||
autoFixCi: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>Auto-fix CI failures</span>
|
||||
</label>
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
htmlFor="automation-address-comments"
|
||||
>
|
||||
<Checkbox
|
||||
checked={automation.addressComments}
|
||||
data-testid="automation-address-comments"
|
||||
id="automation-address-comments"
|
||||
onCheckedChange={(checked) =>
|
||||
updateChatWorkAutomation(chatId, {
|
||||
addressComments: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>Address comments & resolve</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function CiStatus({
|
||||
checks,
|
||||
}: {
|
||||
checks: {
|
||||
total: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
succeeded: number;
|
||||
} | null;
|
||||
}) {
|
||||
if (!checks || checks.total === 0) {
|
||||
return (
|
||||
<>
|
||||
<CircleDashed className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">No checks</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (checks.pending > 0) {
|
||||
return (
|
||||
<>
|
||||
<LoaderCircle className="sprout-arc-spinner h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
CI running ({checks.total - checks.pending}/{checks.total})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (checks.failed > 0) {
|
||||
return (
|
||||
<>
|
||||
<CircleX className="h-3.5 w-3.5 shrink-0 text-[color:var(--status-deleted)]" />
|
||||
<span className="font-medium text-[color:var(--status-deleted)]">
|
||||
CI failing ({checks.failed})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<CircleCheck className="h-3.5 w-3.5 shrink-0 text-[color:var(--status-added)]" />
|
||||
<span className="font-medium text-[color:var(--status-added)]">
|
||||
CI passing
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<GithubCheckSummary | null>(
|
||||
"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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user