mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Expandable CI monitor with per-check runs and open-comment count
The CI chip in the chat work panel is now a dropdown: the summary row keeps the aggregate state plus a chevron, and expanding it lists each check run with its own pass/fail/pending icon, followed by the two automation checkboxes (moved out of their standalone chip). The comment count now shows review threads still awaiting the PR author's reply instead of the raw total — REST can't see GitHub's resolved bit, so replying is what clears a thread. New fetch_github_pr_comment_state command groups review comments into threads and counts the ones whose last word isn't the author's; the address-comments automation nudges on rises in that count and re-arms once it returns to zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1ee49bce2f
commit
e787698a76
@@ -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<GithubCheckRun>,
|
||||
}
|
||||
|
||||
#[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<Option<GithubCommentState>, 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<i64, String> =
|
||||
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<String> {
|
||||
["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| {
|
||||
std::env::var(name)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<aside
|
||||
@@ -116,55 +113,76 @@ export function ChatWorkPanel({
|
||||
<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>
|
||||
<details
|
||||
className={cn(CHIP_CLASS, "group/ci block p-0")}
|
||||
data-testid="chat-ci-monitor"
|
||||
>
|
||||
<summary className="flex cursor-pointer select-none list-none items-center gap-1.5 px-3 py-2.5 [&::-webkit-details-marker]:hidden">
|
||||
<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="min-w-0 truncate text-muted-foreground">
|
||||
{openThreads} open comment{openThreads === 1 ? "" : "s"}
|
||||
</span>
|
||||
<ChevronDown className="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-open/ci:rotate-180" />
|
||||
</summary>
|
||||
<div className="flex flex-col gap-2.5 border-t border-border/40 px-3 py-2.5">
|
||||
{checks && checks.runs.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{dedupeRunKeys(checks.runs).map(({ key, run }) => (
|
||||
<li className="flex items-center gap-1.5" key={key}>
|
||||
<CheckRunIcon state={run.state} />
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{run.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
No checks reported yet.
|
||||
</span>
|
||||
)}
|
||||
<div aria-hidden="true" className="h-px bg-border/40" />
|
||||
<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>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,16 +190,33 @@ export function ChatWorkPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function CiStatus({
|
||||
checks,
|
||||
}: {
|
||||
checks: {
|
||||
total: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
succeeded: number;
|
||||
} | null;
|
||||
}) {
|
||||
/** Matrix jobs can repeat a check name; suffix repeats for stable keys. */
|
||||
function dedupeRunKeys(runs: GithubCheckSummary["runs"]) {
|
||||
const seen = new Map<string, number>();
|
||||
return runs.map((run) => {
|
||||
const count = seen.get(run.name) ?? 0;
|
||||
seen.set(run.name, count + 1);
|
||||
return { key: count === 0 ? run.name : `${run.name} (${count})`, run };
|
||||
});
|
||||
}
|
||||
|
||||
function CheckRunIcon({ state }: { state: "pending" | "success" | "failure" }) {
|
||||
if (state === "pending") {
|
||||
return (
|
||||
<LoaderCircle className="sprout-arc-spinner h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
);
|
||||
}
|
||||
if (state === "failure") {
|
||||
return (
|
||||
<CircleX className="h-3.5 w-3.5 shrink-0 text-[color:var(--status-deleted)]" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CircleCheck className="h-3.5 w-3.5 shrink-0 text-[color:var(--status-added)]" />
|
||||
);
|
||||
}
|
||||
|
||||
function CiStatus({ checks }: { checks: GithubCheckSummary | null }) {
|
||||
if (!checks || checks.total === 0) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -26,6 +26,16 @@ export type GithubCheckSummary = {
|
||||
pending: number;
|
||||
failed: number;
|
||||
succeeded: number;
|
||||
/** Individual check runs for the expanded monitor view. */
|
||||
runs: Array<{ name: string; state: "pending" | "success" | "failure" }>;
|
||||
};
|
||||
|
||||
export type GithubCommentState = {
|
||||
/**
|
||||
* Review threads still awaiting the PR author's reply. REST can't see the
|
||||
* "resolved" bit, so replying is what clears a thread from this count.
|
||||
*/
|
||||
openThreads: number;
|
||||
};
|
||||
|
||||
export type GithubPullRequestRef = {
|
||||
@@ -116,3 +126,27 @@ export function useGithubCheckSummaryQuery(
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGithubCommentStateQuery(ref: GithubPullRequestRef | null) {
|
||||
return useQuery({
|
||||
enabled: ref !== null,
|
||||
queryKey: [
|
||||
"github-pr-comment-state",
|
||||
ref?.owner ?? "",
|
||||
ref?.repo ?? "",
|
||||
ref?.number ?? 0,
|
||||
],
|
||||
queryFn: async () =>
|
||||
(await invokeTauri<GithubCommentState | null>(
|
||||
"fetch_github_pr_comment_state",
|
||||
{
|
||||
owner: ref?.owner ?? "",
|
||||
repo: ref?.repo ?? "",
|
||||
number: ref?.number ?? 0,
|
||||
},
|
||||
)) ?? null,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8889,7 +8889,20 @@ 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 };
|
||||
return {
|
||||
total: 4,
|
||||
pending: 0,
|
||||
failed: 0,
|
||||
succeeded: 4,
|
||||
runs: [
|
||||
{ name: "ci / lint", state: "success" },
|
||||
{ name: "ci / unit-tests", state: "success" },
|
||||
{ name: "ci / desktop-build", state: "success" },
|
||||
{ name: "ci / integration", state: "success" },
|
||||
],
|
||||
};
|
||||
case "fetch_github_pr_comment_state":
|
||||
return { openThreads: 2 };
|
||||
case "fetch_github_pull_request": {
|
||||
// Deterministic PR details so the rich GitHub card renders in mocks.
|
||||
const prPayload = payload as { number?: number };
|
||||
|
||||
@@ -155,9 +155,14 @@ 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");
|
||||
// CI monitor summary shows check state plus unreplied review threads.
|
||||
const ciMonitor = page.getByTestId("chat-ci-monitor");
|
||||
await expect(ciMonitor).toContainText("CI passing");
|
||||
await expect(ciMonitor).toContainText("2 open comments");
|
||||
// Collapsed by default; expanding reveals the runs + automation toggles.
|
||||
await expect(page.getByTestId("automation-auto-fix-ci")).not.toBeVisible();
|
||||
await ciMonitor.locator("summary").click();
|
||||
await expect(ciMonitor).toContainText("ci / unit-tests");
|
||||
await expect(page.getByTestId("automation-auto-fix-ci")).toBeVisible();
|
||||
await expect(page.getByTestId("automation-address-comments")).toBeVisible();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user