mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
Load GitHub changes and reviews in experimental task channels
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
112
desktop/src-tauri/src/commands/task_github.rs
Normal file
112
desktop/src-tauri/src/commands/task_github.rs
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user