fix(github): qualify dedicated app setup and issue automation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 13:04:15 -05:00
1 parent 322fd7de2f
commit 470e66db0d
25 files changed
+945 -93

No files matched your search

+2 -1
View File
@@ -35,13 +35,14 @@ export const githubChatApi = {
}),
registration: (id: string, input: GitHubAppRegistrationInput) =>
api.post<GitHubAppWizardState>(`${path(id)}/registration`, input),
restartRegistration: (id: string, registrationId: string) => api.post<GitHubAppWizardState>(`${path(id)}/registration/restart`, { registrationId, appNotCreated: true }),
saveDraft: (id: string, input: GitHubAppRegistrationInput) => api.put<{ saved: true }>(`${path(id)}/draft`, input),
advance: (id: string) => api.post<GitHubAppWizardState>(`${path(id)}/setup`, {}),
startIdentity: (id: string) => api.post<{ authorizationUrl: string }>(`${path(id)}/identity/start`, {}),
confirmIdentity: (id: string, githubUserId: string) => api.post<GitHubAppWizardState>(`${path(id)}/identity/confirm`, { githubUserId }),
connectApp: (
id: string,
credentials: { appId: string; privateKey: string; webhookSecret: string },
credentials: { appId: string; privateKey: string; webhookSecret: string; clientId?: string; clientSecret?: string },
) => api.post<ChatEndpoint>(`${path(id)}/app`, credentials),
refreshRepositories: (id: string) =>
api.post<ChatEndpointResource[]>(`${path(id)}/repositories/refresh`, {}),
@@ -71,7 +71,7 @@ export function GitHubPolicyEditor({
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="github-invocation">
When should this agent review?
When should this agent work?
</Label>
<select
id="github-invocation"
@@ -85,19 +85,19 @@ export function GitHubPolicyEditor({
}
>
<option value="linked_authors">
Linked members’ PRs and authorized mentions
Linked members’ enabled events and authorized mentions
</option>
<option value="mentions_only">Authorized mentions only</option>
<option value="allowed_authors">
Allowed authors’ PRs and authorized mentions
Allowed authors’ enabled events and authorized mentions
</option>
</select>
<p className="text-xs text-muted-foreground">
Newly added people have a separate automatic-review setting in Access.
Newly added people have a separate automatic-event setting in Access.
</p>
</div>
<div>
<h3 className="text-sm font-medium">Automatic review events</h3>
<h3 className="text-sm font-medium">Automatic events</h3>
{GITHUB_REVIEW_EVENTS.slice(0, 4).map((event) => (
<GitHubToggle
key={event}
@@ -113,6 +113,22 @@ export function GitHubPolicyEditor({
}
/>
))}
<GitHubToggle
label="New GitHub issues"
description="Start a task for an authorized author's new issue. Uses the same repository and person restrictions."
checked={policy.issueOpened === true}
onChange={(enabled) => set("issueOpened", enabled)}
/>
{policy.issueOpened && (
<div className="space-y-2">
<Label htmlFor="github-issue-opened-instructions">New issue instructions</Label>
<Textarea
id="github-issue-opened-instructions"
value={policy.issueOpenedInstructions ?? ""}
onChange={(event) => set("issueOpenedInstructions", event.target.value)}
/>
</div>
)}
<GitHubToggle
label="Include draft PRs"
checked={policy.reviewDrafts}
@@ -120,7 +136,7 @@ export function GitHubPolicyEditor({
/>
<GitHubToggle
label="Include bot authors"
description="Also allow the bot account in Access with a sponsor and automatic reviews enabled."
description="Also allow the bot account in Access with a sponsor and automatic events enabled."
checked={policy.reviewBotAuthors}
onChange={(value) => set("reviewBotAuthors", value)}
/>
@@ -541,7 +557,7 @@ export function GitHubAccessEditor({
</Button>
</div>
<GitHubToggle
label={`Automatic PR reviews for @${person.login}`}
label={`Automatic events for @${person.login}`}
checked={person.automaticReviews}
onChange={(value) =>
onChange({
+66 -28
View File
@@ -4,10 +4,11 @@ import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GitHubChatSetup, submitGitHubAppManifest } from "./GitHubChatSetup";
import { GitHubChatSetup, gitHubAppManifestAction } from "./GitHubChatSetup";
import { agentsApi } from "@/api/agents";
import { chatEndpointsApi } from "@/api/chatEndpoints";
import { githubChatApi } from "@/api/githubChat";
import { toolsApi } from "@/api/tools";
import { copyTextToClipboard } from "@/lib/clipboard";
const fixture = vi.hoisted(() => ({
@@ -36,7 +37,7 @@ vi.mock("@/components/SetupWizard", () => ({
SetupWizardNavigation: () => null,
SetupWizardFooter: ({ children, onSaveExit }: any) => (
<footer>
<button onClick={onSaveExit}>Save &amp; exit</button>
<button type="button" onClick={onSaveExit}>Save &amp; exit</button>
{children}
</footer>
),
@@ -72,6 +73,7 @@ vi.mock("@/api/githubChat", () => ({
githubChatApi: {
advance: vi.fn(),
registration: vi.fn(),
restartRegistration: vi.fn(),
saveDraft: vi.fn(),
startIdentity: vi.fn(),
confirmIdentity: vi.fn(),
@@ -221,6 +223,19 @@ describe("GitHub App wizard", () => {
await act(async () => button!.click());
await settle();
}
it("binds Cloud enrollment return to the same draft without a duplicated company prefix", async () => {
vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "enrollment" });
vi.mocked(toolsApi.startCloudConnectorEnrollment).mockResolvedValue({ configured: false, status: "pending", brokerBaseUrl: "https://gateway.example", instanceId: "instance-1", environment: "staging", origins: [] });
const prior = `${window.location.pathname}${window.location.search}`;
window.history.replaceState(null, "", "/BOT/apps/chat/connect?provider=github&purpose=chat&resume=draft-1");
try {
await render("purpose=chat&resume=draft-1");
await click("Connect Paperclip Cloud");
expect(toolsApi.startCloudConnectorEnrollment).toHaveBeenCalledWith("company-1", undefined, "/apps/chat/connect?provider=github&purpose=chat&resume=draft-1");
} finally {
window.history.replaceState(null, "", prior);
}
});
it("starts with agent selection and saves the low-trust repair before removing its warning", async () => {
await render();
expect(container.querySelector("h1")?.textContent).toBe("Choose agent");
@@ -279,6 +294,27 @@ describe("GitHub App wizard", () => {
).toBe("Reviewer");
expect(chatEndpointsApi.create).not.toHaveBeenCalled();
});
it("requires no-App-created confirmation before restarting an expired handoff", async () => {
vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "recovery", restartableRegistrationId: "expired-1", message: "Handoff expired" });
vi.mocked(githubChatApi.restartRegistration).mockResolvedValue({ endpointId: "draft-1", state: "create" });
await render("resume=draft-1");
const button = [...container.querySelectorAll("button")].find(b => b.textContent === "Continue to GitHub")!;
expect(button.disabled).toBe(true);
expect(githubChatApi.restartRegistration).not.toHaveBeenCalled();
await act(async () => (container.querySelector("#github-app-not-created") as HTMLElement).click());
expect(button.disabled).toBe(false);
await click("Continue to GitHub");
expect(githubChatApi.restartRegistration).toHaveBeenCalledWith("draft-1", "expired-1");
expect(chatEndpointsApi.create).not.toHaveBeenCalled();
expect(githubChatApi.registration).not.toHaveBeenCalled();
});
it("does not offer registration restart for an uncertain exchange", async () => {
vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "recovery", message: "Recover existing App" });
await render("resume=draft-1");
expect(container.querySelector("#github-app-not-created")).toBeNull();
expect(container.textContent).not.toContain("Continue to GitHub");
expect(container.textContent).toContain("Use existing App credentials");
});
it("saves ownership and the suggested name when exiting", async () => {
await render("resume=draft-1");
const owner = container.querySelector(
@@ -349,35 +385,37 @@ describe("GitHub App wizard", () => {
state: "create",
registration,
});
const submitted: HTMLFormElement[] = [];
const submit = vi
.spyOn(HTMLFormElement.prototype, "submit")
.mockImplementation(function (this: HTMLFormElement) {
submitted.push(this.cloneNode(true) as HTMLFormElement);
});
await render("resume=draft-1");
const form = container.querySelector("form")!;
expect(form.method).toBe("post");
expect(form.action).toBe(registration.registrationUrl);
expect((form.querySelector('[name="manifest"]') as HTMLInputElement).value)
.toBe(JSON.stringify(registration.manifest));
expect(form.querySelector('button[type="submit"]')?.textContent).toBe("Continue to GitHub");
expect((form.querySelector('button[type="button"]') as HTMLButtonElement).textContent).toBe("Save & exit");
expect(githubChatApi.registration).not.toHaveBeenCalled();
expect((container.querySelector("#github-owner-type") as HTMLSelectElement).disabled).toBe(true);
expect(() => gitHubAppManifestAction({ ...registration, registrationUrl: "https://evil.example/apps/new" }))
.toThrow("invalid registration");
});
it("submits the mounted native form after preparing a fresh registration", async () => {
const registration = {
registrationUrl: "https://github.com/settings/apps/new?state=bound",
manifest: { name: "Reviewer", public: false }, expiresAt: "2026-10-07T20:00:00Z",
};
vi.mocked(githubChatApi.registration).mockResolvedValue({ endpointId: "draft-1", state: "create", registration });
const requestSubmit = vi.spyOn(HTMLFormElement.prototype, "requestSubmit").mockImplementation(function (this: HTMLFormElement) {
expect(this.isConnected).toBe(true);
expect((this.querySelector('[name="manifest"]') as HTMLInputElement).value).toBe(JSON.stringify(registration.manifest));
this.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
try {
await render("resume=draft-1");
await click("Continue to GitHub");
expect(githubChatApi.registration).not.toHaveBeenCalled();
expect(submitted[0].method).toBe("post");
expect(submitted[0].action).toBe(registration.registrationUrl);
expect(
(submitted[0].querySelector('[name="manifest"]') as HTMLInputElement)
.value,
).toBe(JSON.stringify(registration.manifest));
expect(
(container.querySelector("#github-owner-type") as HTMLSelectElement)
.disabled,
).toBe(true);
expect(() =>
submitGitHubAppManifest({
...registration,
registrationUrl: "https://evil.example/apps/new",
}),
).toThrow("invalid registration");
} finally {
submit.mockRestore();
}
await settle();
expect(requestSubmit).toHaveBeenCalledTimes(1);
expect(githubChatApi.registration).toHaveBeenCalledTimes(1);
} finally { requestSubmit.mockRestore(); }
});
it("shows passive installation progress without a duplicate repository picker or refresh gate", async () => {
vi.mocked(githubChatApi.advance).mockResolvedValue({
+63 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, ExternalLink, Loader2 } from "lucide-react";
import type { GitHubAppWizardState } from "@paperclipai/shared";
@@ -13,6 +13,7 @@ import {
SetupWizardFooter,
} from "@/components/SetupWizard";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
@@ -22,29 +23,52 @@ import { useNavigate, useSearchParams, Link } from "@/lib/router";
import { buildPermissionsForTrustPreset } from "@/lib/trust-policy-ui";
import { copyTextToClipboard } from "@/lib/clipboard";
/** Submit the supported manifest form directly, with no intermediate preparation screen. */
export function submitGitHubAppManifest(
/** Restrict native manifest submission to GitHub registration endpoints. */
export function gitHubAppManifestAction(
registration: NonNullable<GitHubAppWizardState["registration"]>,
) {
const url = new URL(registration.registrationUrl);
if (
url.origin !== "https://github.com" ||
url.username || url.password || url.hash ||
!/^\/(settings|organizations\/[A-Za-z0-9-]+\/settings)\/apps\/new$/.test(
url.pathname,
)
)
throw new Error("GitHub returned an invalid registration address");
const form = document.createElement("form");
form.method = "POST";
form.action = url.toString();
const manifest = document.createElement("input");
manifest.type = "hidden";
manifest.name = "manifest";
manifest.value = JSON.stringify(registration.manifest);
form.append(manifest);
document.body.append(form);
form.submit();
form.remove();
return url.toString();
}
function GitHubAppManifestForm({
registration, onSaveExit, disabled, autoSubmit, onSubmit,
}: {
registration: NonNullable<GitHubAppWizardState["registration"]>;
onSaveExit: () => void;
disabled: boolean;
autoSubmit: boolean;
onSubmit: () => void;
}) {
const form = useRef<HTMLFormElement>(null);
const requested = useRef(false);
useEffect(() => {
if (!autoSubmit) requested.current = false;
if (autoSubmit && !disabled && !requested.current && form.current) {
requested.current = true;
form.current.requestSubmit();
}
}, [autoSubmit, disabled]);
return (
<form
ref={form}
method="post"
action={gitHubAppManifestAction(registration)}
onSubmit={onSubmit}
>
<input type="hidden" name="manifest" value={JSON.stringify(registration.manifest)} />
<SetupWizardFooter onSaveExit={onSaveExit} disabled={disabled}>
<Button type="submit" disabled={disabled}>Continue to GitHub</Button>
</SetupWizardFooter>
</form>
);
}
export function GitHubChatSetup() {
const [params, setParams] = useSearchParams();
@@ -62,6 +86,7 @@ export function GitHubChatSetup() {
const [ownerLogin, setOwnerLogin] = useState("");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [submitManifest, setSubmitManifest] = useState(false);
const [error, setError] = useState("");
const [existing, setExisting] = useState(params.get("reconnect") === "1");
const [credentials, setCredentials] = useState({
@@ -69,6 +94,7 @@ export function GitHubChatSetup() {
privateKey: "",
webhookSecret: "",
});
const [appNotCreated, setAppNotCreated] = useState(false);
const [copied, setCopied] = useState(false);
const [legacyAccount, setLegacyAccount] = useState("");
const [identityLinked, setIdentityLinked] = useState(false);
@@ -539,7 +565,7 @@ export function GitHubChatSetup() {
const result = await toolsApi.startCloudConnectorEnrollment(
selectedCompanyId!,
undefined,
`${window.location.pathname}${window.location.search}`,
`/apps/chat/connect${window.location.search}`,
);
if (!result.verificationUrl)
throw new Error("Cloud enrollment could not be started");
@@ -554,7 +580,21 @@ export function GitHubChatSetup() {
<Button variant="ghost" onClick={() => setExisting(true)}>
Use existing App credentials
</Button>
{footer("Try again", refresh)}
{state.restartableRegistrationId && (
<div className="flex items-center gap-2">
<Checkbox id="github-app-not-created" checked={appNotCreated} disabled={busy}
onCheckedChange={(checked) => setAppNotCreated(checked === true)} />
<Label htmlFor="github-app-not-created">I haven't created this App on GitHub.</Label>
</div>
)}
{state.restartableRegistrationId
? footer("Continue to GitHub", async () => {
const result = await githubChatApi.restartRegistration(bot.id, state.restartableRegistrationId!);
setAppNotCreated(false);
queryClient.setQueryData(["github-wizard", bot.id], result);
if (result.registration) setSubmitManifest(true);
}, !appNotCreated)
: footer("Try again", refresh)}
</>
) : state?.state === "verify" ? (
<>
@@ -631,18 +671,17 @@ export function GitHubChatSetup() {
>
I already have an App
</button>
{footer(
{state?.registration ? (
<GitHubAppManifestForm registration={state.registration} onSaveExit={exit}
disabled={busy} autoSubmit={submitManifest} onSubmit={() => setSubmitManifest(false)} />
) : footer(
"Continue to GitHub",
async () => {
const result = state?.registration
? state
: await githubChatApi.registration(bot.id, appInput());
const result = await githubChatApi.registration(bot.id, appInput());
queryClient.setQueryData(["github-wizard", bot.id], result);
if (result.registration)
submitGitHubAppManifest(result.registration);
if (result.registration) setSubmitManifest(true);
},
progress.isPending ||
(ownerType === "organization" && !ownerLogin.trim()),
progress.isPending || (ownerType === "organization" && !ownerLogin.trim()),
)}
</>
)}