Load GitHub changes and reviews in experimental task channels

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-09-09 12:43:31 -04:00
parent cf4f091d2c
commit 7fb8d8f490
8 changed files with 307 additions and 14 deletions

View File

@@ -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;

View File

@@ -0,0 +1,112 @@
use std::process::Stdio;
use tokio::io::AsyncReadExt;
async fn github(args: &[&str]) -> Result<serde_json::Value, String> {
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<serde_json::Value, String> {
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::<String>();
github(&[
"api",
"--hostname",
"github.com",
&format!(
"repos/{repository}/compare/{}...{}",
encode(&base),
encode(&branch)
),
])
.await
}

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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<string, unknown> | 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<string, unknown>;
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<string, unknown>;
const title = string(task.title);

View File

@@ -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<GithubChanges | GithubReview[]>("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 (
<a className="text-primary hover:underline" href={href}>
<a
className="text-primary hover:underline"
href={href}
target="_blank"
rel="noreferrer"
onClick={(event) => {
event.preventDefault();
void openUrl(href).catch(() =>
toast.error("Could not open link in browser"),
);
}}
>
{children}
</a>
);
}
function TaskOverview({ task }: { task: ChannelBackedTask }) {
const repository = task.branch && githubRepositoryUrl(task.branch.repository);
return (
<div
className="mx-auto w-full max-w-3xl space-y-6 px-6 py-8"
@@ -52,13 +114,13 @@ function TaskOverview({ task }: { task: ChannelBackedTask }) {
<div>
<dt className="text-xs text-muted-foreground">Origin</dt>
<dd className="mt-1">
<TaskLink href={task.originatingThread}>Open conversation</TaskLink>
<Markdown content={task.originatingThread} />
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Parent</dt>
<dd className="mt-1">
<TaskLink href={task.parentChannel}>Open parent channel</TaskLink>
<Markdown content={task.parentChannel} />
</dd>
</div>
<div className="sm:col-span-2">
@@ -66,7 +128,15 @@ function TaskOverview({ task }: { task: ChannelBackedTask }) {
Implementation branch
</dt>
<dd className="mt-1 font-mono text-xs">
{task.branch?.name ?? "Not started"}
{repository && task.branch ? (
<TaskLink
href={`${repository}/tree/${encodeURIComponent(task.branch.name)}`}
>
{task.branch.name}
</TaskLink>
) : (
(task.branch?.name ?? "Not started")
)}
</dd>
</div>
</dl>
@@ -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 <BuzzLoadingState label="Loading GitHub changes" />;
if (github.error)
return (
<EmptyView
title="Could not load GitHub changes"
description={github.error.message}
/>
);
const changes = github.data as GithubChanges;
return (
<div className="mx-auto w-full max-w-4xl space-y-4 p-6">
<TaskLink href={changes.html_url}>
{branch?.name ?? "Branch changes"}
</TaskLink>
{changes.files.map((file) => (
<details key={file.filename} className="rounded border p-3" open>
<summary className="cursor-pointer font-mono text-xs">
{file.filename}{" "}
<span className="text-green-600">+{file.additions}</span>{" "}
<span className="text-red-600">−{file.deletions}</span>
</summary>
{file.patch ? (
<pre className="mt-3 overflow-x-auto whitespace-pre font-mono text-xs">
{file.patch}
</pre>
) : (
<TaskLink href={file.blob_url}>View file on GitHub</TaskLink>
)}
</details>
))}
<p className="text-xs text-muted-foreground">
GitHub returns patches for up to 300 files. The branch link opens the
complete comparison.
</p>
</div>
);
}
if (!branch)
return (
<EmptyView
@@ -141,6 +252,7 @@ function TaskChanges({ task }: { task: ChannelBackedTask }) {
}
function TaskReview({ task }: { task: ChannelBackedTask }) {
const github = useGithubTask(task, "review");
const repository = task.branch
? githubRepositoryUrl(task.branch.repository)
: null;
@@ -148,6 +260,52 @@ function TaskReview({ task }: { task: ChannelBackedTask }) {
repository && task.branch
? `${repository}/pulls?q=${encodeURIComponent(`is:pr head:${task.branch.name}`)}`
: null;
if (repository && task.branch) {
if (github.isPending)
return <BuzzLoadingState label="Finding pull request" />;
if (github.error)
return (
<EmptyView
title="Could not load pull request"
description={github.error.message}
/>
);
const prs = github.data as GithubReview[];
if (!prs.length)
return (
<EmptyView
title="No pull request yet"
description={`No pull request found for ${task.branch.name}.`}
/>
);
const pr = prs.find((pr) => pr.state === "OPEN") ?? prs[0];
return (
<div className="mx-auto w-full max-w-3xl space-y-4 p-6">
<h2 className="text-lg font-semibold">
<TaskLink href={pr.url}>{`#${pr.number} ${pr.title}`}</TaskLink>
</h2>
<p className="text-sm">
{pr.isDraft ? "Draft" : pr.state} ·{" "}
{pr.reviewDecision || "No review decision"}
</p>
<ul className="space-y-2 text-sm">
{pr.statusCheckRollup?.map((check) => (
<li
key={
check.detailsUrl ??
check.targetUrl ??
check.name ??
check.context
}
>
{check.name ?? check.context}:{" "}
{check.conclusion || check.state || check.status}
</li>
))}
</ul>
</div>
);
}
return (
<EmptyView
action={

View File

@@ -1,10 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import {
channelIdFromLink,
parseChannelBackedTask,
} from "@/features/channels/lib/channelBackedTask";
import { taskParentChannel } from "@/features/channels/lib/channelBackedTask";
import { getCanvases } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
@@ -23,8 +20,7 @@ export function useChannelBackedTaskParents(channels: Channel[]) {
return React.useMemo(() => {
const parents = new Map<string, string>();
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;

View File

@@ -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