Show custom GitHub App mentions and branding controls

This commit is contained in:
Dotta committed 2026-10-07 19:53:27 -05:00
1 parent e2f6ba33c4
commit ed5e3b6ab5
12 files changed
+337 -63

No files matched your search

+17
View File
@@ -40,6 +40,23 @@ behavior, and narrower saved repository restrictions remain intact. Later
repository additions require enablement in Access. Advanced review rules and
prompts live in Settings.
The App belongs to the selected account or organization and uses its own bot
identity. The editable App name determines GitHub's slug and `@mention`; there
is no separate editable bot username. Paperclip shows and copies the verified
mention in the connected bot's header. Type `@app-slug`, without the `[bot]`
suffix shown on GitHub's API author records.
**GitHub App name and logo** is optional on the connected page and in Settings.
Download the agent's avatar as a PNG, then follow the link to this App's GitHub
settings to upload it under **Display information**. GitHub's
[manifest parameters](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest)
do not include an avatar; the
[logo upload](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/creating-a-custom-badge-for-your-github-app)
remains a GitHub settings action. After renaming the App on GitHub, use the
existing **reconnect this App** flow to refresh its verified identity using its
stored credentials. Legacy manual connections without recorded ownership link
to the App settings list instead of assuming an owner.
Local instances receive public callbacks and signed events through an enrolled
Paperclip Cloud connector, using outbound requests instead of a public tunnel.
The Cloud gateway capability must be deployed before localhost onboarding.
@@ -325,3 +325,34 @@ copy edit and passes. Repository typecheck/build, production Storybook build,
and token gates pass; the final discovery change also received focused server
typecheck/build. These checks do not qualify a new provider or model run. The
earlier full-suite failure and outstanding current-head CI/review still apply.
### Custom App identity and branding qualification
The live bot is the dedicated **Animal Bot E2E 20261007** App owned by
`paperclipai`, not the shared Paperclip App. Its verified GitHub author is
`animal-bot-e2e-20261007[bot]`; the human mention is
`@animal-bot-e2e-20261007`. The published review's GitHub author and App fields
agree with the saved endpoint identity.
The setup name field now explains GitHub's derived mention. Connected bots show
the verified, copyable mention in the header. Settings and wizard completion
share an optional App name/logo disclosure, the agent's PNG download, and the
owning App's GitHub settings link. Personal accounts use their corresponding App
settings path. Unknown legacy ownership uses the settings list, never an assumed
organization or the installation settings page. A renamed App can refresh its
identity through existing-App reconnect with stored credentials.
The preserved test drive was restarted after an interruption. The live header
showed the correct mention; copying and pasting it into an unsaved field proved
the exact text, then the original field was restored without saving. Downloaded
avatar is a 512×512 PNG, 30,616 bytes. Desktop and 390px branding layouts were
inspected. GitHub's App settings link reached its signed-in re-authentication
gate; no name or logo was changed, and App rename/reconnect was not tested live.
GitHub's manifest has no logo field, so provider upload remains an optional
GitHub settings action. No configuration, credentials or permissions changed in
this walkthrough.
All 59 focused management, setup, avatar-download and clipboard tests pass,
including Slack's existing download/error/retry coverage for the shared
downloader. UI typecheck/build, production Storybook build and token gates pass.
The earlier full-suite failure and current-head CI/review limits still apply.
+71
View File
@@ -0,0 +1,71 @@
import { useState } from "react";
import { Download, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
export function agentAvatarFilename(name: string) {
return `${name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "agent"}-avatar.png`;
}
/** Downloads the instance's generated PNG; provider upload remains explicit. */
export function AgentAvatarDownload({
avatarUrl,
name,
}: {
avatarUrl: string;
name: string;
}) {
const [downloading, setDownloading] = useState(false);
const [failed, setFailed] = useState(false);
const download = async () => {
if (downloading) return;
setDownloading(true);
setFailed(false);
try {
const response = await fetch(avatarUrl);
if (
!response.ok ||
!response.headers.get("content-type")?.startsWith("image/png")
)
throw new Error("Avatar unavailable");
const url = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = url;
link.download = agentAvatarFilename(name);
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1_000);
} catch {
setFailed(true);
} finally {
setDownloading(false);
}
};
return (
<div className="space-y-2">
<Button variant="outline" asChild>
<a
href={avatarUrl}
download={agentAvatarFilename(name)}
aria-disabled={downloading}
onClick={(event) => {
event.preventDefault();
void download();
}}
>
{downloading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
Download avatar
</a>
</Button>
{failed && (
<p role="alert" className="text-sm text-destructive">
Couldn’t download the avatar. Try downloading it again.
</p>
)}
</div>
);
}
@@ -8,6 +8,7 @@ import { resolveAgentAppearance } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
import { ChatConversationList } from "./ChatConversationList";
import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement";
import { GitHubBotMention } from "./GitHubAppIdentity";
import { EmailEndpointSettings } from "./EmailEndpointSetup";
import { EmailConnectionAccess } from "@/components/EmailConnectionAccess";
import { emailApi } from "@/api/email";
@@ -309,6 +310,7 @@ export function ChatEndpointDetail() {
<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 === "github" && <div className="mt-1"><GitHubBotMention endpoint={endpoint} /></div>}
{endpoint.provider === "imessage-photon" && endpoint.botExternalId && endpoint.photonAllocation !== "shared" && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
<span>{endpoint.botExternalId}</span>
@@ -338,7 +340,8 @@ export function ChatEndpointDetail() {
</div>
</header>
{endpoint.provider === "github" && <div hidden={activeTab !== "settings" && activeTab !== "access"}>
<GitHubBotManagement key={endpoint.id} endpoint={endpoint} view={activeTab === "access" ? "access" : "settings"} />
<GitHubBotManagement key={endpoint.id} endpoint={endpoint} view={activeTab === "access" ? "access" : "settings"}
avatarUrl={avatarAgent.data ? agentAvatarUrl(resolveAgentAppearance(avatarAgent.data.appearance, endpoint.assignedAgentId), 512, 1, "rest") : undefined} />
</div>}
{activeTab === "settings" && endpoint.provider !== "github" && <Settings endpointId={endpoint.id} endpoint={endpoint} />}
{activeTab === "reviews" && endpoint.provider === "github" && <GitHubReviews endpointId={endpoint.id} reviewId={reviewId} />}
@@ -0,0 +1,106 @@
import { ExternalLink } from "lucide-react";
import type { ChatEndpoint } from "@/api/chatEndpoints";
import { AgentAvatarDownload } from "@/components/AgentAvatarDownload";
import { CopyText } from "@/components/CopyText";
import { Link } from "@/lib/router";
export function gitHubBotMention(endpoint: Pick<ChatEndpoint, "botUsername">) {
const slug = endpoint.botUsername?.replace(/^@/, "").replace(/\[bot\]$/, "");
return slug ? `@${slug}` : null;
}
export function gitHubAppSettingsUrl(endpoint: ChatEndpoint) {
const github = endpoint.setup?.github;
const slug = gitHubBotMention(endpoint)?.slice(1);
if (!slug) return "https://github.com/settings/apps";
if (github?.ownerType === "organization" && github.ownerLogin)
return `https://github.com/organizations/${encodeURIComponent(github.ownerLogin)}/settings/apps/${encodeURIComponent(slug)}`;
if (github?.ownerType === "personal")
return `https://github.com/settings/apps/${encodeURIComponent(slug)}`;
// Legacy manual connections may not record ownership type. Never assume it.
return "https://github.com/settings/apps";
}
export function GitHubBotMention({ endpoint }: { endpoint: ChatEndpoint }) {
const mention = gitHubBotMention(endpoint);
return mention ? (
<CopyText
text={mention}
ariaLabel="Copy GitHub mention"
title="Copy GitHub mention"
className="break-all font-mono text-xs"
>
{mention}
</CopyText>
) : null;
}
/** Optional provider-owned branding, shared by completion and management. */
export function GitHubAppBranding({
endpoint,
avatarUrl,
}: {
endpoint: ChatEndpoint;
avatarUrl?: string;
}) {
return (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground">
GitHub App name and logo
</summary>
<div className="mt-4 space-y-4">
<p>
This is your custom GitHub App
{endpoint.botLabel ? (
<>
: <strong>{endpoint.botLabel}</strong>.
</>
) : (
"."
)}
{endpoint.providerAccountLabel && (
<>
{" "}
Owned by <strong>{endpoint.providerAccountLabel}</strong>.
</>
)}
</p>
<p className="text-xs text-muted-foreground">
GitHub derives the @mention from the App name. Upload your logo in the
App’s Display information settings.
</p>
{avatarUrl && (
<div className="flex flex-wrap items-center gap-4">
<img
src={avatarUrl}
alt={`${endpoint.assignedAgentName}’s avatar for download`}
className="size-20 rounded-lg bg-muted object-contain"
/>
<AgentAvatarDownload
avatarUrl={avatarUrl}
name={endpoint.botLabel ?? endpoint.assignedAgentName}
/>
</div>
)}
<a
href={gitHubAppSettingsUrl(endpoint)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 underline underline-offset-4"
>
Edit App name and logo on GitHub <ExternalLink className="size-3" />
</a>
<p className="text-xs text-muted-foreground">
After renaming the App,{" "}
<Link
className="underline underline-offset-4"
to={`/apps/chat/connect?provider=github&resume=${endpoint.id}&reconnect=1`}
>
reconnect this App
</Link>{" "}
to refresh its mention in Paperclip.
</p>
</div>
</details>
);
}
@@ -14,6 +14,8 @@ import { GitHubPolicyEditor } from "./GitHubBotConfiguration";
import { conversationDestination } from "./ChatConversationList";
import { queryKeys } from "@/lib/queryKeys";
import { TooltipProvider } from "@/components/ui/tooltip";
import { gitHubAppSettingsUrl, gitHubBotMention } from "./GitHubAppIdentity";
import type { ChatEndpoint } from "@/api/chatEndpoints";
const mocks = vi.hoisted(() => ({
tab: "settings",
@@ -217,6 +219,55 @@ describe("GitHub bot management", () => {
);
});
}
it("shows the verified custom App mention and its own organization branding settings", async () => {
const branded: ChatEndpoint = {
...endpoint,
provider: "github",
status: "active",
botLabel: "Maya Reviews",
botUsername: "maya-reviews[bot]",
providerAccountLabel: "acme",
setup: {
step: "complete",
github: {
stage: "verify",
ownerType: "organization",
ownerLogin: "acme",
appSlug: "old-draft-name",
},
},
};
mocks.get.mockResolvedValue(branded);
await render();
expect(
container.querySelector('button[aria-label="Copy GitHub mention"]')
?.textContent,
).toBe("@maya-reviews");
expect(container.textContent).toContain("This is your custom GitHub App");
const branding = [...container.querySelectorAll("a")].find((a) =>
a.textContent?.includes("Edit App name and logo"),
);
expect(branding?.href).toBe(
"https://github.com/organizations/acme/settings/apps/maya-reviews",
);
expect(
container.querySelector("a[download]")?.getAttribute("download"),
).toBe("Maya-Reviews-avatar.png");
expect(mocks.save).not.toHaveBeenCalled();
expect(
gitHubAppSettingsUrl({
...branded,
setup: {
step: "complete",
github: { stage: "verify", ownerType: "personal" },
},
}),
).toBe("https://github.com/settings/apps/maya-reviews");
expect(
gitHubAppSettingsUrl({ ...branded, setup: { step: "complete" } }),
).toBe("https://github.com/settings/apps");
expect(gitHubBotMention({ botUsername: null })).toBeNull();
});
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");
@@ -36,6 +36,7 @@ import {
GitHubToggle,
githubSelectClass,
} from "./GitHubBotConfiguration";
import { GitHubAppBranding } from "./GitHubAppIdentity";
export function GitHubRepositoryAccess({
resources,
@@ -115,9 +116,11 @@ export function GitHubRepositoryAccess({
export function GitHubBotManagement({
endpoint,
view,
avatarUrl,
}: {
endpoint: ChatEndpoint;
view: "settings" | "access";
avatarUrl?: string;
}) {
const client = useQueryClient();
const query = useQuery({
@@ -314,6 +317,7 @@ export function GitHubBotManagement({
)}
</div>
</details>
<GitHubAppBranding endpoint={endpoint} avatarUrl={avatarUrl} />
</>
)}
</fieldset>
@@ -528,6 +528,7 @@ describe("GitHub App wizard", () => {
await render("resume=draft-1");
expect(container.querySelector("h1")?.textContent).toBe("GitHub connected");
expect(container.textContent).toContain("Actual GitHub Name");
expect(container.textContent).toContain("GitHub App name and logo");
expect(container.textContent).toContain("acme/repo");
expect(container.textContent).toContain("Configure a runtime");
expect(container.textContent).not.toContain("Finish");
+10 -1
View File
@@ -22,6 +22,9 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useNavigate, useSearchParams, Link } from "@/lib/router";
import { buildPermissionsForTrustPreset } from "@/lib/trust-policy-ui";
import { copyTextToClipboard } from "@/lib/clipboard";
import { resolveAgentAppearance } from "@paperclipai/shared";
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
import { GitHubAppBranding, gitHubBotMention } from "./GitHubAppIdentity";
/** Restrict native manifest submission to GitHub registration endpoints. */
export function gitHubAppManifestAction(
@@ -233,7 +236,7 @@ export function GitHubChatSetup() {
})}
</div>
);
const mention = `@${bot?.botUsername?.replace(/\[bot\]$/, "") ?? "your-bot"} review this pull request`;
const mention = `${(bot && gitHubBotMention(bot)) || "@your-bot"} review this pull request`;
return (
<div className="mx-auto w-full max-w-3xl space-y-6 px-4 py-6 sm:px-6">
{!identityOnly && !connected && (
@@ -356,6 +359,8 @@ export function GitHubChatSetup() {
{copied ? "Copied" : "Copy mention"}
</Button>
</div>
<GitHubAppBranding endpoint={bot}
avatarUrl={selectedAgent ? agentAvatarUrl(resolveAgentAppearance(selectedAgent.appearance, bot.assignedAgentId), 512, 1, "rest") : undefined} />
<Link
className="text-sm underline"
to={`/apps/chat/${bot.id}/settings`}
@@ -649,6 +654,7 @@ export function GitHubChatSetup() {
<Label htmlFor="github-app-name">App name</Label>
<Input
id="github-app-name"
aria-describedby="github-app-name-help"
disabled={busy || !!state?.registration}
maxLength={34}
value={
@@ -659,6 +665,9 @@ export function GitHubChatSetup() {
}
onChange={(event) => setName(event.target.value)}
/>
<p id="github-app-name-help" className="text-xs text-muted-foreground">
Creates your own GitHub App. Its name determines the @mention; GitHub confirms the final handle.
</p>
</div>
<p className="text-sm text-muted-foreground">
GitHub will ask you to approve creation and select repositories.
+8 -60
View File
@@ -1,13 +1,11 @@
import { useId, useState } from "react";
import {
ArrowRight,
Check,
Download,
ExternalLink,
Loader2,
} from "lucide-react";
import { useId } from "react";
import { ArrowRight, Check, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SetupWizardFooter } from "@/components/SetupWizard";
import {
AgentAvatarDownload,
agentAvatarFilename,
} from "@/components/AgentAvatarDownload";
export interface SlackAvatarProps {
agentName: string;
@@ -23,35 +21,7 @@ export function SlackAvatarContent({
compact = false,
}: SlackAvatarProps & { compact?: boolean }) {
const id = useId();
const filename = `${appName.replace(/[^a-zA-Z0-9_-]+/g, "-") || "agent"}-avatar.png`;
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState(false);
const download = async () => {
if (downloading) return;
setDownloading(true);
setDownloadError(false);
try {
const response = await fetch(avatarUrl);
if (
!response.ok ||
!response.headers.get("content-type")?.startsWith("image/png")
)
throw new Error("Avatar unavailable");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1_000);
} catch {
setDownloadError(true);
} finally {
setDownloading(false);
}
};
const filename = agentAvatarFilename(appName);
return (
<div className="space-y-8">
<section
@@ -76,29 +46,7 @@ export function SlackAvatarContent({
PNG · 512 × 512 · Ready for Slack
</p>
</div>
<Button variant="outline" asChild>
<a
href={avatarUrl}
download={filename}
aria-disabled={downloading}
onClick={(event) => {
event.preventDefault();
void download();
}}
>
{downloading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
Download avatar
</a>
</Button>
{downloadError && (
<p role="alert" className="text-sm text-destructive">
Couldn’t download the avatar. Try downloading it again.
</p>
)}
<AgentAvatarDownload avatarUrl={avatarUrl} name={appName} />
</div>
</section>
@@ -25,7 +25,15 @@ export const endpoint: ChatEndpoint = {
botUsername: "maya-reviews[bot]",
providerAccountLabel: "acme",
allowUnlinkedPeople: false,
setup: { step: "complete" },
setup: {
step: "complete",
github: {
stage: "verify",
appSlug: "maya-reviews",
ownerType: "organization",
ownerLogin: "acme",
},
},
};
export const configuration: GitHubChatConfiguration = {
version: 1,
@@ -14,8 +14,15 @@ import {
GitHubReviewDetail,
} from "@/pages/apps/chat/GitHubBotManagement";
import { ChatConversationList } from "@/pages/apps/chat/ChatConversationList";
import {
GitHubAppBranding,
GitHubBotMention,
} from "@/pages/apps/chat/GitHubAppIdentity";
import { resolveAgentAppearance } from "@paperclipai/shared";
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
import {
endpoint,
agent,
configuration,
resources,
reviews,
@@ -157,6 +164,24 @@ export const RepositoryAccess: Story = {
name: "03 Components / Repository access",
render: () => component(<Repositories />),
};
export const AppIdentityAndLogo: Story = {
name: "03 Components / App identity and logo",
render: () =>
component(
<div className="space-y-6">
<GitHubBotMention endpoint={endpoint} />
<GitHubAppBranding
endpoint={endpoint}
avatarUrl={agentAvatarUrl(
resolveAgentAppearance(agent.appearance, agent.id),
512,
1,
"rest",
)}
/>
</div>,
),
};
export const ReviewHistory: Story = {
name: "03 Components / Review rows",
render: () =>