mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
Improve GitHub bot management hierarchy
Separate behavior and access, retain drafts across tabs, and make current reviews and task conversations easier to scan. Add production Storybook journeys and regression coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
bfc9cc504f
commit
7c1d2503db
12 files changed
+2129
-789
No files matched your search
@@ -33,8 +33,8 @@ required GitHub checks, read
|
||||
New connections default to authorized mentions, advisory reviews, linked-member
|
||||
access, guests off, and enabled bot GitHub tools. Existing instructions, explicit
|
||||
behavior, and narrower saved repository restrictions remain intact. Later
|
||||
repository additions require enablement in connection settings. Advanced review
|
||||
rules and prompts also live in settings.
|
||||
repository additions require enablement in Access. Advanced review rules and
|
||||
prompts live in Settings.
|
||||
|
||||
Local instances receive public callbacks and signed events through an enrolled
|
||||
Paperclip Cloud connector, using outbound requests instead of a public tunnel.
|
||||
@@ -57,6 +57,25 @@ Connection setup reports tool/runtime support and isolation separately; an actua
|
||||
task is still required to prove that the chosen provider can execute in the
|
||||
selected environment.
|
||||
|
||||
## Manage a connected bot
|
||||
|
||||
- **Settings:** edit instructions, choose when the bot runs, and set review output.
|
||||
Event-specific instructions, filters, formal approvals, and repository overrides
|
||||
are available in disclosures.
|
||||
- **Access:** choose enabled repositories, allowed people, each person’s automatic
|
||||
events, and the bot’s GitHub tools. Repository switches save immediately. Other
|
||||
changes use **Save changes**. A linked account alone does not grant selected-member
|
||||
access. External contributors still require a sponsor and restricted permissions.
|
||||
- **Reviews:** see the latest review for each pull request. The result names the
|
||||
reviewed commit; earlier reviews remain in history. A previous passing result
|
||||
does not stand in for a pending review of a newer commit.
|
||||
- **Conversations:** follow the task title to Paperclip or the repository/thread
|
||||
label to GitHub.
|
||||
|
||||
Settings and Access share unsaved edits while you switch connection tabs. Save
|
||||
before reloading or leaving the connection. No permissions change merely by
|
||||
opening a tab or configuring an implicit linked member’s individual event settings.
|
||||
|
||||
## Who can start work
|
||||
|
||||
Linked members may be allowed together or selected individually. Teammates
|
||||
|
||||
@@ -253,3 +253,35 @@ Qualification limits: personal-account setup, delayed organization approval and
|
||||
### Cleanup completed
|
||||
|
||||
Closed disposable issues 1, 2 and 4 and PR 3 without merging. Deleted only the test branch. Retained the App, original connection, bot, vault and test-drive data. The public relay/tunnel was stopped. The private test drive remains available on port 3110 for inspection. Fixes and qualification record were pushed to draft PR #15416; current-head CI/review remain outstanding.
|
||||
|
||||
|
||||
### Management page hierarchy qualification
|
||||
|
||||
The connected bot now separates instructions, triggers, and review output in
|
||||
Settings from repository, people, and tool permissions in Access. Each linked
|
||||
person appears once. Advanced controls are disclosed. Settings and Access share
|
||||
one versioned draft across all connection tabs. Reviews show the latest assessment
|
||||
per repository/PR and preserve earlier commit evidence in history. Conversation
|
||||
titles link directly to tasks; provider labels link directly to their threads.
|
||||
|
||||
Walkthrough: Connectors → search GitHub → Animal Bot → Settings → Access → Reviews
|
||||
→ Conversations. A harmless trailing newline was saved from Access after editing
|
||||
Settings, persisted after a reload, and then was removed through the UI. Redacted
|
||||
API snapshots confirm the full configuration returned exactly to its original
|
||||
value (revision 4 → 6). Repository and people permissions were not changed.
|
||||
|
||||
The real instance was inspected at its desktop width, 820px, and 390px with the
|
||||
actual mobile shell. Review history expanded correctly and the ASCII animal
|
||||
rendered as a code block. Production Storybook fixtures verified a saved edit,
|
||||
a pending current commit with its previous passing score hidden, a recoverable
|
||||
configuration failure, and a mobile empty conversation state. These fixtures do
|
||||
not qualify any new provider execution. Screenshots and configuration comparisons
|
||||
are in the existing local E2E evidence directory.
|
||||
|
||||
Focused UI checks cover draft retention, revision conflicts, isolated repository
|
||||
updates, override preservation/unique field IDs, implicit linked-member event
|
||||
configuration, mentions-only trigger preservation, latest-review ordering, pending
|
||||
head presentation, and GitHub thread labels. The affected management, setup,
|
||||
AgentMail, sidebar, and shared UI-contract group has 47 passing tests. Repository
|
||||
typecheck/build, Storybook build, and token gates pass. The earlier full-suite
|
||||
failure and outstanding current-head CI/review remain as recorded above.
|
||||
@@ -0,0 +1,110 @@
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import type { ChatConversation, ChatProvider } from "@/api/chatEndpoints";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Link } from "@/lib/router";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { AppLogo } from "../AppLogo";
|
||||
|
||||
export function conversationDestination(
|
||||
row: ChatConversation,
|
||||
provider: ChatProvider,
|
||||
) {
|
||||
if (provider === "github" && row.externalUrl) {
|
||||
try {
|
||||
const url = new URL(row.externalUrl);
|
||||
const parts = url.pathname.split("/");
|
||||
if (
|
||||
url.hostname === "github.com" &&
|
||||
["issues", "pull"].includes(parts[3]) &&
|
||||
/^\d+$/.test(parts[4])
|
||||
)
|
||||
return `${parts[1]}/${parts[2]} #${parts[4]}`;
|
||||
} catch {
|
||||
/* Preserve the provider label when no thread URL is available. */
|
||||
}
|
||||
}
|
||||
return row.externalLabel;
|
||||
}
|
||||
|
||||
export function ChatConversationList({
|
||||
rows,
|
||||
provider,
|
||||
}: {
|
||||
rows: ChatConversation[];
|
||||
provider: ChatProvider;
|
||||
}) {
|
||||
if (!rows.length)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
message="No conversations yet"
|
||||
description={
|
||||
provider === "agentmail"
|
||||
? "Send an email to this agent’s address to start one."
|
||||
: "Mention the agent in an enabled destination to start one."
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ul
|
||||
aria-label="Conversations"
|
||||
className="divide-y divide-border border-y border-border"
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} className="flex items-start gap-3 py-4 text-sm">
|
||||
<AppLogo
|
||||
name={provider}
|
||||
brandKey={provider}
|
||||
compact
|
||||
className="mt-0.5 size-5! shrink-0 rounded-sm bg-transparent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="break-words">
|
||||
{row.issueIdentifier && (
|
||||
<span className="mr-2 text-xs text-muted-foreground">
|
||||
{row.issueIdentifier}
|
||||
</span>
|
||||
)}
|
||||
{row.issueId ? (
|
||||
<Link
|
||||
className="break-words font-medium hover:underline"
|
||||
to={`/issues/${row.issueId}`}
|
||||
>
|
||||
{row.issueTitle ?? row.issueIdentifier ?? "Paperclip task"}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
{row.issueTitle ?? "Waiting for task"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{row.externalUrl ? (
|
||||
<a
|
||||
href={row.externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-words hover:text-foreground hover:underline"
|
||||
>
|
||||
{conversationDestination(row, provider)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="break-all">{row.externalLabel}</span>
|
||||
)}
|
||||
{row.updatedAt && (
|
||||
<time dateTime={row.updatedAt}>
|
||||
{formatDateTime(row.updatedAt)}
|
||||
</time>
|
||||
)}
|
||||
{row.state === "completed" && <span>Completed</span>}
|
||||
</div>
|
||||
</div>
|
||||
{row.state !== "active" && row.state !== "completed" && (
|
||||
<StatusBadge status={row.state} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { SlackAvatarSettings } from "./SlackAvatarStep";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
|
||||
import { resolveAgentAppearance } from "@paperclipai/shared";
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { ChatConversationList } from "./ChatConversationList";
|
||||
import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement";
|
||||
import { EmailEndpointSettings } from "./EmailEndpointSetup";
|
||||
import { EmailConnectionAccess } from "@/components/EmailConnectionAccess";
|
||||
@@ -243,6 +245,11 @@ export function ChatEndpointDetail() {
|
||||
: false,
|
||||
});
|
||||
const endpoint = endpointQuery.data;
|
||||
const avatarAgent = useQuery({
|
||||
queryKey: queryKeys.agents.detail(endpoint?.assignedAgentId ?? ""),
|
||||
queryFn: () => agentsApi.get(endpoint!.assignedAgentId),
|
||||
enabled: endpoint?.provider === "github",
|
||||
});
|
||||
const [copyStatus, setCopyStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -291,12 +298,14 @@ export function ChatEndpointDetail() {
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6 pb-12">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{endpoint.provider === "github" && <AgentAvatar agent={avatarAgent.data ?? { id: endpoint.assignedAgentId, name: endpoint.assignedAgentName }} size={40} />}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-bold">
|
||||
{endpoint.assignedAgentName} in {providerNames[endpoint.provider]}
|
||||
{endpoint.provider === "github" ? <Link to={`/agents/${endpoint.assignedAgentId}`} className="hover:underline">{endpoint.assignedAgentName}</Link> : `${endpoint.assignedAgentName} in ${providerNames[endpoint.provider]}`}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{endpoint.providerAccountLabel ?? (endpoint.provider === "agentmail" ? endpoint.botExternalId ?? "Email connection" : "Chat connection")}
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{endpoint.provider === "github" ? <><AppLogo name="GitHub" brandKey="github" compact className="size-4! rounded-sm bg-transparent" />{endpoint.botLabel ?? endpoint.botUsername ?? "GitHub"}{endpoint.providerAccountLabel && <span>· {endpoint.providerAccountLabel}</span>}</> : endpoint.providerAccountLabel ?? (endpoint.provider === "agentmail" ? endpoint.botExternalId ?? "Email connection" : "Chat connection")}
|
||||
</p>
|
||||
{endpoint.provider === "imessage-photon" && endpoint.botExternalId && endpoint.photonAllocation !== "shared" && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
@@ -308,6 +317,7 @@ export function ChatEndpointDetail() {
|
||||
<span role="status" className="text-muted-foreground">{copyStatus}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{setupIncomplete ? (
|
||||
@@ -325,14 +335,11 @@ export function ChatEndpointDetail() {
|
||||
{endpoint.status !== "active" && <StatusBadge status={endpoint.status} />}
|
||||
</div>
|
||||
</header>
|
||||
{activeTab === "settings" && (
|
||||
<>
|
||||
{endpoint.provider === "github" && <GitHubBotManagement endpoint={endpoint} view="settings" />}
|
||||
{endpoint.provider !== "github" && <Settings endpointId={endpoint.id} endpoint={endpoint} />}
|
||||
</>
|
||||
)}
|
||||
{endpoint.provider === "github" && <div hidden={activeTab !== "settings" && activeTab !== "access"}>
|
||||
<GitHubBotManagement key={endpoint.id} endpoint={endpoint} view={activeTab === "access" ? "access" : "settings"} />
|
||||
</div>}
|
||||
{activeTab === "settings" && endpoint.provider !== "github" && <Settings endpointId={endpoint.id} endpoint={endpoint} />}
|
||||
{activeTab === "reviews" && endpoint.provider === "github" && <GitHubReviews endpointId={endpoint.id} />}
|
||||
{activeTab === "access" && endpoint.provider === "github" && <GitHubBotManagement endpoint={endpoint} view="access" />}
|
||||
{activeTab === "access" && endpoint.provider === "agentmail" && <EmailAccess endpoint={endpoint} />}
|
||||
{activeTab === "access" && endpoint.provider !== "github" && endpoint.provider !== "agentmail" && (
|
||||
<Access
|
||||
@@ -769,43 +776,14 @@ function Conversations({
|
||||
queryFn: () => chatEndpointsApi.listConversations(endpointId),
|
||||
...liveChatQueryOptions,
|
||||
});
|
||||
const rows = query.data ?? [];
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Conversations</h2>
|
||||
</div>
|
||||
{query.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading conversations…</p> : query.isError ? (
|
||||
<div className="space-y-3">
|
||||
<p role="alert" className="text-sm text-destructive">Conversations could not be loaded.</p>
|
||||
<Button variant="outline" onClick={() => void query.refetch()}>Try again</Button>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{provider === "agentmail"
|
||||
? "No email conversations yet. Send an email to this agent’s address to start one."
|
||||
: "No conversations yet. Address the agent in an enabled destination to start one."}
|
||||
</p>
|
||||
) : (
|
||||
<ul aria-label="Conversations" className="divide-y divide-border overflow-x-auto border-y border-border">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} className="flex min-w-xl items-center gap-3 px-2 py-3 text-sm transition-colors hover:bg-accent/50">
|
||||
<AppLogo name={providerNames[provider]} brandKey={provider} compact className="size-5! rounded-sm bg-transparent" />
|
||||
<div className="flex min-w-0 max-w-56 items-center gap-2">
|
||||
<span className="truncate font-medium" title={row.externalLabel}>{row.externalLabel}</span>
|
||||
{row.externalUrl && <a href={row.externalUrl} target="_blank" rel="noreferrer" className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">Open {providerNames[provider]}<ExternalLink className="size-3" /></a>}
|
||||
</div>
|
||||
<span aria-hidden="true" className="text-muted-foreground">·</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate" title={row.issueTitle ?? undefined}>{row.issueTitle ?? "Waiting for task"}</span>
|
||||
{row.issueId && <Link to={`/issues/${row.issueId}`} className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">Open task<ExternalLink className="size-3" /></Link>}
|
||||
</div>
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground xl:inline">{row.issueIdentifier}</span>
|
||||
{row.state !== "active" && <StatusBadge status={row.state} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
) : <ChatConversationList rows={query.data} provider={provider} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,418 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
defaultGitHubReviewPolicy,
|
||||
type GitHubChatConfiguration,
|
||||
type GitHubTaskReview,
|
||||
} from "@paperclipai/shared";
|
||||
import { ChatEndpointDetail } from "./ChatEndpointDetail";
|
||||
import { GitHubReviewList, groupGitHubReviews } from "./GitHubBotManagement";
|
||||
import { GitHubPolicyEditor } from "./GitHubBotConfiguration";
|
||||
import { conversationDestination } from "./ChatConversationList";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
tab: "settings",
|
||||
get: vi.fn(),
|
||||
config: vi.fn(),
|
||||
save: vi.fn(),
|
||||
resources: vi.fn(),
|
||||
updateResources: vi.fn(),
|
||||
reviews: vi.fn(),
|
||||
members: vi.fn(),
|
||||
links: vi.fn(),
|
||||
setBreadcrumbs: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/api/githubChat", () => ({
|
||||
githubChatApi: {
|
||||
configuration: mocks.config,
|
||||
save: mocks.save,
|
||||
reviews: mocks.reviews,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api/chatEndpoints", () => ({
|
||||
chatEndpointsApi: {
|
||||
get: mocks.get,
|
||||
listResources: mocks.resources,
|
||||
updateResources: mocks.updateResources,
|
||||
listPrincipals: mocks.links,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api/access", () => ({ accessApi: { listMembers: mocks.members } }));
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: { get: vi.fn() } }));
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: mocks.setBreadcrumbs }),
|
||||
}));
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/components/MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useParams: () => ({ endpointId: "bot", tab: mocks.tab }),
|
||||
useNavigate: () => vi.fn(),
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & { to: string }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
Navigate: () => null,
|
||||
}));
|
||||
const endpoint = {
|
||||
id: "bot",
|
||||
companyId: "company",
|
||||
provider: "github",
|
||||
assignedAgentId: "agent",
|
||||
assignedAgentName: "Maya",
|
||||
status: "active",
|
||||
allowUnlinkedPeople: false,
|
||||
setup: { step: "complete" },
|
||||
};
|
||||
const base: GitHubChatConfiguration = {
|
||||
version: 1,
|
||||
toolsEnabled: true,
|
||||
responsibleUserId: "member",
|
||||
memberAccess: "selected",
|
||||
people: [
|
||||
{
|
||||
kind: "member",
|
||||
userId: "member",
|
||||
githubUserId: "42",
|
||||
login: "maya",
|
||||
automaticReviews: true,
|
||||
},
|
||||
],
|
||||
defaults: {
|
||||
...defaultGitHubReviewPolicy(),
|
||||
instructions: "Keep existing instructions",
|
||||
issueOpened: true,
|
||||
},
|
||||
repositories: {},
|
||||
};
|
||||
|
||||
describe("GitHub bot management", () => {
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let client: QueryClient;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.tab = "settings";
|
||||
mocks.get.mockResolvedValue(endpoint);
|
||||
mocks.config.mockResolvedValue({
|
||||
revision: 4,
|
||||
configuration: structuredClone(base),
|
||||
});
|
||||
mocks.resources.mockResolvedValue([
|
||||
{
|
||||
id: "repo",
|
||||
type: "repository",
|
||||
label: "acme/web",
|
||||
enabled: true,
|
||||
availability: "available",
|
||||
metadata: { providerRepositoryId: "100" },
|
||||
},
|
||||
]);
|
||||
mocks.links.mockResolvedValue([
|
||||
{
|
||||
id: "link",
|
||||
principalId: "identity",
|
||||
status: "linked",
|
||||
githubUserId: "42",
|
||||
githubLogin: "maya",
|
||||
paperclipUserId: "member",
|
||||
paperclipUserLabel: "Maya",
|
||||
},
|
||||
]);
|
||||
mocks.members.mockResolvedValue({
|
||||
members: [
|
||||
{
|
||||
principalId: "member",
|
||||
status: "active",
|
||||
membershipRole: "owner",
|
||||
user: { name: "Maya" },
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.reviews.mockResolvedValue([]);
|
||||
mocks.save.mockImplementation(async (_id, revision, configuration) => ({
|
||||
revision: revision + 1,
|
||||
configuration,
|
||||
}));
|
||||
mocks.updateResources.mockResolvedValue([]);
|
||||
client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
client.setQueryData(queryKeys.agents.detail("agent"), {
|
||||
id: "agent",
|
||||
name: "Maya",
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
client.clear();
|
||||
container.remove();
|
||||
});
|
||||
async function render(tab = mocks.tab) {
|
||||
mocks.tab = tab;
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TooltipProvider>
|
||||
<ChatEndpointDetail />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
container.querySelector(
|
||||
'textarea[placeholder="What should this agent do on GitHub?"]',
|
||||
) || container.querySelector("#github-member-access"),
|
||||
).not.toBeNull(),
|
||||
);
|
||||
}
|
||||
async function click(name: string) {
|
||||
const target = [...container.querySelectorAll("button")].find(
|
||||
(b) =>
|
||||
b.getAttribute("aria-label") === name || b.textContent?.trim() === name,
|
||||
);
|
||||
expect(target).toBeDefined();
|
||||
await act(async () => target!.click());
|
||||
}
|
||||
async function input(
|
||||
element: HTMLTextAreaElement | HTMLSelectElement,
|
||||
value: string,
|
||||
) {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
element instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLSelectElement.prototype,
|
||||
"value",
|
||||
)!.set!.call(element, value);
|
||||
element.dispatchEvent(
|
||||
new Event(element instanceof HTMLSelectElement ? "change" : "input", {
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
it("keeps one draft across Settings, Access, and read-only tabs, then saves with the original revision", async () => {
|
||||
await render();
|
||||
await input(container.querySelector("textarea")!, "Edited instructions");
|
||||
await render("access");
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("@maya"));
|
||||
await click("Automatic events for @maya");
|
||||
await render("reviews");
|
||||
await render("settings");
|
||||
expect(container.querySelector("textarea")?.value).toBe(
|
||||
"Edited instructions",
|
||||
);
|
||||
expect(mocks.save).not.toHaveBeenCalled();
|
||||
await click("Save changes");
|
||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalledTimes(1));
|
||||
const [id, revision, saved] = mocks.save.mock.calls[0];
|
||||
expect(id).toBe("bot");
|
||||
expect(revision).toBe(4);
|
||||
expect(saved).toEqual({
|
||||
...base,
|
||||
defaults: { ...base.defaults, instructions: "Edited instructions" },
|
||||
people: [{ ...base.people[0], automaticReviews: false }],
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain("Changes saved."),
|
||||
);
|
||||
expect(
|
||||
[...container.querySelectorAll("button")].some(
|
||||
(b) => b.textContent === "Save changes",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("retains a rejected draft and lets the user discard it", async () => {
|
||||
mocks.save.mockRejectedValue(
|
||||
new Error("Configuration changed. Reload before saving."),
|
||||
);
|
||||
await render();
|
||||
await input(container.querySelector("textarea")!, "Unsaved");
|
||||
await click("Save changes");
|
||||
await vi.waitFor(() =>
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
"Configuration changed",
|
||||
),
|
||||
);
|
||||
expect(container.querySelector("textarea")?.value).toBe("Unsaved");
|
||||
await click("Discard changes");
|
||||
expect(container.querySelector("textarea")?.value).toBe(
|
||||
base.defaults.instructions,
|
||||
);
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
it("saves repository restrictions separately without touching the behavior draft", async () => {
|
||||
await render();
|
||||
await input(container.querySelector("textarea")!, "Unsaved behavior");
|
||||
await render("access");
|
||||
await click("acme/web");
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.updateResources).toHaveBeenCalledWith("bot", [
|
||||
{ id: "repo", enabled: false },
|
||||
]),
|
||||
);
|
||||
expect(mocks.save).not.toHaveBeenCalled();
|
||||
await render("settings");
|
||||
expect(container.querySelector("textarea")?.value).toBe("Unsaved behavior");
|
||||
});
|
||||
it("creates repository overrides without overwriting defaults or duplicating field IDs", async () => {
|
||||
await render();
|
||||
await input(
|
||||
container.querySelector<HTMLSelectElement>("#github-policy-repository")!,
|
||||
"100",
|
||||
);
|
||||
await click("Use custom settings for this repository");
|
||||
const areas = [
|
||||
...container.querySelectorAll<HTMLTextAreaElement>(
|
||||
'textarea[placeholder="What should this agent do on GitHub?"]',
|
||||
),
|
||||
];
|
||||
expect(areas).toHaveLength(2);
|
||||
await input(areas[1], "Repository guidance");
|
||||
const ids = [...container.querySelectorAll("[id]")].map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
await click("Save changes");
|
||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
||||
const saved = mocks.save.mock.calls[0][2];
|
||||
expect(saved.defaults).toEqual(base.defaults);
|
||||
expect(saved.repositories["100"].instructions).toBe("Repository guidance");
|
||||
});
|
||||
it("configures implicit linked-member events without switching to a narrower selected-member list", async () => {
|
||||
mocks.config.mockResolvedValue({
|
||||
revision: 4,
|
||||
configuration: { ...base, memberAccess: "all_linked", people: [] },
|
||||
});
|
||||
await render("access");
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain("Configure events"),
|
||||
);
|
||||
await click("Configure events");
|
||||
await click("Save changes");
|
||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
||||
expect(mocks.save.mock.calls[0][2].memberAccess).toBe("all_linked");
|
||||
expect(mocks.save.mock.calls[0][2].people[0].automaticReviews).toBe(false);
|
||||
});
|
||||
it("keeps saved automatic triggers when changing to mentions-only", async () => {
|
||||
const change = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<GitHubPolicyEditor policy={base.defaults} onChange={change} />,
|
||||
),
|
||||
);
|
||||
await input(
|
||||
container.querySelector<HTMLSelectElement>(
|
||||
'select[id$="github-invocation"]',
|
||||
)!,
|
||||
"mentions_only",
|
||||
);
|
||||
expect(change.mock.calls[0][0]).toEqual({
|
||||
...base.defaults,
|
||||
invocation: "mentions_only",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function review(
|
||||
id: string,
|
||||
repositoryId: string,
|
||||
pullNumber: number,
|
||||
createdAt: string,
|
||||
updatedAt = createdAt,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
repositoryId,
|
||||
pullNumber,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
repository: "acme/web",
|
||||
headSha: id,
|
||||
issueId: "task",
|
||||
event: { title: "Review focus handling" },
|
||||
state: "queued",
|
||||
assessment: null,
|
||||
conclusion: null,
|
||||
} as GitHubTaskReview;
|
||||
}
|
||||
describe("review history and thread labels", () => {
|
||||
it("groups by repository and PR and orders by creation, not late updates to an older head", () => {
|
||||
const old = review(
|
||||
"old",
|
||||
"repo",
|
||||
1,
|
||||
"2026-10-07T10:00:00Z",
|
||||
"2026-10-07T14:00:00Z",
|
||||
);
|
||||
const current = review("current", "repo", 1, "2026-10-07T11:00:00Z");
|
||||
const other = review("other", "another-repo", 1, "2026-10-07T12:00:00Z");
|
||||
expect(groupGitHubReviews([old, current, other])).toEqual([
|
||||
[other],
|
||||
[current, old],
|
||||
]);
|
||||
});
|
||||
it("does not present a previous passing score as the pending current commit’s result", async () => {
|
||||
const old = review("old", "repo", 1, "2026-10-07T10:00:00Z");
|
||||
old.state = "completed";
|
||||
old.conclusion = "success";
|
||||
old.assessment = {
|
||||
reviewedCommit: "old",
|
||||
score: 5,
|
||||
complete: true,
|
||||
summary: "Previous result",
|
||||
rationale: "Complete",
|
||||
findings: [],
|
||||
coverage: { reviewedPaths: [], omittedPaths: [], limitations: [] },
|
||||
};
|
||||
const current = review("current", "repo", 1, "2026-10-07T11:00:00Z");
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () =>
|
||||
root.render(<GitHubReviewList reviews={[old, current]} />),
|
||||
);
|
||||
const article = container.querySelector("article")!;
|
||||
const latest = article.cloneNode(true) as HTMLElement;
|
||||
latest.querySelectorAll("details").forEach((details) => details.remove());
|
||||
expect(latest.textContent).not.toContain("5/5");
|
||||
expect(latest.textContent).toContain("Queued");
|
||||
const history = [...article.querySelectorAll("details")].find((e) =>
|
||||
e.querySelector("summary")?.textContent?.includes("earlier review"),
|
||||
);
|
||||
expect(history?.textContent).toContain("5/5 · Passed");
|
||||
expect(history?.open).toBe(false);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
});
|
||||
it("identifies GitHub thread numbers while preserving labels for other providers and invalid URLs", () => {
|
||||
const row = {
|
||||
externalLabel: "acme/web",
|
||||
externalUrl: "https://github.com/acme/web/issues/42#issuecomment-1",
|
||||
} as any;
|
||||
expect(conversationDestination(row, "github")).toBe("acme/web #42");
|
||||
expect(conversationDestination(row, "slack")).toBe("acme/web");
|
||||
expect(
|
||||
conversationDestination({ ...row, externalUrl: "invalid" }, "github"),
|
||||
).toBe("acme/web");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ExternalLink, RefreshCw } from "lucide-react";
|
||||
import type { GitHubChatConfiguration } from "@paperclipai/shared";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
GitHubChatConfiguration,
|
||||
GitHubTaskReview,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
githubChatApi,
|
||||
type GitHubConfigurationRecord,
|
||||
} from "@/api/githubChat";
|
||||
import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints";
|
||||
import {
|
||||
chatEndpointsApi,
|
||||
type ChatEndpoint,
|
||||
type ChatEndpointResource,
|
||||
} from "@/api/chatEndpoints";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Link } from "@/lib/router";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import {
|
||||
@@ -17,6 +37,81 @@ import {
|
||||
githubSelectClass,
|
||||
} from "./GitHubBotConfiguration";
|
||||
|
||||
export function GitHubRepositoryAccess({
|
||||
resources,
|
||||
managementUrl,
|
||||
pending,
|
||||
onRefresh,
|
||||
onChange,
|
||||
}: {
|
||||
resources: ChatEndpointResource[];
|
||||
managementUrl: string;
|
||||
pending: boolean;
|
||||
onRefresh: () => void;
|
||||
onChange: (id: string, enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">Repositories</h2>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Manage repositories"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={managementUrl} target="_blank" rel="noreferrer">
|
||||
Manage on GitHub
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={pending} onClick={onRefresh}>
|
||||
<RefreshCw className="size-4" />
|
||||
Refresh repositories
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose where the bot can receive messages and use tools. Repository
|
||||
changes save immediately.
|
||||
</p>
|
||||
<div className="divide-y divide-border border-y border-border">
|
||||
{resources.map((resource) => (
|
||||
<div key={resource.id} className="py-3">
|
||||
<GitHubToggle
|
||||
label={resource.label ?? resource.providerResourceId}
|
||||
checked={resource.enabled}
|
||||
disabled={
|
||||
pending ||
|
||||
(resource.availability !== "available" && !resource.enabled)
|
||||
}
|
||||
description={
|
||||
resource.availability === "available"
|
||||
? undefined
|
||||
: "Unavailable on GitHub. Update the App’s installation access, then refresh."
|
||||
}
|
||||
onChange={(enabled) => onChange(resource.id, enabled)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{resources.length === 0 && (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
No repositories available. Add repository access on GitHub, then
|
||||
refresh.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubBotManagement({
|
||||
endpoint,
|
||||
view,
|
||||
@@ -24,6 +119,7 @@ export function GitHubBotManagement({
|
||||
endpoint: ChatEndpoint;
|
||||
view: "settings" | "access";
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const query = useQuery({
|
||||
queryKey: ["github-bot-configuration", endpoint.id],
|
||||
queryFn: () => githubChatApi.configuration(endpoint.id),
|
||||
@@ -38,6 +134,11 @@ export function GitHubBotManagement({
|
||||
const [notice, setNotice] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const record = draft ?? query.data;
|
||||
const dirty = Boolean(
|
||||
draft &&
|
||||
JSON.stringify(draft.configuration) !==
|
||||
JSON.stringify(query.data?.configuration),
|
||||
);
|
||||
const edit = (configuration: GitHubChatConfiguration) => {
|
||||
if (record) setDraft({ ...record, configuration });
|
||||
setNotice("");
|
||||
@@ -68,179 +169,149 @@ export function GitHubBotManagement({
|
||||
</Button>
|
||||
</p>
|
||||
);
|
||||
if (!record)
|
||||
if (!record || resources.isPending)
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Loading configuration…</p>
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
Loading configuration…
|
||||
</p>
|
||||
);
|
||||
const config = record.configuration;
|
||||
const repositories = (resources.data ?? []).filter(
|
||||
(resource) => resource.type === "repository",
|
||||
);
|
||||
const override = repository ? config.repositories[repository] : undefined;
|
||||
return (
|
||||
<section className="max-w-3xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{view === "access"
|
||||
? "Who can start work"
|
||||
: "Agent and review behavior"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{endpoint.assignedAgentName} is permanently assigned to this bot.
|
||||
GitHub messages create or continue Paperclip tasks; reviews are
|
||||
results of those runs.
|
||||
</p>
|
||||
<Link
|
||||
className="text-sm underline"
|
||||
to={`/apps/${endpoint.connectionId}`}
|
||||
>
|
||||
Bot’s GitHub tool connection
|
||||
</Link>
|
||||
</div>
|
||||
{view === "access" ? (
|
||||
<GitHubAccessEditor
|
||||
endpointId={endpoint.id}
|
||||
companyId={endpoint.companyId}
|
||||
configuration={config}
|
||||
onChange={edit}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<GitHubToggle
|
||||
label="Agent can use this bot’s GitHub tools"
|
||||
description="Uses the same GitHub App, limited to this bot’s enabled repositories and bound tasks. Tool policies still apply."
|
||||
checked={config.toolsEnabled}
|
||||
onChange={(toolsEnabled) => edit({ ...config, toolsEnabled })}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-medium">Repository access</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
void act(async () => {
|
||||
await githubChatApi.refreshRepositories(endpoint.id);
|
||||
await resources.refetch();
|
||||
setNotice(
|
||||
"Repository access refreshed. New repositories stay disabled.",
|
||||
);
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={
|
||||
endpoint.setup?.github?.managementUrl ??
|
||||
endpoint.setup?.github?.installationUrl ??
|
||||
"https://github.com/settings/installations"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Configure on GitHub
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These repositories come from the bot App’s installation. Choose
|
||||
where this bot can receive messages and use tools in Paperclip.
|
||||
</p>
|
||||
{resources.data
|
||||
?.filter((r) => r.type === "repository")
|
||||
.map((resource) => (
|
||||
<GitHubToggle
|
||||
key={resource.id}
|
||||
label={resource.label ?? resource.providerResourceId}
|
||||
description={
|
||||
resource.availability === "available"
|
||||
? undefined
|
||||
: "Installation access is unavailable. Update access on GitHub and refresh."
|
||||
}
|
||||
checked={resource.enabled}
|
||||
onChange={(enabled) =>
|
||||
void act(async () => {
|
||||
await chatEndpointsApi.updateResources(endpoint.id, [
|
||||
{ id: resource.id, enabled },
|
||||
]);
|
||||
await resources.refetch();
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="github-policy-repository"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Review configuration
|
||||
</label>
|
||||
<select
|
||||
id="github-policy-repository"
|
||||
className={githubSelectClass}
|
||||
value={repository}
|
||||
onChange={(e) => setRepository(e.target.value)}
|
||||
>
|
||||
<option value="">Connection defaults</option>
|
||||
{resources.data
|
||||
?.filter(
|
||||
(r) =>
|
||||
r.type === "repository" &&
|
||||
r.enabled &&
|
||||
r.metadata?.providerRepositoryId,
|
||||
)
|
||||
.map((r) => (
|
||||
<option
|
||||
key={r.id}
|
||||
value={String(r.metadata?.providerRepositoryId)}
|
||||
>
|
||||
{r.label ?? r.providerResourceId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{repository && (
|
||||
<GitHubToggle
|
||||
label="Override connection defaults"
|
||||
description="This repository can have its own prompts, filters, and publication permissions."
|
||||
checked={!!override}
|
||||
onChange={(enabled) => {
|
||||
const repositories = { ...config.repositories };
|
||||
if (enabled) repositories[repository] = { ...config.defaults };
|
||||
else delete repositories[repository];
|
||||
edit({ ...config, repositories });
|
||||
}}
|
||||
<section className="max-w-3xl space-y-8">
|
||||
<fieldset disabled={pending} className="min-w-0 space-y-8">
|
||||
{view === "access" ? (
|
||||
<>
|
||||
<GitHubRepositoryAccess
|
||||
resources={repositories}
|
||||
pending={pending}
|
||||
managementUrl={
|
||||
endpoint.setup?.github?.managementUrl ??
|
||||
endpoint.setup?.github?.installationUrl ??
|
||||
"https://github.com/settings/installations"
|
||||
}
|
||||
onRefresh={() =>
|
||||
void act(async () => {
|
||||
await githubChatApi.refreshRepositories(endpoint.id);
|
||||
await resources.refetch();
|
||||
setNotice(
|
||||
"Repository access refreshed. New repositories stay disabled.",
|
||||
);
|
||||
})
|
||||
}
|
||||
onChange={(id, enabled) =>
|
||||
void act(async () => {
|
||||
await chatEndpointsApi.updateResources(endpoint.id, [
|
||||
{ id, enabled },
|
||||
]);
|
||||
await resources.refetch();
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!repository || override ? (
|
||||
<GitHubAccessEditor
|
||||
endpointId={endpoint.id}
|
||||
companyId={endpoint.companyId}
|
||||
configuration={config}
|
||||
onChange={edit}
|
||||
/>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-semibold">Agent tools</h2>
|
||||
<GitHubToggle
|
||||
label="Use this bot’s GitHub tools"
|
||||
description="Limited to enabled repositories and this bot’s tasks. Tool policies still apply."
|
||||
checked={config.toolsEnabled}
|
||||
onChange={(toolsEnabled) => edit({ ...config, toolsEnabled })}
|
||||
/>
|
||||
{endpoint.connectionId && (
|
||||
<Link
|
||||
className="text-xs text-muted-foreground underline underline-offset-4"
|
||||
to={`/apps/${endpoint.connectionId}`}
|
||||
>
|
||||
Manage tool permissions
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitHubPolicyEditor
|
||||
policy={{ ...config.defaults, ...override }}
|
||||
onChange={(policy) =>
|
||||
edit(
|
||||
repository
|
||||
? {
|
||||
policy={config.defaults}
|
||||
onChange={(defaults) => edit({ ...config, defaults })}
|
||||
/>
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
Repository overrides
|
||||
{Object.keys(config.repositories).length > 0
|
||||
? ` (${Object.keys(config.repositories).length})`
|
||||
: ""}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-5">
|
||||
<label
|
||||
htmlFor="github-policy-repository"
|
||||
className="block text-sm font-medium"
|
||||
>
|
||||
Repository
|
||||
</label>
|
||||
<select
|
||||
id="github-policy-repository"
|
||||
className={githubSelectClass}
|
||||
value={repository}
|
||||
onChange={(e) => setRepository(e.target.value)}
|
||||
>
|
||||
<option value="">Choose a repository</option>
|
||||
{repositories
|
||||
.filter(
|
||||
(r) => r.enabled && r.metadata?.providerRepositoryId,
|
||||
)
|
||||
.map((r) => (
|
||||
<option
|
||||
key={r.id}
|
||||
value={String(r.metadata?.providerRepositoryId)}
|
||||
>
|
||||
{r.label ?? r.providerResourceId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{repository && (
|
||||
<GitHubToggle
|
||||
label="Use custom settings for this repository"
|
||||
checked={!!override}
|
||||
onChange={(enabled) => {
|
||||
const overrides = { ...config.repositories };
|
||||
if (enabled)
|
||||
overrides[repository] = { ...config.defaults };
|
||||
else delete overrides[repository];
|
||||
edit({ ...config, repositories: overrides });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{repository && override && (
|
||||
<GitHubPolicyEditor
|
||||
policy={{ ...config.defaults, ...override }}
|
||||
onChange={(policy) =>
|
||||
edit({
|
||||
...config,
|
||||
repositories: {
|
||||
...config.repositories,
|
||||
[repository]: policy,
|
||||
},
|
||||
}
|
||||
: { ...config, defaults: policy },
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This repository follows the connection defaults.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{repository && !override && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uses the settings above.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
@@ -251,40 +322,198 @@ export function GitHubBotManagement({
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!draft || pending}
|
||||
onClick={() => {
|
||||
setDraft(null);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!draft || pending}
|
||||
onClick={() =>
|
||||
void act(async () => {
|
||||
const saved = await githubChatApi.save(
|
||||
endpoint.id,
|
||||
record.revision,
|
||||
config,
|
||||
);
|
||||
setDraft(saved);
|
||||
await query.refetch();
|
||||
{dirty && (
|
||||
<div className="sticky bottom-(--tc-composer-bottom) z-10 flex items-center justify-between gap-3 border-t border-border bg-background py-4 md:bottom-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setDraft(null);
|
||||
setNotice("Configuration saved.");
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
void act(async () => {
|
||||
const saved = await githubChatApi.save(
|
||||
endpoint.id,
|
||||
record.revision,
|
||||
config,
|
||||
);
|
||||
client.setQueryData(
|
||||
["github-bot-configuration", endpoint.id],
|
||||
saved,
|
||||
);
|
||||
setDraft(null);
|
||||
setNotice("Changes saved.");
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function groupGitHubReviews(reviews: GitHubTaskReview[]) {
|
||||
const groups = new Map<string, GitHubTaskReview[]>();
|
||||
for (const review of [...reviews].sort(
|
||||
(a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
|
||||
)) {
|
||||
const key = `${review.repositoryId}:${review.pullNumber}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), review]);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function ReviewResult({ review }: { review: GitHubTaskReview }) {
|
||||
const completed = review.state === "completed" && review.assessment?.complete;
|
||||
const conclusion = review.conclusion
|
||||
? {
|
||||
success: "Passed",
|
||||
failure: "Below threshold",
|
||||
neutral: "Report only",
|
||||
action_required: "Action required",
|
||||
}[review.conclusion]
|
||||
: "Awaiting check";
|
||||
const state = {
|
||||
queued: "Queued",
|
||||
running: "Reviewing",
|
||||
completed: "Incomplete",
|
||||
incomplete: "Incomplete",
|
||||
error: "Review failed",
|
||||
superseded: "Superseded",
|
||||
manual_required: "Action required",
|
||||
}[review.state];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
{completed ? `${review.assessment!.score}/5 · ${conclusion}` : state}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Commit <code>{review.headSha.slice(0, 7)}</code> ·{" "}
|
||||
{formatDateTime(review.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm">
|
||||
{review.assessment?.summary ?? review.event.title}
|
||||
</MarkdownBody>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<Link
|
||||
className="underline underline-offset-4"
|
||||
to={`/issues/${review.issueId}`}
|
||||
>
|
||||
Paperclip task
|
||||
</Link>
|
||||
{review.summaryUrl && (
|
||||
<a
|
||||
className="underline underline-offset-4"
|
||||
href={review.summaryUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub comment
|
||||
</a>
|
||||
)}
|
||||
{review.checkUrl && (
|
||||
<a
|
||||
className="underline underline-offset-4"
|
||||
href={review.checkUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Check
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
Review details
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
{review.runId && (
|
||||
<Link
|
||||
className="text-xs underline underline-offset-4"
|
||||
to={`/issues/${review.issueId}?runId=${review.runId}`}
|
||||
>
|
||||
Agent run
|
||||
</Link>
|
||||
)}
|
||||
{review.assessment && (
|
||||
<>
|
||||
<MarkdownBody>{review.assessment.rationale}</MarkdownBody>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{review.assessment.coverage.reviewedPaths.length} files reviewed
|
||||
· {review.assessment.coverage.omittedPaths.length} omitted
|
||||
</p>
|
||||
{review.assessment.coverage.limitations.map((limit, index) => (
|
||||
<p key={index} className="text-xs text-muted-foreground">
|
||||
{limit}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubReviewList({ reviews }: { reviews: GitHubTaskReview[] }) {
|
||||
if (!reviews.length)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={GitPullRequest}
|
||||
message="No reviews yet"
|
||||
description="Mention the bot on a pull request, or enable automatic events in Settings."
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="divide-y divide-border border-y border-border">
|
||||
{groupGitHubReviews(reviews).map(([latest, ...history]) => (
|
||||
<article
|
||||
key={`${latest.repositoryId}:${latest.pullNumber}`}
|
||||
className="space-y-4 py-5"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<a
|
||||
className="text-sm font-semibold hover:underline"
|
||||
href={`https://github.com/${latest.repository}/pull/${latest.pullNumber}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{latest.event.title || `Pull request #${latest.pullNumber}`}
|
||||
</a>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{latest.repository} #{latest.pullNumber}
|
||||
</p>
|
||||
</div>
|
||||
<ReviewResult review={latest} />
|
||||
{history.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
{history.length} earlier{" "}
|
||||
{history.length === 1 ? "review" : "reviews"}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-6 border-l border-border pl-4">
|
||||
{history.map((review) => (
|
||||
<ReviewResult key={review.id} review={review} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubReviews({ endpointId }: { endpointId: string }) {
|
||||
const query = useQuery({
|
||||
queryKey: ["github-bot-reviews", endpointId],
|
||||
@@ -293,109 +522,23 @@ export function GitHubReviews({ endpointId }: { endpointId: string }) {
|
||||
});
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Reviews</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Review activity from the agent’s Paperclip tasks. Open a task for the
|
||||
conversation and execution history.
|
||||
</p>
|
||||
</div>
|
||||
{query.isError && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Latest review for each pull request. Results apply to the commit shown.
|
||||
</p>
|
||||
{query.isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Reviews could not be loaded.{" "}
|
||||
<Button variant="link" onClick={() => void query.refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
{query.isLoading && (
|
||||
<p className="text-sm text-muted-foreground">Loading reviews…</p>
|
||||
)}
|
||||
{query.data?.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No reviews yet. Mention the bot on an enabled repository’s PR, or
|
||||
enable automatic review events in Settings.
|
||||
) : query.isPending ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
Loading reviews…
|
||||
</p>
|
||||
) : (
|
||||
<GitHubReviewList reviews={query.data} />
|
||||
)}
|
||||
{query.data?.map((review) => (
|
||||
<article
|
||||
key={review.id}
|
||||
className="space-y-3 rounded-lg border border-border p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<a
|
||||
className="text-sm font-medium underline"
|
||||
href={`https://github.com/${review.repository}/pull/${review.pullNumber}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{review.repository} #{review.pullNumber}
|
||||
</a>
|
||||
<span className="text-sm">
|
||||
{review.assessment?.complete
|
||||
? `${review.assessment.score}/5`
|
||||
: review.state.replaceAll("_", " ")}{" "}
|
||||
·{" "}
|
||||
{review.conclusion?.replaceAll("_", " ") ?? "Awaiting assessment"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{review.assessment?.summary ?? review.event.title}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<code>{review.headSha.slice(0, 12)}</code>
|
||||
<span>{formatDateTime(review.updatedAt)}</span>
|
||||
<Link className="underline" to={`/issues/${review.issueId}`}>
|
||||
Paperclip task
|
||||
</Link>
|
||||
{review.runId && (
|
||||
<Link
|
||||
className="underline"
|
||||
to={`/issues/${review.issueId}?runId=${review.runId}`}
|
||||
>
|
||||
Run
|
||||
</Link>
|
||||
)}
|
||||
{review.summaryUrl && (
|
||||
<a
|
||||
className="underline"
|
||||
href={review.summaryUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Summary
|
||||
</a>
|
||||
)}
|
||||
{review.checkUrl && (
|
||||
<a
|
||||
className="underline"
|
||||
href={review.checkUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Check
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{review.assessment && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer">
|
||||
Rationale and coverage
|
||||
</summary>
|
||||
<p className="mt-2">{review.assessment.rationale}</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{review.assessment.coverage.reviewedPaths.length} files reviewed
|
||||
· {review.assessment.coverage.omittedPaths.length} omitted
|
||||
</p>
|
||||
{review.assessment.coverage.limitations.map((limit, index) => (
|
||||
<p key={index} className="mt-1 text-muted-foreground">
|
||||
{limit}
|
||||
</p>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -53,8 +53,6 @@ describe("chat connector UI contract", () => {
|
||||
expect(detail).toContain(`"${tab}"`);
|
||||
}
|
||||
expect(detail).not.toContain('"overview"');
|
||||
expect(detail).toContain("Open {providerNames[provider]}");
|
||||
expect(detail).toContain("Open task");
|
||||
expect(detail.toLowerCase()).not.toContain("detach");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
endpoint,
|
||||
agent,
|
||||
configuration,
|
||||
resources,
|
||||
reviews,
|
||||
conversations,
|
||||
links,
|
||||
members,
|
||||
} from "./fixtures";
|
||||
|
||||
export type FixtureState = "populated" | "empty" | "loading" | "error" | "long";
|
||||
/** Only fixture IDs are intercepted. All shell requests use Storybook's shared API fixtures. */
|
||||
export function FixtureApi({
|
||||
state = "populated",
|
||||
children,
|
||||
}: {
|
||||
state?: FixtureState;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [client] = useState(() => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
client.setQueryData(queryKeys.chatEndpoints.detail(endpoint.id), endpoint);
|
||||
client.setQueryData(queryKeys.agents.detail(agent.id), agent);
|
||||
client.setQueryData(["github-members", endpoint.companyId], members);
|
||||
return client;
|
||||
});
|
||||
const [ready, setReady] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
const original = window.fetch;
|
||||
let saved = { revision: 2, configuration: structuredClone(configuration) };
|
||||
let repos = structuredClone(resources);
|
||||
let identities = structuredClone(links);
|
||||
const failedPaths = new Set<string>();
|
||||
window.fetch = async (input, init) => {
|
||||
const raw =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
const path = new URL(raw, window.location.origin).pathname;
|
||||
if (!path.includes(`/chat-endpoints/${endpoint.id}`))
|
||||
return original(input, init);
|
||||
if (state === "loading") return new Promise<Response>(() => {});
|
||||
if (
|
||||
state === "error" &&
|
||||
(path.endsWith("/configuration") || path.endsWith("/resources")) &&
|
||||
!failedPaths.has(path)
|
||||
) {
|
||||
failedPaths.add(path);
|
||||
return Response.json(
|
||||
{ error: "Could not reach GitHub. Try again." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : {};
|
||||
if (path.endsWith("/configuration")) {
|
||||
if (init?.method === "PUT")
|
||||
saved = {
|
||||
revision: saved.revision + 1,
|
||||
configuration: body.configuration,
|
||||
};
|
||||
return Response.json(saved);
|
||||
}
|
||||
if (path.endsWith("/repositories/refresh")) return Response.json(repos);
|
||||
if (path.endsWith("/resources")) {
|
||||
if (init?.method === "PUT")
|
||||
repos = repos.map((r) => ({
|
||||
...r,
|
||||
enabled:
|
||||
body.resources?.find(
|
||||
(change: { id: string; enabled: boolean }) =>
|
||||
change.id === r.id,
|
||||
)?.enabled ?? r.enabled,
|
||||
}));
|
||||
const rows =
|
||||
state === "empty"
|
||||
? []
|
||||
: state === "long"
|
||||
? repos.map((r) => ({
|
||||
...r,
|
||||
label: `acme/platform-services-production-web-${r.id}-accessibility-improvements`,
|
||||
}))
|
||||
: repos;
|
||||
return Response.json({ items: rows });
|
||||
}
|
||||
if (path.endsWith("/reviews"))
|
||||
return Response.json(state === "empty" ? [] : reviews);
|
||||
if (path.endsWith("/activity"))
|
||||
return Response.json({ items: [], nextCursor: null });
|
||||
if (path.endsWith("/conversations"))
|
||||
return Response.json({
|
||||
items:
|
||||
state === "empty"
|
||||
? []
|
||||
: state === "long"
|
||||
? conversations.map((r) => ({
|
||||
...r,
|
||||
issueTitle: `${r.issueTitle} for every keyboard, screen reader, and narrow screen entry point`,
|
||||
}))
|
||||
: conversations,
|
||||
});
|
||||
if (path.endsWith("/principals"))
|
||||
return Response.json({ items: state === "empty" ? [] : identities });
|
||||
if (path.endsWith("/link") && init?.method === "DELETE") {
|
||||
identities = identities.filter(
|
||||
(link) => !path.includes(link.principalId),
|
||||
);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
if (path.endsWith("/people/lookup"))
|
||||
return Response.json({ githubUserId: "44", login: body.login });
|
||||
return Response.json(endpoint);
|
||||
};
|
||||
setReady(true);
|
||||
return () => {
|
||||
window.fetch = original;
|
||||
client.clear();
|
||||
};
|
||||
}, [client, state]);
|
||||
return ready ? (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
defaultGitHubReviewPolicy,
|
||||
type GitHubChatConfiguration,
|
||||
type GitHubTaskReview,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
ChatEndpoint,
|
||||
ChatEndpointResource,
|
||||
ChatConversation,
|
||||
ChatIdentityLink,
|
||||
} from "@/api/chatEndpoints";
|
||||
import { STORYBOOK_COMPANY_ID } from "../../fixtures/onboardingDraft";
|
||||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
|
||||
export const agent = { ...storybookAgents[0], name: "Maya" };
|
||||
export const endpoint: ChatEndpoint = {
|
||||
id: "story-github-bot",
|
||||
companyId: STORYBOOK_COMPANY_ID,
|
||||
provider: "github",
|
||||
status: "active",
|
||||
assignedAgentId: agent.id,
|
||||
assignedAgentName: agent.name,
|
||||
connectionId: "story-github-tools",
|
||||
botLabel: "Maya Reviews",
|
||||
botUsername: "maya-reviews[bot]",
|
||||
providerAccountLabel: "acme",
|
||||
allowUnlinkedPeople: false,
|
||||
setup: { step: "complete" },
|
||||
};
|
||||
export const configuration: GitHubChatConfiguration = {
|
||||
version: 1,
|
||||
toolsEnabled: true,
|
||||
responsibleUserId: "user-board",
|
||||
memberAccess: "selected",
|
||||
people: [
|
||||
{
|
||||
kind: "member",
|
||||
userId: "user-board",
|
||||
githubUserId: "42",
|
||||
login: "mayacoder",
|
||||
automaticReviews: true,
|
||||
},
|
||||
],
|
||||
defaults: {
|
||||
...defaultGitHubReviewPolicy(),
|
||||
invocation: "allowed_authors",
|
||||
events: ["opened", "synchronize"],
|
||||
issueOpened: true,
|
||||
instructions:
|
||||
"Review for correctness and security. Explain important findings clearly, and suggest a focused fix. For issues, help the author understand the next step.",
|
||||
},
|
||||
repositories: {},
|
||||
};
|
||||
export const resources: ChatEndpointResource[] = [
|
||||
{
|
||||
id: "web",
|
||||
type: "repository",
|
||||
providerResourceId: "acme/web",
|
||||
label: "acme/web",
|
||||
availability: "available",
|
||||
enabled: true,
|
||||
metadata: { providerRepositoryId: "100" },
|
||||
},
|
||||
{
|
||||
id: "api",
|
||||
type: "repository",
|
||||
providerResourceId: "acme/api",
|
||||
label: "acme/api",
|
||||
availability: "available",
|
||||
enabled: false,
|
||||
metadata: { providerRepositoryId: "101" },
|
||||
},
|
||||
];
|
||||
export const links: ChatIdentityLink[] = [
|
||||
{
|
||||
id: "maya",
|
||||
principalId: "maya",
|
||||
githubUserId: "42",
|
||||
githubLogin: "mayacoder",
|
||||
externalLabel: "mayacoder",
|
||||
paperclipUserId: "user-board",
|
||||
paperclipUserLabel: "Maya Chen",
|
||||
status: "linked",
|
||||
},
|
||||
{
|
||||
id: "vin",
|
||||
principalId: "vin",
|
||||
githubUserId: "43",
|
||||
githubLogin: "vin-dev",
|
||||
externalLabel: "vin-dev",
|
||||
paperclipUserId: "vin-member",
|
||||
paperclipUserLabel: "Vin Patel",
|
||||
status: "linked",
|
||||
},
|
||||
];
|
||||
export const members = {
|
||||
members: [
|
||||
{
|
||||
principalId: "user-board",
|
||||
status: "active",
|
||||
membershipRole: "owner",
|
||||
user: { name: "Maya Chen" },
|
||||
},
|
||||
{
|
||||
principalId: "vin-member",
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
user: { name: "Vin Patel" },
|
||||
},
|
||||
],
|
||||
};
|
||||
export function review(
|
||||
id: string,
|
||||
headSha: string,
|
||||
createdAt: string,
|
||||
): GitHubTaskReview {
|
||||
return {
|
||||
id,
|
||||
companyId: endpoint.companyId,
|
||||
endpointId: endpoint.id,
|
||||
issueId: `task-${id}`,
|
||||
runId: null,
|
||||
repositoryId: "100",
|
||||
repository: "acme/web",
|
||||
pullNumber: 24,
|
||||
headSha,
|
||||
configurationRevision: 2,
|
||||
event: {
|
||||
event: "opened",
|
||||
deliveryId: `delivery-${id}`,
|
||||
repositoryId: "100",
|
||||
repository: "acme/web",
|
||||
pullNumber: 24,
|
||||
title: "Fix keyboard focus in the account picker",
|
||||
body: "",
|
||||
baseSha: "0000000",
|
||||
headSha,
|
||||
baseBranch: "main",
|
||||
author: { id: "42", login: "mayacoder", isBot: false },
|
||||
sender: { id: "42", login: "mayacoder" },
|
||||
draft: false,
|
||||
labels: [],
|
||||
},
|
||||
state: "completed",
|
||||
assessment: {
|
||||
reviewedCommit: headSha,
|
||||
score: 5,
|
||||
complete: true,
|
||||
summary:
|
||||
"**5/5** — Focus returns to the trigger after dismissal. Keyboard navigation and the loading state are covered.\n\n```text\n /\\_/\\\n( o.o )\n > ^ <\n```",
|
||||
rationale:
|
||||
"The change preserves the picker’s existing access rules and handles dismissal without losing focus.",
|
||||
coverage: {
|
||||
reviewedPaths: ["ui/AccountPicker.tsx", "ui/AccountPicker.test.tsx"],
|
||||
omittedPaths: [],
|
||||
limitations: [],
|
||||
},
|
||||
findings: [],
|
||||
},
|
||||
conclusion: "success",
|
||||
checkUrl: "https://github.com/acme/web/pull/24/checks",
|
||||
summaryUrl: "https://github.com/acme/web/pull/24#issuecomment-123",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
export const reviews = [
|
||||
review(
|
||||
"latest",
|
||||
"b046e7e18bdda6208515088540aee438091ab120",
|
||||
"2026-10-07T12:43:00Z",
|
||||
),
|
||||
review(
|
||||
"previous",
|
||||
"c1525d7a904142590babb421971e7590d0a03b26",
|
||||
"2026-10-07T12:33:00Z",
|
||||
),
|
||||
];
|
||||
export const conversations: ChatConversation[] = [
|
||||
{
|
||||
id: "pr",
|
||||
externalLabel: "acme/web",
|
||||
externalUrl: "https://github.com/acme/web/pull/24",
|
||||
issueId: "task-latest",
|
||||
issueIdentifier: "PAP-24",
|
||||
issueTitle: "Fix keyboard focus in the account picker",
|
||||
state: "completed",
|
||||
updatedAt: "2026-10-07T12:43:00Z",
|
||||
},
|
||||
{
|
||||
id: "issue",
|
||||
externalLabel: "acme/web",
|
||||
externalUrl: "https://github.com/acme/web/issues/25",
|
||||
issueId: "task-issue",
|
||||
issueIdentifier: "PAP-25",
|
||||
issueTitle: "Investigate the sign-in error after an invitation",
|
||||
state: "active",
|
||||
updatedAt: "2026-10-07T12:50:00Z",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { ChatEndpointDetail } from "@/pages/apps/chat/ChatEndpointDetail";
|
||||
import {
|
||||
GitHubPolicyEditor,
|
||||
GitHubAccessEditor,
|
||||
} from "@/pages/apps/chat/GitHubBotConfiguration";
|
||||
import {
|
||||
GitHubRepositoryAccess,
|
||||
GitHubReviewList,
|
||||
} from "@/pages/apps/chat/GitHubBotManagement";
|
||||
import { ChatConversationList } from "@/pages/apps/chat/ChatConversationList";
|
||||
import {
|
||||
endpoint,
|
||||
configuration,
|
||||
resources,
|
||||
reviews,
|
||||
conversations,
|
||||
} from "./fixtures";
|
||||
import { FixtureApi, type FixtureState } from "./fixture-api";
|
||||
|
||||
const meta = {
|
||||
title: "Connectors/GitHub bot management",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
initialEntries: [`/PAP/apps/chat/${endpoint.id}/settings`],
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Production pages and components. Start with **01 Journey / Settings**, then use the actual contextual sidebar to visit Access, Reviews, and Conversations. Try editing instructions, switch tabs, and save or discard the shared draft. **02 States** covers empty, loading, recoverable error, long content, and mobile. **03 Components** isolates the policy editor, people access, repository access, latest review/history, pending current commit, and conversations. Provider operations use isolated fixture APIs; these stories are not live GitHub evidence.",
|
||||
},
|
||||
},
|
||||
},
|
||||
args: { state: "populated" as FixtureState },
|
||||
argTypes: {
|
||||
state: {
|
||||
control: "select",
|
||||
options: ["populated", "empty", "loading", "error", "long"],
|
||||
},
|
||||
},
|
||||
render: ({ state }) => (
|
||||
<FixtureApi key={state} state={state}>
|
||||
<PluginLauncherProvider>
|
||||
<Routes>
|
||||
<Route path="/:companyPrefix" element={<Layout />}>
|
||||
<Route
|
||||
path="apps/chat/:endpointId/:tab"
|
||||
element={<ChatEndpointDetail />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</PluginLauncherProvider>
|
||||
</FixtureApi>
|
||||
),
|
||||
} satisfies Meta<{ state: FixtureState }>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
const route = (tab: string) => ({
|
||||
initialEntries: [`/PAP/apps/chat/${endpoint.id}/${tab}`],
|
||||
});
|
||||
export const SettingsJourney: Story = { name: "01 Journey / Settings" };
|
||||
export const AccessJourney: Story = {
|
||||
name: "01 Journey / Access",
|
||||
parameters: route("access"),
|
||||
};
|
||||
export const ReviewsJourney: Story = {
|
||||
name: "01 Journey / Reviews",
|
||||
parameters: route("reviews"),
|
||||
};
|
||||
export const ConversationsJourney: Story = {
|
||||
name: "01 Journey / Conversations",
|
||||
parameters: route("conversations"),
|
||||
};
|
||||
export const FirstUse: Story = {
|
||||
name: "02 States / No conversations",
|
||||
args: { state: "empty" },
|
||||
parameters: route("conversations"),
|
||||
};
|
||||
export const NoReviews: Story = {
|
||||
name: "02 States / No reviews",
|
||||
args: { state: "empty" },
|
||||
parameters: route("reviews"),
|
||||
};
|
||||
export const Loading: Story = {
|
||||
name: "02 States / Loading settings",
|
||||
args: { state: "loading" },
|
||||
};
|
||||
export const Error: Story = {
|
||||
name: "02 States / Settings failure",
|
||||
args: { state: "error" },
|
||||
};
|
||||
export const LongAccess: Story = {
|
||||
name: "02 States / Long repository names",
|
||||
args: { state: "long" },
|
||||
parameters: route("access"),
|
||||
};
|
||||
export const Mobile: Story = {
|
||||
name: "02 States / Mobile conversations",
|
||||
args: { state: "long" },
|
||||
parameters: {
|
||||
...route("conversations"),
|
||||
viewport: { defaultViewport: "mobile1" },
|
||||
},
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
};
|
||||
function Policy() {
|
||||
const [policy, setPolicy] = useState(configuration.defaults);
|
||||
return <GitHubPolicyEditor policy={policy} onChange={setPolicy} />;
|
||||
}
|
||||
function People() {
|
||||
const [config, setConfig] = useState(configuration);
|
||||
return (
|
||||
<FixtureApi>
|
||||
<GitHubAccessEditor
|
||||
endpointId={endpoint.id}
|
||||
companyId={endpoint.companyId}
|
||||
configuration={config}
|
||||
onChange={setConfig}
|
||||
/>
|
||||
</FixtureApi>
|
||||
);
|
||||
}
|
||||
function Repositories() {
|
||||
const [rows, setRows] = useState(resources);
|
||||
return (
|
||||
<GitHubRepositoryAccess
|
||||
resources={rows}
|
||||
managementUrl="https://github.com/settings/installations"
|
||||
pending={false}
|
||||
onRefresh={() => {}}
|
||||
onChange={(id, enabled) =>
|
||||
setRows(rows.map((r) => (r.id === id ? { ...r, enabled } : r)))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const component = (children: React.ReactNode) => (
|
||||
<div className="max-w-3xl p-6">{children}</div>
|
||||
);
|
||||
export const InstructionsAndBehavior: Story = {
|
||||
name: "03 Components / Instructions and behavior",
|
||||
render: () => component(<Policy />),
|
||||
};
|
||||
export const PeopleAccess: Story = {
|
||||
name: "03 Components / People access",
|
||||
render: () => component(<People />),
|
||||
};
|
||||
export const RepositoryAccess: Story = {
|
||||
name: "03 Components / Repository access",
|
||||
render: () => component(<Repositories />),
|
||||
};
|
||||
export const ReviewHistory: Story = {
|
||||
name: "03 Components / Latest review and history",
|
||||
render: () => component(<GitHubReviewList reviews={reviews} />),
|
||||
};
|
||||
export const PendingReview: Story = {
|
||||
name: "03 Components / Pending current commit",
|
||||
render: () =>
|
||||
component(
|
||||
<GitHubReviewList
|
||||
reviews={[
|
||||
{
|
||||
...reviews[0],
|
||||
state: "running",
|
||||
assessment: null,
|
||||
conclusion: null,
|
||||
},
|
||||
reviews[1],
|
||||
]}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
export const ConversationRows: Story = {
|
||||
name: "03 Components / Conversation rows",
|
||||
render: () =>
|
||||
component(<ChatConversationList rows={conversations} provider="github" />),
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { expect, userEvent, within } from "storybook/test";
|
||||
import { GitHubChatPreview } from "../prototypes/github-chat/GitHubChatPreview";
|
||||
|
||||
const meta = {
|
||||
title: "Apps/GitHub chat & reviews",
|
||||
title: "Historical/GitHub chat prototypes",
|
||||
component: GitHubChatPreview,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
|
||||
Reference in new issue
Block a user