mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user