import { copyTextToClipboard } from "@/lib/clipboard";
import { useEffect, useId, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
GITHUB_REVIEW_EVENTS,
type GitHubChatConfiguration,
type GitHubReviewPolicy,
type GitHubAllowedPerson,
} from "@paperclipai/shared";
import { accessApi } from "@/api/access";
import { chatEndpointsApi } from "@/api/chatEndpoints";
import { githubChatApi } from "@/api/githubChat";
import { useOptionalCompany } from "@/context/CompanyContext";
import { Button } from "@/components/ui/button";
import {
AtSign,
Check,
Copy,
HelpCircle,
MoreHorizontal,
Plus,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { GitHubSettingsDisclosure } from "./GitHubSettingsDisclosure";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownEditor } from "@/components/MarkdownEditor";
import { Label } from "@/components/ui/label";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Link } from "@/lib/router";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export const githubSelectClass =
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
const eventLabels = {
opened: "New pull request",
synchronize: "Updated commits",
reopened: "Reopened",
ready_for_review: "Ready for review",
mention: "Mention",
comment: "Follow-up comment",
};
function GitHubHelp({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
setOpen(true)}
>
{children}
);
}
export function GitHubToggle({
label,
description,
help,
checked,
onChange,
ariaLabel,
disabled,
}: {
label: string;
description?: string;
help?: string;
checked: boolean;
onChange: (checked: boolean) => void;
ariaLabel?: string;
disabled?: boolean;
}) {
return (
{description && (
{description}
)}
);
}
/** Keep separators while typing; the saved policy still contains normalized lists. */
function GitHubListInput({
id,
values,
separator = "\n",
onChange,
}: {
id: string;
values: string[];
separator?: "\n" | ",";
onChange: (values: string[]) => void;
}) {
const joiner = separator === "," ? ", " : "\n";
const [text, setText] = useState(values.join(joiner));
const parse = (value: string) =>
value
.split(separator)
.map((item) => item.trim())
.filter(Boolean);
useEffect(() => {
setText((current) =>
JSON.stringify(parse(current)) === JSON.stringify(values)
? current
: values.join(joiner),
);
}, [values, separator]);
const props = {
id,
value: text,
onChange: (
event: React.ChangeEvent,
) => {
setText(event.target.value);
onChange(parse(event.target.value));
},
};
return separator === "\n" ? : ;
}
function GitHubPassingScore({
value,
onChange,
onValidityChange,
text: controlledText,
onTextChange,
}: {
text?: string;
onTextChange?: (text: string) => void;
value: GitHubReviewPolicy["ratingThreshold"];
onChange: (value: GitHubReviewPolicy["ratingThreshold"]) => void;
onValidityChange?: (valid: boolean) => void;
}) {
const id = useId();
const [localText, setLocalText] = useState(String(value ?? 5));
const text = controlledText ?? localText;
const setText = onTextChange ?? setLocalText;
const number = Number(text);
const valid = value === null || (text.trim() !== "" && Number.isInteger(number) && number >= 1 && number <= 5);
useEffect(() => setLocalText(String(value ?? 5)), [value]);
useEffect(() => { onValidityChange?.(valid); }, [valid, onValidityChange]);
return (
Passing score
{!valid &&
Enter a whole number from 1 to 5.
}
{value === null
? "Publishes a neutral check without a score requirement. GitHub can accept neutral checks for merging."
: "Paperclip passes the check when a complete review of the latest commit meets this score. Lower scores fail. This does not approve the PR."}
To block merging, require the Paperclip Review check from this App in your GitHub branch rules.{" "}
Configure on GitHub
);
}
export function GitHubPolicyEditor({
policy,
onChange,
accessHref,
onValidityChange,
scoreText,
onScoreTextChange,
rememberedAutomaticMode,
readOnly = false,
}: {
accessHref?: string;
readOnly?: boolean;
scoreText?: string;
onScoreTextChange?: (text: string) => void;
rememberedAutomaticMode?: "linked_authors" | "allowed_authors";
policy: GitHubReviewPolicy;
onChange: (policy: GitHubReviewPolicy) => void;
onValidityChange?: (valid: boolean) => void;
}) {
const id = useId();
const automaticMode = useRef<"linked_authors" | "allowed_authors">(
"linked_authors",
);
if (policy.invocation !== "mentions_only")
automaticMode.current = policy.invocation;
const filterField = (
key:
| "includeAuthors"
| "excludeAuthors"
| "targetBranches"
| "excludedBranches"
| "requiredLabels"
| "excludedLabels"
| "ignoredPaths",
label: string,
help: string,
) => (
{label}
set(key, values)}
/>
{help}
);
const [prompt, setPrompt] = useState<
(typeof GITHUB_REVIEW_EVENTS)[number] | "issue_opened"
>("opened");
const set = (
key: K,
value: GitHubReviewPolicy[K],
) => onChange({ ...policy, [key]: value });
return (
Instructions
set("instructions", value)}
/>
Included in every GitHub task. Type / to select a skill for the agent
to use.
Event
setPrompt(e.target.value as typeof prompt)}
>
{GITHUB_REVIEW_EVENTS.map((event) => (
{eventLabels[event]}
))}
New issue
prompt === "issue_opened"
? set("issueOpenedInstructions", value)
: set("prompts", {
...policy.prompts,
[prompt]: value,
})
}
/>
Added to the main instructions for this event. Type / to select a skill for the agent to use.
When to run
When mentioned
Authorized people can always @mention this bot.{" "}
{accessHref && (
Manage people in Access
)}
set("invocation", enabled ? (rememberedAutomaticMode ?? automaticMode.current) : "mentions_only")
}
/>
{policy.invocation !== "mentions_only" && (
{GITHUB_REVIEW_EVENTS.slice(0, 4).map((event) => (
set(
"events",
enabled === true
? [...new Set([...policy.events, event])]
: policy.events.filter((value) => value !== event),
)
}
/>
{eventLabels[event]}
))}
set("issueOpened", enabled === true)
}
/>
New issues
Runs for authorized authors in Access. Use the author filters below to limit whose issues and PRs start work.
set("reviewDrafts", value)}
/>
set("reviewBotAuthors", value)}
/>
{filterField(
"includeAuthors",
"Included authors",
"Leave empty for all authorized authors. One username or glob per line.",
)}
{filterField(
"excludeAuthors",
"Excluded authors",
"One username or glob per line.",
)}
{filterField(
"targetBranches",
"Target branches",
"Leave empty for all branches. Supports * and **.",
)}
{filterField(
"excludedBranches",
"Excluded branches",
"Supports * and **.",
)}
{filterField(
"requiredLabels",
"Required labels",
"All listed labels must be present.",
)}
{filterField(
"excludedLabels",
"Excluded labels",
"Any listed label prevents automatic review.",
)}
Reviews requested by @mention bypass these scheduling filters.
Repository access and ignored files still apply.
)}
Review results
set("publishSummary", value)}
/>
set("publishInline", value)}
/>
set("ratingThreshold", value)} onValidityChange={onValidityChange} />
{filterField(
"ignoredPaths",
"File patterns",
"Excluded from all reviews, including @mentions. One pattern per line; supports * and **.",
)}
Finding categories
set("findingCategories", values)}
/>
Minimum inline severity
set(
"minimumCommentSeverity",
e.target
.value as GitHubReviewPolicy["minimumCommentSeverity"],
)
}
>
Info
Warning
Error
Hidden findings still count in the assessment.
Optional formal decisions on GitHub. Scores, comments, and checks
still work when these are off.
set("allowApprove", value)}
/>
set("allowRequestChanges", value)}
/>
);
}
export function GitHubAccessEditor({
endpointId,
companyId,
configuration,
onChange,
}: {
endpointId: string;
companyId: string;
configuration: GitHubChatConfiguration;
onChange: (configuration: GitHubChatConfiguration) => void;
}) {
const company = useOptionalCompany()?.companies.find((item) => item.id === companyId);
const accountLink = useRef(null);
const [linkCopied, setLinkCopied] = useState(false);
const [dialog, setDialog] = useState<"member" | "guest" | null>(null);
const members = useQuery({
queryKey: ["github-members", companyId],
queryFn: () => accessApi.listMembers(companyId),
});
const links = useQuery({
queryKey: ["github-linked-members", endpointId],
queryFn: () => chatEndpointsApi.listPrincipals(endpointId),
});
const [login, setLogin] = useState("");
const [sponsor, setSponsor] = useState(configuration.responsibleUserId);
const [candidate, setCandidate] = useState<{
githubUserId: string;
login: string;
} | null>(null);
const [error, setError] = useState("");
const [lookupError, setLookupError] = useState("");
const [busy, setBusy] = useState(false);
const activeMembers = (members.data?.members ?? []).filter(
(member) =>
member.status === "active" && member.membershipRole !== "viewer",
);
const linkedAccounts = (links.data ?? []).filter(
(link) => link.status === "linked",
);
const responsible = activeMembers.find(
(member) => member.principalId === configuration.responsibleUserId,
);
const memberName = (userId: string) =>
activeMembers.find((member) => member.principalId === userId)?.user?.name ??
userId;
const automatic = configuration.defaults.invocation !== "mentions_only";
const guestsAutomatic =
configuration.defaults.invocation === "allowed_authors";
const guests = configuration.people.filter(
(person) => person.kind === "guest",
);
// Preserve configured members whose identity link was revoked; show why they cannot run.
const memberRows = [
...configuration.people.filter((person) => person.kind === "member"),
...linkedAccounts
.filter(
(link) =>
link.githubUserId &&
link.paperclipUserId &&
!configuration.people.some(
(person) => person.githubUserId === link.githubUserId,
),
)
.map((link): GitHubAllowedPerson => ({
kind: "member",
userId: link.paperclipUserId!,
githubUserId: link.githubUserId!,
login: link.githubLogin ?? link.externalLabel,
})),
];
const updatePerson = (person: GitHubAllowedPerson) =>
onChange({
...configuration,
people: configuration.people.some(
(p) => p.githubUserId === person.githubUserId,
)
? configuration.people.map((p) =>
p.githubUserId === person.githubUserId ? person : p,
)
: [...configuration.people, person],
});
const removePerson = (person: GitHubAllowedPerson) =>
onChange({
...configuration,
people: configuration.people.filter(
(p) => p.githubUserId !== person.githubUserId,
),
});
const unlink = async (principalId: string) => {
setBusy(true);
setError("");
try {
await chatEndpointsApi.revokeLink(endpointId, principalId);
await links.refetch();
} catch (e) {
setError(
e instanceof Error ? e.message : "Could not unlink this account.",
);
} finally {
setBusy(false);
}
};
const peopleRows = (people: GitHubAllowedPerson[]) => (
{people.map((person) => {
const identity = linkedAccounts.find(
(link) => link.githubUserId === person.githubUserId,
);
const linked = person.kind === "guest" || Boolean(identity);
const explicit = configuration.people.some(
(p) => p.githubUserId === person.githubUserId,
);
const mentions =
person.kind === "guest" ||
configuration.memberAccess === "all_linked" ||
explicit;
const removable = explicit && (person.kind === "guest" || configuration.memberAccess === "selected");
return (
@{person.login}
{person.kind === "guest"
? `Sponsored by ${memberName(person.sponsorUserId)}`
: (identity?.paperclipUserLabel ?? "Company member")}
{!linked && (
GitHub account needs linking.
)}
Mentions
{person.kind === "guest" ||
configuration.memberAccess === "all_linked" ? (
{linked && (
)}
{linked ? "Allowed" : "Unlinked"}
) : (
enabled ? updatePerson(person) : removePerson(person)
}
/>
)}
{(identity || removable) &&
{removable && (
removePerson(person)}>
Remove access
)}
{identity && (
void unlink(identity.principalId)}
>
Unlink GitHub account
)}
}
);
})}
);
return (
People
Choose who can mention this bot and whose activity can start work
automatically.
Company members
{
setDialog("member");
setLinkCopied(false);
setError("");
}}
>
Link a member
onChange({
...configuration,
memberAccess: enabled ? "all_linked" : "selected",
})
}
/>
{peopleRows(memberRows)}
{links.isPending && (
Loading linked accounts…
)}
{!links.isPending && !links.isError && memberRows.length === 0 && (
No members linked yet. Link an account to get started.
)}
{automatic ? (
<>
Automatic runs use the events and author filters in{" "}
Settings
.
>
) : (
<>
Automatic runs are off in{" "}
Settings
. Authorized @mentions still work.
>
)}
External contributors
{
setDialog("guest");
setLookupError("");
}}
>
Add external contributor
Allow specific GitHub users with a member as their sponsor. They
receive restricted access, without company membership or the sponsor’s
personal credentials.
{peopleRows(guests)}
{guests.length > 0 && (
onChange({
...configuration,
defaults: {
...configuration.defaults,
invocation: enabled ? "allowed_authors" : "linked_authors",
},
})
}
/>
)}
{activeMembers.length === 1 && responsible ? (
Responsible member: {memberName(configuration.responsibleUserId)}
) : (
<>
Responsible member
onChange({
...configuration,
responsibleUserId: e.target.value,
})
}
>
{!responsible && (
{configuration.responsibleUserId || "Select a member"}
)}
{activeMembers.map((member) => (
{member.user?.name ??
member.user?.email ??
member.principalId}
))}
>
)}
The member accountable for automatically created Paperclip tasks.
GitHub authors remain recorded separately. This grants no access to
the member’s personal credentials.
{error && (
{error}
)}
{(members.error || links.error) && (
Could not load members or linked accounts.
{
void members.refetch();
void links.refetch();
}}
>
Try again
)}
{
if (!open) setDialog(null);
}}
>
{dialog === "member"
? "Link a company member"
: "Add external contributor"}
{dialog === "member"
? "Each member confirms their own GitHub identity. Share this link with an existing company member, or link your own account."
: "Allow a GitHub user to mention this bot. A company member must sponsor their restricted access."}
{dialog === "member" ? (
<>
{
if (accountLink.current)
void copyTextToClipboard(accountLink.current.href).then(
() => setLinkCopied(true),
() =>
setError(
"Could not copy the link. Open account linking and copy the address.",
),
);
}}
>
{linkCopied ? "Copied" : "Copy invitation link"}
Link your account
{error && (
{error}
)}
>
) : (
<>
Sponsor
{candidate && (
{configuration.people.some(
(p) => p.githubUserId === candidate.githubUserId,
) ||
linkedAccounts.some(
(link) => link.githubUserId === candidate.githubUserId,
)
? `@${candidate.login} is already listed in People.`
: `Found @${candidate.login}. They will be able to mention the bot after you save.`}
)}
{lookupError && (
{lookupError}
)}
setDialog(null)}>
Cancel
member.principalId === sponsor,
) ||
configuration.people.some(
(p) => p.githubUserId === candidate.githubUserId,
) ||
linkedAccounts.some(
(link) => link.githubUserId === candidate.githubUserId,
)
}
onClick={() => {
if (candidate) {
updatePerson({
...candidate,
kind: "guest",
sponsorUserId: sponsor,
permissionProfile: "restricted",
});
setDialog(null);
setCandidate(null);
setLogin("");
}
}}
>
Add contributor
>
)}
);
}