From 7fb8d8f490a8a6fe524a62b918264482b2da9556 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 9 Sep 2026 12:43:31 -0400 Subject: [PATCH] Load GitHub changes and reviews in experimental task channels Signed-off-by: John Tennant --- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/commands/task_github.rs | 112 ++++++++++++ desktop/src-tauri/src/lib.rs | 1 + .../channels/lib/channelBackedTask.test.mjs | 9 + .../channels/lib/channelBackedTask.ts | 19 +- .../channels/ui/TaskChannelWorkspace.tsx | 168 +++++++++++++++++- .../channels/useChannelBackedTaskParents.ts | 8 +- docs/experiments/channel-backed-tasks.md | 2 +- 8 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 desktop/src-tauri/src/commands/task_github.rs diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index c8184a0103..d6fa154988 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -13,6 +13,8 @@ mod agent_update_rollback; mod agents; mod bestie; mod canvas; +mod task_github; +pub use task_github::*; mod channel_reconnect_repair; mod channel_templates; mod channel_window; diff --git a/desktop/src-tauri/src/commands/task_github.rs b/desktop/src-tauri/src/commands/task_github.rs new file mode 100644 index 0000000000..83221e9712 --- /dev/null +++ b/desktop/src-tauri/src/commands/task_github.rs @@ -0,0 +1,112 @@ +use std::process::Stdio; +use tokio::io::AsyncReadExt; + +async fn github(args: &[&str]) -> Result { + let executable = crate::managed_agents::resolve_command("gh") + .ok_or("Install GitHub CLI and run gh auth login to view GitHub tasks")?; + let mut child = tokio::process::Command::new(executable) + .args(args) + .env("GH_PROMPT_DISABLED", "1") + .env("GH_HOST", "github.com") + .env_remove("BUZZ_PRIVATE_KEY") + .env_remove("BUZZ_AUTH_TAG") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| e.to_string())?; + let stdout = child.stdout.take().ok_or("Missing GitHub stdout")?; + let stderr = child.stderr.take().ok_or("Missing GitHub stderr")?; + tokio::time::timeout(std::time::Duration::from_secs(45), async { + let mut output = Vec::new(); + let mut error = Vec::new(); + let mut stdout = stdout.take(8 * 1024 * 1024); + let mut stderr = stderr.take(64 * 1024); + tokio::try_join!( + stdout.read_to_end(&mut output), + stderr.read_to_end(&mut error), + ) + .map_err(|e| e.to_string())?; + if output.len() == 8 * 1024 * 1024 || error.len() == 64 * 1024 { + return Err("GitHub response exceeded the preview limit".into()); + } + if !child.wait().await.map_err(|e| e.to_string())?.success() { + return Err(String::from_utf8_lossy(&error).trim().to_string()); + } + serde_json::from_slice(&output).map_err(|e| e.to_string()) + }) + .await + .map_err(|_| "GitHub request timed out".to_string())? +} + +/// Read a branch's GitHub changes or pull requests using the local gh login. +#[tauri::command] +pub async fn get_task_github( + repository: String, + branch: String, + view: String, +) -> Result { + let parts: Vec<_> = repository.split('/').collect(); + if parts.len() != 2 + || parts.iter().any(|p| { + p.is_empty() + || p.starts_with('.') + || !p + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) + }) + { + return Err("Expected GitHub owner/repository".into()); + } + if branch.is_empty() || branch.len() > 1024 { + return Err("Expected a branch name".into()); + } + let prs = github(&[ + "pr", + "list", + "--repo", + &repository, + "--head", + &branch, + "--state", + "all", + "--limit", + "20", + "--json", + "number,title,url,state,isDraft,baseRefName,reviewDecision,statusCheckRollup", + ]) + .await?; + if view == "review" { + return Ok(prs); + } + if view != "changes" { + return Err("Unknown task view".into()); + } + let prs = prs.as_array().ok_or("Invalid GitHub PR response")?; + let pr = prs + .iter() + .find(|pr| pr["state"] == "OPEN") + .or_else(|| prs.first()); + let base = match pr.and_then(|pr| pr["baseRefName"].as_str()) { + Some(base) => base.to_string(), + None => github(&["repo", "view", &repository, "--json", "defaultBranchRef"]).await? + ["defaultBranchRef"]["name"] + .as_str() + .ok_or("Repository has no default branch")? + .to_string(), + }; + let encode = + |value: &str| url::form_urlencoded::byte_serialize(value.as_bytes()).collect::(); + github(&[ + "api", + "--hostname", + "github.com", + &format!( + "repos/{repository}/compare/{}...{}", + encode(&base), + encode(&branch) + ), + ]) + .await +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8f02ca39c6..79e151f04e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -569,6 +569,7 @@ pub fn run() { get_project_repo_snapshot, get_project_repo_file_content, get_project_repo_diff, + get_task_github, get_project_local_repo_diff, get_project_local_repo_snapshot, get_project_local_repo_file_content, diff --git a/desktop/src/features/channels/lib/channelBackedTask.test.mjs b/desktop/src/features/channels/lib/channelBackedTask.test.mjs index e89fa14678..385a167157 100644 --- a/desktop/src/features/channels/lib/channelBackedTask.test.mjs +++ b/desktop/src/features/channels/lib/channelBackedTask.test.mjs @@ -6,6 +6,7 @@ import { githubRepositoryUrl, nestChannels, parseChannelBackedTask, + taskParentChannel, } from "./channelBackedTask.ts"; const CANVAS = `--- @@ -68,6 +69,14 @@ describe("githubRepositoryUrl", () => { }); describe("channel-backed task hierarchy", () => { + it("keeps parent relationships when branch metadata is incomplete", () => { + assert.equal( + taskParentChannel( + CANVAS.replace(/branch:[\s\S]*?\n---/, 'branch: "sol/telemetry"\n---'), + ), + "parent", + ); + }); const channel = (id) => ({ id, name: id }); it("reads channel links and recursively nests children", () => { diff --git a/desktop/src/features/channels/lib/channelBackedTask.ts b/desktop/src/features/channels/lib/channelBackedTask.ts index 1dff6828dd..e2a4c28989 100644 --- a/desktop/src/features/channels/lib/channelBackedTask.ts +++ b/desktop/src/features/channels/lib/channelBackedTask.ts @@ -14,9 +14,9 @@ function string(value: unknown): string | null { } /** Read the experimental channel-backed task contract from canvas frontmatter. */ -export function parseChannelBackedTask( +function taskFrontmatter( content: string | null | undefined, -): ChannelBackedTask | null { +): Record | null { const frontmatter = content?.match( /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/, )?.[1]; @@ -32,6 +32,21 @@ export function parseChannelBackedTask( const record = value as Record; if (record.buzz_schema !== "channel-backed-task/v1") return null; + return record; +} + +export function taskParentChannel( + content: string | null | undefined, +): string | null { + const parent = string(taskFrontmatter(content)?.parent_channel); + return parent ? channelIdFromLink(parent) : null; +} + +export function parseChannelBackedTask( + content: string | null | undefined, +): ChannelBackedTask | null { + const record = taskFrontmatter(content); + if (!record) return null; if (!record.task || typeof record.task !== "object") return null; const task = record.task as Record; const title = string(task.title); diff --git a/desktop/src/features/channels/ui/TaskChannelWorkspace.tsx b/desktop/src/features/channels/ui/TaskChannelWorkspace.tsx index 98869faa26..90cf23a4f9 100644 --- a/desktop/src/features/channels/ui/TaskChannelWorkspace.tsx +++ b/desktop/src/features/channels/ui/TaskChannelWorkspace.tsx @@ -1,6 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { GitBranch, GitPullRequest, MessagesSquare } from "lucide-react"; import * as React from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { toast } from "sonner"; import { useCanvasQuery } from "@/features/channels/hooks"; import { @@ -13,6 +15,54 @@ import { channelChrome } from "@/shared/layout/chromeLayout"; import { Button } from "@/shared/ui/button"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; import { cn } from "@/shared/lib/cn"; +import { Markdown } from "@/shared/ui/markdown"; +import { invokeTauri } from "@/shared/api/tauri"; + +type GithubReview = { + number: number; + title: string; + url: string; + state: string; + isDraft: boolean; + reviewDecision: string; + statusCheckRollup: + | { + name?: string; + context?: string; + detailsUrl?: string; + targetUrl?: string; + conclusion?: string; + status?: string; + state?: string; + }[] + | null; +}; +type GithubChanges = { + html_url: string; + files: { + filename: string; + additions: number; + deletions: number; + patch?: string; + blob_url: string; + }[]; +}; + +function useGithubTask(task: ChannelBackedTask, view: "changes" | "review") { + const repository = task.branch && githubRepositoryUrl(task.branch.repository); + return useQuery({ + queryKey: ["task-github", repository, task.branch?.name, view], + enabled: Boolean(repository && task.branch), + queryFn: () => + invokeTauri("get_task_github", { + repository: repository?.replace("https://github.com/", ""), + branch: task.branch?.name, + view, + }), + staleTime: 30_000, + retry: false, + }); +} type TaskView = "overview" | "changes" | "review" | "conversation"; @@ -25,13 +75,25 @@ const VIEWS: readonly TaskView[] = [ function TaskLink({ href, children }: { href: string; children: string }) { return ( - + { + event.preventDefault(); + void openUrl(href).catch(() => + toast.error("Could not open link in browser"), + ); + }} + > {children} ); } function TaskOverview({ task }: { task: ChannelBackedTask }) { + const repository = task.branch && githubRepositoryUrl(task.branch.repository); return (
Origin
- Open conversation +
Parent
- Open parent channel +
@@ -66,7 +128,15 @@ function TaskOverview({ task }: { task: ChannelBackedTask }) { Implementation branch
- {task.branch?.name ?? "Not started"} + {repository && task.branch ? ( + + {task.branch.name} + + ) : ( + (task.branch?.name ?? "Not started") + )}
@@ -76,8 +146,10 @@ function TaskOverview({ task }: { task: ChannelBackedTask }) { function TaskChanges({ task }: { task: ChannelBackedTask }) { const branch = task.branch; + const github = useGithubTask(task, "changes"); + const isGithub = Boolean(branch && githubRepositoryUrl(branch.repository)); const diff = useQuery({ - enabled: Boolean(branch), + enabled: Boolean(branch) && !isGithub, queryKey: ["channel-backed-task", branch?.repository, branch?.name, "diff"], queryFn: () => getProjectRepoDiff({ @@ -88,6 +160,45 @@ function TaskChanges({ task }: { task: ChannelBackedTask }) { retry: 1, staleTime: 30_000, }); + if (isGithub) { + if (github.isPending) + return ; + if (github.error) + return ( + + ); + const changes = github.data as GithubChanges; + return ( +
+ + {branch?.name ?? "Branch changes"} + + {changes.files.map((file) => ( +
+ + {file.filename}{" "} + +{file.additions}{" "} + −{file.deletions} + + {file.patch ? ( +
+                {file.patch}
+              
+ ) : ( + View file on GitHub + )} +
+ ))} +

+ GitHub returns patches for up to 300 files. The branch link opens the + complete comparison. +

+
+ ); + } if (!branch) return ( ; + if (github.error) + return ( + + ); + const prs = github.data as GithubReview[]; + if (!prs.length) + return ( + + ); + const pr = prs.find((pr) => pr.state === "OPEN") ?? prs[0]; + return ( +
+

+ {`#${pr.number} ${pr.title}`} +

+

+ {pr.isDraft ? "Draft" : pr.state} ·{" "} + {pr.reviewDecision || "No review decision"} +

+
    + {pr.statusCheckRollup?.map((check) => ( +
  • + {check.name ?? check.context}:{" "} + {check.conclusion || check.state || check.status} +
  • + ))} +
+
+ ); + } return ( { const parents = new Map(); for (const channelId of channelIds) { - const task = parseChannelBackedTask(canvases.data?.[channelId]?.content); - const parentId = task && channelIdFromLink(task.parentChannel); + const parentId = taskParentChannel(canvases.data?.[channelId]?.content); if (parentId) parents.set(channelId, parentId); } return parents; diff --git a/docs/experiments/channel-backed-tasks.md b/docs/experiments/channel-backed-tasks.md index 93485ed431..bea48dfb80 100644 --- a/docs/experiments/channel-backed-tasks.md +++ b/docs/experiments/channel-backed-tasks.md @@ -16,7 +16,7 @@ Conversation → local task + new channel → implementation branch → pull req - A branch created outside this workflow can remain unbound temporarily and should be flagged for resolution. A person or agent can attach it to an appropriate channel or create a new one. - Rebinding an established branch is for correcting mistakes, not routine restructuring. - The branch, pull request, CI, and review use the task's channel. -- The client recognizes the canvas schema and adds Overview, Changes, Review, and Conversation views to that channel. Changes are read from the linked branch; Review remains a placeholder until review data is linked. +- The client recognizes the canvas schema and adds Overview, Changes, Review, and Conversation views to that channel. GitHub branches use the local `gh` login to load patches and discover their pull request, review decision, and CI checks. Buzz-hosted branches use the relay's diff support. ## Canvas data model