mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
fix(github): qualify dedicated app setup and issue automation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
322fd7de2f
commit
470e66db0d
25 files changed
+945
-93
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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 & exit</button>
|
||||
<button type="button" onClick={onSaveExit}>Save & 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({
|
||||
|
||||
@@ -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()),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user