From 2c51436fb5bab2d17da0f09513fcfdbf3f2abb8f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 2 Oct 2026 20:04:11 +0200 Subject: [PATCH 1/4] feat(code-review): dedicated Code Review section with analytics and org-wide review rules Pull request review moves out of the GitHub integration card into its own section: Analytics, Pull requests, Issues and Settings tabs at /code-review, behind the prreview rollout flag. - New codeReview API group (analytics, reviews, review detail, findings) served by PrReviewAnalyticsService over pr_reviews and pr_review_findings. - pr_reviews gains author_login and merged_at; pr_review_settings gains defaults. Reviews run with the org defaults merged under each repository's own config (instructions and ignored paths add up). - Settings page holds the model, default rules, and per-repository switch and overrides; the old integrations dialog is removed. --- apps/api/src/routes/v1/code-review.http.ts | 36 + apps/api/src/runtime/http-graph.ts | 2 + apps/api/src/runtime/service-graph.ts | 2 + .../code-review/code-review-analytics.tsx | 545 + .../code-review/code-review-format.ts | 117 + .../code-review/code-review-layout.tsx | 276 + .../code-review/code-review-settings.tsx | 424 + .../code-review/review-detail-sheet.tsx | 322 + .../code-review/review-rules-form.tsx | 491 + .../web/src/components/dashboard/nav-items.ts | 7 +- .../integrations/github-integration-card.tsx | 113 +- .../integrations/pr-review-settings.tsx | 734 - apps/web/src/routeTree.gen.ts | 84 + apps/web/src/routes/code-review/index.tsx | 92 + apps/web/src/routes/code-review/issues.tsx | 269 + .../src/routes/code-review/pull-requests.tsx | 333 + apps/web/src/routes/code-review/settings.tsx | 16 + docs/pr-review-agent-plan.md | 20 +- .../integrations/vcs/VcsRepository.ts | 32 +- .../vcs/vendor/github/GithubConnectService.ts | 72 +- .../__tests__/GithubConnectService.test.ts | 38 + .../PrReviewAnalyticsService.test.ts | 325 + .../pr-review/PrReviewAnalyticsService.ts | 685 + .../src/services/pr-review/PrReviewService.ts | 23 +- .../migration.sql | 5 + .../snapshot.json | 17163 ++++++++++++++++ packages/db/src/schema/vcs.ts | 9 +- packages/domain/src/http/api.ts | 2 + packages/domain/src/http/code-review.ts | 239 + packages/domain/src/http/index.ts | 1 + packages/domain/src/http/integrations.ts | 4 +- packages/domain/src/http/pr-review.ts | 48 +- packages/ui/src/lib/severity.ts | 3 + 33 files changed, 21662 insertions(+), 870 deletions(-) create mode 100644 apps/api/src/routes/v1/code-review.http.ts create mode 100644 apps/web/src/components/code-review/code-review-analytics.tsx create mode 100644 apps/web/src/components/code-review/code-review-format.ts create mode 100644 apps/web/src/components/code-review/code-review-layout.tsx create mode 100644 apps/web/src/components/code-review/code-review-settings.tsx create mode 100644 apps/web/src/components/code-review/review-detail-sheet.tsx create mode 100644 apps/web/src/components/code-review/review-rules-form.tsx delete mode 100644 apps/web/src/components/integrations/pr-review-settings.tsx create mode 100644 apps/web/src/routes/code-review/index.tsx create mode 100644 apps/web/src/routes/code-review/issues.tsx create mode 100644 apps/web/src/routes/code-review/pull-requests.tsx create mode 100644 apps/web/src/routes/code-review/settings.tsx create mode 100644 packages/backend/src/services/pr-review/PrReviewAnalyticsService.test.ts create mode 100644 packages/backend/src/services/pr-review/PrReviewAnalyticsService.ts create mode 100644 packages/db/drizzle/20261002173832_code_review_analytics/migration.sql create mode 100644 packages/db/drizzle/20261002173832_code_review_analytics/snapshot.json create mode 100644 packages/domain/src/http/code-review.ts diff --git a/apps/api/src/routes/v1/code-review.http.ts b/apps/api/src/routes/v1/code-review.http.ts new file mode 100644 index 0000000000..96a5e9aac3 --- /dev/null +++ b/apps/api/src/routes/v1/code-review.http.ts @@ -0,0 +1,36 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { CurrentTenant, MapleApi } from "@maple/domain/http" +import { Effect } from "effect" +import { PrReviewAnalyticsService } from "@maple/backend/services/pr-review/PrReviewAnalyticsService" + +export const HttpCodeReviewLive = HttpApiBuilder.group(MapleApi, "codeReview", (handlers) => + Effect.gen(function* () { + const analytics = yield* PrReviewAnalyticsService + + return handlers + .handle("analytics", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + return yield* analytics.analytics(tenant.orgId, query) + }), + ) + .handle("listReviews", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + return yield* analytics.listReviews(tenant.orgId, query) + }), + ) + .handle("getReview", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + return yield* analytics.getReview(tenant.orgId, params.reviewId) + }), + ) + .handle("listFindings", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + return yield* analytics.listFindings(tenant.orgId, query) + }), + ) + }), +) diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index a721ebb5c9..25cbd9464d 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -16,6 +16,7 @@ import { V1ErrorBoundaryLive } from "@maple/backend/http/error-boundary" import { HttpDemoLive } from "@/routes/internal/demo.http" import { DiscoveryRouter, instanceApiV2, NotFoundRouter } from "@/routes/discovery.http" import { HttpDigestLive } from "@/routes/internal/digest.http" +import { HttpCodeReviewLive } from "@/routes/v1/code-review.http" import { HttpErrorsLive } from "@/routes/v1/errors.http" import { HttpIntegrationsLive, IntegrationsCallbackRouter } from "@/routes/v1/integrations.http" import { OAuthDiscoveryRouter } from "@/routes/v1/oauth-discovery.http" @@ -110,6 +111,7 @@ const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe( Layer.provide(HttpAuthPublicLive), Layer.provide(HttpAuthLive), Layer.provide(HttpBillingPublicLive), + Layer.provide(HttpCodeReviewLive), Layer.provide(HttpErrorsLive), Layer.provide(HttpIntegrationsLive), Layer.provide(HttpOrgClickHouseSettingsLive), diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 3ed7f2bebe..e0146e8895 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -68,6 +68,7 @@ import { WarehouseQueryService } from "@maple/backend/services/warehouse/Warehou import { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngineService" import { OrgClickHouseSettingsService } from "@maple/backend/services/org/OrgClickHouseSettingsService" import { VcsSourceService } from "@maple/backend/services/integrations/vcs/VcsSourceService" +import { PrReviewAnalyticsService } from "@maple/backend/services/pr-review/PrReviewAnalyticsService" /** Services consumed by HTTP routes; each service owns its implementation dependencies. */ export const HttpServicesLive = Layer.mergeAll( @@ -132,6 +133,7 @@ export const HttpServicesLive = Layer.mergeAll( WebAnalyticsDigestService.layer, DemoService.layer, GithubConnectService.layer, + PrReviewAnalyticsService.layer, VcsCommitService.layer, VcsSourceService.layer, ChatWorkspaceService.layer, diff --git a/apps/web/src/components/code-review/code-review-analytics.tsx b/apps/web/src/components/code-review/code-review-analytics.tsx new file mode 100644 index 0000000000..563bff96df --- /dev/null +++ b/apps/web/src/components/code-review/code-review-analytics.tsx @@ -0,0 +1,545 @@ +import { useMemo, useState, type ReactNode } from "react" +import { Link } from "@tanstack/react-router" +import type { CodeReviewAnalytics, CodeReviewTotals, PrReviewSeverity } from "@maple/domain/http" +import { QueryBuilderBarChart } from "@maple/ui/components/charts" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { cn } from "@maple/ui/lib/utils" + +import { ArrowRightIcon, ArrowTrendDownIcon, ArrowTrendUpIcon, GithubIcon } from "@/components/icons" +import { pickTimeRangeSearch } from "@/components/time-range-picker/search" + +import type { CodeReviewSearch } from "./code-review-layout" +import { + CATEGORY_LABELS, + bucketIso, + deltaOf, + formatCount, + formatDelta, + formatSpan, + type Delta, +} from "./code-review-format" + +type VolumeMetric = "pullRequests" | "reviews" +type SeverityFilter = "all" | PrReviewSeverity + +const VOLUME_LABELS = { pullRequests: "PRs reviewed", reviews: "Reviews" } satisfies Record< + VolumeMetric, + string +> +const SEVERITY_FILTER_LABELS = { + all: "All severities", + critical: "Critical", + warn: "Warning", + info: "Note", +} satisfies Record +/** Series names the shared palette colours by severity (see `SEVERITY_COLORS`). */ +const SEVERITY_SERIES = { critical: "Critical", warn: "Warning", info: "Note" } satisfies Record< + PrReviewSeverity, + string +> + +export function CodeReviewAnalyticsView({ + analytics, + search, + windowLabel, +}: { + analytics: CodeReviewAnalytics + search: CodeReviewSearch + /** Names the comparison, e.g. "30d". */ + windowLabel: string +}) { + return ( +
+ +
+ + +
+
+ + + +
+ +
+ ) +} + +export function CodeReviewAnalyticsSkeleton() { + return ( +
+ +
+ + +
+
+ ) +} + +/* ---------------------------------------------------------------------------------------------- */ + +function HeadlineStrip({ + current, + previous, + windowLabel, +}: { + current: CodeReviewTotals + previous: CodeReviewTotals + windowLabel: string +}) { + return ( +
+ + + + 0 + ? `in ${current.repositoriesWithFindings} ${current.repositoriesWithFindings === 1 ? "repo" : "repos"}` + : undefined + } + windowLabel={windowLabel} + /> +
+ ) +} + +function Headline({ + label, + value, + suffix, + delta, + windowLabel, + hint, +}: { + label: string + value: string + suffix?: string + delta?: Delta | null + windowLabel: string + hint?: string +}) { + return ( +
+ {label} + + {value} + {suffix ? {suffix} : null} + {delta ? : null} + + + {delta ? `vs previous ${windowLabel}` : null} + +
+ ) +} + +/** Colour follows improvement, the arrow follows the number, so colour never carries it alone. */ +function DeltaBadge({ delta }: { delta: Delta }) { + const Icon = delta.change >= 0 ? ArrowTrendUpIcon : ArrowTrendDownIcon + return ( + + + {formatDelta(delta)} + + ) +} + +/* ---------------------------------------------------------------------------------------------- */ + +function Card({ + title, + control, + children, + footer, + className, +}: { + title: string + control?: ReactNode + children: ReactNode + footer?: ReactNode + className?: string +}) { + return ( +
+
+

{title}

+ {control} +
+
{children}
+ {footer ?
{footer}
: null} +
+ ) +} + +function ChartFrame({ empty, children }: { empty: boolean; children: ReactNode }) { + return empty ? ( +
+ Nothing in this window +
+ ) : ( +
{children}
+ ) +} + +function VolumeCard({ analytics }: { analytics: CodeReviewAnalytics }) { + const [metric, setMetric] = useState("pullRequests") + const label = VOLUME_LABELS[metric] + const rows = useMemo( + () => analytics.series.map((point) => ({ bucket: bucketIso(point.bucket), [label]: point[metric] })), + [analytics.series, metric, label], + ) + const repositories = [...analytics.repositories] + .filter((repo) => repo.reviews > 0) + .sort((a, b) => b.reviews - a.reviews) + .slice(0, 5) + + return ( + { + if (value === "pullRequests" || value === "reviews") setMetric(value) + }} + > + + + + + PRs reviewed + Reviews + + + } + footer={ + ({ + key: repo.repositoryId, + name: repo.fullName, + value: repo.reviews, + }))} + /> + } + > + + + + + ) +} + +function IssuesCard({ analytics, search }: { analytics: CodeReviewAnalytics; search: CodeReviewSearch }) { + const [severity, setSeverity] = useState("all") + const rows = useMemo( + () => + analytics.series.map((point) => + severity === "all" + ? { + bucket: bucketIso(point.bucket), + [SEVERITY_SERIES.critical]: point.critical, + [SEVERITY_SERIES.warn]: point.warn, + [SEVERITY_SERIES.info]: point.info, + } + : { bucket: bucketIso(point.bucket), [SEVERITY_SERIES[severity]]: point[severity] }, + ), + [analytics.series, severity], + ) + const repositories = [...analytics.repositories] + .filter((repo) => repo.findings > 0) + .sort((a, b) => b.findings - a.findings) + .slice(0, 5) + + return ( + { + if (value === "all" || value === "critical" || value === "warn" || value === "info") + setSeverity(value) + }} + > + + + + + {(["all", "critical", "warn", "info"] as const).map((value) => ( + + {SEVERITY_FILTER_LABELS[value]} + + ))} + + + } + footer={ +
+ ({ + key: repo.repositoryId, + name: repo.fullName, + value: repo.findings, + }))} + /> + + See all issues caught + + +
+ } + > + + + +
+ ) +} + +function RepositoryList({ + title, + rows, +}: { + title: string + rows: ReadonlyArray<{ key: string; name: string; value: number }> +}) { + return ( +
+ {title} + {rows.length === 0 ? ( + No repositories in this window + ) : ( +
    + {rows.map((row) => ( +
  • + + {row.name} + + {formatCount(row.value)} + +
  • + ))} +
+ )} +
+ ) +} + +/* ---------------------------------------------------------------------------------------------- */ + +function BarList({ + rows, + emptyLabel, +}: { + rows: ReadonlyArray<{ key: string; label: string; value: number; tone?: string }> + emptyLabel: string +}) { + const max = Math.max(...rows.map((row) => row.value), 1) + if (rows.length === 0) + return

{emptyLabel}

+ return ( +
    + {rows.map((row) => ( +
  • + + {row.label} + {formatCount(row.value)} + + + + +
  • + ))} +
+ ) +} + +function CategoriesCard({ analytics }: { analytics: CodeReviewAnalytics }) { + return ( + + ({ + key: row.category, + label: CATEGORY_LABELS[row.category], + value: row.findings, + }))} + /> + + ) +} + +function OutcomesCard({ current, analytics }: { current: CodeReviewTotals; analytics: CodeReviewAnalytics }) { + return ( + + row.value > 0)} + /> + + ) +} + +function AuthorsCard({ analytics }: { analytics: CodeReviewAnalytics }) { + return ( + + {analytics.authors.length === 0 ? ( +

No authors in this window

+ ) : ( + + + + + + + + + + {analytics.authors.map((row) => ( + + + + + + ))} + +
AuthorPRsIssues
{row.author} + {formatCount(row.pullRequests)} + + {formatCount(row.findings)} +
+ )} +
+ ) +} + +/* ---------------------------------------------------------------------------------------------- */ + +function SecondaryStats({ current }: { current: CodeReviewTotals }) { + const settled = current.resolvedFindings + current.dismissedFindings + const stats = [ + { + label: "Avg confidence", + value: current.avgConfidence === null ? "–" : `${current.avgConfidence.toFixed(1)}/5`, + hint: "How safe the reviewer judged each change to merge", + }, + { + label: "Avg quality", + value: current.avgScore === null ? "–" : `${Math.round(current.avgScore)}/100`, + hint: "100 minus a fixed penalty per open finding", + }, + { + label: "Avg review time", + value: formatSpan(current.avgReviewSeconds), + hint: "From the push to the posted review", + }, + { + label: "Fixed rate", + value: + current.findings === 0 + ? "–" + : `${Math.round((current.resolvedFindings / current.findings) * 100)}%`, + hint: "Issues a later push resolved", + }, + { + label: "Dismissed", + value: settled === 0 ? "–" : formatCount(current.dismissedFindings), + hint: "Issues a person dismissed as not worth fixing", + }, + { + label: "Critical issues", + value: formatCount(current.criticalFindings), + }, + { + label: "Tokens", + value: formatCount(current.inputTokens + current.outputTokens), + hint: `${formatCount(current.inputTokens)} in, ${formatCount(current.outputTokens)} out`, + }, + ] + return ( +
+ {stats.map((stat) => ( +
+
{stat.label}
+
{stat.value}
+
+ ))} +
+ ) +} diff --git a/apps/web/src/components/code-review/code-review-format.ts b/apps/web/src/components/code-review/code-review-format.ts new file mode 100644 index 0000000000..8cff7b3ba7 --- /dev/null +++ b/apps/web/src/components/code-review/code-review-format.ts @@ -0,0 +1,117 @@ +import type { + CodeReviewListItem, + PrReviewCategory, + PrReviewFindingStatus, + PrReviewSeverity, + PrReviewSkipReason, +} from "@maple/domain/http" + +export const CATEGORY_LABELS = { + correctness: "Correctness", + security: "Security", + performance: "Performance", + observability: "Observability", + convention: "Conventions", + tests: "Tests", + maintainability: "Maintainability", +} satisfies Record + +export const SEVERITY_LABELS = { + critical: "Critical", + warn: "Warning", + info: "Note", +} satisfies Record + +/** Text tones for a severity, from the shared severity tokens. */ +export const SEVERITY_TONES = { + critical: "text-[var(--severity-error)]", + warn: "text-[var(--severity-warn)]", + info: "text-muted-foreground", +} satisfies Record + +export const FINDING_STATUS_LABELS = { + open: "Open", + resolved: "Resolved", + dismissed: "Dismissed", +} satisfies Record + +export const SKIP_LABELS = { + disabled: "reviews off", + draft: "draft", + bot_author: "bot author", + action: "not reviewed for this event", + no_head_sha: "no head commit", + quota: "daily limit reached", + duplicate: "already reviewed", + superseded: "a newer push was reviewed", + agent_unavailable: "reviewer unavailable", + not_rolled_out: "not enabled for this organization", + automatic_limit: "pull request limit reached", +} satisfies Record + +const COUNT = new Intl.NumberFormat("en-US") +const COMPACT = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }) + +export const formatCount = (value: number) => (value >= 10_000 ? COMPACT.format(value) : COUNT.format(value)) + +/** A span in the largest unit that keeps it readable: `42s`, `14m`, `5.2h`, `7.0d`. */ +export function formatSpan(seconds: number | null): string { + if (seconds === null || !Number.isFinite(seconds)) return "–" + if (seconds < 60) return `${Math.round(seconds)}s` + if (seconds < 3_600) return `${Math.round(seconds / 60)}m` + if (seconds < 86_400) return `${(seconds / 3_600).toFixed(1)}h` + return `${(seconds / 86_400).toFixed(1)}d` +} + +export interface Delta { + /** Signed fraction, e.g. -0.6 for "60% fewer". */ + readonly change: number + /** Whether the move is the good direction for this number. */ + readonly good: boolean +} + +/** + * Change against the previous window. Null with nothing to compare against: a jump from zero is + * not a percentage anyone can read. + */ +export function deltaOf( + current: number | null, + previous: number | null, + higherIsBetter: boolean, +): Delta | null { + if (current === null || previous === null || previous === 0) return null + const change = (current - previous) / previous + if (!Number.isFinite(change)) return null + return { change, good: change === 0 || change > 0 === higherIsBetter } +} + +export const formatDelta = (delta: Delta) => + `${delta.change > 0 ? "+" : ""}${Math.round(delta.change * 100)}%` + +export interface ReviewOutcome { + readonly label: string + readonly tone: string + readonly kind: "queued" | "running" | "failed" | "skipped" | "issues" | "clean" | "neutral" +} + +export const outcomeOf = (review: Pick): ReviewOutcome => { + switch (review.status) { + case "queued": + return { label: "Queued", tone: "text-muted-foreground", kind: "queued" } + case "running": + return { label: "Reviewing", tone: "text-info-foreground", kind: "running" } + case "failed": + return { label: "Failed", tone: "text-destructive-foreground", kind: "failed" } + case "skipped": + return { label: "Skipped", tone: "text-muted-foreground", kind: "skipped" } + case "completed": + return review.verdict === "issues" + ? { label: "Issues found", tone: "text-warning-foreground", kind: "issues" } + : review.verdict === "not_applicable" + ? { label: "Nothing to review", tone: "text-muted-foreground", kind: "neutral" } + : { label: "Clean", tone: "text-success-foreground", kind: "clean" } + } +} + +/** Bucket starts as the ISO strings the shared charts parse. */ +export const bucketIso = (bucketMs: number) => new Date(bucketMs).toISOString() diff --git a/apps/web/src/components/code-review/code-review-layout.tsx b/apps/web/src/components/code-review/code-review-layout.tsx new file mode 100644 index 0000000000..04a4705626 --- /dev/null +++ b/apps/web/src/components/code-review/code-review-layout.tsx @@ -0,0 +1,276 @@ +import type { ReactNode } from "react" +import { Link } from "@tanstack/react-router" +import { Schema } from "effect" +import { + PrReviewCategory, + PrReviewFindingStatus, + PrReviewId, + PrReviewSeverity, + VcsRepositoryId, +} from "@maple/domain/http" +import { Button } from "@maple/ui/components/ui/button" +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@maple/ui/components/ui/empty" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { cn } from "@maple/ui/lib/utils" + +import { BranchForkIcon, ChartBarIcon, CircleWarningIcon, GearIcon } from "@/components/icons" +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" +import { + TimeRangeSearchFields, + pickTimeRangeSearch, + type TimeRangeSearch, +} from "@/components/time-range-picker/search" +import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" +import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" +import { Result, useAtomValue } from "@/lib/effect-atom" +import { retainedQuery } from "@/lib/services/common/atom-client" +import { LONG_RANGE_PRESET_OPTIONS } from "@/lib/time-utils" + +/** Reviews are slow-moving: a month is the window that has something in it. */ +export const CODE_REVIEW_DEFAULT_PRESET = "30d" +export const CODE_REVIEW_MAX_RANGE_SECONDS = 365 * 24 * 60 * 60 + +/** The filters the Analytics and Pull requests tabs share, carried between them. */ +export const CodeReviewSearchFields = { + repo: Schema.optional(VcsRepositoryId), + author: Schema.optional(Schema.String), + ...TimeRangeSearchFields, +} + +export const CodeReviewIssuesSearchFields = { + ...CodeReviewSearchFields, + severity: Schema.optional(PrReviewSeverity), + category: Schema.optional(PrReviewCategory), + state: Schema.optional(PrReviewFindingStatus), + review: Schema.optional(PrReviewId), +} + +export const CodeReviewListSearchFields = { + ...CodeReviewSearchFields, + status: Schema.optional(Schema.Literals(["completed", "failed", "skipped", "running", "queued"])), + review: Schema.optional(PrReviewId), +} + +export interface CodeReviewSearch extends TimeRangeSearch { + repo?: VcsRepositoryId + author?: string +} + +export type CodeReviewTab = "analytics" | "pull-requests" | "issues" | "settings" + +const TABS = [ + { tab: "analytics", to: "/code-review", label: "Analytics", Icon: ChartBarIcon }, + { tab: "pull-requests", to: "/code-review/pull-requests", label: "Pull requests", Icon: BranchForkIcon }, + { tab: "issues", to: "/code-review/issues", label: "Issues", Icon: CircleWarningIcon }, + { tab: "settings", to: "/code-review/settings", label: "Settings", Icon: GearIcon }, +] as const + +/** + * Real links rather than a `Tabs` widget: each tab is a route, so middle-click, Copy link and Back + * work. The window and the repository and author filters travel between the tabs that read them. + */ +function CodeReviewTabs({ active, search }: { active: CodeReviewTab; search: CodeReviewSearch }) { + const carried = { ...pickTimeRangeSearch(search), repo: search.repo, author: search.author } + return ( + + ) +} + +/** + * The frame every Code Review tab renders in. Gated on the `prReview` rollout like the reviewer + * itself; while the flags load, a skeleton rather than a not-enabled flash. + */ +export function CodeReviewLayout({ + active, + search, + toolbar, + children, +}: { + active: CodeReviewTab + search: CodeReviewSearch + /** Right of the title: the filters, on the tabs that have them. */ + toolbar?: ReactNode + children: ReactNode +}) { + const { flags, isLoaded } = useOrganizationFeatureFlags() + const label = TABS.find((tab) => tab.tab === active)?.label + + return ( + + + + + + + + {flags.prReview ? toolbar : null} + + + + {!isLoaded ? ( +
+ + + +
+ ) : !flags.prReview ? ( + + + Code review is not enabled + + Pull request reviews are rolling out by organization. Ask us to + turn them on for yours. + + + + ) : ( +
+ + {children} +
+ )} +
+
+
+
+
+ ) +} + +const ALL = "all" + +/** + * Repository, author and window. The repositories are the GitHub installation's; the authors are + * whoever opened a reviewed pull request in the window, which the caller already has. + */ +export function CodeReviewFilters({ + search, + authors, + onChange, +}: { + search: CodeReviewSearch + authors: ReadonlyArray + onChange: (patch: Partial & { timePreset?: string }) => void +}) { + const status = useAtomValue( + retainedQuery("integrations", "githubStatus", { reactivityKeys: ["githubIntegrationStatus"] }), + ) + const repositories = Result.builder(status) + .onSuccess((response) => response.repositories.filter((repo) => repo.prReviewEnabled)) + .orElse(() => []) + const repoItems = Object.fromEntries([ + [ALL, "All repositories"], + ...repositories.map((repo) => [repo.id, repo.fullName]), + ]) + // A filter from a link keeps its option even when the author has nothing in the new window. + const authorOptions = + search.author && !authors.includes(search.author) ? [search.author, ...authors] : authors + const authorItems = Object.fromEntries([[ALL, "All authors"], ...authorOptions.map((a) => [a, a])]) + const isRepositoryId = Schema.is(VcsRepositoryId) + + return ( +
+ + + + onChange( + range.presetValue + ? { timePreset: range.presetValue, startTime: undefined, endTime: undefined } + : { startTime: range.startTime, endTime: range.endTime, timePreset: undefined }, + ) + } + /> +
+ ) +} + +/** The empty state of a tab whose filters left nothing: one way back to everything. */ +export function NothingInWindow({ + title, + description, + onClear, +}: { + title: string + description: string + onClear?: () => void +}) { + return ( + + + {title} + {description} + + {onClear ? ( + + ) : null} + + ) +} diff --git a/apps/web/src/components/code-review/code-review-settings.tsx b/apps/web/src/components/code-review/code-review-settings.tsx new file mode 100644 index 0000000000..cd8645e77b --- /dev/null +++ b/apps/web/src/components/code-review/code-review-settings.tsx @@ -0,0 +1,424 @@ +import { useState, type ReactNode } from "react" +import { Link } from "@tanstack/react-router" +import { Exit, Schema } from "effect" +import { + GithubPrReviewConfigRequest, + GithubPrReviewSettingsRequest, + GithubSetPrReviewRequest, + PR_REVIEW_MODELS, + PrReviewModel, + PrReviewOrgSettings, + type GithubRepoSummary, + PrReviewRepositoryConfig, +} from "@maple/domain/http" +import { Badge } from "@maple/ui/components/ui/badge" +import { Button } from "@maple/ui/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogPanel, + DialogTitle, +} from "@maple/ui/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Switch } from "@maple/ui/components/ui/switch" +import { toastManager } from "@maple/ui/components/ui/toast" + +import { QueryErrorState } from "@/components/common/query-error-state" +import { ExternalLinkIcon, GearIcon, GithubIcon } from "@/components/icons" +import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" +import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" +import { errorMessage } from "@/lib/error-toast" +import { currentRegion } from "@/lib/region" +import { MapleApiAtomClient, retainedQuery } from "@/lib/services/common/atom-client" + +import { ReviewRulesForm } from "./review-rules-form" + +const SETTINGS_KEY = "githubPrReviewSettings" +const STATUS_KEY = "githubIntegrationStatus" +const configKey = (repo: GithubRepoSummary) => `githubPrReviewConfig:${repo.id}` + +const DEFAULT_MODEL = "default" +/** The EU instance only serves models with an in-region provider. */ +const MODEL_OPTIONS = PR_REVIEW_MODELS.filter((model) => currentRegion !== "eu" || model.eu) +const DEFAULT_MODEL_LABEL = "Maple default" +const MODEL_LABELS = Object.fromEntries([ + [DEFAULT_MODEL, DEFAULT_MODEL_LABEL], + ...MODEL_OPTIONS.map((model) => [model.id, model.label]), +]) +const isModel = Schema.is(PrReviewModel) +// One instance, so the form's "the config changed" check does not fire on every render. +const EMPTY_CONFIG = new PrReviewRepositoryConfig({}) + +/** Organization settings first, since every repository inherits them; then the repositories. */ +export function CodeReviewSettingsView() { + const query = retainedQuery("integrations", "githubGetPrReviewSettings", { + reactivityKeys: [SETTINGS_KEY], + }) + const result = useAtomValue(query) + const refresh = useAtomRefresh(query) + + return Result.builder(result) + .onInitial(() => ( +
+ + +
+ )) + .onError((error) => ( + + )) + .onSuccess((response) => ( +
+
+ +
+
+
+ +
+
+
+ +
+
+ )) + .render() +} + +function Section({ + title, + description, + children, +}: { + title: string + description: string + children: ReactNode +}) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ) +} + +function useSaveSettings() { + const save = useAtomSet(MapleApiAtomClient.mutation("integrations", "githubSetPrReviewSettings"), { + mode: "promiseExit", + }) + return (settings: PrReviewOrgSettings) => + save({ payload: new GithubPrReviewSettingsRequest({ settings }), reactivityKeys: [SETTINGS_KEY] }) +} + +function ModelSetting({ settings }: { settings: PrReviewOrgSettings }) { + const isAdmin = useIsOrgAdmin() + const save = useSaveSettings() + const [saving, setSaving] = useState(false) + const current = + settings.model !== undefined && MODEL_OPTIONS.some((model) => model.id === settings.model) + ? settings.model + : DEFAULT_MODEL + + async function handleChange(value: unknown) { + if (value === current) return + const model = isModel(value) ? value : undefined + setSaving(true) + // The defaults ride along: the settings are one row, written whole. + const result = await save( + new PrReviewOrgSettings({ + ...(model === undefined ? undefined : { model }), + ...(settings.defaults === undefined ? undefined : { defaults: settings.defaults }), + }), + ) + setSaving(false) + toastManager.add( + Exit.isSuccess(result) + ? { title: `Reviews now run on ${MODEL_LABELS[model ?? DEFAULT_MODEL]}`, type: "success" } + : { title: errorMessage(result, "Failed to save the review model."), type: "error" }, + ) + } + + return ( +
+
+
Review model
+
Runs pull request reviews and replies.
+
+ +
+ ) +} + +function ReviewRulesDefaults({ settings }: { settings: PrReviewOrgSettings }) { + const save = useSaveSettings() + return ( + { + const result = await save( + new PrReviewOrgSettings({ + ...(settings.model === undefined ? undefined : { model: settings.model }), + defaults, + }), + ) + if (Exit.isFailure(result)) return errorMessage(result, "Failed to save the default rules.") + toastManager.add({ title: "Default review rules saved", type: "success" }) + return null + }} + /> + ) +} + +function RepositoriesSection({ inherited }: { inherited: PrReviewRepositoryConfig | undefined }) { + const query = retainedQuery("integrations", "githubStatus", { reactivityKeys: [STATUS_KEY] }) + const result = useAtomValue(query) + const refresh = useAtomRefresh(query) + + return Result.builder(result) + .onInitial(() => ) + .onError((error) => ( + + )) + .onSuccess((status) => { + const repositories = status.repositories.filter((repo) => repo.status === "active") + if (!status.connected || repositories.length === 0) + return ( +
+
+ + {status.connected + ? "No repositories yet" + : "Connect GitHub to review pull requests"} +
+

+ {status.connected + ? "Grant the Maple GitHub App access to a repository, and it appears here." + : "Maple reviews pull requests through its GitHub App. Install it on the repositories you want reviewed."} +

+ +
+ ) + const enabled = repositories.filter((repo) => repo.prReviewEnabled).length + return ( +
+
+ + {enabled} of {repositories.length}{" "} + {repositories.length === 1 ? "repository" : "repositories"} reviewed + + + Manage GitHub access + +
+
    + {repositories.map((repo) => ( + + ))} +
+
+ ) + }) + .render() +} + +function RepositoryRow({ + repo, + inherited, +}: { + repo: GithubRepoSummary + inherited: PrReviewRepositoryConfig | undefined +}) { + const isAdmin = useIsOrgAdmin() + const setPrReview = useAtomSet(MapleApiAtomClient.mutation("integrations", "githubSetPrReview"), { + mode: "promiseExit", + }) + const [enabled, setEnabled] = useState(repo.prReviewEnabled) + const [busy, setBusy] = useState(false) + const [open, setOpen] = useState(false) + // A fresh server value wins over the optimistic one; adjusted during render, not in an effect. + const [seenServer, setSeenServer] = useState(repo.prReviewEnabled) + if (seenServer !== repo.prReviewEnabled) { + setSeenServer(repo.prReviewEnabled) + setEnabled(repo.prReviewEnabled) + } + const id = `code-review-repo-${repo.id}` + + async function handleToggle(next: boolean) { + // Ignored while a change is in flight, rather than disabling the switch, which would drop + // keyboard focus mid-toggle. + if (busy) return + setEnabled(next) + setBusy(true) + const result = await setPrReview({ + params: { repositoryId: repo.id }, + payload: new GithubSetPrReviewRequest({ enabled: next }), + reactivityKeys: [STATUS_KEY], + }) + setBusy(false) + if (Exit.isSuccess(result)) { + toastManager.add({ + title: next + ? `Maple will review pull requests on ${repo.fullName}` + : `Reviews off for ${repo.fullName}`, + type: "success", + }) + return + } + setEnabled(!next) + toastManager.add({ + title: errorMessage(result, "Failed to change pull request reviews."), + type: "error", + }) + } + + return ( +
  • + +
    + + {repo.fullName} + + + {repo.isPrivate ? ( + + Private + + ) : null} +
    + + + + + Repository rules + + {repo.fullName}. Anything left on the organization default follows the default + review rules. + + + + + + + + void handleToggle(next)} + /> +
  • + ) +} + +function RepositoryConfig({ + repo, + inherited, +}: { + repo: GithubRepoSummary + inherited: PrReviewRepositoryConfig | undefined +}) { + const result = useAtomValue( + retainedQuery("integrations", "githubGetPrReviewConfig", { + params: { repositoryId: repo.id }, + reactivityKeys: [configKey(repo)], + }), + ) + const save = useAtomSet(MapleApiAtomClient.mutation("integrations", "githubSetPrReviewConfig"), { + mode: "promiseExit", + }) + + return Result.builder(result) + .onInitial(() => ( +
    + + + +
    + )) + .onError((error) => ( +

    + {errorMessage(error, "Failed to load the repository's rules.")} +

    + )) + .onSuccess((response) => ( + { + const saved = await save({ + params: { repositoryId: repo.id }, + payload: new GithubPrReviewConfigRequest({ config }), + reactivityKeys: [configKey(repo)], + }) + if (Exit.isFailure(saved)) + return errorMessage(saved, "Failed to save the repository's rules.") + toastManager.add({ title: `Rules saved for ${repo.fullName}`, type: "success" }) + return null + }} + /> + )) + .render() +} diff --git a/apps/web/src/components/code-review/review-detail-sheet.tsx b/apps/web/src/components/code-review/review-detail-sheet.tsx new file mode 100644 index 0000000000..421c48c4c7 --- /dev/null +++ b/apps/web/src/components/code-review/review-detail-sheet.tsx @@ -0,0 +1,322 @@ +import { + PR_REVIEW_MODELS, + type CodeReviewDetail, + type CodeReviewFinding, + type PrReviewFinding, + type PrReviewId, +} from "@maple/domain/http" +import { Badge } from "@maple/ui/components/ui/badge" +import { Button } from "@maple/ui/components/ui/button" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetPanel, + SheetTitle, +} from "@maple/ui/components/ui/sheet" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { cn } from "@maple/ui/lib/utils" +import { formatRelativeFrom } from "@maple/ui/lib/time-format" + +import { MessageResponse } from "@/components/ai-elements/message-response" +import { QueryErrorState } from "@/components/common/query-error-state" +import { ExternalLinkIcon } from "@/components/icons" +import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" +import { retainedQuery } from "@/lib/services/common/atom-client" + +import { + CATEGORY_LABELS, + FINDING_STATUS_LABELS, + SEVERITY_LABELS, + SEVERITY_TONES, + SKIP_LABELS, + formatCount, + formatSpan, + outcomeOf, +} from "./code-review-format" + +/** One review, opened from the list: the report as the reviewer filed it, and its findings' fate. */ +export function ReviewDetailSheet({ + reviewId, + onClose, + onSelect, +}: { + reviewId: PrReviewId | undefined + onClose: () => void + onSelect: (reviewId: PrReviewId) => void +}) { + return ( + (open ? undefined : onClose())}> + + {reviewId === undefined ? null : } + + + ) +} + +function ReviewDetailBody({ + reviewId, + onSelect, +}: { + reviewId: PrReviewId + onSelect: (reviewId: PrReviewId) => void +}) { + const query = retainedQuery("codeReview", "getReview", { params: { reviewId } }) + const result = useAtomValue(query) + const refresh = useAtomRefresh(query) + + return Result.builder(result) + .onInitial(() => ( + <> + + + + + + + + + + + )) + .onError((error) => ( + + + + )) + .onSuccess((detail) => ) + .render() +} + +function ReviewDetailContent({ + detail, + onSelect, +}: { + detail: CodeReviewDetail + onSelect: (reviewId: PrReviewId) => void +}) { + const { review, report } = detail + const outcome = outcomeOf(review) + const lifecycle = new Map(detail.findings.map((finding) => [finding.handle, finding])) + const durationSeconds = + review.finishedAt === null + ? null + : (review.finishedAt - (detail.startedAt ?? review.createdAt)) / 1000 + const problem = + review.error ?? (review.publishError === null ? null : `Not posted to GitHub: ${review.publishError}`) + + return ( + <> + + + #{review.number} + {review.title ?? "Untitled pull request"} + + + {review.repositoryFullName} + {review.authorLogin ? ` · opened by ${review.authorLogin}` : null} · head{" "} + {review.headSha.slice(0, 7)} + +
    + Pull request + {review.commentUrl ? ( + Summary comment + ) : null} + {detail.reviewUrl ? Inline review : null} + {detail.checkRunUrl ? Check run : null} +
    +
    + +
    + {outcome.label}} /> + + + + + + + +
    + + {review.status === "skipped" && review.skipReason !== null ? ( +

    + Skipped: {SKIP_LABELS[review.skipReason]}. +

    + ) : null} + {problem !== null ? ( +

    + {problem} +

    + ) : null} + + {report !== null ? ( + <> +
    + + {report.summary} + + {report.confidenceReason ? ( +

    {report.confidenceReason}

    + ) : null} +
    + {report.keyChanges && report.keyChanges.length > 0 ? ( +
    + +
    + ) : null} +
    + {report.findings.length === 0 ? ( +

    No issues filed on this head.

    + ) : ( +
      + {report.findings.map((finding, index) => ( + + ))} +
    + )} +
    + {report.checked && report.checked.length > 0 ? ( +
    + +
    + ) : null} + {report.unreviewed && report.unreviewed.length > 0 ? ( +
    +
      + {report.unreviewed.map((path) => ( +
    • {path}
    • + ))} +
    +
    + ) : null} + + ) : null} + + {detail.history.length > 1 ? ( +
    +
      + {detail.history.map((entry) => { + const entryOutcome = outcomeOf(entry) + const current = entry.id === review.id + return ( +
    • + +
    • + ) + })} +
    +
    + ) : null} +
    + + ) +} + +function FindingCard({ + finding, + tracked, +}: { + finding: PrReviewFinding + tracked: CodeReviewFinding | undefined +}) { + return ( +
  • +
    + + {SEVERITY_LABELS[finding.severity]} + + {CATEGORY_LABELS[finding.category]} + {finding.handle ? ( + {finding.handle} + ) : null} + {tracked ? ( + + {FINDING_STATUS_LABELS[tracked.status]} + + ) : null} +
    +

    {finding.title}

    +

    + {finding.path}:{finding.line} + {finding.endLine && finding.endLine !== finding.line ? `-${finding.endLine}` : null} +

    + + {finding.body} + +
  • + ) +} + +const modelLabel = (model: string | null) => + model === null ? "–" : (PR_REVIEW_MODELS.find((entry) => entry.id === model)?.label ?? model) + +function Stat({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
    +

    {title}

    + {children} +
    + ) +} + +function BulletList({ items }: { items: ReadonlyArray }) { + return ( +
      + {items.map((item, index) => ( +
    • {item}
    • + ))} +
    + ) +} + +function LinkButton({ href, children }: { href: string; children: React.ReactNode }) { + return ( + + ) +} diff --git a/apps/web/src/components/code-review/review-rules-form.tsx b/apps/web/src/components/code-review/review-rules-form.tsx new file mode 100644 index 0000000000..6549e2adfd --- /dev/null +++ b/apps/web/src/components/code-review/review-rules-form.tsx @@ -0,0 +1,491 @@ +import { useState } from "react" +import { Schema } from "effect" +import { + PrReviewCategory, + PrReviewFeedbackScope, + PrReviewRepositoryConfig, + PrReviewSeverity, +} from "@maple/domain/http" +import { Button } from "@maple/ui/components/ui/button" +import { Checkbox } from "@maple/ui/components/ui/checkbox" +import { Input } from "@maple/ui/components/ui/input" +import { Label } from "@maple/ui/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { Switch } from "@maple/ui/components/ui/switch" +import { Textarea } from "@maple/ui/components/ui/textarea" + +import { LoaderIcon } from "@/components/icons" +import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" + +import { CATEGORY_LABELS } from "./code-review-format" + +const INSTRUCTIONS_MAX = 4_000 +const IGNORE_PATH_MAX = 200 +const DAILY_LIMIT_MAX = 500 +const AUTOMATIC_LIMIT_MAX = 50 +const ALL_CATEGORIES = PrReviewCategory.literals +const INHERIT = "inherit" + +const SEVERITY_LABELS = { + critical: "Critical only", + warn: "Warning and critical", + info: "Every issue", +} satisfies Record +const FEEDBACK_LABELS = { + organization: "Whole organization", + repository: "This repository", + off: "Off", +} satisfies Record +const isSeverity = Schema.is(PrReviewSeverity) +const isFeedbackScope = Schema.is(PrReviewFeedbackScope) + +/** What a review uses when neither the organization nor the repository says otherwise. */ +const BUILT_IN = { + minInlineSeverity: "warn", + reviewDrafts: false, + feedbackScope: "organization", +} as const + +/** + * `organization` edits the rules every repository starts from; an absent field there is the + * built-in default. `repository` edits one repository's overrides; an absent field there inherits + * the organization's value, and instructions and ignored paths add to the organization's. + */ +export type ReviewRulesMode = "organization" | "repository" + +type Inheritable = A | typeof INHERIT + +interface FormState { + readonly instructions: string + readonly ignorePaths: string + /** Null inherits the organization's lenses. */ + readonly categories: ReadonlyArray | null + readonly minInlineSeverity: Inheritable + readonly reviewDrafts: Inheritable<"on" | "off"> + readonly dailyLimit: string + readonly automaticReviewLimit: string + readonly feedbackScope: Inheritable +} + +const stateFromConfig = (mode: ReviewRulesMode, config: PrReviewRepositoryConfig): FormState => { + const inherits = mode === "repository" + return { + instructions: config.instructions ?? "", + ignorePaths: (config.ignorePaths ?? []).join("\n"), + categories: config.categories ?? (inherits ? null : ALL_CATEGORIES), + minInlineSeverity: config.minInlineSeverity ?? (inherits ? INHERIT : BUILT_IN.minInlineSeverity), + reviewDrafts: + config.reviewDrafts === undefined + ? inherits + ? INHERIT + : "off" + : config.reviewDrafts + ? "on" + : "off", + dailyLimit: config.dailyLimit === undefined ? "" : String(config.dailyLimit), + automaticReviewLimit: + config.automaticReviewLimit === undefined ? "" : String(config.automaticReviewLimit), + feedbackScope: config.feedbackScope ?? (inherits ? INHERIT : BUILT_IN.feedbackScope), + } +} + +const sameCategories = (a: FormState["categories"], b: FormState["categories"]) => + a === null || b === null ? a === b : a.length === b.length && a.every((category) => b.includes(category)) + +const sameState = (a: FormState, b: FormState) => + a.instructions === b.instructions && + a.ignorePaths === b.ignorePaths && + a.minInlineSeverity === b.minInlineSeverity && + a.reviewDrafts === b.reviewDrafts && + a.dailyLimit === b.dailyLimit && + a.automaticReviewLimit === b.automaticReviewLimit && + a.feedbackScope === b.feedbackScope && + sameCategories(a.categories, b.categories) + +const parseIgnorePaths = (text: string) => + text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + +const checkLimit = (text: string, max: number, label: string): string | null => { + const trimmed = text.trim() + if (trimmed === "") return null + const value = Number(trimmed) + return Number.isInteger(value) && value >= 1 && value <= max + ? null + : `${label} must be a whole number from 1 to ${max}.` +} + +/** The first problem that would make the server reject the form, if any. */ +const validate = (state: FormState): string | null => { + if (state.instructions.length > INSTRUCTIONS_MAX) + return `Instructions are limited to ${INSTRUCTIONS_MAX} characters.` + if (parseIgnorePaths(state.ignorePaths).some((path) => path.length > IGNORE_PATH_MAX)) + return `Each ignored path is limited to ${IGNORE_PATH_MAX} characters.` + if (parseIgnorePaths(state.ignorePaths).length > 50) return "At most 50 ignored paths." + if (state.categories !== null && state.categories.length === 0) return "Pick at least one lens." + return ( + checkLimit(state.dailyLimit, DAILY_LIMIT_MAX, "Daily limit") ?? + checkLimit(state.automaticReviewLimit, AUTOMATIC_LIMIT_MAX, "Reviews per pull request") + ) +} + +/** + * Inherited and built-in values are omitted rather than stored, so a later change to the + * organization's rules (or to a built-in default) still reaches this config. + */ +const configFromState = (mode: ReviewRulesMode, state: FormState) => { + const instructions = state.instructions.trim() + const ignorePaths = parseIgnorePaths(state.ignorePaths) + const limit = state.dailyLimit.trim() + const perPullRequest = state.automaticReviewLimit.trim() + const org = mode === "organization" + const categories = + state.categories === null || (org && state.categories.length === ALL_CATEGORIES.length) + ? undefined + : ALL_CATEGORIES.filter((category) => state.categories?.includes(category)) + const severity = + state.minInlineSeverity === INHERIT || (org && state.minInlineSeverity === BUILT_IN.minInlineSeverity) + ? undefined + : state.minInlineSeverity + const drafts = + state.reviewDrafts === INHERIT || (org && state.reviewDrafts === "off") + ? undefined + : state.reviewDrafts === "on" + const feedback = + state.feedbackScope === INHERIT || (org && state.feedbackScope === BUILT_IN.feedbackScope) + ? undefined + : state.feedbackScope + return new PrReviewRepositoryConfig({ + ...(instructions === "" ? undefined : { instructions }), + ...(ignorePaths.length === 0 ? undefined : { ignorePaths }), + ...(categories === undefined ? undefined : { categories }), + ...(severity === undefined ? undefined : { minInlineSeverity: severity }), + ...(drafts === undefined ? undefined : { reviewDrafts: drafts }), + ...(limit === "" ? undefined : { dailyLimit: Number(limit) }), + ...(perPullRequest === "" ? undefined : { automaticReviewLimit: Number(perPullRequest) }), + ...(feedback === undefined ? undefined : { feedbackScope: feedback }), + }) +} + +export function ReviewRulesForm({ + mode, + config, + inherited, + onSave, + idPrefix, +}: { + mode: ReviewRulesMode + config: PrReviewRepositoryConfig + /** The organization's rules, named in a repository form's "Organization default" options. */ + inherited?: PrReviewRepositoryConfig + /** Resolves with an error message, or null once saved. */ + onSave: (config: PrReviewRepositoryConfig) => Promise + idPrefix: string +}) { + const isAdmin = useIsOrgAdmin() + const [state, setState] = useState(() => stateFromConfig(mode, config)) + const [saved, setSaved] = useState(() => stateFromConfig(mode, config)) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + // What was last sent, so a refetch landing after the save keeps any edit made since. + const [submitted, setSubmitted] = useState(null) + // A refetched config replaces the form unless it was edited after Save; adjusted during render. + const [seenConfig, setSeenConfig] = useState(config) + if (seenConfig !== config) { + setSeenConfig(config) + setSaved(stateFromConfig(mode, config)) + if (submitted === null || sameState(state, submitted)) setState(stateFromConfig(mode, config)) + setSubmitted(null) + } + + const problem = validate(state) + const dirty = !sameState(state, saved) + const repository = mode === "repository" + const parent = inherited ?? new PrReviewRepositoryConfig({}) + + const update = (patch: Partial) => { + setState((prev) => ({ ...prev, ...patch })) + setError(null) + } + + async function handleSave() { + setSaving(true) + setSubmitted(state) + setError(null) + const failure = await onSave(configFromState(mode, state)) + setSaving(false) + if (failure !== null) setError(failure) + } + + const inheritedSeverity = SEVERITY_LABELS[parent.minInlineSeverity ?? BUILT_IN.minInlineSeverity] + const inheritedFeedback = FEEDBACK_LABELS[parent.feedbackScope ?? BUILT_IN.feedbackScope] + const inheritedDrafts = (parent.reviewDrafts ?? BUILT_IN.reviewDrafts) ? "Review drafts" : "Skip drafts" + const limitPlaceholder = (value: number | undefined) => + repository ? `Organization default (${value ?? "no limit"})` : "No limit" + + const severityItems = { + ...(repository ? { [INHERIT]: `Organization default (${inheritedSeverity})` } : undefined), + ...SEVERITY_LABELS, + } + const feedbackItems = { + ...(repository ? { [INHERIT]: `Organization default (${inheritedFeedback})` } : undefined), + ...FEEDBACK_LABELS, + } + const draftItems = { + [INHERIT]: `Organization default (${inheritedDrafts})`, + on: "Review drafts", + off: "Skip drafts", + } + const parentCategories = parent.categories ?? ALL_CATEGORIES + + // Frozen while saving: a refetch after the save would replace any edit made in the meantime. + return ( +
    +
    + +