fix(projects): surface partial repository failures

Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
This commit is contained in:
Wrench
2026-08-24 00:25:08 -04:00
parent 19fe55e2d7
commit 69a30f2ea1
7 changed files with 282 additions and 71 deletions

View File

@@ -78,11 +78,9 @@ export type {
ProjectPullRequestCommentAnchor,
Repository,
};
export type ProjectPullRequestCommentDecision = "request-changes";
const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1";
export type RepoState = {
branches: Array<{ name: string; commit: string }>;
tags: Array<{ name: string; commit: string }>;
@@ -213,8 +211,10 @@ function eventToRepoState(event: RelayEvent): RepoState {
updatedAt: event.created_at,
};
}
async function fetchRepoState(project: Repository): Promise<RepoState | null> {
/** Load the trusted relay state used to resolve a repository's live refs. */
export async function fetchRepoState(
project: Repository,
): Promise<RepoState | null> {
const relaySelf = await getRelaySelf();
const trustedAuthors = [
...new Set(

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
applyProjectHomeCanvas,
PROJECT_HOME_CHANNEL_TEMPLATE,
PROJECT_HOME_TEMPLATE_ID,
renderProjectHomeCanvas,
@@ -13,6 +14,42 @@ test("project home is the built-in default project template", () => {
assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.name, "Project home");
});
test("project home dispatches its rendered canvas to the created channel", async () => {
const calls = [];
const originalWindow = globalThis.window;
const tauriInternals = {
invoke: async (command, args) => {
calls.push({ command, args });
return { ok: true, event_id: "event-1" };
},
};
globalThis.window = { __TAURI_INTERNALS__: tauriInternals };
globalThis.__TAURI_INTERNALS__ = tauriInternals;
try {
const applied = await applyProjectHomeCanvas({
channelId: "11111111-1111-4111-8111-111111111111",
project: {
id: "30621:owner:space-invaders",
dtag: "space-invaders",
name: "Space Invaders",
owner: "a".repeat(64),
repositories: [],
},
});
assert.equal(applied, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].command, "set_canvas");
assert.equal(
calls[0].args.channelId,
"11111111-1111-4111-8111-111111111111",
);
assert.match(calls[0].args.content, /# Project Channel: Space Invaders/);
} finally {
globalThis.window = originalWindow;
delete globalThis.__TAURI_INTERNALS__;
}
});
test("project home canvas fills project, repository, and channel values", () => {
const content = renderProjectHomeCanvas({
channelId: "11111111-1111-4111-8111-111111111111",

View File

@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
});
after(() => dom.window.close());
function repository(id, name) {
return {
id,
name,
repoAddress: `30617:owner:${id}`,
defaultBranch: "main",
};
}
test("multi-repository commits remain visibly degraded when one repository fails", async () => {
const { cleanup, render, screen } = await import("@testing-library/react");
const { ProjectHomeCommitsPanel } = await import(
"./ProjectHomeCommitsPanel.tsx"
);
const loadedRepository = repository("loaded", "Loaded");
const failedRepository = repository("failed", "Failed");
const React = await import("react");
try {
render(
React.createElement(ProjectHomeCommitsPanel, {
onSelectCommit: () => {},
projectId: "project-1",
pullRequests: [],
results: [
{
error: null,
isLoading: false,
repository: loadedRepository,
snapshot: {
contributors: [],
commits: [
{
hash: "a".repeat(40),
shortHash: "aaaaaaa",
authorName: "Alice",
authorEmail: "alice@example.com",
timestamp: 2,
subject: "Loaded commit",
},
],
},
},
{
error: new Error("unavailable"),
isLoading: false,
repository: failedRepository,
snapshot: null,
},
],
}),
);
assert.match(
screen.getByTestId("project-home-commits-degraded").textContent,
/Showing commits from 1 of 2 repositories/,
);
assert.match(document.body.textContent, /Loaded commit/);
} finally {
cleanup();
}
});

View File

@@ -1,4 +1,5 @@
import type { ProjectPullRequest, Repository } from "@/features/projects/hooks";
import { AlertTriangle } from "lucide-react";
import {
projectRepoUnavailablePresentation,
projectRepoUnavailableReason,
@@ -72,18 +73,35 @@ export function ProjectHomeCommitsPanel({
const firstItem = commitItems[0];
if (!firstItem) return null;
return (
<ActivityPanel
commitItems={commitItems}
error={null}
isLoading={false}
onSelectCommit={onSelectCommit}
profiles={profiles}
project={firstItem.project}
projectId={projectId}
pullRequests={pullRequests}
repoContributors={firstItem.repoContributors}
snapshot={null}
viewerGitIdentity={viewerGitIdentity}
/>
<div className="space-y-3">
{failed.length > 0 ? (
<div
className="mx-4 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm"
data-testid="project-home-commits-degraded"
role="status"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<p>
Showing commits from {loaded.length} of {results.length}{" "}
repositories. {failed.length}{" "}
{failed.length === 1 ? "repository could" : "repositories could"}{" "}
not be loaded.
</p>
</div>
) : null}
<ActivityPanel
commitItems={commitItems}
error={null}
isLoading={false}
onSelectCommit={onSelectCommit}
profiles={profiles}
project={firstItem.project}
projectId={projectId}
pullRequests={pullRequests}
repoContributors={firstItem.repoContributors}
snapshot={null}
viewerGitIdentity={viewerGitIdentity}
/>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildCreateProjectAgents } from "./useCreateProjectFormSettings.ts";
const runtime = {
id: "buzz-agent",
label: "Buzz Agent",
availability: "available",
command: "buzz-agent",
binaryPath: "/bin/buzz-agent",
};
function persona(id, displayName) {
return {
id,
displayName,
avatarUrl: null,
systemPrompt: `${displayName} instructions`,
runtime: null,
model: null,
};
}
test("project creation expands a team and deduplicates the separately selected persona", () => {
const alpha = persona("alpha", "Alpha");
const beta = persona("beta", "Beta");
const agents = buildCreateProjectAgents({
agentPersonaId: "beta",
personas: [alpha, beta],
runtimes: [runtime],
teamId: "builders",
teams: [{ id: "builders", personaIds: ["alpha", "beta"] }],
});
assert.deepEqual(
agents.map(({ personaId, teamId }) => ({ personaId, teamId })),
[
{ personaId: "alpha", teamId: "builders" },
{ personaId: "beta", teamId: "builders" },
],
);
assert.equal(agents[0].runtime, runtime);
});

View File

@@ -18,7 +18,70 @@ import {
PROJECT_HOME_TEMPLATE_ID,
} from "@/features/projects/lib/projectHomeTemplate";
import type { ProjectListingVisibility } from "@/features/projects/projectCreation";
import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
import type {
AcpRuntime,
AgentPersona,
AgentTeam,
ChannelTemplate,
ChannelVisibility,
} from "@/shared/api/types";
/** Expand the selected team and persona into deduplicated channel agents. */
export function buildCreateProjectAgents(input: {
agentPersonaId: string;
personas: AgentPersona[];
runtimes: AcpRuntime[];
teamId: string;
teams: AgentTeam[];
}): CreateChannelManagedAgentInput[] {
const defaultRuntime = input.runtimes[0] ?? null;
const agents: CreateChannelManagedAgentInput[] = [];
const seenPersonaIds = new Set<string>();
const addPersona = (persona: AgentPersona, selectedTeamId?: string) => {
if (seenPersonaIds.has(persona.id)) return;
const resolved = resolvePersonaRuntime(
persona.runtime,
input.runtimes,
defaultRuntime,
false,
);
if (!resolved.runtime) {
throw new Error(
resolved.warnings[0] ??
"No agent runtimes are available. Install a runtime to add agents.",
);
}
seenPersonaIds.add(persona.id);
agents.push({
runtime: resolved.runtime,
name: persona.displayName,
personaId: persona.id,
teamId: selectedTeamId,
harnessOverride: false,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
role: "bot",
backend: { type: "local" },
});
};
if (input.teamId) {
const team = input.teams.find((entry) => entry.id === input.teamId);
if (!team) throw new Error("Choose a team that still exists.");
const resolution = resolveTeamPersonas(team, input.personas);
for (const persona of resolution.resolvedPersonas) {
addPersona(persona, team.id);
}
}
if (input.agentPersonaId) {
const persona = input.personas.find(
(entry) => entry.id === input.agentPersonaId,
);
if (!persona) throw new Error("Choose an agent that still exists.");
addPersona(persona);
}
return agents;
}
export function useCreateProjectFormSettings(
active: boolean,
@@ -85,57 +148,17 @@ export function useCreateProjectFormSettings(
}
}, [templateId, templates]);
const buildAgents =
React.useCallback((): CreateChannelManagedAgentInput[] => {
const defaultRuntime = runtimesQuery.data[0] ?? null;
const inputs: CreateChannelManagedAgentInput[] = [];
const seenPersonaIds = new Set<string>();
const addPersona = (
persona: (typeof personas)[number],
selectedTeamId?: string,
) => {
if (seenPersonaIds.has(persona.id)) return;
const resolved = resolvePersonaRuntime(
persona.runtime,
runtimesQuery.data,
defaultRuntime,
false,
);
if (!resolved.runtime) {
throw new Error(
resolved.warnings[0] ??
"No agent runtimes are available. Install a runtime to add agents.",
);
}
seenPersonaIds.add(persona.id);
inputs.push({
runtime: resolved.runtime,
name: persona.displayName,
personaId: persona.id,
teamId: selectedTeamId,
harnessOverride: false,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
role: "bot",
backend: { type: "local" },
});
};
if (teamId) {
const team = teams.find((entry) => entry.id === teamId);
if (!team) throw new Error("Choose a team that still exists.");
const resolution = resolveTeamPersonas(team, personas);
for (const persona of resolution.resolvedPersonas) {
addPersona(persona, team.id);
}
}
if (agentPersonaId) {
const persona = personas.find((entry) => entry.id === agentPersonaId);
if (!persona) throw new Error("Choose an agent that still exists.");
addPersona(persona);
}
return inputs;
}, [agentPersonaId, personas, runtimesQuery.data, teamId, teams]);
const buildAgents = React.useCallback(
() =>
buildCreateProjectAgents({
agentPersonaId,
personas,
runtimes: runtimesQuery.data,
teamId,
teams,
}),
[agentPersonaId, personas, runtimesQuery.data, teamId, teams],
);
const applyTemplate = React.useCallback(
(template: ChannelTemplate) => {

View File

@@ -4,6 +4,8 @@ import type {
ProjectRepoSnapshot,
Repository,
} from "@/features/projects/hooks";
import { fetchRepoState } from "@/features/projects/hooks";
import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches";
import { getProjectRepoSnapshot } from "@/shared/api/projectGit";
export type ProjectRepositorySnapshotResult = {
@@ -24,10 +26,15 @@ export function useProjectRepositorySnapshots(
queryFn: async () => {
const cloneUrl = repository.cloneUrls[0];
if (!cloneUrl) return null;
const repoState = await fetchRepoState(repository);
const defaultBranch = resolveProjectDefaultBranch(
repository.defaultBranch,
repoState,
);
return getProjectRepoSnapshot({
baseBranch: repository.defaultBranch,
baseBranch: defaultBranch,
cloneUrl,
defaultBranch: repository.defaultBranch,
defaultBranch,
});
},
queryKey: [