mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
PersonHog was the last of it, and it was not only a name. The package is personinsights and the types in it are already Person* -- the shipped descriptor under personinsights_client/proto/generated says PersonService and the tests say TestFakePersonClient -- but the .proto still declared PersonHogService. A client dialling one service name and a server built from a .proto declaring another is not a naming problem, it is a wire that does not connect; the only reason nobody hit it is that none of these services runs here yet. Five service declarations now say what the generated client has said all along. StreamHog was a second fictional company, invented so a Stripe mock had someone to bill. This fork already has Hedgebox, in five hundred fixtures, so the mock bills Hedgebox and there is one made-up company again instead of two. REVIEW_HOG was an enum member in three enums that had to agree, whose value has been `review` since the app was renamed. Only the member said otherwise. Every rule is in bin/debrand and every spelling is refused there, so a fresh upstream import lands on these names and none of them can come back. Two are named as outstanding rather than refused, with the reason each is still sayable: hogdesk survives inside applied migrations' choices lists, where Django compares it in Python and never writes it -- the member is Desk = "desk" and all three tables that store it hold no rows; sherlockhog keys a service in another repository that still calls itself that, and renaming our half would classify its traffic as a product that does not exist. Co-authored-by: Hanzo Dev <[email protected]>
1673 lines
75 KiB
TypeScript
1673 lines
75 KiB
TypeScript
import { useActions, useValues } from 'kea'
|
||
|
||
import {
|
||
IconBalance,
|
||
IconChevronDown,
|
||
IconDirectedGraph,
|
||
IconExternal,
|
||
IconFilter,
|
||
IconGithub,
|
||
IconPlus,
|
||
IconPullRequest,
|
||
IconSearch,
|
||
IconShield,
|
||
IconStack,
|
||
} from '@hanzo/icons'
|
||
import {
|
||
Banner,
|
||
Button,
|
||
Input,
|
||
SegmentedButton,
|
||
Skeleton,
|
||
Slider,
|
||
Switch,
|
||
Tabs,
|
||
Tag,
|
||
Link,
|
||
Spinner,
|
||
Tooltip,
|
||
} from '@hanzo/elements'
|
||
|
||
import { Logomark } from 'lib/brand'
|
||
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
|
||
import { NotFound } from 'lib/components/NotFound'
|
||
import { TZLabel } from 'lib/components/TZLabel'
|
||
import { IconStamp } from 'lib/elements/icons'
|
||
import { Card } from 'lib/elements/Card'
|
||
import { Collapse } from 'lib/elements/Collapse'
|
||
import { Drawer } from 'lib/elements/Drawer'
|
||
import { Markdown } from 'lib/elements/Markdown'
|
||
import { SceneExport } from 'scenes/sceneTypes'
|
||
import { urls } from 'scenes/urls'
|
||
import { userLogic } from 'scenes/userLogic'
|
||
|
||
import { SceneContent } from '~/layout/scenes/components/SceneContent'
|
||
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
|
||
|
||
import type {
|
||
ReviewFindingApi,
|
||
ReviewIssuePriorityEnumApi,
|
||
ReviewPerspectiveStatItemApi,
|
||
ReviewRecentReviewApi,
|
||
UrgencyThresholdEnumApi,
|
||
} from 'products/review/frontend/generated/api.schemas'
|
||
import { ReviewReviewsListScope } from 'products/review/frontend/generated/api.schemas'
|
||
|
||
import { PipelineDetailModal } from './PipelineDetailModal'
|
||
import { REVIEWS_PAGE_SIZE, ReviewDrawerTab, ReviewSkillKind, reviewSettingsLogic } from './reviewSettingsLogic'
|
||
|
||
/** "review-script-perspective-logic-correctness" → "Logic correctness" */
|
||
function prettifySkillName(skillName: string): string {
|
||
const cleaned = skillName
|
||
.replace(/^review-script-(perspective|blind-spots|validation)-/, '')
|
||
.replace(/[-_]/g, ' ')
|
||
.trim()
|
||
return cleaned ? cleaned.charAt(0).toUpperCase() + cleaned.slice(1) : skillName
|
||
}
|
||
|
||
// Step numbering and names match the detailed-view modal (PipelineDetailModal) — keep them in sync.
|
||
const PIPELINE_PHASES: { name: string; hint: string; steps: { number: string; title: string; caption: string }[] }[] = [
|
||
{
|
||
name: 'Prepare',
|
||
hint: 'get the diff ready to read',
|
||
steps: [
|
||
{
|
||
number: '01',
|
||
title: 'Meaningful diff',
|
||
caption: "we fetch the PR's diff; generated files, lock files, and snapshots are skipped",
|
||
},
|
||
{
|
||
number: '02',
|
||
title: 'Split into chunks',
|
||
caption: 'larger PRs are split into logically reviewable chunks',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Review',
|
||
hint: 'pick the lenses, read in parallel',
|
||
steps: [
|
||
{
|
||
number: '03',
|
||
title: 'Pick perspectives',
|
||
caption: 'each chunk gets only the perspectives it actually needs',
|
||
},
|
||
{ number: '04', title: 'Perspectives', caption: 'specialist reviewers read each chunk in parallel' },
|
||
{ number: '05', title: 'Blind spots', caption: 'one more sweep for what every perspective missed' },
|
||
],
|
||
},
|
||
{
|
||
name: 'Refine & publish',
|
||
hint: 'clean up and ship the review',
|
||
steps: [
|
||
{ number: '06', title: 'Dedupe', caption: 'overlapping findings are merged' },
|
||
{ number: '07', title: 'Validate', caption: 'each finding is checked against your quality bar' },
|
||
{ number: '08', title: 'Publish', caption: 'a cleaned-up review lands on the pull request' },
|
||
],
|
||
},
|
||
]
|
||
|
||
const URGENCY_STOPS: { key: UrgencyThresholdEnumApi; label: string; description: string }[] = [
|
||
{
|
||
key: 'consider',
|
||
label: 'All issues',
|
||
description:
|
||
'Every validated finding is published, including the minor consider-level ones. This is the default.',
|
||
},
|
||
{
|
||
key: 'should_fix',
|
||
label: 'Should fix',
|
||
description: 'Recommended fixes and anything more serious. Minor consider-level findings are dropped.',
|
||
},
|
||
{
|
||
key: 'must_fix',
|
||
label: 'Must fix',
|
||
description: 'Only blocking issues, the highest-priority findings. Everything else is dropped.',
|
||
},
|
||
]
|
||
|
||
function SectionHeader({
|
||
icon,
|
||
title,
|
||
pill,
|
||
action,
|
||
children,
|
||
}: {
|
||
icon: JSX.Element
|
||
title: string
|
||
pill?: JSX.Element
|
||
action?: JSX.Element
|
||
children: string
|
||
}): JSX.Element {
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<div className="flex items-center gap-2">
|
||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md border border-primary bg-surface-primary text-secondary *:size-4">
|
||
{icon}
|
||
</span>
|
||
<h3 className="m-0 text-base font-semibold">{title}</h3>
|
||
{pill}
|
||
{action && <div className="ml-auto">{action}</div>}
|
||
</div>
|
||
{/* Indented so the copy aligns under the title, not the icon tile */}
|
||
<p className="m-0 ml-9 max-w-160 text-xs text-secondary">{children}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Page-level scope switch: one control governing the hero proof card, the effectiveness cards,
|
||
* and the recent-reviews list below. Skill toggles stay per-user regardless of scope.
|
||
*/
|
||
function PageScopeSwitch(): JSX.Element {
|
||
const { reviewsScope } = useValues(reviewSettingsLogic)
|
||
const { setReviewsScope } = useActions(reviewSettingsLogic)
|
||
return (
|
||
<SegmentedButton
|
||
size="small"
|
||
value={reviewsScope}
|
||
onChange={(value) => setReviewsScope(value)}
|
||
options={[
|
||
{
|
||
value: ReviewReviewsListScope.Mine,
|
||
label: 'For you',
|
||
tooltip: 'Stats and reviews for pull requests you authored',
|
||
},
|
||
{
|
||
value: ReviewReviewsListScope.Everyone,
|
||
label: 'Entire project',
|
||
tooltip: 'Stats and reviews for every pull request on this project',
|
||
},
|
||
]}
|
||
/>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Hero proof block: the validation funnel across the in-scope recent reviews, summed from the same
|
||
* stats the effectiveness cards use. Hidden while there are no reviewed findings in scope yet.
|
||
*/
|
||
function ProofCard(): JSX.Element | null {
|
||
const { perspectiveStats, reviewsScope } = useValues(reviewSettingsLogic)
|
||
const everyone = reviewsScope === ReviewReviewsListScope.Everyone
|
||
|
||
if (perspectiveStats === null) {
|
||
return <Skeleton className="h-24 w-full" />
|
||
}
|
||
const raised = perspectiveStats.perspectives.reduce((sum, p) => sum + p.raised, 0)
|
||
const kept = perspectiveStats.perspectives.reduce((sum, p) => sum + p.kept, 0)
|
||
const dismissed = perspectiveStats.perspectives.reduce((sum, p) => sum + p.dismissed, 0)
|
||
if (raised === 0) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<Card hoverEffect={false} className="mt-2 flex flex-col gap-3 p-5">
|
||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||
<span className="text-3xl font-bold tabular-nums text-warning">{kept}</span>
|
||
<span className="text-sm font-semibold">
|
||
{everyone ? "findings worth the team's time" : 'findings worth your time'}
|
||
</span>
|
||
<span className="text-xs text-secondary">
|
||
from <span className="font-semibold text-default">{raised}</span> raised ·{' '}
|
||
<span className="font-semibold text-default">{dismissed}</span> filtered as noise
|
||
</span>
|
||
<span className="ml-auto text-xxs text-tertiary">
|
||
{everyone ? "This project's last" : 'Your last'} {perspectiveStats.report_count} review
|
||
{perspectiveStats.report_count === 1 ? '' : 's'}
|
||
</span>
|
||
</div>
|
||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-fill-highlight-100">
|
||
<div className="h-full rounded-full bg-warning" style={{ width: `${(kept / raised) * 100}%` }} />
|
||
</div>
|
||
<div className="flex items-center gap-4 text-xs text-tertiary">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="inline-block size-2 rounded-full bg-warning" /> Worth your time
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="inline-block size-2 rounded-full bg-border-bold" /> Filtered as noise
|
||
</span>
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function PipelineSection(): JSX.Element {
|
||
const { openPipelineDetail } = useActions(reviewSettingsLogic)
|
||
return (
|
||
<section className="flex flex-col gap-4 border-t border-primary pt-8">
|
||
<SectionHeader
|
||
icon={<IconDirectedGraph />}
|
||
title="How we review your PRs"
|
||
action={
|
||
<Button
|
||
type="secondary"
|
||
size="small"
|
||
onClick={() => openPipelineDetail()}
|
||
data-attr="review-pipeline-detailed-view"
|
||
>
|
||
Detailed view
|
||
</Button>
|
||
}
|
||
>
|
||
Every review runs through the same steps before it's published.
|
||
</SectionHeader>
|
||
<div className="flex flex-wrap items-stretch gap-2.5">
|
||
{PIPELINE_PHASES.map((phase, i) => (
|
||
<div key={phase.name} className="flex min-w-52 flex-1 items-center gap-2.5">
|
||
{i > 0 && <span className="shrink-0 text-lg text-tertiary">→</span>}
|
||
<Card hoverEffect={false} className="flex h-full flex-1 flex-col gap-3 p-4">
|
||
<div className="flex flex-col">
|
||
<span className="text-sm font-semibold">{phase.name}</span>
|
||
<span className="text-xs text-tertiary">{phase.hint}</span>
|
||
</div>
|
||
<div className="flex flex-col gap-2.5">
|
||
{phase.steps.map((step) => (
|
||
<div key={step.number} className="flex items-baseline gap-2">
|
||
<span className="font-mono text-xxs text-warning">{step.number}</span>
|
||
<div className="flex flex-col">
|
||
<span className="text-xs font-semibold">{step.title}</span>
|
||
<span className="text-xxs text-tertiary">{step.caption}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<PipelineDetailModal />
|
||
</section>
|
||
)
|
||
}
|
||
|
||
const PRIORITY_TAG: Record<ReviewIssuePriorityEnumApi, { type: 'danger' | 'warning' | 'muted'; label: string }> = {
|
||
must_fix: { type: 'danger', label: 'Must fix' },
|
||
should_fix: { type: 'warning', label: 'Should fix' },
|
||
consider: { type: 'muted', label: 'Consider' },
|
||
}
|
||
|
||
/** "best_practice" → "Best practice" */
|
||
function prettifyCategory(category: string): string {
|
||
const cleaned = category.replace(/_/g, ' ')
|
||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1)
|
||
}
|
||
|
||
/** Scoreboard label for a finding's source skill — blind-spot skills all read as one sweep. */
|
||
function perspectiveLabel(skillName: string): string {
|
||
if (skillName.startsWith('review-script-blind-spots-')) {
|
||
return 'Blind spots'
|
||
}
|
||
if (skillName === 'unknown') {
|
||
return 'Unknown'
|
||
}
|
||
return prettifySkillName(skillName)
|
||
}
|
||
|
||
const COUNT_CHIPS: {
|
||
key: 'must_fix_count' | 'should_fix_count' | 'consider_count'
|
||
label: string
|
||
dot: string
|
||
text: string
|
||
}[] = [
|
||
{ key: 'must_fix_count', label: 'must fix', dot: 'bg-danger', text: 'text-danger' },
|
||
{ key: 'should_fix_count', label: 'should fix', dot: 'bg-warning', text: 'text-warning' },
|
||
{ key: 'consider_count', label: 'consider', dot: 'bg-border-bold', text: 'text-secondary' },
|
||
]
|
||
|
||
function FindingCounts({ review }: { review: ReviewRecentReviewApi }): JSX.Element {
|
||
const total = review.must_fix_count + review.should_fix_count + review.consider_count
|
||
if (total === 0) {
|
||
return <span>No findings</span>
|
||
}
|
||
return (
|
||
<span className="flex items-center gap-2.5">
|
||
{COUNT_CHIPS.filter((chip) => review[chip.key] > 0).map((chip) => (
|
||
<span key={chip.key} className="flex items-center gap-1 whitespace-nowrap">
|
||
<span className={`size-1.5 rounded-full ${chip.dot}`} />
|
||
<span className={`font-semibold tabular-nums ${chip.text}`}>{review[chip.key]}</span>
|
||
<span>{chip.label}</span>
|
||
</span>
|
||
))}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** Leading status dot: red when the review found a blocker, gold for other findings, green for a clean pass. */
|
||
function ReviewStatusDot({ review }: { review: ReviewRecentReviewApi }): JSX.Element {
|
||
const total = review.must_fix_count + review.should_fix_count + review.consider_count
|
||
const color = review.must_fix_count > 0 ? 'bg-danger' : total > 0 ? 'bg-warning' : 'bg-success'
|
||
return (
|
||
<span className="flex w-6 shrink-0 justify-center">
|
||
<span className={`size-2 rounded-full ${color}`} />
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function reviewTitle(review: ReviewRecentReviewApi): string {
|
||
return review.pr_title ?? `${review.repository}#${review.pr_number ?? review.head_branch}`
|
||
}
|
||
|
||
function progressLabel(review: ReviewRecentReviewApi): string {
|
||
if (!review.progress) {
|
||
return 'Review in progress'
|
||
}
|
||
// Steps match the pipeline as users think of it: chunking → pick perspectives → review →
|
||
// dedupe → validation → finalize. Fetching folds into step 1.
|
||
const { review_stage, done, total } = review.progress
|
||
const percent = done !== null && total !== null && total > 0 ? ` · ${Math.round((done / total) * 100)}%` : ''
|
||
switch (review_stage) {
|
||
case 'fetching':
|
||
return 'Step 1/6 · Preparing the diff'
|
||
case 'chunking':
|
||
return 'Step 1/6 · Splitting into chunks'
|
||
case 'selecting':
|
||
return 'Step 2/6 · Picking perspectives'
|
||
case 'reviewing':
|
||
return `Step 3/6 · Running review passes${percent}`
|
||
case 'deduplicating':
|
||
return 'Step 4/6 · Merging overlapping findings'
|
||
case 'validating':
|
||
return `Step 5/6 · Validating findings${percent}`
|
||
case 'finalizing':
|
||
return 'Step 6/6 · Finalizing the review'
|
||
}
|
||
}
|
||
|
||
/** A first review still running: no findings to expand into yet, just the live stage. */
|
||
function RunningReviewRow({ review }: { review: ReviewRecentReviewApi }): JSX.Element {
|
||
const { reviewsScope } = useValues(reviewSettingsLogic)
|
||
// On the Entire project scope, whose PR it is matters at a glance; on For you it's always yours.
|
||
const showAuthor = reviewsScope === ReviewReviewsListScope.Everyone && !!review.pr_author
|
||
return (
|
||
<div className="flex items-center gap-3 px-4 py-3">
|
||
<span className="flex w-6 shrink-0 justify-center">
|
||
<Spinner className="text-lg" />
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-semibold">{reviewTitle(review)}</div>
|
||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-secondary">
|
||
<span className="whitespace-nowrap font-mono text-tertiary">
|
||
{review.repository}#{review.pr_number ?? review.head_branch}
|
||
</span>
|
||
{showAuthor && (
|
||
<>
|
||
<span className="text-tertiary">·</span>
|
||
<span className="whitespace-nowrap">by {review.pr_author}</span>
|
||
</>
|
||
)}
|
||
<span className="text-tertiary">·</span>
|
||
<span className="whitespace-nowrap font-medium text-warning">{progressLabel(review)}</span>
|
||
</div>
|
||
</div>
|
||
<Button size="small" type="secondary" to={review.github_url} targetBlank sideIcon={<IconExternal />}>
|
||
{review.github_url.includes('/pull/') ? 'View PR' : 'View branch'}
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** One expandable review row: essentials collapsed; PR facts + funnel + findings entry when open. */
|
||
function RecentReviewRow({ review }: { review: ReviewRecentReviewApi }): JSX.Element {
|
||
const { expandedReviewIds, reviewsScope } = useValues(reviewSettingsLogic)
|
||
const { toggleReviewRowExpanded, openReviewDetail } = useActions(reviewSettingsLogic)
|
||
const expanded = expandedReviewIds.includes(review.id)
|
||
const validated = review.must_fix_count + review.should_fix_count + review.consider_count
|
||
// On the Entire project scope, whose PR it is matters at a glance; on For you it's always yours.
|
||
const showAuthor = reviewsScope === ReviewReviewsListScope.Everyone && !!review.pr_author
|
||
|
||
// A first review has no completed turn to expand into — it renders as a live progress row.
|
||
if (review.in_progress && review.run_count === 0) {
|
||
return <RunningReviewRow review={review} />
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col">
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-expanded={expanded}
|
||
onClick={() => toggleReviewRowExpanded(review.id)}
|
||
onKeyDown={(e) => e.key === 'Enter' && toggleReviewRowExpanded(review.id)}
|
||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-fill-highlight-50"
|
||
>
|
||
<ReviewStatusDot review={review} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate text-sm font-semibold">{reviewTitle(review)}</span>
|
||
{review.in_progress && (
|
||
<Tag type="warning" size="small" className="inline-flex items-center gap-1">
|
||
<Spinner className="text-xs" /> Re-reviewing · {progressLabel(review)}
|
||
</Tag>
|
||
)}
|
||
{!review.published && (
|
||
<Tag type="muted" size="small">
|
||
Not published
|
||
</Tag>
|
||
)}
|
||
</div>
|
||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-secondary">
|
||
<span className="whitespace-nowrap font-mono text-tertiary">
|
||
{review.repository}#{review.pr_number ?? review.head_branch}
|
||
</span>
|
||
{showAuthor && (
|
||
<>
|
||
<span className="text-tertiary">·</span>
|
||
<span className="whitespace-nowrap">by {review.pr_author}</span>
|
||
</>
|
||
)}
|
||
<span className="text-tertiary">·</span>
|
||
<FindingCounts review={review} />
|
||
{review.last_run_at && (
|
||
<>
|
||
<span className="text-tertiary">·</span>
|
||
<TZLabel time={review.last_run_at} />
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* stopPropagation so the buttons don't also toggle the row */}
|
||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||
<Button
|
||
size="small"
|
||
type="secondary"
|
||
to={review.github_url}
|
||
targetBlank
|
||
sideIcon={<IconExternal />}
|
||
>
|
||
{review.github_url.includes('/pull/') ? 'View PR' : 'View branch'}
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
type="tertiary"
|
||
aria-label={expanded ? 'Hide review details' : 'Show review details'}
|
||
icon={<IconChevronDown className={expanded ? 'rotate-180' : ''} />}
|
||
onClick={() => toggleReviewRowExpanded(review.id)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{expanded && (
|
||
<div className="flex flex-col gap-2 border-t border-primary bg-fill-highlight-50 py-3 pl-13 pr-4">
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-secondary">
|
||
{review.pr_author && <span className="whitespace-nowrap">by {review.pr_author}</span>}
|
||
{review.additions !== null && review.deletions !== null && (
|
||
<>
|
||
<span className="text-tertiary">·</span>
|
||
<span className="whitespace-nowrap font-mono">
|
||
<span className="text-success">+{review.additions}</span>{' '}
|
||
<span className="text-danger">−{review.deletions}</span>
|
||
</span>
|
||
</>
|
||
)}
|
||
{review.changed_files !== null && (
|
||
<>
|
||
<span className="text-tertiary">·</span>
|
||
<span className="whitespace-nowrap">{review.changed_files} files changed</span>
|
||
</>
|
||
)}
|
||
{review.files_reviewed !== null && (
|
||
<>
|
||
<span className="text-tertiary">·</span>
|
||
<span className="whitespace-nowrap">{review.files_reviewed} reviewed</span>
|
||
</>
|
||
)}
|
||
{review.chunk_count !== null && (
|
||
<>
|
||
<span className="text-tertiary">·</span>
|
||
<span className="whitespace-nowrap">
|
||
{review.chunk_count} chunk{review.chunk_count === 1 ? '' : 's'}
|
||
</span>
|
||
</>
|
||
)}
|
||
<span className="text-tertiary">·</span>
|
||
<span className="whitespace-nowrap">
|
||
{review.run_count} review turn{review.run_count === 1 ? '' : 's'}
|
||
</span>
|
||
</div>
|
||
<div className="text-xs text-secondary">
|
||
<span className="font-semibold text-default">{review.candidate_count}</span> findings raised →{' '}
|
||
<span className="font-semibold text-default">{validated}</span> kept after validation →{' '}
|
||
<span className="font-semibold text-default">{review.dismissed_count}</span> dismissed
|
||
</div>
|
||
<div>
|
||
<Button size="small" type="secondary" onClick={() => openReviewDetail(review)}>
|
||
View findings
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Compact proof-of-life block under the hero — hidden entirely until the project has reviews. */
|
||
function RecentReviewsSection(): JSX.Element | null {
|
||
const {
|
||
recentReviews,
|
||
recentReviewsPageLoading,
|
||
moreReviewsAvailable,
|
||
reviewsExpanding,
|
||
reviewsScope,
|
||
hasUserChosenReviewsScope,
|
||
} = useValues(reviewSettingsLogic)
|
||
const { showMoreReviews, showFewerReviews } = useActions(reviewSettingsLogic)
|
||
const everyone = reviewsScope === ReviewReviewsListScope.Everyone
|
||
const loadedEmpty = recentReviews !== null && recentReviews.length === 0
|
||
|
||
// Settled-and-empty on the Entire project scope means the project has no reviews at all — hide
|
||
// the section entirely (an in-flight load keeps it mounted with skeletons instead of flashing
|
||
// it away mid-switch). An empty For-you scope keeps the section, with an empty state pointing
|
||
// at the page-level scope switch.
|
||
if (loadedEmpty && everyone && !recentReviewsPageLoading) {
|
||
return null
|
||
}
|
||
// A stale EMPTY list must not render an empty state while a reload (scope switch, auto-default)
|
||
// is in flight — but previous ROWS are kept during refreshes, so the in-progress poll never
|
||
// flashes skeletons.
|
||
const emptyAwaitingReload = loadedEmpty && (recentReviewsPageLoading || !hasUserChosenReviewsScope)
|
||
|
||
return (
|
||
// The one section without a top hairline — it reads as a continuation of the hero.
|
||
<section className="flex flex-col gap-4">
|
||
<SectionHeader icon={<IconPullRequest />} title={everyone ? 'Recent reviews' : 'Your recent reviews'}>
|
||
{everyone
|
||
? 'The latest Review runs on pull requests across this project. Expand a review for its details and findings.'
|
||
: 'The latest Review runs on pull requests you authored. Expand a review for its details and findings.'}
|
||
</SectionHeader>
|
||
<Card hoverEffect={false} className="divide-y divide-primary p-0">
|
||
{recentReviews === null || emptyAwaitingReload ? (
|
||
[0, 1, 2].map((i) => (
|
||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||
<span className="flex w-6 shrink-0 justify-center">
|
||
<Skeleton.Circle className="size-2" />
|
||
</span>
|
||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||
<Skeleton className="h-4 w-80 max-w-full" />
|
||
<Skeleton className="h-3 w-56 max-w-full" />
|
||
</div>
|
||
<Skeleton className="h-8 w-24 shrink-0" />
|
||
</div>
|
||
))
|
||
) : recentReviews.length ? (
|
||
<>
|
||
{recentReviews.map((review) => (
|
||
<RecentReviewRow key={review.id} review={review} />
|
||
))}
|
||
{(moreReviewsAvailable || recentReviews.length > REVIEWS_PAGE_SIZE) && (
|
||
<div className="flex justify-center gap-2 px-4 py-1.5">
|
||
{moreReviewsAvailable && (
|
||
<Button
|
||
size="small"
|
||
type="tertiary"
|
||
onClick={showMoreReviews}
|
||
loading={reviewsExpanding}
|
||
>
|
||
Show more
|
||
</Button>
|
||
)}
|
||
{recentReviews.length > REVIEWS_PAGE_SIZE && (
|
||
<Button size="small" type="tertiary" onClick={showFewerReviews}>
|
||
Show fewer
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="px-4 py-6 text-center text-sm text-secondary">
|
||
No reviews of your pull requests yet. Switch to "Entire project" at the top of the page to see
|
||
the whole team's.
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* "Review a pull request": paste any PR URL the project's GitHub App installation can access and
|
||
* start a publishing review, acting as the requesting user. Hidden unless the backend says this
|
||
* project can trigger reviews (limited to the designated Review team while in alpha).
|
||
*/
|
||
function TriggerReviewSection(): JSX.Element | null {
|
||
const { settings, triggerPrUrl, triggeringReview } = useValues(reviewSettingsLogic)
|
||
const { setTriggerPrUrl, submitTriggerReview } = useActions(reviewSettingsLogic)
|
||
|
||
if (!settings?.can_trigger_reviews) {
|
||
return null
|
||
}
|
||
return (
|
||
<section className="flex flex-col gap-4">
|
||
<SectionHeader icon={<IconGithub />} title="Review a pull request">
|
||
Start a review of any pull request the GitHub App can access. The review is posted back to the pull
|
||
request, runs with your perspectives, and shows up under your recent reviews.
|
||
</SectionHeader>
|
||
<form
|
||
className="ml-9 flex flex-wrap items-center gap-2"
|
||
onSubmit={(e) => {
|
||
e.preventDefault()
|
||
submitTriggerReview()
|
||
}}
|
||
>
|
||
<Input
|
||
className="min-w-80 flex-1"
|
||
placeholder="https://github.com/Insights/hanzo.ai/pull/1234"
|
||
value={triggerPrUrl}
|
||
onChange={setTriggerPrUrl}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
htmlType="submit"
|
||
loading={triggeringReview}
|
||
disabledReason={!triggerPrUrl.trim() ? 'Paste a pull request URL first' : undefined}
|
||
>
|
||
Review
|
||
</Button>
|
||
</form>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
type FindingSection = 'description' | 'suggestion' | 'validator'
|
||
|
||
function FindingCard({ finding, dismissed }: { finding: ReviewFindingApi; dismissed?: boolean }): JSX.Element {
|
||
const { reviewDetail } = useValues(reviewSettingsLogic)
|
||
const priority = PRIORITY_TAG[finding.effective_priority]
|
||
const location = finding.lines.length
|
||
? `${finding.file}:${finding.lines.map((r) => (r.end && r.end !== r.start ? `${r.start}–${r.end}` : `${r.start}`)).join(', ')}`
|
||
: finding.file
|
||
const firstRange = finding.lines[0]
|
||
// Deep link to the exact reviewed code: the head SHA pins the lines even after later pushes.
|
||
const githubHref = reviewDetail?.head_sha
|
||
? `https://github.com/${reviewDetail.repository}/blob/${reviewDetail.head_sha}/${finding.file}` +
|
||
(firstRange
|
||
? `#L${firstRange.start}${firstRange.end && firstRange.end !== firstRange.start ? `-L${firstRange.end}` : ''}`
|
||
: '')
|
||
: null
|
||
return (
|
||
<div className="flex flex-col gap-2 py-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Tag type={dismissed ? 'muted' : priority.type} size="small">
|
||
{priority.label}
|
||
</Tag>
|
||
{finding.effective_priority !== finding.reviewer_priority && (
|
||
<Tag type="muted" size="small">
|
||
was {PRIORITY_TAG[finding.reviewer_priority].label.toLowerCase()}
|
||
</Tag>
|
||
)}
|
||
{finding.validator_category && (
|
||
<Tag type="muted" size="small">
|
||
{prettifyCategory(finding.validator_category)}
|
||
</Tag>
|
||
)}
|
||
{finding.source_perspective && (
|
||
<span className="text-xs text-tertiary">{prettifySkillName(finding.source_perspective)}</span>
|
||
)}
|
||
</div>
|
||
<span className={`text-base font-semibold ${dismissed ? 'text-secondary' : ''}`}>{finding.title}</span>
|
||
{githubHref ? (
|
||
<Link
|
||
to={githubHref}
|
||
target="_blank"
|
||
className="self-start font-mono text-xs text-tertiary hover:text-default"
|
||
>
|
||
{location}
|
||
</Link>
|
||
) : (
|
||
<span className="font-mono text-xs text-tertiary">{location}</span>
|
||
)}
|
||
{/* Collapsed by default, like the published PR comment — title + location scan, text on demand. */}
|
||
<Collapse<FindingSection>
|
||
multiple
|
||
size="small"
|
||
panels={[
|
||
{
|
||
key: 'description',
|
||
header: 'Description',
|
||
content: (
|
||
<Markdown className="text-sm text-secondary" disableImages>
|
||
{finding.body}
|
||
</Markdown>
|
||
),
|
||
},
|
||
{
|
||
key: 'suggestion',
|
||
header: 'Suggested fix',
|
||
content: (
|
||
<Markdown className="text-sm text-secondary" disableImages>
|
||
{finding.suggestion}
|
||
</Markdown>
|
||
),
|
||
},
|
||
{
|
||
key: 'validator',
|
||
header: dismissed ? 'Why it was dismissed' : "Why we think it's a valid issue",
|
||
content: (
|
||
<Markdown className="text-sm text-secondary" disableImages>
|
||
{finding.validator_note}
|
||
</Markdown>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DrawerFindingsSkeleton(): JSX.Element {
|
||
return (
|
||
<div className="flex flex-col gap-2.5">
|
||
<Skeleton className="h-24 w-full" />
|
||
<Skeleton className="h-24 w-full" />
|
||
<Skeleton className="h-24 w-full" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** The "Published" tab: the findings that crossed the urgency threshold, caveated while unpublished. */
|
||
function DrawerPublishedTab(): JSX.Element {
|
||
const { reviewFindingsSplit, reviewDetail } = useValues(reviewSettingsLogic)
|
||
|
||
if (!reviewFindingsSplit) {
|
||
return <DrawerFindingsSkeleton />
|
||
}
|
||
const isPublished = reviewDetail?.published ?? false
|
||
if (!reviewFindingsSplit.published.length) {
|
||
return (
|
||
<div className="text-sm text-secondary">
|
||
{isPublished
|
||
? "Nothing crossed the review's urgency threshold — no comments were posted to the pull request."
|
||
: "Nothing crossed the review's urgency threshold, and this review hasn't been published to the pull request."}
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
{!isPublished && (
|
||
<p className="m-0 text-xs text-secondary">
|
||
This review hasn't been published to the pull request yet — these findings crossed the review's
|
||
urgency threshold, but no comments have been posted.
|
||
</p>
|
||
)}
|
||
<div className="flex flex-col divide-y divide-primary">
|
||
{reviewFindingsSplit.published.map((finding, i) => (
|
||
<FindingCard key={i} finding={finding} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** The "Below threshold" tab: findings the validator kept, but the user's urgency bar held back. */
|
||
function DrawerBelowThresholdTab(): JSX.Element {
|
||
const { reviewFindingsSplit } = useValues(reviewSettingsLogic)
|
||
|
||
if (!reviewFindingsSplit) {
|
||
return <DrawerFindingsSkeleton />
|
||
}
|
||
if (!reviewFindingsSplit.belowThreshold.length) {
|
||
return (
|
||
<div className="text-sm text-secondary">
|
||
Nothing was held back — every validated finding crossed the review's urgency threshold.
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<p className="m-0 text-xs text-secondary">
|
||
Validated as real, but under the urgency bar this review ran with — kept here instead of the pull
|
||
request.
|
||
</p>
|
||
<div className="flex flex-col divide-y divide-primary">
|
||
{reviewFindingsSplit.belowThreshold.map((finding, i) => (
|
||
<FindingCard key={i} finding={finding} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** The "Dismissed" tab: findings that failed validation, each with the validator's reasoning. */
|
||
function DrawerDismissedTab(): JSX.Element {
|
||
const { reviewDetail } = useValues(reviewSettingsLogic)
|
||
|
||
if (!reviewDetail) {
|
||
return <DrawerFindingsSkeleton />
|
||
}
|
||
if (!reviewDetail.dismissed_findings.length) {
|
||
return <div className="text-sm text-secondary">The validator dismissed nothing on this review.</div>
|
||
}
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<p className="m-0 text-xs text-secondary">
|
||
Raised during review, judged not worth your time — each with the validator's reasoning.
|
||
</p>
|
||
<div className="flex flex-col divide-y divide-primary">
|
||
{reviewDetail.dismissed_findings.map((finding, i) => (
|
||
<FindingCard key={i} finding={finding} dismissed />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** The "Chunks" tab: how the PR was split, each chunk's files (expandable), and its perspective picks. */
|
||
function DrawerChunksTab(): JSX.Element {
|
||
const { reviewDetail } = useValues(reviewSettingsLogic)
|
||
|
||
if (!reviewDetail) {
|
||
return <DrawerFindingsSkeleton />
|
||
}
|
||
const selection = reviewDetail.perspective_selection
|
||
if (!selection) {
|
||
return (
|
||
<div className="text-sm text-secondary">
|
||
No chunk plan was recorded for this review — it ran before per-chunk planning existed, or the planner
|
||
fell back to running every perspective on every chunk.
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<p className="m-0 text-xs text-secondary">
|
||
How the pull request was split for review, and which perspectives each chunk actually needed — every
|
||
skipped lens saves a full review session.
|
||
</p>
|
||
<div className="flex flex-col divide-y divide-primary">
|
||
{selection.chunks.map((chunk) => (
|
||
<div key={chunk.chunk_id} className="flex flex-col gap-1.5 py-3">
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="text-sm font-semibold">Chunk {chunk.chunk_id}</span>
|
||
{chunk.chunk_type && (
|
||
<Tag type="muted" size="small">
|
||
{prettifyCategory(chunk.chunk_type)}
|
||
</Tag>
|
||
)}
|
||
</div>
|
||
{chunk.files.length > 0 && (
|
||
<Collapse
|
||
embedded
|
||
size="xsmall"
|
||
panels={[
|
||
{
|
||
key: 'files',
|
||
header: (
|
||
<span className="flex min-w-0 items-baseline gap-2 text-xs">
|
||
<span className="shrink-0">
|
||
{chunk.files.length} file{chunk.files.length === 1 ? '' : 's'}
|
||
</span>
|
||
<span className="truncate font-mono font-normal text-tertiary">
|
||
{chunk.files.join(' · ')}
|
||
</span>
|
||
</span>
|
||
),
|
||
content: (
|
||
<ul className="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-xs text-secondary">
|
||
{chunk.files.map((file) => (
|
||
<li key={file}>{file}</li>
|
||
))}
|
||
</ul>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
)}
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{chunk.perspectives.map((name) => (
|
||
<Tag key={name} type="muted" size="small">
|
||
{perspectiveLabel(name)}
|
||
</Tag>
|
||
))}
|
||
{chunk.skipped.map((name) => (
|
||
<Tag key={name} type="muted" size="small" className="line-through opacity-60">
|
||
{perspectiveLabel(name)}
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
{chunk.reason && <div className="text-xs text-secondary">{chunk.reason}</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ReviewDetailDrawer(): JSX.Element {
|
||
const {
|
||
reviewDrawerOpen,
|
||
openedReview,
|
||
reviewDetail,
|
||
reviewDrawerTab,
|
||
reviewFindingsSplit,
|
||
perspectiveScoreboard,
|
||
} = useValues(reviewSettingsLogic)
|
||
const { closeReviewDrawer, setReviewDrawerTab } = useActions(reviewSettingsLogic)
|
||
|
||
// The list row carries the header facts, so the drawer opens instantly while findings load.
|
||
const review = reviewDetail ?? openedReview
|
||
|
||
return (
|
||
<Drawer
|
||
isOpen={reviewDrawerOpen}
|
||
onClose={closeReviewDrawer}
|
||
title={review ? reviewTitle(review) : ''}
|
||
description={
|
||
review
|
||
? `${review.repository}#${review.pr_number ?? review.head_branch}${
|
||
review.pr_author ? ` · by ${review.pr_author}` : ''
|
||
}`
|
||
: undefined
|
||
}
|
||
width={640}
|
||
footer={
|
||
review ? (
|
||
<Button type="secondary" to={review.github_url} targetBlank icon={<IconExternal />}>
|
||
{review.github_url.includes('/pull/') ? 'View PR on GitHub' : 'View branch on GitHub'}
|
||
</Button>
|
||
) : undefined
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-2">
|
||
{reviewDetail ? (
|
||
<div className="text-sm text-secondary">
|
||
<span className="font-semibold text-default">{reviewDetail.candidate_count}</span> findings
|
||
raised · <span className="font-semibold text-default">{reviewDetail.findings.length}</span> kept
|
||
after validation ·{' '}
|
||
<span className="font-semibold text-default">{reviewDetail.dismissed_count}</span> dismissed by
|
||
your quality bar
|
||
</div>
|
||
) : (
|
||
<Skeleton className="h-5 w-80" />
|
||
)}
|
||
{perspectiveScoreboard && (
|
||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-secondary">
|
||
<span>Found by:</span>
|
||
{perspectiveScoreboard.map(({ skillName, count }) => (
|
||
<Tag key={skillName} type="muted" size="small">
|
||
{perspectiveLabel(skillName)} {count}
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
)}
|
||
<Tabs<ReviewDrawerTab>
|
||
activeKey={reviewDrawerTab}
|
||
onChange={setReviewDrawerTab}
|
||
tabs={[
|
||
{
|
||
key: 'published',
|
||
// "Published" is a claim about the PR — only make it when the review
|
||
// actually posted; findings a store-only run kept above the bar read
|
||
// "Kept". `review` falls back to the list row, so a published review
|
||
// doesn't flash "Kept" while its detail loads.
|
||
label: `${review?.published ? 'Published' : 'Kept'}${
|
||
reviewFindingsSplit ? ` (${reviewFindingsSplit.published.length})` : ''
|
||
}`,
|
||
content: <DrawerPublishedTab />,
|
||
},
|
||
{
|
||
key: 'below_threshold',
|
||
label: `Below threshold${
|
||
reviewFindingsSplit ? ` (${reviewFindingsSplit.belowThreshold.length})` : ''
|
||
}`,
|
||
content: <DrawerBelowThresholdTab />,
|
||
},
|
||
{
|
||
key: 'dismissed',
|
||
label: `Dismissed${reviewDetail ? ` (${reviewDetail.dismissed_findings.length})` : ''}`,
|
||
content: <DrawerDismissedTab />,
|
||
},
|
||
{
|
||
key: 'chunks',
|
||
label: 'Chunks',
|
||
content: <DrawerChunksTab />,
|
||
},
|
||
{
|
||
key: 'review',
|
||
label: 'Review body',
|
||
content: reviewDetail ? (
|
||
reviewDetail.report_markdown ? (
|
||
<Markdown className="text-sm" disableImages>
|
||
{reviewDetail.report_markdown}
|
||
</Markdown>
|
||
) : (
|
||
<div className="text-sm text-secondary">
|
||
No review body was rendered for this pull request.
|
||
</div>
|
||
)
|
||
) : (
|
||
<Skeleton className="h-40 w-full" />
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
</Drawer>
|
||
)
|
||
}
|
||
|
||
function TriggersSection(): JSX.Element {
|
||
const { settings, settingsLoading } = useValues(reviewSettingsLogic)
|
||
const { updateSettings } = useActions(reviewSettingsLogic)
|
||
|
||
const switchDisabledReason = settings === null ? 'Loading…' : settingsLoading ? 'Saving…' : undefined
|
||
|
||
return (
|
||
<section className="flex flex-col gap-4 border-t border-primary pt-8">
|
||
<SectionHeader icon={<IconFilter />} title="What gets reviewed">
|
||
Choose which pull requests Review picks up automatically.
|
||
</SectionHeader>
|
||
<Card hoverEffect={false} className="divide-y divide-primary p-0">
|
||
<div className="flex items-center gap-4 p-4">
|
||
<div className="flex size-9 shrink-0 items-center justify-center rounded border border-primary bg-primary *:h-auto *:w-5">
|
||
<Logomark />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-semibold">Review all your Inbox PRs</div>
|
||
<div className="text-xs text-secondary">
|
||
When a self-driving implementation from your Inbox opens a pull request, Review reviews
|
||
it and posts the review to the pull request automatically.
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
aria-label="Review all your Inbox PRs"
|
||
checked={settings?.review_inbox_prs ?? false}
|
||
onChange={(checked) => updateSettings({ review_inbox_prs: checked })}
|
||
disabledReason={switchDisabledReason}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-4 p-4">
|
||
<div className="flex size-9 shrink-0 items-center justify-center rounded border border-primary bg-primary">
|
||
<IconStamp className="size-5" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-semibold">Let Stamp review your Inbox PRs</div>
|
||
<div className="text-xs text-secondary">
|
||
When a self-driving implementation from your Inbox opens a pull request, Stamp reviews it
|
||
and approves it if it passes.
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
aria-label="Let Stamp review your Inbox PRs"
|
||
checked={settings?.stamp_review_inbox_prs ?? false}
|
||
onChange={(checked) => updateSettings({ stamp_review_inbox_prs: checked })}
|
||
disabledReason={
|
||
// A switch that is already on stays usable while disconnected, so
|
||
// turning it off never requires connecting Stamp first.
|
||
settings && !settings.stamp_connected && !settings.stamp_review_inbox_prs
|
||
? 'Connect a repository to Stamp first. Stamp is not set up for this project yet.'
|
||
: switchDisabledReason
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-4 p-4">
|
||
<div className="flex size-9 shrink-0 items-center justify-center rounded border border-primary bg-primary">
|
||
<IconGithub className="size-5" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-semibold">
|
||
Review all your PRs with the{' '}
|
||
<CopyToClipboardInline
|
||
explicitValue="review"
|
||
description="label"
|
||
iconSize="xsmall"
|
||
className="rounded border border-warning bg-warning-highlight px-1.5 py-0.5 font-mono text-xs text-warning"
|
||
>
|
||
review
|
||
</CopyToClipboardInline>{' '}
|
||
label
|
||
</div>
|
||
<div className="text-xs text-secondary">
|
||
Add the review label to a pull request you author in a connected repository and Review
|
||
reviews it.
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
aria-label="Review all your PRs with the review label"
|
||
checked={settings?.review_labeled_prs ?? true}
|
||
onChange={(checked) => updateSettings({ review_labeled_prs: checked })}
|
||
disabledReason={switchDisabledReason}
|
||
/>
|
||
</div>
|
||
</Card>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function UrgencySection(): JSX.Element {
|
||
const { settings } = useValues(reviewSettingsLogic)
|
||
const { updateSettings } = useActions(reviewSettingsLogic)
|
||
|
||
const activeIndex = Math.max(
|
||
0,
|
||
URGENCY_STOPS.findIndex((stop) => stop.key === (settings?.urgency_threshold ?? 'consider'))
|
||
)
|
||
|
||
return (
|
||
<section className="flex flex-col gap-4 border-t border-primary pt-8">
|
||
<SectionHeader icon={<IconBalance />} title="Urgency threshold">
|
||
Set how strict Review is. The further right, the fewer findings reach the pull request — but the
|
||
higher their priority.
|
||
</SectionHeader>
|
||
<Card hoverEffect={false} className="flex flex-col p-5">
|
||
<Slider
|
||
min={0}
|
||
max={2}
|
||
step={1}
|
||
value={activeIndex}
|
||
onChange={(value) => {
|
||
// Slider fires per mousemove while dragging — only PATCH on a stop change.
|
||
if (value !== activeIndex) {
|
||
updateSettings({ urgency_threshold: URGENCY_STOPS[value].key })
|
||
}
|
||
}}
|
||
disabledReason={settings === null ? 'Loading…' : undefined}
|
||
/>
|
||
<div className="mt-3 flex justify-between">
|
||
{URGENCY_STOPS.map((stop, i) => (
|
||
<button
|
||
key={stop.key}
|
||
type="button"
|
||
disabled={settings === null}
|
||
aria-pressed={i === activeIndex}
|
||
className={`cursor-pointer border-0 bg-transparent p-0 text-xs font-semibold ${
|
||
i === activeIndex ? 'text-default' : 'text-tertiary'
|
||
}`}
|
||
onClick={() => {
|
||
if (i !== activeIndex) {
|
||
updateSettings({ urgency_threshold: stop.key })
|
||
}
|
||
}}
|
||
>
|
||
{stop.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="mt-2 flex justify-between text-xxs text-tertiary">
|
||
<span>← every finding</span>
|
||
<span>only the most urgent →</span>
|
||
</div>
|
||
<div className="mt-3 border-t border-primary pt-3 text-xs text-secondary">
|
||
{URGENCY_STOPS[activeIndex].description}
|
||
</div>
|
||
</Card>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
interface SkillCardData {
|
||
skill_name: string
|
||
description: string
|
||
body: string
|
||
on: boolean
|
||
}
|
||
|
||
function SkillCard({
|
||
skill,
|
||
onLabel,
|
||
offLabel,
|
||
onToggle,
|
||
}: {
|
||
skill: SkillCardData
|
||
onLabel: string
|
||
offLabel: string
|
||
onToggle: (checked: boolean) => void
|
||
}): JSX.Element {
|
||
const { savingSkillNames } = useValues(reviewSettingsLogic)
|
||
const { viewSkill } = useActions(reviewSettingsLogic)
|
||
const title = prettifySkillName(skill.skill_name)
|
||
|
||
return (
|
||
<Card hoverEffect={false} className="p-4">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex min-w-0 flex-col gap-1.5">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-semibold">{title}</span>
|
||
<Tag type={skill.on ? 'warning' : 'muted'} size="small">
|
||
{skill.on ? onLabel : offLabel}
|
||
</Tag>
|
||
</div>
|
||
<p className="m-0 max-w-130 text-xs text-secondary">{skill.description}</p>
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<Link onClick={() => viewSkill({ title, body: skill.body, skillName: skill.skill_name })}>
|
||
View skill
|
||
</Link>
|
||
<span className="text-tertiary">·</span>
|
||
<Link
|
||
to={urls.skill(skill.skill_name)}
|
||
target="_blank"
|
||
className="inline-flex items-center gap-0.5"
|
||
>
|
||
Edit skill <IconExternal className="size-3" />
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
aria-label={title}
|
||
checked={skill.on}
|
||
onChange={onToggle}
|
||
disabledReason={savingSkillNames.includes(skill.skill_name) ? 'Saving…' : undefined}
|
||
/>
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function SkillListSkeleton(): JSX.Element {
|
||
return (
|
||
<div className="flex flex-col gap-2.5">
|
||
<Skeleton className="h-24 w-full" />
|
||
<Skeleton className="h-24 w-full" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CreateYourOwnButton({ kind, label }: { kind: ReviewSkillKind; label: string }): JSX.Element {
|
||
const { creatingSkillKind } = useValues(reviewSettingsLogic)
|
||
const { startSkillAuthorTask } = useActions(reviewSettingsLogic)
|
||
return (
|
||
<div>
|
||
<Button
|
||
type="secondary"
|
||
icon={<IconPlus />}
|
||
onClick={() => startSkillAuthorTask(kind)}
|
||
loading={creatingSkillKind === kind}
|
||
disabledReason={
|
||
creatingSkillKind && creatingSkillKind !== kind ? 'Another authoring task is starting…' : undefined
|
||
}
|
||
>
|
||
{label}
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EffectivenessRows({
|
||
items,
|
||
maxRaised,
|
||
}: {
|
||
items: ReviewPerspectiveStatItemApi[]
|
||
maxRaised: number
|
||
}): JSX.Element {
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
{items.map((stat) => (
|
||
<Tooltip
|
||
key={stat.skill_name}
|
||
title={`${stat.raised} raised · ${stat.kept} kept · ${stat.dismissed} dismissed by validation`}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className="w-44 shrink-0 truncate text-xs">{prettifySkillName(stat.skill_name)}</span>
|
||
<div className="flex h-2 flex-1 items-center">
|
||
{stat.kept > 0 && (
|
||
<div
|
||
className="h-2 rounded-sm bg-success"
|
||
style={{ width: `${(stat.kept / maxRaised) * 100}%` }}
|
||
/>
|
||
)}
|
||
{stat.dismissed > 0 && (
|
||
<div
|
||
className="ml-0.5 h-2 rounded-sm bg-fill-highlight-100"
|
||
style={{ width: `${(stat.dismissed / maxRaised) * 100}%` }}
|
||
/>
|
||
)}
|
||
</div>
|
||
<span className="w-24 shrink-0 text-right text-xs tabular-nums text-secondary">
|
||
{stat.kept} of {stat.raised} kept
|
||
</span>
|
||
</div>
|
||
</Tooltip>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Aggregate effectiveness across the in-scope recent reviews for one reviewer kind: per skill, a
|
||
* bar of findings it raised split into validator-kept (green) vs dismissed (muted). Rendered once
|
||
* in the Perspectives section and once in the Blind-spot section, above each one's skill cards;
|
||
* both cards share one scale so bar lengths stay comparable. Hidden until there is data for the
|
||
* kind. On the Entire project scope this can list skills beyond the user's own cards below — the
|
||
* stats describe the project, while the toggles stay per-user.
|
||
*/
|
||
function EffectivenessCard({ kind }: { kind: 'perspectives' | 'blind_spots' }): JSX.Element | null {
|
||
const { perspectiveStats, reviewsScope } = useValues(reviewSettingsLogic)
|
||
const everyone = reviewsScope === ReviewReviewsListScope.Everyone
|
||
|
||
if (!perspectiveStats?.perspectives.length) {
|
||
return null
|
||
}
|
||
const items = perspectiveStats.perspectives.filter(
|
||
(p) => p.skill_name.startsWith('review-script-blind-spots-') === (kind === 'blind_spots')
|
||
)
|
||
if (!items.length) {
|
||
return null
|
||
}
|
||
const maxRaised = Math.max(...perspectiveStats.perspectives.map((p) => p.raised))
|
||
const noun = kind === 'blind_spots' ? 'sweep' : 'perspective'
|
||
|
||
return (
|
||
<Card hoverEffect={false} className="flex flex-col gap-3 p-4">
|
||
{/* Overline header: this card is an instrument panel about the skills below, not one of
|
||
them — it must not share the skill cards' title style. */}
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-xxs font-semibold uppercase tracking-wide text-tertiary">Effectiveness</span>
|
||
<p className="m-0 text-xs text-secondary">
|
||
Findings each {noun} raised across {everyone ? "this project's" : 'your'} last{' '}
|
||
{perspectiveStats.report_count} review{perspectiveStats.report_count === 1 ? '' : 's'}, and how many
|
||
survived validation.
|
||
</p>
|
||
</div>
|
||
<EffectivenessRows items={items} maxRaised={maxRaised} />
|
||
<div className="flex items-center gap-4 text-xs text-tertiary">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="inline-block h-2 w-2 rounded-sm bg-success" /> Kept
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="inline-block h-2 w-2 rounded-sm bg-fill-highlight-100" /> Dismissed by validation
|
||
</span>
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* The validator's flip side of the effectiveness cards: one bar for the whole quality bar (verdicts
|
||
* aren't attributed to a validator skill), with dismissals as the headline — its job is filtering.
|
||
*/
|
||
function ValidatorEffectivenessCard(): JSX.Element | null {
|
||
const { perspectiveStats, reviewsScope } = useValues(reviewSettingsLogic)
|
||
const everyone = reviewsScope === ReviewReviewsListScope.Everyone
|
||
|
||
if (!perspectiveStats?.perspectives.length) {
|
||
return null
|
||
}
|
||
const kept = perspectiveStats.perspectives.reduce((sum, p) => sum + p.kept, 0)
|
||
const dismissed = perspectiveStats.perspectives.reduce((sum, p) => sum + p.dismissed, 0)
|
||
const judged = kept + dismissed
|
||
if (judged === 0) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<Card hoverEffect={false} className="flex flex-col gap-3 p-4">
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-xxs font-semibold uppercase tracking-wide text-tertiary">Effectiveness</span>
|
||
<p className="m-0 text-xs text-secondary">
|
||
{everyone
|
||
? `Of the ${judged} findings reviewers raised across this project's last ${perspectiveStats.report_count} review${perspectiveStats.report_count === 1 ? '' : 's'}, this is how much noise validation kept off pull requests.`
|
||
: `Of the ${judged} findings your reviewers raised across your last ${perspectiveStats.report_count} review${perspectiveStats.report_count === 1 ? '' : 's'}, this is how much noise your quality bar kept off your pull requests.`}
|
||
</p>
|
||
</div>
|
||
<Tooltip
|
||
title={`${judged} judged · ${kept} kept · ${dismissed} dismissed by ${everyone ? 'validation' : 'your quality bar'}`}
|
||
>
|
||
{/* Unlike the reviewer cards, green here is the DISMISSED share — this card celebrates noise removed. */}
|
||
<div className="flex items-center gap-3">
|
||
<span className="w-44 shrink-0 truncate text-xs">
|
||
{/* Project scope aggregates every author's active validator, not one user's bar. */}
|
||
{everyone ? 'Validation' : 'Your quality bar'}
|
||
</span>
|
||
<div className="flex h-2 flex-1 items-center">
|
||
{dismissed > 0 && (
|
||
<div
|
||
className="h-2 rounded-sm bg-success"
|
||
style={{ width: `${(dismissed / judged) * 100}%` }}
|
||
/>
|
||
)}
|
||
{kept > 0 && (
|
||
<div
|
||
className="ml-0.5 h-2 rounded-sm bg-fill-highlight-100"
|
||
style={{ width: `${(kept / judged) * 100}%` }}
|
||
/>
|
||
)}
|
||
</div>
|
||
<span className="w-24 shrink-0 text-right text-xs tabular-nums text-secondary">
|
||
{dismissed} of {judged} dismissed
|
||
</span>
|
||
</div>
|
||
</Tooltip>
|
||
<div className="flex items-center gap-4 text-xs text-tertiary">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="inline-block h-2 w-2 rounded-sm bg-success" />{' '}
|
||
{everyone ? 'Dismissed by validation' : 'Dismissed by your bar'}
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="inline-block h-2 w-2 rounded-sm bg-fill-highlight-100" /> Kept
|
||
</span>
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function PerspectivesSection(): JSX.Element {
|
||
const { perspectives } = useValues(reviewSettingsLogic)
|
||
const { togglePerspective } = useActions(reviewSettingsLogic)
|
||
|
||
return (
|
||
<section className="flex flex-col gap-4">
|
||
<SectionHeader
|
||
icon={<IconStack />}
|
||
title="Perspectives"
|
||
pill={
|
||
<Tag type="muted" size="small">
|
||
Enable as many as you like · at least one stays on
|
||
</Tag>
|
||
}
|
||
>
|
||
Each perspective is a skill — an editable instruction set the reviewer follows. Toggles here apply only
|
||
to reviews of your pull requests; editing a skill changes it for the whole team.
|
||
</SectionHeader>
|
||
<EffectivenessCard kind="perspectives" />
|
||
{perspectives === null ? (
|
||
<SkillListSkeleton />
|
||
) : (
|
||
<div className="flex flex-col gap-2.5">
|
||
{perspectives.map((p) => (
|
||
<SkillCard
|
||
key={p.skill_name}
|
||
skill={{ ...p, on: p.enabled }}
|
||
onLabel="Enabled"
|
||
offLabel="Disabled"
|
||
onToggle={(checked) => togglePerspective(p.skill_name, checked)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<CreateYourOwnButton kind="perspective" label="Create your own perspective" />
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function SingleActiveSection({
|
||
icon,
|
||
title,
|
||
intro,
|
||
kind,
|
||
kindLabel,
|
||
createLabel,
|
||
skills,
|
||
onSelect,
|
||
preamble,
|
||
}: {
|
||
icon: JSX.Element
|
||
title: string
|
||
intro: string
|
||
kind: ReviewSkillKind
|
||
kindLabel: string
|
||
createLabel: string
|
||
skills: SkillCardData[] | null
|
||
onSelect: (skillName: string) => void
|
||
preamble?: JSX.Element
|
||
}): JSX.Element {
|
||
const { blockSingleActiveDeactivation } = useActions(reviewSettingsLogic)
|
||
|
||
return (
|
||
<section className="flex flex-col gap-4">
|
||
<SectionHeader
|
||
icon={icon}
|
||
title={title}
|
||
pill={
|
||
<Tag type="warning" size="small">
|
||
One active at a time
|
||
</Tag>
|
||
}
|
||
>
|
||
{intro}
|
||
</SectionHeader>
|
||
{preamble}
|
||
{skills === null ? (
|
||
<SkillListSkeleton />
|
||
) : (
|
||
<div className="flex flex-col gap-2.5">
|
||
{skills.map((skill) => (
|
||
<SkillCard
|
||
key={skill.skill_name}
|
||
skill={skill}
|
||
onLabel="Active"
|
||
offLabel="Off"
|
||
onToggle={(checked) =>
|
||
checked ? onSelect(skill.skill_name) : blockSingleActiveDeactivation(kindLabel)
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<CreateYourOwnButton kind={kind} label={createLabel} />
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function SkillDrawer(): JSX.Element {
|
||
const { viewedSkill, skillDrawerOpen } = useValues(reviewSettingsLogic)
|
||
const { closeSkillDrawer } = useActions(reviewSettingsLogic)
|
||
|
||
return (
|
||
<Drawer
|
||
isOpen={skillDrawerOpen}
|
||
onClose={closeSkillDrawer}
|
||
title={viewedSkill?.title ?? ''}
|
||
description="Skill · read-only"
|
||
width={440}
|
||
footer={
|
||
<Button
|
||
type="secondary"
|
||
to={viewedSkill ? urls.skill(viewedSkill.skillName) : urls.skills()}
|
||
targetBlank
|
||
icon={<IconExternal />}
|
||
>
|
||
Open in Skills editor
|
||
</Button>
|
||
}
|
||
>
|
||
<pre className="m-0 whitespace-pre-wrap font-mono text-xs leading-relaxed text-secondary">
|
||
{viewedSkill?.body}
|
||
</pre>
|
||
</Drawer>
|
||
)
|
||
}
|
||
|
||
export const scene: SceneExport = {
|
||
component: CodeReviewScene,
|
||
logic: reviewSettingsLogic,
|
||
}
|
||
|
||
/**
|
||
* The "Code review" scene: Review's combined onboarding and settings page. One scrollable
|
||
* guided-configuration page — every control is live from load, no save step. See
|
||
* `reviewSettingsLogic` for the data flow. Staff-only while Review is an internal alpha;
|
||
* the FEATURE_FLAGS.REVIEW flag only controls the menu entry's visibility.
|
||
*/
|
||
export function CodeReviewScene(): JSX.Element {
|
||
const { user } = useValues(userLogic)
|
||
const { blindSpots, validators, initialLoadFailed } = useValues(reviewSettingsLogic)
|
||
const { selectBlindSpots, selectValidator, loadAll } = useActions(reviewSettingsLogic)
|
||
|
||
if (user != null && !user.is_staff) {
|
||
return <NotFound object="page" />
|
||
}
|
||
|
||
return (
|
||
<SceneContent>
|
||
<SceneTitleSection
|
||
name="Code review"
|
||
description="Automated code reviews of your pull requests, and your review agent settings."
|
||
resourceType={{ type: 'code_review' }}
|
||
/>
|
||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-6 pb-30 pt-4">
|
||
<section className="flex flex-col gap-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="size-1.5 rounded-full bg-warning" />
|
||
<span className="text-xxs font-semibold uppercase tracking-widest text-tertiary">
|
||
Automated pull request review
|
||
</span>
|
||
{/* Top of the page so it visibly governs everything below it, not one section. */}
|
||
<div className="ml-auto">
|
||
<PageScopeSwitch />
|
||
</div>
|
||
</div>
|
||
<h2 className="m-0 text-3xl font-bold" style={{ textWrap: 'balance' }}>
|
||
Review reviews pull requests before humans do
|
||
</h2>
|
||
<p className="m-0 max-w-155 text-sm text-secondary">
|
||
Specialist review skills read your changed code in parallel each from their own perspective, a
|
||
blind-spot sweep catches what they missed, and only validated findings get published back to the
|
||
pull request.
|
||
</p>
|
||
<ProofCard />
|
||
</section>
|
||
|
||
{initialLoadFailed && (
|
||
<Banner type="error" action={{ children: 'Retry', onClick: () => loadAll() }}>
|
||
Some Review settings failed to load.
|
||
</Banner>
|
||
)}
|
||
|
||
<TriggerReviewSection />
|
||
<RecentReviewsSection />
|
||
<PipelineSection />
|
||
<TriggersSection />
|
||
<section className="flex flex-col gap-8 border-t border-primary pt-8">
|
||
<div className="flex flex-col gap-1.5">
|
||
<h2 className="m-0 text-xl font-bold">Review skills</h2>
|
||
<p className="m-0 max-w-160 text-sm text-secondary">
|
||
Everything below is a regular skill, stored in your Insights skills store like anything else
|
||
you've added. Review skills read your changed code, a blind-spot sweep catches what they
|
||
missed, and validation criteria decide what reaches the pull request. Toggling one here
|
||
applies only to your pull request reviews; editing a skill changes it for the whole team.
|
||
</p>
|
||
</div>
|
||
<PerspectivesSection />
|
||
<SingleActiveSection
|
||
icon={<IconSearch />}
|
||
title="Blind-spot check"
|
||
intro="After the enabled perspectives finish, Review runs one more sweep over each chunk — it sees what they found and hunts for real issues they all missed. Add as many sweeps as you like, but only one runs."
|
||
kind="blind_spots"
|
||
kindLabel="blind-spot check"
|
||
preamble={<EffectivenessCard kind="blind_spots" />}
|
||
createLabel="Create your own blind-spot check"
|
||
skills={blindSpots?.map((s) => ({ ...s, on: s.active })) ?? null}
|
||
onSelect={selectBlindSpots}
|
||
/>
|
||
<SingleActiveSection
|
||
icon={<IconShield />}
|
||
title="Validation criteria"
|
||
intro="Every candidate finding is checked against your quality bar before publishing, so noisy, speculative, or low-value issues never reach the pull request. Keep several bars on hand, but only one is applied."
|
||
kind="validator"
|
||
kindLabel="validator"
|
||
createLabel="Create your own validation criteria"
|
||
preamble={<ValidatorEffectivenessCard />}
|
||
skills={validators?.map((s) => ({ ...s, on: s.active })) ?? null}
|
||
onSelect={selectValidator}
|
||
/>
|
||
</section>
|
||
<UrgencySection />
|
||
|
||
<SkillDrawer />
|
||
<ReviewDetailDrawer />
|
||
</div>
|
||
</SceneContent>
|
||
)
|
||
}
|
||
|
||
export default CodeReviewScene
|