open-swe/ui/src/routes/my-settings.tsx
Johannes du Plessis b2a0ac3b79
feat: auto-review PRs on opened / ready-for-review (#1325)
* feat: auto-review PRs on opened / ready-for-review

Trigger Open SWE Review on `pull_request` actions `opened` and
`ready_for_review` against the canonical reviewer thread (no need to
request open-swe[bot] as a reviewer). `converted_to_draft` now also
flips watch=False on the existing reviewer thread.

Draft PRs are gated by a tri-state user setting on the profile:
inherit team default, always on, or always off. The team-wide
`review_draft_prs` setting is the org-wide default; each user can
override it in My Settings.

External contributors with no Open SWE profile fall back to the team
default.

* fix: PR review comments — auth source + draft-aware watch toggle

- `process_github_pr_ready` now dispatches with `source="github"` so the
  auth resolver finds the bot token persisted on the thread. The previous
  `source="github_auto"` fell through to the email-based path in non
  bot-token-only deployments and failed with a missing-user-email error.

- `converted_to_draft` no longer unconditionally clears `watch`. When the
  PR author's effective `review_draft_prs` setting is on, watch stays on
  so subsequent pushes still trigger re-reviews while the PR is in draft.

* feat(reviewer): skip "no issues found" comment on empty re-reviews

A re-review run with no new findings to surface no longer posts another
"Open SWE Review: No issues found" comment on the PR. The "no issues"
summary now only appears on the first review of a PR — matching Devin's
behavior, where subsequent reviews are silent unless there's something
new to flag.

Resolved-thread reconciliation and ``last_reviewed_sha`` persistence
still happen on the skipped path, so findings the user just fixed still
get their GitHub threads marked resolved, and the next push event sees
an up-to-date dedup SHA.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-05-22 13:36:14 -07:00

137 lines
4.3 KiB
TypeScript

import { Navigate, createFileRoute, useNavigate } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/api";
import { buildProfileUpdate, useOptions, useProfile, useSaveProfile } from "@/lib/profile";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/my-settings")({ component: MySettingsPage });
type DraftReviewChoice = "team_default" | "always_on" | "always_off";
function toChoice(value: boolean | null | undefined): DraftReviewChoice {
if (value === true) return "always_on";
if (value === false) return "always_off";
return "team_default";
}
function fromChoice(choice: DraftReviewChoice): boolean | null {
if (choice === "always_on") return true;
if (choice === "always_off") return false;
return null;
}
function MySettingsPage() {
const session = useSession();
const qc = useQueryClient();
const navigate = useNavigate();
const profile = useProfile();
const options = useOptions();
const save = useSaveProfile();
const teamSettings = useQuery({
queryKey: ["teamSettings"],
queryFn: api.getTeamSettings,
enabled: !!session.data,
});
const [error, setError] = useState<string | null>(null);
if (session.isLoading) {
return (
<main className="p-6">
<Skeleton className="h-40 w-full" />
</main>
);
}
if (!session.data) return <Navigate to="/login" />;
const handleLogout = async () => {
await api.logout();
qc.setQueryData(["session"], null);
void navigate({ to: "/login" });
};
const firstModel = options.data?.models[0];
const fallbackModel = firstModel?.id ?? "";
const fallbackEffort = firstModel?.default_effort ?? "";
const draftChoice = toChoice(profile.data?.review_draft_prs);
const teamDefaultOn = teamSettings.data?.review_draft_prs ?? false;
const teamDefaultLabel = `Use team default (currently: ${teamDefaultOn ? "On" : "Off"})`;
const handleDraftChoiceChange = (next: DraftReviewChoice) => {
setError(null);
save
.mutateAsync(
buildProfileUpdate(
profile.data,
{ review_draft_prs: fromChoice(next) },
fallbackModel,
fallbackEffort,
),
)
.catch((e: Error) => setError(e.message));
};
return (
<AppShell user={session.data} title="My Settings">
<SettingsSection title="Profile">
<SettingsRow
label="Email"
control={
<span className="text-xs text-muted-foreground">
{session.data.email ?? "—"}
</span>
}
/>
</SettingsSection>
<SettingsSection title="Open SWE Review">
<SettingsRow
label="Review my draft PRs"
description="Whether Open SWE Review runs on pull requests you open in draft. When set to the team default, your admin's org-wide setting applies."
control={
<Select
value={draftChoice}
onValueChange={(v) => handleDraftChoiceChange(v as DraftReviewChoice)}
disabled={profile.isLoading || save.isPending}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="team_default">{teamDefaultLabel}</SelectItem>
<SelectItem value="always_on">Always review my drafts</SelectItem>
<SelectItem value="always_off">Never review my drafts</SelectItem>
</SelectContent>
</Select>
}
/>
</SettingsSection>
<SettingsSection title="Account">
<SettingsRow
label="Sign out"
description="End your dashboard session."
control={
<Button size="sm" variant="outline" onClick={() => void handleLogout()}>
Sign out
</Button>
}
/>
</SettingsSection>
{error && <p className="text-xs text-destructive">{error}</p>}
</AppShell>
);
}