diff --git a/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx b/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx index d4f958346898..675003b40a2f 100644 --- a/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx +++ b/static/app/views/autofixIssuesDemo/useAutofixIssues.tsx @@ -5,18 +5,23 @@ import { type ExplorerAutofixState, getOrderedAutofixSections, } from 'sentry/components/events/autofix/useExplorerAutofix'; -import type {Level} from 'sentry/types/event'; -import type {PlatformKey} from 'sentry/types/platform'; +import {t} from 'sentry/locale'; import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {useOrganization} from 'sentry/utils/useOrganization'; +import { + type OverviewIssue, + QUERY_STALE_TIME, + REQUIRED_ISSUE_FILTER, + RUNS_QUERY, + type SeerRun, +} from 'sentry/views/seerWorkflows/overview/types'; + +export {REQUIRED_ISSUE_FILTER}; // Visible default query for the search bar. The required autofix filter below // is always applied on top, so it isn't part of the editable query. export const DEFAULT_ISSUE_QUERY = 'is:unresolved'; -// Always applied to the issue query: only issues Seer has run autofix on. -export const REQUIRED_ISSUE_FILTER = 'has:issue.seer_last_run'; - function withRequiredFilter(query: string): string { const trimmed = query.trim(); if (!trimmed) { @@ -27,13 +32,9 @@ function withRequiredFilter(query: string): string { : `${trimmed} ${REQUIRED_ISSUE_FILTER}`; } -// Runs filter: the explorer runs autofix creates. Combined with a -// ``group:[...]`` filter so we only fetch runs for the issues on the page. -const RUNS_QUERY = 'type:explorer source:autofix'; - -// Custom one-shot question asked about each run, sent to the endpoint as a -// repeatable `question` param (see organization_seer_runs.py). Edit this list -// to iterate on prompts without a backend change. Capped at 5 by the endpoint. +// Custom one-shot questions asked about each run, sent to the endpoint as a +// repeatable `question` param (see organization_seer_runs.py). Capped at 5 by +// the endpoint. const DEMO_QUESTIONS = [ 'What was the most important bit of evidence while investigating this issue?', 'What is the complexity of the fix? Count or estimate number of lines and files touched.', @@ -45,11 +46,11 @@ const PER_PAGE = 10; export type AutofixPhase = 'rca' | 'planning' | 'coding' | 'pr_open' | 'pr_merged'; export const AUTOFIX_PHASE_LABELS: Record = { - rca: 'Root cause', - planning: 'Planning', - coding: 'Coding', - pr_open: 'PR open', - pr_merged: 'PR merged', + rca: t('Root cause'), + planning: t('Planning'), + coding: t('Coding'), + pr_open: t('PR open'), + pr_merged: t('PR merged'), }; /** @@ -81,50 +82,10 @@ function deriveAutofixPhase(runState: ExplorerAutofixState | null): AutofixPhase } } -// One answered question, mirrors the run output in -// src/sentry/api/serializers/models/seer_run.py -export interface RunQuestion { - answer: string; - key: string; - // The question text, echoed back only for user-supplied questions. - question?: string; -} - -// A pull request linked to a run, serialized by PullRequestSerializer -// src/sentry/api/serializers/models/pullrequest.py -// `status` is 'open' | 'merged' | 'closed' | 'draft' | 'unknown'. -interface RunPullRequest { - status: string | null; - mergedAt?: string | null; -} - -// Subset of the runs list response we consume -// src/sentry/api/serializers/models/seer_run.py -interface SeerRun { - groupId: string | null; - id: string; - lastTriggeredAt: string; - source: string | null; - // Present only when ?outputs is requested. - outputs?: RunQuestion[]; - // Linked PRs with merge status. - pullRequests?: RunPullRequest[]; -} - -// Subset of the issue-stream group we render. -interface Issue { - // Event count over the stats period. Endpoint sadly returns a string. - count: string; +// The overview's issue shape plus the two extra fields this demo renders. +interface Issue extends OverviewIssue { culprit: string; - id: string; - lastSeen: string; - level: Level; - project: {slug: string; platform?: PlatformKey}; - seerAutofixLastTriggered: string | null; seerFixabilityScore: number | null; - shortId: string; - title: string; - userCount: number; } export interface AutofixIssue extends Issue { @@ -136,6 +97,16 @@ export interface AutofixIssue extends Issue { interface UseAutofixIssuesParams { cursor?: string; + // Gates the issues request; pass page-filters readiness so the initial + // fetch waits for the restored project selection. Defaults to true. + enabled?: boolean; + // Fetch exactly these group ids instead of searching the stream. The + // endpoint ignores every other query component in this mode, so a + // deep-linked issue resolves even outside the list's filters/pagination. + groupIds?: string[]; + // Project ids to scope the issue stream to (page-filters selection: [] is + // "My Projects", [-1] is all). Defaults to all accessible projects. + projects?: number[]; query?: string; // One-shot questions asked about each run (repeatable `question` param, // capped at 5 by the endpoint). Defaults to this page's demo set. @@ -161,6 +132,9 @@ interface UseAutofixIssuesResult { export function useAutofixIssues({ query, cursor, + enabled = true, + groupIds: pinnedGroupIds, + projects, questions = DEMO_QUESTIONS, runsQuery: runsQueryFilter = RUNS_QUERY, }: UseAutofixIssuesParams): UseAutofixIssuesResult { @@ -173,7 +147,10 @@ export function useAutofixIssues({ query: { query: withRequiredFilter(query ?? ''), cursor, - project: -1, + group: pinnedGroupIds, + // In group-id mode the page-filters project selection must not hide + // the deep-linked issue — the backend still enforces access. + project: pinnedGroupIds ? -1 : (projects ?? -1), statsPeriod: '90d', // Explicit endpoint default: last-seen desc selects the issues still // actively occurring as the candidate pool; callers order the loaded @@ -181,8 +158,9 @@ export function useAutofixIssues({ sort: 'date', limit: PER_PAGE, }, - staleTime: 30_000, + staleTime: QUERY_STALE_TIME, }), + enabled, select: selectJsonWithHeaders, }); @@ -190,12 +168,9 @@ export function useAutofixIssues({ const groupIds = useMemo(() => issues.map(issue => issue.id), [issues]); const runsEnabled = groupIds.length > 0; - // 2. Enrich with each group's latest run, one request per group with - // per_page=1. A single batched group:[...] request looked cheaper, but the - // endpoint caps outputs-enabled pages at 10 runs ordered by recency — when - // the page's groups collectively have more runs than that, the oldest - // groups' runs fall off and their issues silently lose all answers. Per- - // group requests make coverage guaranteed with the same total one-shot work. + // One request per group (per_page=1): the endpoint caps outputs-enabled pages + // at 10 runs by recency, so a batched group:[...] request would silently drop + // the oldest groups' runs. const runResults = useQueries({ queries: groupIds.map(groupId => apiOptions.as()('/organizations/$organizationIdOrSlug/seer/runs/', { @@ -205,7 +180,7 @@ export function useAutofixIssues({ question: questions, per_page: 1, }, - staleTime: 30_000, + staleTime: QUERY_STALE_TIME, }) ), }); @@ -213,7 +188,7 @@ export function useAutofixIssues({ // 3. Fetch the full autofix state per group to derive its phase. This is one // request per issue on the page (bounded by PER_PAGE) -- the runs list // endpoint doesn't expose fine-grained phase, so we read each run's state - // ("the whole history") directly. useQueries preserves groupIds order. + // directly. useQueries preserves groupIds order. const autofixResults = useQueries({ queries: groupIds.map(groupId => apiOptions.as<{autofix: ExplorerAutofixState | null}>()( @@ -221,7 +196,7 @@ export function useAutofixIssues({ { path: {organizationIdOrSlug: organization.slug, issueId: groupId}, query: {mode: 'explorer'}, - staleTime: 30_000, + staleTime: QUERY_STALE_TIME, } ) ), @@ -234,8 +209,8 @@ export function useAutofixIssues({ const enriched: AutofixIssue[] = issues.map((issue, i) => { const autofixResult = autofixResults[i]; const autofixState = autofixResult?.data?.autofix ?? null; - // The server already scopes each request to its group; the find() guards - // against mocks/responses carrying runs for other groups. + // The server scopes each request to its group; find() guards against a + // response carrying runs for another group. const runs = runResults[i]?.data; const run = runs?.find(candidate => candidate.groupId === issue.id) ?? null; return { diff --git a/static/app/views/seerWorkflows/overview/attentionBadge.tsx b/static/app/views/seerWorkflows/overview/attentionBadge.tsx deleted file mode 100644 index 3d318f8569cb..000000000000 --- a/static/app/views/seerWorkflows/overview/attentionBadge.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import styled from '@emotion/styled'; -import type {LocationDescriptor} from 'history'; - -import {LinkButton} from '@sentry/scraps/button'; -import {Tooltip} from '@sentry/scraps/tooltip'; - -import {IconCode, IconCommit, IconPullRequest, IconRefresh, IconUser} from 'sentry/icons'; -import {t} from 'sentry/locale'; - -import type {AttentionReason, OverviewRow} from './types'; - -type LinkButtonVariant = React.ComponentProps['variant']; - -export const ATTENTION_REASONS: AttentionReason[] = [ - 'awaiting_input', - 'review_pr', - 'code_changes_ready', - 'solution_ready', - 'errored', -]; - -export const ATTENTION_META: Record< - AttentionReason, - { - Icon: React.ComponentType<{size?: 'xs' | 'sm' | 'md'}>; - description: string; - label: string; - variant: LinkButtonVariant; - } -> = { - awaiting_input: { - Icon: IconUser, - label: t('Add context'), - variant: 'primary', - description: t( - 'Autofix paused and is asking for more information before it can proceed.' - ), - }, - review_pr: { - Icon: IconPullRequest, - label: t('Review PR'), - variant: 'warning', - description: t('Autofix opened a pull request. Review and merge it.'), - }, - code_changes_ready: { - Icon: IconCommit, - label: t('Open PR'), - variant: 'secondary', - description: t('Autofix wrote a diff. Review it and open a pull request.'), - }, - solution_ready: { - Icon: IconCode, - label: t('Generate code'), - variant: 'secondary', - description: t( - 'Autofix proposed a fix. Continue the pipeline to generate code changes.' - ), - }, - errored: { - Icon: IconRefresh, - label: t('Retry'), - variant: 'secondary', - description: t('Autofix run errored. Open it to investigate or retry.'), - }, -}; - -export function getAttentionReason(row: OverviewRow): AttentionReason | null { - // A run that's still working has nothing actionable yet. - if (row.isProcessing) { - return null; - } - if (row.autofixRunStatus === 'NEED_MORE_INFORMATION') { - return 'awaiting_input'; - } - if (row.autofixRunStatus === 'ERROR') { - return 'errored'; - } - const set = new Set(row.outcomes); - // A merged PR needs nothing further; when merge state is unknown (the - // deployed API doesn't return it yet) an opened PR reads as needing review. - if (row.prMerged) { - return null; - } - if (set.has('pr_opened')) { - return 'review_pr'; - } - if (set.has('code_changes')) { - return 'code_changes_ready'; - } - if (set.has('solution')) { - return 'solution_ready'; - } - return null; -} - -// Actionable tiers ordered by urgency-to-a-human: blocked-on-you first, then -// nearest-to-shipping, with the low-confidence retry last. -const ATTENTION_TRIAGE_RANK: Record = { - awaiting_input: 0, - review_pr: 1, - code_changes_ready: 2, - solution_ready: 3, - errored: 4, -}; - -/** - * Queue position for the overview's triage sort: everything a human can act on - * (ranked by urgency), then rows whose state is still loading (parked in the - * middle so they don't leap from the top when they resolve), then Seer-is- - * working, then diagnosed-only, with merged wins sinking to the bottom as an - * archive shelf. Lower sorts first. - */ -export function getTriageRank(row: OverviewRow, attention: AttentionReason | null) { - if (attention !== null) { - return ATTENTION_TRIAGE_RANK[attention]; - } - if (row.statePending) { - return 5; - } - if (row.isProcessing) { - return 6; - } - if (row.prMerged) { - return 8; - } - // Diagnosed-only: informational, optional next step. - return 7; -} - -const AccentLinkButton = styled(LinkButton)` - background: ${p => p.theme.tokens.background.accent}; - border-color: ${p => p.theme.tokens.border.accent}; - color: ${p => p.theme.tokens.content.accent}; - &:hover { - color: ${p => p.theme.tokens.content.accent}; - } -`; - -const SuccessLinkButton = styled(LinkButton)` - background: ${p => p.theme.tokens.background.success}; - border-color: ${p => p.theme.tokens.border.success}; - color: ${p => p.theme.tokens.content.success}; - &:hover { - color: ${p => p.theme.tokens.content.success}; - } -`; - -const MutedLinkButton = styled(LinkButton)` - background: transparent; - border-color: ${p => p.theme.tokens.border.neutral}; - color: ${p => p.theme.tokens.content.secondary}; -`; - -export function AttentionBadge({ - reason, - to, -}: { - reason: AttentionReason; - to: LocationDescriptor; -}) { - const meta = ATTENTION_META[reason]; - - if (reason === 'code_changes_ready') { - return ( - - } to={to}> - {meta.label} - - - ); - } - if (reason === 'solution_ready') { - return ( - - } to={to}> - {meta.label} - - - ); - } - if (reason === 'errored') { - return ( - - } to={to}> - {meta.label} - - - ); - } - - return ( - - } to={to}> - {meta.label} - - - ); -} diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts new file mode 100644 index 000000000000..2c5ef3a25809 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.spec.ts @@ -0,0 +1,364 @@ +import type {ExplorerAutofixState} from 'sentry/components/events/autofix/useExplorerAutofix'; +import { + buildAnalysis, + deriveSectionKey, + extractPatchInfo, + extractPendingQuestion, + INLINE_DIFF_MAX_CHANGED_LINES, + INLINE_DIFF_MAX_FILES, + mostRecentTimestamp, + normalizeBulletList, + parseRootCause, +} from 'sentry/views/seerWorkflows/overview/buildOverviewRows'; +import type {SeerRun} from 'sentry/views/seerWorkflows/overview/types'; + +function makeBlock(step: string) { + return {message: {role: 'assistant', content: step, metadata: {step}}}; +} + +function makePatch({ + repo = 'getsentry/sentry', + path = 'src/cart.py', + added = 1, + removed = 0, + lines = 1, +}: { + added?: number; + lines?: number; + path?: string; + removed?: number; + repo?: string; +} = {}) { + return { + repo_name: repo, + diff: '--- a\n+++ b', + patch: { + path, + source_file: path, + target_file: path, + type: 'M', + added, + removed, + hunks: [ + { + section_header: '', + source_start: 1, + source_length: lines, + target_start: 1, + target_length: lines, + lines: Array.from({length: lines}, (_, index) => ({ + value: `line ${index}`, + line_type: '+', + source_line_no: null, + target_line_no: index + 1, + diff_line_no: index + 1, + })), + }, + ], + }, + }; +} + +function makeCodeChangesState( + patches: Array> +): ExplorerAutofixState { + return makeState({ + status: 'completed', + blocks: [ + { + message: { + role: 'assistant', + content: 'code_changes', + metadata: {step: 'code_changes'}, + }, + merged_file_patches: patches, + }, + ], + }); +} + +function makeState(overrides: Record = {}): ExplorerAutofixState { + return { + status: 'completed', + blocks: [], + ...overrides, + } as unknown as ExplorerAutofixState; +} + +function makeRun(overrides: Partial = {}): SeerRun { + return { + id: 'run-1', + groupId: '2', + source: 'autofix', + lastTriggeredAt: '2026-07-14T09:00:00Z', + ...overrides, + }; +} + +describe('parseRootCause', () => { + it('splits a headline from the root cause on the first pipe', () => { + expect( + parseRootCause('Cart total is null|Commit c5bb895 removed the guard.') + ).toEqual({ + headline: 'Cart total is null', + answer: 'Commit c5bb895 removed the guard.', + }); + }); + + it('splits only on the first pipe, keeping later pipes in the body', () => { + expect(parseRootCause('Headline|body a | body b')).toEqual({ + headline: 'Headline', + answer: 'body a | body b', + }); + }); + + it('returns the whole answer unchanged when there is no pipe', () => { + expect(parseRootCause('No delimiter here')).toEqual({answer: 'No delimiter here'}); + }); + + it('strips wrapping emphasis and quote characters from the headline', () => { + expect(parseRootCause('**"Broken cart"**|because reasons')).toEqual({ + headline: 'Broken cart', + answer: 'because reasons', + }); + }); + + it('treats a headline past the max length as a parse failure', () => { + const answer = `${'word '.repeat(40)}|the cause`; + expect(parseRootCause(answer)).toEqual({answer}); + }); + + it('treats an empty headline or empty body as a parse failure', () => { + expect(parseRootCause('|only a body')).toEqual({answer: '|only a body'}); + expect(parseRootCause('only a headline|')).toEqual({answer: 'only a headline|'}); + }); +}); + +describe('normalizeBulletList', () => { + it('rewrites inline bullets into their own markdown list lines', () => { + expect(normalizeBulletList('Do this: •First •Second •Third')).toBe( + 'Do this:\n- First\n- Second\n- Third' + ); + }); + + it('handles a bullet with no trailing space', () => { + expect(normalizeBulletList('Intro •Item')).toBe('Intro\n- Item'); + }); + + it('leaves input without a bullet untouched', () => { + expect(normalizeBulletList('A single next step.')).toBe('A single next step.'); + }); + + it('drops empty items from consecutive bullets', () => { + expect(normalizeBulletList('A ••B')).toBe('A\n- B'); + }); + + it('drops a trailing empty bullet', () => { + expect(normalizeBulletList('Intro •First •')).toBe('Intro\n- First'); + }); +}); + +describe('mostRecentTimestamp', () => { + it('returns the numerically latest of mixed defined candidates', () => { + // The .500Z timestamp is later in time but sorts *before* the whole-second + // one lexicographically ('.' < 'Z'), so this pins the numeric comparison. + expect( + mostRecentTimestamp( + '2026-07-01T10:00:00Z', + undefined, + '2026-07-01T10:00:00.500Z', + null + ) + ).toBe('2026-07-01T10:00:00.500Z'); + }); + + it('returns an empty string when every candidate is nullish', () => { + expect(mostRecentTimestamp(null, undefined)).toBe(''); + }); +}); + +describe('buildAnalysis', () => { + it('maps an output with no question field to its positional config', () => { + const {entries, headline} = buildAnalysis([ + {key: '', answer: 'Cart total is null|The guard was removed.'}, + ]); + expect(headline).toBe('Cart total is null'); + expect(entries).toEqual([ + {key: 'root_cause', label: 'Root cause', answer: 'The guard was removed.'}, + ]); + }); + + it('falls back positionally when the question string matches nothing', () => { + const {entries} = buildAnalysis([ + {key: '', question: 'not a real prompt', answer: 'A headline|a cause'}, + {key: '', question: 'still not real', answer: 'Adds a guard.'}, + ]); + expect(entries.map(entry => entry.key)).toEqual(['root_cause', 'fix_summary']); + }); + + it('drops empty-string answers', () => { + const {entries} = buildAnalysis([ + {key: '', answer: 'A headline|a cause'}, + {key: '', answer: ''}, + {key: '', answer: 'Confirm the config value.'}, + ]); + expect(entries.map(entry => entry.key)).toEqual(['root_cause', 'next_steps']); + }); +}); + +describe('extractPatchInfo', () => { + it('renders inline at exactly the changed-line limit', () => { + const {inlinePatches, patchStats} = extractPatchInfo( + makeCodeChangesState([ + makePatch({added: INLINE_DIFF_MAX_CHANGED_LINES, removed: 0, lines: 3}), + ]) + ); + expect(patchStats?.added).toBe(INLINE_DIFF_MAX_CHANGED_LINES); + expect(inlinePatches).toHaveLength(1); + }); + + it('falls back to a pill one line over the changed-line limit', () => { + const {inlinePatches, patchStats} = extractPatchInfo( + makeCodeChangesState([ + makePatch({added: INLINE_DIFF_MAX_CHANGED_LINES + 1, removed: 0, lines: 3}), + ]) + ); + expect(patchStats?.added).toBe(INLINE_DIFF_MAX_CHANGED_LINES + 1); + expect(inlinePatches).toBeUndefined(); + }); + + it('renders inline at exactly the file limit', () => { + const patches = Array.from({length: INLINE_DIFF_MAX_FILES}, (_, index) => + makePatch({path: `src/file${index}.py`, added: 1, removed: 0, lines: 1}) + ); + const {inlinePatches} = extractPatchInfo(makeCodeChangesState(patches)); + expect(inlinePatches).toHaveLength(INLINE_DIFF_MAX_FILES); + }); + + it('falls back to a pill one file over the file limit', () => { + const patches = Array.from({length: INLINE_DIFF_MAX_FILES + 1}, (_, index) => + makePatch({path: `src/file${index}.py`, added: 1, removed: 0, lines: 1}) + ); + const {inlinePatches, patchStats} = extractPatchInfo(makeCodeChangesState(patches)); + expect(inlinePatches).toBeUndefined(); + expect(patchStats?.files).toBe(INLINE_DIFF_MAX_FILES + 1); + }); + + it('prefixes file paths with the repo when the diff spans repos', () => { + const {inlinePatches, patchStats} = extractPatchInfo( + makeCodeChangesState([ + makePatch({repo: 'getsentry/sentry', path: 'src/a.py', added: 1, lines: 1}), + makePatch({repo: 'getsentry/getsentry', path: 'src/b.py', added: 1, lines: 1}), + ]) + ); + expect(patchStats?.fileList.map(file => file.path).sort()).toEqual([ + 'getsentry/getsentry:src/b.py', + 'getsentry/sentry:src/a.py', + ]); + expect(inlinePatches?.every(patch => patch.repoName !== undefined)).toBe(true); + }); +}); + +describe('extractPendingQuestion', () => { + it('returns nothing when the run is not awaiting user input', () => { + expect(extractPendingQuestion(makeState({status: 'completed'}))).toBeUndefined(); + expect(extractPendingQuestion(null)).toBeUndefined(); + }); + + it('reads the canonical questions[0].question shape', () => { + const state = makeState({ + status: 'awaiting_user_input', + pending_user_input: {data: {questions: [{question: 'Which env?'}]}}, + }); + expect(extractPendingQuestion(state)).toBe('Which env?'); + }); + + it('falls back to the flat question / text / message keys in order', () => { + for (const key of ['question', 'text', 'message']) { + const state = makeState({ + status: 'awaiting_user_input', + pending_user_input: {data: {[key]: `via ${key}`}}, + }); + expect(extractPendingQuestion(state)).toBe(`via ${key}`); + } + }); + + it('ignores blank or missing payloads', () => { + const blankNested = makeState({ + status: 'awaiting_user_input', + pending_user_input: {data: {questions: [{question: ' '}]}}, + }); + expect(extractPendingQuestion(blankNested)).toBeUndefined(); + + const blankFlat = makeState({ + status: 'awaiting_user_input', + pending_user_input: {data: {question: ' '}}, + }); + expect(extractPendingQuestion(blankFlat)).toBeUndefined(); + + const noPayload = makeState({status: 'awaiting_user_input'}); + expect(extractPendingQuestion(noPayload)).toBeUndefined(); + }); +}); + +describe('deriveSectionKey', () => { + const cases: Array<{ + expected: string; + name: string; + run: SeerRun | null; + state: ExplorerAutofixState | null; + }> = [ + { + name: 'a merged PR beats every reached step', + run: makeRun({pullRequests: [{status: 'merged'}]}), + state: makeState({ + blocks: [makeBlock('code_changes')], + repo_pr_states: {r: {pr_creation_status: 'completed'}}, + }), + expected: 'merged', + }, + { + name: 'a created PR beats code changes', + run: makeRun(), + state: makeState({ + blocks: [makeBlock('code_changes')], + repo_pr_states: {r: {pr_creation_status: 'completed'}}, + }), + expected: 'review_pr', + }, + { + name: 'code changes beat a solution', + run: makeRun(), + state: makeState({blocks: [makeBlock('solution'), makeBlock('code_changes')]}), + expected: 'code_changes_ready', + }, + { + name: 'a coding-agents-only run reads as code changes ready', + run: makeRun(), + state: makeState({blocks: [makeBlock('coding_agents')]}), + expected: 'code_changes_ready', + }, + { + name: 'a solution beats the floor', + run: makeRun(), + state: makeState({blocks: [makeBlock('root_cause'), makeBlock('solution')]}), + expected: 'solution_ready', + }, + { + name: 'a diagnosis-only run falls to needs_investigation', + run: makeRun(), + state: makeState({blocks: [makeBlock('root_cause')]}), + expected: 'needs_investigation', + }, + { + name: 'a null run and state fall to needs_investigation', + run: null, + state: null, + expected: 'needs_investigation', + }, + ]; + + it.each(cases)('$name', ({run, state, expected}) => { + expect(deriveSectionKey(run, state)).toBe(expected); + }); +}); diff --git a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts index b421520fd6aa..4e9a9d501f05 100644 --- a/static/app/views/seerWorkflows/overview/buildOverviewRows.ts +++ b/static/app/views/seerWorkflows/overview/buildOverviewRows.ts @@ -5,78 +5,68 @@ import { isCodeChangesArtifact, isCodeChangesSection, } from 'sentry/components/events/autofix/useExplorerAutofix'; -import type { - AutofixIssue, - RunQuestion, -} from 'sentry/views/autofixIssuesDemo/useAutofixIssues'; import {RUN_QUESTIONS} from './runQuestions'; import {mapRunSourceToTrigger} from './triggerBadge'; -import type { - AutofixOutcome, - AutofixRunStatus, - OverviewRow, - PatchStats, - RunAnalysisEntry, +import { + type AutofixStateKey, + type OverviewIssue, + type OverviewRow, + type PatchStats, + PIPELINE, + type RunAnalysisEntry, + type RunQuestion, + type SeerRun, } from './types'; -const OUTCOME_ORDER: AutofixOutcome[] = [ - 'root_cause', - 'solution', - 'code_changes', - 'pr_opened', -]; +// The pipeline steps the run has reached, from getOrderedAutofixSections' +// section steps (which fold repo_pr_states into a synthetic pull_request +// section and coding_agents into their own). +function reachedSteps(state: ExplorerAutofixState | null): Set { + return new Set(getOrderedAutofixSections(state).map(section => section.step)); +} /** - * Every pipeline stage the run has produced so far, in stage order. - * - * Cumulative (unlike deriveAutofixPhase's single furthest phase) because the - * attention logic tests stage membership: "code changes but no PR" is a - * different action than "PR opened". + * The focus-mode fallback for a card with no server section: walk the pipeline + * furthest-first (by fill) and return the furthest stage the run reached. The + * issues endpoint doesn't return issue.autofix_state for a single pinned id, so + * this reconstructs it from the enrichment the same way the section query would + * have bucketed it. One precedence encoding, shared with the section list. */ -function deriveAutofixOutcomes(runState: ExplorerAutofixState | null): AutofixOutcome[] { - const reached = new Set(); - for (const section of getOrderedAutofixSections(runState)) { - switch (section.step) { - case 'root_cause': - reached.add('root_cause'); - break; - case 'solution': - reached.add('solution'); - break; - case 'code_changes': - case 'coding_agents': - reached.add('code_changes'); - break; - case 'pull_request': - reached.add('pr_opened'); - break; - default: - break; - } - } - return OUTCOME_ORDER.filter(outcome => reached.has(outcome)); +export function deriveSectionKey( + run: SeerRun | null, + state: ExplorerAutofixState | null +): AutofixStateKey { + const steps = reachedSteps(state); + const reached: Record = { + merged: (run?.pullRequests ?? []).some(pr => pr.status === 'merged'), + review_pr: steps.has('pull_request'), + code_changes_ready: steps.has('code_changes') || steps.has('coding_agents'), + solution_ready: steps.has('solution'), + needs_investigation: true, + }; + return [...PIPELINE].sort((a, b) => b.fill - a.fill).find(stage => reached[stage.key])! + .key; } -function deriveRunStatus(state: ExplorerAutofixState | null): AutofixRunStatus { - switch (state?.status) { - case 'awaiting_user_input': - return 'NEED_MORE_INFORMATION'; - case 'error': - return 'ERROR'; - default: - return 'COMPLETED'; - } -} +// A diff qualifies for the on-card differ only when it is genuinely small: +// few files, few changed lines, and bounded hunk context so a fix with huge +// surrounding context can't blow the card up. +export const INLINE_DIFF_MAX_FILES = 2; +export const INLINE_DIFF_MAX_CHANGED_LINES = 25; +const INLINE_DIFF_MAX_RENDERED_LINES = 60; -function extractPatchStats(state: ExplorerAutofixState | null): PatchStats | undefined { +export function extractPatchInfo(state: ExplorerAutofixState | null): { + inlinePatches?: OverviewRow['inlinePatches']; + patchStats?: PatchStats; +} { const section = getOrderedAutofixSections(state).find(isCodeChangesSection); if (!section) { - return undefined; + return {}; } const artifact = getAutofixArtifactFromSection(section); if (!isCodeChangesArtifact(artifact)) { - return undefined; + return {}; } // Disambiguate paths with the repo name only when the diff spans repos. const multiRepo = new Set(artifact.map(filePatch => filePatch.repo_name)).size > 1; @@ -90,11 +80,26 @@ function extractPatchStats(state: ExplorerAutofixState | null): PatchStats | und })) // Most-changed files first, so a capped tooltip shows what matters. .sort((a, b) => b.added + b.removed - (a.added + a.removed)); + const added = artifact.reduce((sum, filePatch) => sum + filePatch.patch.added, 0); + const removed = artifact.reduce((sum, filePatch) => sum + filePatch.patch.removed, 0); + const renderedLines = artifact.reduce( + (sum, filePatch) => + sum + filePatch.patch.hunks.reduce((lines, hunk) => lines + hunk.lines.length, 0), + 0 + ); + const inlineEligible = + artifact.length <= INLINE_DIFF_MAX_FILES && + added + removed <= INLINE_DIFF_MAX_CHANGED_LINES && + renderedLines > 0 && + renderedLines <= INLINE_DIFF_MAX_RENDERED_LINES; return { - fileList, - files: artifact.length, - added: artifact.reduce((sum, filePatch) => sum + filePatch.patch.added, 0), - removed: artifact.reduce((sum, filePatch) => sum + filePatch.patch.removed, 0), + patchStats: {fileList, files: artifact.length, added, removed}, + inlinePatches: inlineEligible + ? artifact.map(filePatch => ({ + patch: filePatch.patch, + repoName: multiRepo ? filePatch.repo_name : undefined, + })) + : undefined, }; } @@ -110,7 +115,9 @@ function extractPr( // The pending-input payload is untyped (Record). The canonical // ask_user_question shape is {questions: [{question, options}]} (see // usePendingUserInput's AskUserQuestionData); fall back to a flat key otherwise. -function extractPendingQuestion(state: ExplorerAutofixState | null): string | undefined { +export function extractPendingQuestion( + state: ExplorerAutofixState | null +): string | undefined { if (state?.status !== 'awaiting_user_input') { return undefined; } @@ -130,21 +137,13 @@ function extractPendingQuestion(state: ExplorerAutofixState | null): string | un return undefined; } -/** - * Join the run's answered questions back to their question configs. - * - * Matches primarily on the echoed question text (the endpoint echoes prompts - * back for user-supplied questions), falling back to position — answers are - * returned in question order. Empty answers mean "not applicable" (the prompts - * ask for an empty string) and are dropped. - */ // A headline longer than this means the model ignored the 14-word instruction // (or the pipe landed somewhere unintended) — treat it as a parse failure. const MAX_HEADLINE_LENGTH = 140; // The root_cause prompt asks for "headline|root cause". Split on the first // pipe; strip stray emphasis/quote characters the model might wrap it in. -function parseRootCause(answer: string): {answer: string; headline?: string} { +export function parseRootCause(answer: string): {answer: string; headline?: string} { const pipeIndex = answer.indexOf('|'); if (pipeIndex === -1) { return {answer}; @@ -163,17 +162,27 @@ function parseRootCause(answer: string): {answer: string; headline?: string} { // The model sometimes emits inline "•" bullets run together in one paragraph; // markdown only renders a list when each item is its own "- " line. The bullet // may be followed by no space ("•Item"), so the trailing \s is optional. -function normalizeBulletList(answer: string): string { +export function normalizeBulletList(answer: string): string { if (!answer.includes('•')) { return answer; } const [head = '', ...items] = answer.split(/\s*•\s*/); - return [head.trim(), ...items.map(item => `- ${item.trim()}`)] + const bullets = items + .map(item => item.trim()) .filter(Boolean) - .join('\n'); + .map(item => `- ${item}`); + return [head.trim(), ...bullets].filter(Boolean).join('\n'); } -function buildAnalysis(outputs: RunQuestion[] | undefined): { +/** + * Join the run's answered questions back to their question configs. + * + * Matches primarily on the echoed question text (the endpoint echoes prompts + * back for user-supplied questions), falling back to position — answers are + * returned in question order. Empty answers mean "not applicable" (the prompts + * ask for an empty string) and are dropped. + */ +export function buildAnalysis(outputs: RunQuestion[] | undefined): { entries: RunAnalysisEntry[]; headline?: string; } { @@ -195,23 +204,45 @@ function buildAnalysis(outputs: RunQuestion[] | undefined): { const rootCause = parseRootCause(output.answer); headline = rootCause.headline; answer = rootCause.answer; - } else if (config.key === 'reviewer_notes') { + } else if (config.key === 'next_steps') { answer = normalizeBulletList(output.answer); } entries.push({ key: config.key, label: config.label, - placement: config.placement, answer, }); }); return {entries, headline}; } -function buildOverviewRow(issue: AutofixIssue): OverviewRow { - const state = issue.autofixState; +export function mostRecentTimestamp( + ...candidates: Array +): string { + let latest = ''; + let latestTime = -Infinity; + for (const candidate of candidates) { + if (!candidate) { + continue; + } + const time = new Date(candidate).getTime(); + if (time > latestTime) { + latest = candidate; + latestTime = time; + } + } + return latest; +} + +export function buildOverviewRow( + issue: OverviewIssue, + run: SeerRun | null, + state: ExplorerAutofixState | null, + statePending: boolean, + statsPeriod: string +): OverviewRow { const eventCount = Number(issue.count); - const {entries: analysis, headline} = buildAnalysis(issue.run?.outputs); + const {entries: analysis, headline} = buildAnalysis(run?.outputs); return { headline, @@ -222,27 +253,20 @@ function buildOverviewRow(issue: AutofixIssue): OverviewRow { project: issue.project, eventCount: Number.isFinite(eventCount) ? eventCount : 0, userCount: issue.userCount, - lastSeen: issue.lastSeen, - fixabilityScore: issue.seerFixabilityScore, - lastActivityAt: - state?.updated_at ?? - issue.run?.lastTriggeredAt ?? - issue.seerAutofixLastTriggered ?? - issue.lastSeen, - autofixRunStatus: deriveRunStatus(state), - prMerged: (issue.run?.pullRequests ?? []).some(pr => pr.status === 'merged'), - isProcessing: state?.status === 'processing', - statePending: issue.autofixPhasePending, - outcomes: deriveAutofixOutcomes(state), - trigger: mapRunSourceToTrigger(issue.run?.source ?? null), - rawSource: issue.run?.source ?? null, + statsPeriod, + lastActivityAt: mostRecentTimestamp( + state?.updated_at, + run?.lastTriggeredAt, + issue.seerAutofixLastTriggered, + issue.lastSeen + ), + runStatus: state?.status ?? null, + statePending, + trigger: mapRunSourceToTrigger(run?.source ?? null), + rawSource: run?.source ?? null, analysis, - patchStats: extractPatchStats(state), + ...extractPatchInfo(state), pendingQuestion: extractPendingQuestion(state), ...extractPr(state), }; } - -export function buildOverviewRows(issues: AutofixIssue[]): OverviewRow[] { - return issues.map(buildOverviewRow); -} diff --git a/static/app/views/seerWorkflows/overview/cardAction.spec.tsx b/static/app/views/seerWorkflows/overview/cardAction.spec.tsx new file mode 100644 index 000000000000..f2c338ad66d4 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/cardAction.spec.tsx @@ -0,0 +1,134 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import { + deriveCardAction, + IssuePrimaryAction, +} from 'sentry/views/seerWorkflows/overview/cardAction'; +import type { + AutofixStateKey, + CardAction, + OverviewRow, + RunStatus, +} from 'sentry/views/seerWorkflows/overview/types'; + +function makeRow(overrides: Partial = {}): OverviewRow { + return { + analysis: [], + eventCount: 1, + id: '2', + lastActivityAt: '2026-07-14T10:00:00Z', + level: 'error', + project: {slug: 'proj'}, + runStatus: null, + shortId: 'PROJ-1', + statePending: false, + statsPeriod: '90d', + title: 'Boom', + userCount: 0, + ...overrides, + }; +} + +const runUrl = { + pathname: '/organizations/org-slug/issues/2/', + query: {seerDrawer: 'true'}, +}; + +describe('deriveCardAction', () => { + it.each([ + 'code_changes_ready', + 'solution_ready', + 'needs_investigation', + 'merged', + ] as AutofixStateKey[])('maps the %s section to its own action', sectionKey => { + expect(deriveCardAction(sectionKey, makeRow())).toEqual({type: sectionKey}); + }); + + it('carries the linked PR on the review_pr action', () => { + const action = deriveCardAction( + 'review_pr', + makeRow({prUrl: 'https://github.com/o/r/pull/9', prNumber: 9}) + ); + expect(action).toEqual({ + type: 'review_pr', + prUrl: 'https://github.com/o/r/pull/9', + prNumber: 9, + }); + }); +}); + +describe('IssuePrimaryAction', () => { + function renderAction(action: CardAction, row: OverviewRow) { + return render(); + } + + it('renders a placeholder while the run state is pending', () => { + renderAction({type: 'needs_investigation'}, makeRow({statePending: true})); + + expect(screen.getByText('…')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it.each([ + {runStatus: 'processing' as RunStatus, overlay: 'Running'}, + {runStatus: 'error' as RunStatus, overlay: 'Retry'}, + {runStatus: 'awaiting_user_input' as RunStatus, overlay: 'Add context'}, + ])( + 'paints the $overlay overlay over the section action when the run is $runStatus', + ({runStatus, overlay}) => { + // A review_pr card (section anchor) whose live run is mid-flight: the + // overlay shows and the Review PR anchor is hidden, but the section is + // not reclassified — see the anchor assertion below. + const action: CardAction = { + type: 'review_pr', + prUrl: 'https://github.com/o/r/pull/9', + prNumber: 9, + }; + renderAction(action, makeRow({runStatus})); + + expect(screen.getByText(overlay)).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Review PR'})).not.toBeInTheDocument(); + } + ); + + it('shows the section-driven Review PR anchor once no overlay applies', () => { + const action: CardAction = { + type: 'review_pr', + prUrl: 'https://github.com/o/r/pull/9', + prNumber: 9, + }; + renderAction(action, makeRow({runStatus: 'completed'})); + + expect(screen.getByRole('button', {name: 'Review PR'})).toHaveAttribute( + 'href', + 'https://github.com/o/r/pull/9' + ); + }); + + it.each([ + {type: 'merged', label: 'Merged'}, + {type: 'code_changes_ready', label: 'Open PR'}, + {type: 'solution_ready', label: 'Generate code'}, + {type: 'needs_investigation', label: 'Investigate'}, + ] as Array<{label: string; type: Exclude}>)( + 'renders the $label action for a completed $type card', + ({type, label}) => { + renderAction({type}, makeRow({runStatus: 'completed'})); + + expect(screen.getByText(label)).toBeInTheDocument(); + } + ); + + it('falls back to the internal review link when a review_pr card has no PR url', () => { + renderAction( + {type: 'review_pr', prUrl: undefined, prNumber: undefined}, + makeRow({runStatus: 'completed'}) + ); + + expect(screen.getByRole('button', {name: 'Review PR'})).toHaveAttribute( + 'href', + '/organizations/org-slug/issues/2/?seerDrawer=true' + ); + }); +}); diff --git a/static/app/views/seerWorkflows/overview/cardAction.tsx b/static/app/views/seerWorkflows/overview/cardAction.tsx new file mode 100644 index 000000000000..7946db403530 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/cardAction.tsx @@ -0,0 +1,236 @@ +import styled from '@emotion/styled'; +import type {LocationDescriptor} from 'history'; + +import {Tag} from '@sentry/scraps/badge'; +import {LinkButton} from '@sentry/scraps/button'; +import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import { + IconCode, + IconCommit, + IconMerge, + IconPullRequest, + IconRefresh, + IconSearch, + IconUser, +} from 'sentry/icons'; +import type {SVGIconProps} from 'sentry/icons/svgIcon'; +import {t} from 'sentry/locale'; + +import type {AutofixStateKey, CardAction, OverviewRow} from './types'; + +type LinkButtonVariant = React.ComponentProps['variant']; + +// Stage actions are keyed by section; the two extras (awaiting_input, errored) +// are the transient live-status overlays. Strings are translated product copy. +type ActionKey = + | 'review_pr' + | 'code_changes_ready' + | 'solution_ready' + | 'needs_investigation' + | 'awaiting_input' + | 'errored'; + +const ACTION_META: Record< + ActionKey, + { + Icon: React.ComponentType; + description: string; + label: string; + variant: LinkButtonVariant; + } +> = { + review_pr: { + Icon: IconPullRequest, + label: t('Review PR'), + variant: 'warning', + description: t('Autofix opened a pull request. Review and merge it.'), + }, + code_changes_ready: { + Icon: IconCommit, + label: t('Open PR'), + variant: 'secondary', + description: t('Autofix wrote a diff. Review it and open a pull request.'), + }, + solution_ready: { + Icon: IconCode, + label: t('Generate code'), + variant: 'secondary', + description: t( + 'Autofix proposed a fix. Continue the pipeline to generate code changes.' + ), + }, + needs_investigation: { + Icon: IconSearch, + label: t('Investigate'), + variant: 'secondary', + description: t( + 'Seer stopped at a diagnosis. Open the run to investigate the root cause.' + ), + }, + awaiting_input: { + Icon: IconUser, + label: t('Add context'), + variant: 'primary', + description: t( + 'Autofix paused and is asking for more information before it can proceed.' + ), + }, + errored: { + Icon: IconRefresh, + label: t('Retry'), + variant: 'secondary', + description: t('Autofix run errored. Open it to investigate or retry.'), + }, +}; + +export function deriveCardAction( + sectionKey: AutofixStateKey, + row: OverviewRow +): CardAction { + if (sectionKey === 'review_pr') { + return {type: 'review_pr', prUrl: row.prUrl, prNumber: row.prNumber}; + } + return {type: sectionKey}; +} + +const AccentLinkButton = styled(LinkButton)` + background: ${p => p.theme.tokens.background.accent}; + border-color: ${p => p.theme.tokens.border.accent}; + color: ${p => p.theme.tokens.content.accent}; + &:hover { + color: ${p => p.theme.tokens.content.accent}; + } +`; + +const SuccessLinkButton = styled(LinkButton)` + background: ${p => p.theme.tokens.background.success}; + border-color: ${p => p.theme.tokens.border.success}; + color: ${p => p.theme.tokens.content.success}; + &:hover { + color: ${p => p.theme.tokens.content.success}; + } +`; + +const MutedLinkButton = styled(LinkButton)` + background: transparent; + border-color: ${p => p.theme.tokens.border.neutral}; + color: ${p => p.theme.tokens.content.secondary}; +`; + +function ActionButton({actionKey, to}: {actionKey: ActionKey; to: LocationDescriptor}) { + const meta = ACTION_META[actionKey]; + if (actionKey === 'code_changes_ready') { + return ( + + } to={to}> + {meta.label} + + + ); + } + if (actionKey === 'solution_ready') { + return ( + + } to={to}> + {meta.label} + + + ); + } + if (actionKey === 'errored') { + return ( + + } to={to}> + {meta.label} + + + ); + } + return ( + + } to={to}> + {meta.label} + + + ); +} + +function ReviewPrButton({ + prUrl, + prNumber, +}: { + prNumber: number | undefined; + prUrl: string; +}) { + const meta = ACTION_META.review_pr; + return ( + + } + href={prUrl} + external + > + {meta.label} + + + ); +} + +/** + * The card's primary action. The section (via `action`) is the anchor; the live + * run status only paints transient overlays over it — the loading placeholder, + * the Running tag, and the Retry / Add-context prompts for a paused or errored + * run — none of which reclassify the card. + */ +export function IssuePrimaryAction({ + action, + row, + runUrl, +}: { + action: CardAction; + row: OverviewRow; + runUrl: LocationDescriptor; +}) { + if (row.statePending) { + return {'…'}; + } + if (row.runStatus === 'processing') { + return {t('Running')}; + } + if (row.runStatus === 'error') { + return ; + } + if (row.runStatus === 'awaiting_user_input') { + return ; + } + + switch (action.type) { + case 'merged': + return ( + + }> + {t('Merged')} + + + ); + case 'review_pr': + return action.prUrl ? ( + + ) : ( + + ); + default: + return ; + } +} diff --git a/static/app/views/seerWorkflows/overview/focusedIssue.tsx b/static/app/views/seerWorkflows/overview/focusedIssue.tsx new file mode 100644 index 000000000000..47ed51985f38 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/focusedIssue.tsx @@ -0,0 +1,79 @@ +import {Fragment} from 'react'; +import {useQuery} from '@tanstack/react-query'; + +import {LinkButton} from '@sentry/scraps/button'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {LoadingError} from 'sentry/components/loadingError'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {IconArrow} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import {SectionIssueCard} from './sectionIssueCard'; +import {type OverviewIssue, QUERY_STALE_TIME} from './types'; + +// Deep-link focus mode: ?id= renders exactly that issue's card, fully +// expanded, fetched by group id so it resolves even outside the list's filters. +export function FocusedIssue({id, period}: {id: string; period: string}) { + const organization = useOrganization(); + const location = useLocation(); + + const pinnedIssueQuery = useQuery( + apiOptions.as()('/organizations/$organizationIdOrSlug/issues/', { + path: {organizationIdOrSlug: organization.slug}, + query: {group: [id], project: -1, statsPeriod: period}, + staleTime: QUERY_STALE_TIME, + }) + ); + const issues = pinnedIssueQuery.data ?? []; + + return ( + + {/* Focus mode swaps the filter toolbar for a way back to the list; every + other param (project, sort, ...) is preserved. */} + + } + to={{ + pathname: location.pathname, + query: {...location.query, id: undefined}, + }} + > + {t('All issues')} + + + + {pinnedIssueQuery.isError ? ( + + ) : pinnedIssueQuery.isPending ? ( + + ) : issues.length === 0 ? ( + + + {t('Issue not found.')} + + + ) : ( + + {issues.map(issue => ( + + ))} + + )} + + ); +} diff --git a/static/app/views/seerWorkflows/overview/index.spec.tsx b/static/app/views/seerWorkflows/overview/index.spec.tsx index 82d47892743b..fc6cc55d1dac 100644 --- a/static/app/views/seerWorkflows/overview/index.spec.tsx +++ b/static/app/views/seerWorkflows/overview/index.spec.tsx @@ -1,8 +1,12 @@ import {GroupFixture} from 'sentry-fixture/group'; import {OrganizationFixture} from 'sentry-fixture/organization'; +import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; +import {ProjectFixture} from 'sentry-fixture/project'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; import AutofixOverview from 'sentry/views/seerWorkflows/overview'; import {RUN_QUESTIONS} from 'sentry/views/seerWorkflows/overview/runQuestions'; @@ -12,128 +16,172 @@ describe('AutofixOverview', () => { }); const basePath = `/organizations/${organization.slug}/issues/autofix/overview/`; + // The five status buckets the page always renders, in fixed order. Each is + // one GET /issues/ differentiated only by its `query` filter, so section + // mocks are matched on that string. + const SECTION_QUERIES = { + review_pr: 'has:issue.seer_last_run issue.autofix_state:review_pr', + code_changes_ready: 'has:issue.seer_last_run issue.autofix_state:code_changes_ready', + solution_ready: 'has:issue.seer_last_run issue.autofix_state:solution_ready', + needs_investigation: + 'has:issue.seer_last_run issue.autofix_state:needs_investigation', + merged: 'has:issue.seer_last_run issue.autofix_state:merged', + }; + const issue = GroupFixture({ id: '2', shortId: 'PROJ-1', title: 'TypeError in checkout cart', count: '100', userCount: 5, - seerFixabilityScore: 0.75, }); - // A run that reached every stage and opened a PR. - const autofixState = { - run_id: 1, - status: 'completed', - updated_at: '2026-07-14T10:00:00Z', - blocks: [ - { - id: 'b1', - timestamp: '2026-07-14T09:00:00Z', - message: { - role: 'assistant', - content: 'rca', - metadata: {step: 'root_cause'}, - }, - }, - { - id: 'b2', - timestamp: '2026-07-14T09:10:00Z', - message: { - role: 'assistant', - content: 'plan', - metadata: {step: 'solution'}, - }, - }, - { - id: 'b3', - timestamp: '2026-07-14T09:20:00Z', - message: { - role: 'assistant', - content: 'code', - metadata: {step: 'code_changes'}, - }, - merged_file_patches: [ - { - repo_name: 'getsentry/sentry', - diff: '--- a/src/cart.py\n+++ b/src/cart.py', - patch: { - path: 'src/cart.py', - source_file: 'src/cart.py', - target_file: 'src/cart.py', - type: 'M', - added: 42, - removed: 7, - hunks: [], + // One answered run question keyed to its prompt (the endpoint echoes the + // prompt back), so buildAnalysis joins it to its question config by text. + function makeOutput(questionIndex: number, answer: string) { + return {question: RUN_QUESTIONS[questionIndex]!.prompt, answer}; + } + + // A pipeline block. Only `message.metadata.step` (section bucketing), + // `message.role`/`content` (section completion), and `merged_file_patches` + // (the diff artifact) are read, so the builder carries just those. + function makeBlock(step: string, overrides: Record = {}) { + return {message: {role: 'assistant', content: step, metadata: {step}}, ...overrides}; + } + + // The per-card runs payload: a night_shift-triggered run whose one-shot + // answers become the card's Root cause / Proposed fix prose. Tests spread + // overrides to vary source, outputs, or linked PRs. + function makeRun(overrides: Record = {}) { + return { + id: 'run-1', + groupId: '2', + source: 'night_shift', + lastTriggeredAt: '2026-07-14T09:00:00Z', + outputs: [ + makeOutput( + 0, + 'Proxy requests fail without Authorization header|Commit c5bb895 stopped sending the Authorization header.' + ), + makeOutput(1, 'Restores the Authorization header as a fallback.'), + ], + ...overrides, + }; + } + + // A run that reached every stage and opened a PR. Tests spread overrides to + // vary status, blocks, the pending-input payload, or the PR states. + function makeAutofixState(overrides: Record = {}) { + return { + run_id: 1, + status: 'completed', + updated_at: '2026-07-14T10:00:00Z', + blocks: [ + makeBlock('root_cause'), + makeBlock('solution'), + makeBlock('code_changes', { + merged_file_patches: [ + { + repo_name: 'getsentry/sentry', + diff: '--- a/src/cart.py\n+++ b/src/cart.py', + patch: { + path: 'src/cart.py', + source_file: 'src/cart.py', + target_file: 'src/cart.py', + type: 'M', + added: 42, + removed: 7, + hunks: [], + }, }, - }, - ], - }, - ], - repo_pr_states: { - 'getsentry/sentry': { - repo_name: 'getsentry/sentry', - branch_name: 'fix/cart', - commit_sha: null, - pr_creation_error: null, - pr_creation_status: 'completed', - pr_id: null, - pr_number: 123, - pr_url: 'https://github.com/getsentry/sentry/pull/123', - title: 'Fix nil cart', + ], + }), + ], + repo_pr_states: { + 'getsentry/sentry': { + repo_name: 'getsentry/sentry', + pr_creation_status: 'completed', + pr_number: 123, + pr_url: 'https://github.com/getsentry/sentry/pull/123', + }, }, - }, - }; + ...overrides, + }; + } + + // The repo-wide IntersectionObserver mock (tests/js/setup.ts) is a no-op + // whose observe() never fires its callback, so LazyRender content would + // stay hidden forever. Report every observed node as immediately + // intersecting so cards hydrate the way they would once scrolled into view. + const OriginalIntersectionObserver = window.IntersectionObserver; + beforeAll(() => { + window.IntersectionObserver = class MockIntersectionObserver { + root = null; + rootMargin = ''; + scrollMargin = ''; + thresholds = []; + takeRecords = jest.fn(); + private readonly callback: IntersectionObserverCallback; + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + } + observe(target: Element) { + this.callback( + [{target, isIntersecting: true} as IntersectionObserverEntry], + this as unknown as IntersectionObserver + ); + } + unobserve() {} + disconnect() {} + } as unknown as typeof IntersectionObserver; + }); + afterAll(() => { + window.IntersectionObserver = OriginalIntersectionObserver; + }); + + // Register one section's issue mock, matched on its query bucket. `hits` + // seeds the X-Hits header that drives the section count badge. + function mockSection(query: string, options: {body?: unknown[]; hits?: string} = {}) { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/`, + match: [MockApiClient.matchQuery({query})], + body: options.body ?? [], + ...(options.hits === undefined ? {} : {headers: {'X-Hits': options.hits}}), + }); + } beforeEach(() => { MockApiClient.clearMockResponses(); - + // Collapsed status groups persist to localStorage; keep tests isolated. + localStorage.clear(); + + // The project page filter needs seeded page-filter + project stores, or + // PageFiltersContainer never reports ready and the section queries stay + // gated off. + PageFiltersStore.onInitializeUrlState(PageFiltersFixture()); + ProjectsStore.loadInitialData([ProjectFixture()]); MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/`, - body: [issue], + url: `/organizations/${organization.slug}/projects/`, + body: [ProjectFixture()], }); + + // One issue lives in the review bucket (X-Hits 3 exceeds the returned + // body, proving the count comes from the header); the other four are empty. + mockSection(SECTION_QUERIES.review_pr, {body: [issue], hits: '3'}); + mockSection(SECTION_QUERIES.code_changes_ready); + mockSection(SECTION_QUERIES.solution_ready); + mockSection(SECTION_QUERIES.needs_investigation); + mockSection(SECTION_QUERIES.merged); + + // Per-card content: the IntersectionObserver override above reports every + // card as in view, so these fire once per rendered card. MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, - body: [ - { - id: 'run-1', - type: 'explorer', - groupId: '2', - source: 'night_shift', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - outputs: [ - { - key: 'user_0', - question: RUN_QUESTIONS[0]!.prompt, - answer: - 'Proxy requests fail without Authorization header|Commit c5bb895 stopped sending the Authorization header.', - }, - { - key: 'user_1', - question: RUN_QUESTIONS[1]!.prompt, - answer: - 'JWT viewer auth landed before the proxy supported it, so requests fail; the run opened a PR restoring the header.', - }, - { - key: 'user_2', - question: RUN_QUESTIONS[2]!.prompt, - answer: 'Restores the Authorization header as a fallback.', - }, - { - key: 'user_3', - question: RUN_QUESTIONS[3]!.prompt, - // Inline "•" bullets — the normalizer must split them into a list. - answer: - '• Confirm the fallback header does not leak the key. • Verify the proxy accepts both headers.', - }, - ], - }, - ], + body: [makeRun()], }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, - body: {autofix: autofixState}, + body: {autofix: makeAutofixState()}, }); }); @@ -154,7 +202,70 @@ describe('AutofixOverview', () => { expect(screen.queryByText('Autofix Overview')).not.toBeInTheDocument(); }); - it('renders a card with real run metadata', async () => { + it('renders the five status sections with server-provided counts', async () => { + renderPage(); + + // Every section renders, in order, with its X-Hits count in the badge. + const reviewHeader = await screen.findByRole('button', { + name: 'Awaiting your review 3', + }); + expect( + screen.getByRole('button', {name: 'Code changes ready 0'}) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Ready to generate code 0'}) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Needs investigation 0'}) + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Merged 0'})).toBeInTheDocument(); + + // The headers render in pipeline (SECTION_ORDER) order; reordering the + // PIPELINE table reorders these and fails here. + const orderedHeaders = [ + /Awaiting your review/, + /Code changes ready/, + /Ready to generate code/, + /Needs investigation/, + /Merged/, + ].map(name => screen.getByRole('button', {name})); + for (let index = 0; index < orderedHeaders.length - 1; index++) { + expect( + orderedHeaders[index]!.compareDocumentPosition(orderedHeaders[index + 1]!) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + } + + // The review bucket's issue renders as a card between its own header and + // the next section — server-bucketed, not classified client-side. + const titleLink = await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }); + const codeHeader = screen.getByRole('button', {name: 'Code changes ready 0'}); + expect( + reviewHeader.compareDocumentPosition(titleLink) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + titleLink.compareDocumentPosition(codeHeader) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + // Card prose is hydrated from the per-card runs fetch. + expect( + screen.getByText('Commit c5bb895 stopped sending the Authorization header.') + ).toBeInTheDocument(); + + // The four empty buckets each show their own empty text. + expect(screen.getAllByText('No issues')).toHaveLength(4); + + // The legacy outcome / needs-attention filters and pagination are gone. + expect(screen.queryByRole('button', {name: /Outcome/})).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: /Needs attention/}) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Next'})).not.toBeInTheDocument(); + }); + + it('renders a card with real run metadata and analysis in thought order', async () => { renderPage(); // The Seer headline replaces the raw issue title and links to the issue. @@ -180,83 +291,73 @@ describe('AutofixOverview', () => { expect(screen.getByText('+42')).toBeInTheDocument(); expect(screen.getByText('−7')).toBeInTheDocument(); + // This diff fails the inline-differ gates twice over (49 changed lines + // is past the cap, and its hunks are empty), so the file path lives only + // in the pill's hover tooltip — no diff header on the card. + expect(screen.queryByText('src/cart.py')).not.toBeInTheDocument(); + // Hovering the diff pill lists the changed files. await userEvent.hover(screen.getByText('1 file')); expect(await screen.findByText('src/cart.py')).toBeInTheDocument(); // Issue impact numbers, abbreviated. expect(screen.getByText(/100 events/)).toBeInTheDocument(); - }); - - it('shows a single body block and collapses the full analysis', async () => { - renderPage(); - // The body is either/or: code was drafted, so the proposed-fix block - // renders and the summary does not (it would describe the same change). - expect(await screen.findByText('Proposed fix')).toBeVisible(); + // Both analysis sections render on the card face with no expansion needed… + const rootCause = screen.getByText('Root cause'); + const proposedFix = screen.getByText('Proposed fix'); + expect( + screen.getByText('Commit c5bb895 stopped sending the Authorization header.') + ).toBeVisible(); expect( screen.getByText('Restores the Authorization header as a fallback.') ).toBeVisible(); + + // …in thought order: what broke, then what changed. expect( - screen.queryByText( - 'JWT viewer auth landed before the proxy supported it, so requests fail; the run opened a PR restoring the header.' - ) - ).not.toBeInTheDocument(); + rootCause.compareDocumentPosition(proposedFix) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); // The timestamp is labeled as run activity. expect(screen.getByText(/^updated/)).toBeInTheDocument(); - // Root cause, notes, and the short id stay behind the disclosure. - const disclosure = screen.getByRole('button', {name: 'Full analysis'}); + // Identity sits in the tail: short id + exactly one level marker. + expect(screen.getByText('PROJ-1')).toBeVisible(); + expect(screen.getAllByText('Level: Warning')).toHaveLength(1); + }); + + it('switches between card and table views', async () => { + renderPage(); + expect( - screen.getByText('Commit c5bb895 stopped sending the Authorization header.') - ).not.toBeVisible(); - expect(screen.getByText('PROJ-1')).not.toBeVisible(); + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); + expect(screen.getByText('Root cause')).toBeVisible(); + expect(screen.getByRole('radio', {name: 'Card view'})).toBeChecked(); - await userEvent.click(disclosure); + await userEvent.click(screen.getByRole('radio', {name: 'Table view'})); + expect(screen.getByRole('radio', {name: 'Table view'})).toBeChecked(); + // The full analysis is card-only; the table row is the scannable summary. + expect(screen.queryByText('Root cause')).not.toBeInTheDocument(); expect(screen.getByText('PROJ-1')).toBeVisible(); - // Section headings are the clean labels, never the raw prompt text. - expect(screen.getByText('Root cause')).toBeVisible(); - expect( - screen.getByText('Commit c5bb895 stopped sending the Authorization header.') - ).toBeVisible(); - // Code was drafted, so the notes section is a review checklist, with the - // inline-bullet answer normalized into separate list items. - expect(screen.getByText('Review checklist')).toBeVisible(); - expect( - screen.getByText('Confirm the fallback header does not leak the key.') - ).toBeVisible(); - expect(screen.getByText('Verify the proxy accepts both headers.')).toBeVisible(); - expect(screen.queryByText(/•/)).not.toBeInTheDocument(); - // Fixability lives in the expanded state as a bucketed tag (0.75 > 0.7). - expect(screen.getByText('High fixability')).toBeVisible(); + expect(screen.getByText(/100 events · 5 users/)).toBeVisible(); + expect(screen.getByRole('button', {name: 'Review PR'})).toHaveAttribute( + 'href', + 'https://github.com/getsentry/sentry/pull/123' + ); }); - it('shows Diagnosis and Next steps when no code was drafted', async () => { + it('leads with the root cause and a single next step when no code was drafted', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/runs/`, body: [ - { - id: 'run-1', - type: 'explorer', - groupId: '2', + makeRun({ source: 'autofix', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - outputs: [ - { - key: 'user_1', - question: RUN_QUESTIONS[1]!.prompt, - answer: 'A mechanism sentence without any drafted fix.', - }, - { - key: 'user_3', - question: RUN_QUESTIONS[3]!.prompt, - answer: '- Decide whether Seer should generate a fix.', - }, - ], - }, + outputs: [makeOutput(2, 'Decide whether to relax the constraint.')], + }), ], }); @@ -267,282 +368,464 @@ describe('AutofixOverview', () => { await screen.findByRole('link', {name: 'TypeError in checkout cart'}) ).toBeInTheDocument(); - // No drafted fix → the body block is the Diagnosis variant. - expect(screen.getByText('Diagnosis')).toBeVisible(); - expect( - screen.getByText('A mechanism sentence without any drafted fix.') - ).toBeVisible(); + // The notes read as the next step (waits for the per-card runs fetch). + expect(await screen.findByText('Next steps')).toBeVisible(); + expect(screen.getByText('Decide whether to relax the constraint.')).toBeVisible(); + // No drafted fix → no fix section. expect(screen.queryByText('Proposed fix')).not.toBeInTheDocument(); + }); + + it('keeps the section review action even when the run enrichment looks merged', async () => { + // A review_pr-section card whose enrichment carries a merged PR: the + // section is the anchor, so the card still shows Review PR rather than a + // stale Merged tag. Merged rendering is asserted in the merged-section test. + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/runs/`, + body: [ + makeRun({ + source: 'autofix', + pullRequests: [{status: 'merged'}], + outputs: [], + }), + ], + }); - await userEvent.click(screen.getByRole('button', {name: 'Full analysis'})); + renderPage(); - // …and the notes section is Next steps rather than a review checklist. - expect(screen.getByText('Next steps')).toBeVisible(); - expect(screen.getByText('Decide whether Seer should generate a fix.')).toBeVisible(); - expect(screen.queryByText('Review checklist')).not.toBeInTheDocument(); + // The review action wins, linking to the open PR from the run state. + expect(await screen.findByRole('button', {name: 'Review PR'})).toHaveAttribute( + 'href', + 'https://github.com/getsentry/sentry/pull/123' + ); + // Only the always-present (empty) Merged section header — no leaked card tag. + expect(screen.getByRole('button', {name: 'Merged 0'})).toBeInTheDocument(); + expect(screen.getAllByText('Merged')).toHaveLength(1); }); - it('toggles quick filters from the stat cards via the URL', async () => { - const {router} = renderPage(); + it('collapses sections individually and in bulk', async () => { + renderPage(); + + const reviewHeader = await screen.findByRole('button', { + name: 'Awaiting your review 3', + }); + expect( + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); + + // Collapsing a section hides only its cards. + await userEvent.click(reviewHeader); + expect( + screen.queryByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).not.toBeInTheDocument(); - // Wait for the card to load so the stat counts reflect the row. - expect(await screen.findByRole('button', {name: 'Review PR'})).toBeInTheDocument(); + await userEvent.click(reviewHeader); + expect( + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); + + // The bulk toggle folds everything, then flips to Expand all. + await userEvent.click(screen.getByRole('button', {name: 'Collapse all'})); + expect( + screen.queryByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Expand all'})); + expect( + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); + }); - // The PR-opened row counts toward "Awaiting your review". - const statCard = screen.getByRole('button', { - name: /Awaiting your review/, + it('renders every fetched issue in a section', async () => { + const many = Array.from({length: 30}, (_, index) => + GroupFixture({ + id: `${100 + index}`, + shortId: `PROJ-${100 + index}`, + title: `Bulk issue ${index}`, + }) + ); + many.forEach(group => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/${group.id}/autofix/`, + body: {autofix: null}, + }); }); - expect(statCard).toHaveTextContent('1'); + mockSection(SECTION_QUERIES.review_pr, {body: many, hits: '30'}); + + renderPage(); + + await screen.findByRole('button', {name: 'Awaiting your review 30'}); + expect(screen.getAllByRole('link', {name: /Bulk issue/})).toHaveLength(30); + expect( + screen.queryByRole('button', {name: /Show \d+ more issue/}) + ).not.toBeInTheDocument(); + }); - await userEvent.click(statCard); - expect(router.location.query.quick).toBe('review_pr'); + it('caps the section count badge at 100+ when hits exceed the fetch limit', async () => { + // Only 100 issues are ever fetched per section, so an exact total above + // that would overstate what scrolling can reveal. + mockSection(SECTION_QUERIES.review_pr, {body: [issue], hits: '150'}); - await userEvent.click(statCard); - expect(router.location.query.quick).toBeUndefined(); + renderPage(); + + expect( + await screen.findByRole('button', {name: 'Awaiting your review 100+'}) + ).toBeInTheDocument(); }); - it('applies the outcome filter with AND semantics', async () => { + it('surfaces the blocking question when a run awaits user input', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, body: { - autofix: { - run_id: 1, - status: 'completed', - updated_at: '2026-07-14T10:00:00Z', - blocks: [ - { - id: 'b1', - timestamp: '2026-07-14T09:00:00Z', - message: { - role: 'assistant', - content: 'rca', - metadata: {step: 'root_cause'}, - }, + autofix: makeAutofixState({ + status: 'awaiting_user_input', + blocks: [makeBlock('root_cause')], + // Canonical ask_user_question shape: the text is nested under + // questions[0].question, not a flat key. + pending_user_input: { + input_type: 'ask_user_question', + data: { + questions: [{question: 'Which environment should I target?', options: []}], }, - ], - }, + }, + }), }, }); renderPage(); - const title = 'Proxy requests fail without Authorization header'; - expect(await screen.findByRole('link', {name: title})).toBeInTheDocument(); + expect( + await screen.findByText('Seer asked: Which environment should I target?') + ).toBeInTheDocument(); + }); - await userEvent.click(screen.getByRole('button', {name: /Outcome/})); + it('scopes the section requests to the selected projects', async () => { + PageFiltersStore.onInitializeUrlState(PageFiltersFixture({projects: [2]})); + const reviewRequest = mockSection(SECTION_QUERIES.review_pr, { + body: [issue], + hits: '3', + }); - await userEvent.click(screen.getByRole('option', {name: 'Root cause'})); - expect(await screen.findByRole('link', {name: title})).toBeInTheDocument(); + renderPage(); - await userEvent.click(screen.getByRole('option', {name: 'Code changes'})); - expect(await screen.findByText('No issues match your filters.')).toBeInTheDocument(); + expect( + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); + // The selector's trigger reflects the selection (the card's project badge + // is a link, so the button role isolates the filter)… + expect(screen.getByRole('button', {name: 'project-slug'})).toBeInTheDocument(); + // …and the section request carries it. + expect(reviewRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/issues/`, + expect.objectContaining({ + query: expect.objectContaining({project: [2]}), + }) + ); }); - it('falls back to a View run action when nothing needs attention', async () => { - // A run that only found a root cause: no attention reason, but the card - // should still offer a way into the run (Seer drawer deep link). + it('renders an inline differ for small diffs, collapsed to a file header', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/2/autofix/`, body: { - autofix: { - run_id: 1, - status: 'completed', - updated_at: '2026-07-14T10:00:00Z', + autofix: makeAutofixState({ blocks: [ - { - id: 'b1', - timestamp: '2026-07-14T09:00:00Z', - message: { - role: 'assistant', - content: 'rca', - metadata: {step: 'root_cause'}, - }, - }, + makeBlock('code_changes', { + merged_file_patches: [ + { + repo_name: 'getsentry/sentry', + diff: '--- a/src/cart.py\n+++ b/src/cart.py', + patch: { + path: 'src/cart.py', + source_file: 'src/cart.py', + target_file: 'src/cart.py', + type: 'M', + added: 2, + removed: 1, + hunks: [ + { + section_header: 'def add_to_cart', + source_start: 10, + source_length: 3, + target_start: 10, + target_length: 4, + lines: [ + { + value: 'def add_to_cart(item):', + line_type: ' ', + source_line_no: 10, + target_line_no: 10, + diff_line_no: 1, + }, + { + value: ' total = None', + line_type: '-', + source_line_no: 11, + target_line_no: null, + diff_line_no: 2, + }, + { + value: ' total = 0', + line_type: '+', + source_line_no: null, + target_line_no: 11, + diff_line_no: 3, + }, + { + value: ' return total', + line_type: '+', + source_line_no: null, + target_line_no: 12, + diff_line_no: 4, + }, + ], + }, + ], + }, + }, + ], + }), ], - }, + repo_pr_states: {}, + }), }, }); renderPage(); - const viewRun = await screen.findByRole('button', {name: 'View run'}); - expect(viewRun).toHaveAttribute( - 'href', - `/organizations/${organization.slug}/issues/2/?seerDrawer=true` - ); + // The differ's file header shows on the card without any interaction… + const fileHeader = await screen.findByText('src/cart.py'); + // …but the diff body starts collapsed. + expect(screen.queryByText(/@@ -10,3 \+10,4 @@/)).not.toBeInTheDocument(); + + await userEvent.click(fileHeader); + + expect(screen.getByText(/@@ -10,3 \+10,4 @@/)).toBeInTheDocument(); }); - it('shows merged state and enables the Merged PRs card when the API returns PR state', async () => { + it('focuses a single fully-expanded card when id is present', async () => { + // The focus fetch pins the exact group id (and the endpoint ignores the + // section filters in that mode). + const groupRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/`, + body: [issue], + match: [MockApiClient.matchQuery({group: ['2']})], + }); MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/runs/`, - body: [ - { - id: 'run-1', - type: 'explorer', - groupId: '2', - source: 'autofix', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - pullRequests: [{status: 'merged', mergedAt: '2026-07-15T09:00:00Z'}], - outputs: [], - }, - ], + url: `/organizations/${organization.slug}/issues/2/autofix/`, + body: { + autofix: makeAutofixState({ + blocks: [ + makeBlock('code_changes', { + merged_file_patches: [ + { + repo_name: 'getsentry/sentry', + diff: '--- a/src/cart.py\n+++ b/src/cart.py', + patch: { + path: 'src/cart.py', + source_file: 'src/cart.py', + target_file: 'src/cart.py', + type: 'M', + added: 1, + removed: 0, + hunks: [ + { + section_header: '', + source_start: 5, + source_length: 1, + target_start: 5, + target_length: 2, + lines: [ + { + value: ' return total', + line_type: '+', + source_line_no: null, + target_line_no: 5, + diff_line_no: 1, + }, + ], + }, + ], + }, + }, + ], + }), + ], + }), + }, }); - const {router} = renderPage(); + renderPage({id: '2'}); + + // The full analysis renders expanded without any interaction… + expect(await screen.findByText('Root cause')).toBeVisible(); + expect(screen.getByText('PROJ-1')).toBeVisible(); + // …and so does the inline diff. + expect(screen.getByText(/@@ -5,1 \+5,2 @@/)).toBeInTheDocument(); + expect(groupRequest).toHaveBeenCalled(); + + // Focus mode hides the section list and offers the way back, keeping the + // other params. + expect( + screen.queryByRole('button', {name: /Awaiting your review/}) + ).not.toBeInTheDocument(); + const backLink = screen.getByRole('button', {name: 'All issues'}); + expect(backLink).toHaveAttribute('href', expect.not.stringContaining('id=2')); + }); + + it('shows the empty state when every section resolves empty', async () => { + // Two projects so page filters default to "My Projects" (empty selection) + // rather than force-selecting the only project; the default period then + // makes this the no-filter case. + ProjectsStore.loadInitialData([ + ProjectFixture(), + ProjectFixture({id: '11', slug: 'project-two'}), + ]); + mockSection(SECTION_QUERIES.review_pr, {body: []}); + + renderPage(); + + expect(await screen.findByText('No completed autofix runs yet.')).toBeInTheDocument(); + // The section list is replaced entirely by the empty state. + expect( + screen.queryByRole('button', {name: /Awaiting your review/}) + ).not.toBeInTheDocument(); + }); - // The merged run wears a Merged tag instead of a Review PR action. - expect(await screen.findByText('Merged')).toBeInTheDocument(); - expect(screen.queryByRole('button', {name: 'Review PR'})).not.toBeInTheDocument(); + it('shows a filter-aware empty message when a non-default period is active', async () => { + // Two projects keep the selection empty, so the non-default period is the + // only active filter. + ProjectsStore.loadInitialData([ + ProjectFixture(), + ProjectFixture({id: '11', slug: 'project-two'}), + ]); + mockSection(SECTION_QUERIES.review_pr, {body: []}); - // The Merged PRs stat card is live and counts the row. - const mergedCard = screen.getByRole('button', {name: /Merged PRs/}); - expect(mergedCard).toBeEnabled(); - expect(mergedCard).toHaveTextContent('1'); + renderPage({period: '24h'}); - await userEvent.click(mergedCard); - expect(router.location.query.quick).toBe('merged'); + expect( + await screen.findByText('No autofix runs match your filters.') + ).toBeInTheDocument(); + expect(screen.queryByText('No completed autofix runs yet.')).not.toBeInTheDocument(); }); - it('orders cards as a triage queue: actionable, then working, then merged', async () => { - // A merged, B awaiting PR review, C still processing → B, C, A. + it('surfaces per-section errors instead of the global empty state', async () => { + mockSection(SECTION_QUERIES.review_pr, {body: []}); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/`, - body: [ - GroupFixture({id: '2', title: 'Issue A'}), - GroupFixture({id: '3', title: 'Issue B'}), - GroupFixture({id: '4', title: 'Issue C'}), - ], + match: [MockApiClient.matchQuery({query: SECTION_QUERIES.code_changes_ready})], + statusCode: 500, + body: {detail: 'boom'}, }); - const runFor = (groupId: string, pullRequests: unknown[]) => ({ - id: `run-${groupId}`, - type: 'explorer', - groupId, - source: 'autofix', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - pullRequests, - outputs: [], + + renderPage(); + + expect( + await screen.findByText('There was an error loading data.') + ).toBeInTheDocument(); + expect(screen.queryByText('No completed autofix runs yet.')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', {name: /Awaiting your review/}) + ).toBeInTheDocument(); + }); + + it('surfaces a per-section error while other sections still load', async () => { + // Only the code-changes bucket fails; the others resolve as seeded. + const codeIssue = GroupFixture({ + id: '3', + shortId: 'PROJ-3', + title: 'Retry succeeded issue', }); MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/runs/`, - body: [ - runFor('2', [{status: 'merged', mergedAt: '2026-07-15T09:00:00Z'}]), - runFor('3', []), - runFor('4', []), - ], + url: `/organizations/${organization.slug}/issues/3/autofix/`, + body: {autofix: null}, }); - // A and B both reached an opened PR; A's merged flag comes from its run. - for (const issueId of ['2', '3']) { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/${issueId}/autofix/`, - body: {autofix: autofixState}, - }); - } MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/4/autofix/`, - body: { - autofix: {...autofixState, status: 'processing', repo_pr_states: {}}, - }, + url: `/organizations/${organization.slug}/issues/`, + match: [MockApiClient.matchQuery({query: SECTION_QUERIES.code_changes_ready})], + statusCode: 500, + body: {detail: 'boom'}, }); renderPage(); - expect(await screen.findByText('Merged')).toBeInTheDocument(); - const titles = screen - .getAllByRole('link') - .map(link => link.textContent) - .filter(text => text === 'Issue A' || text === 'Issue B' || text === 'Issue C'); - expect(titles).toEqual(['Issue B', 'Issue C', 'Issue A']); - }); + // The review bucket still renders its card… + expect( + await screen.findByRole('link', { + name: 'Proxy requests fail without Authorization header', + }) + ).toBeInTheDocument(); + // …while the failed section shows a retryable error inline. + expect( + await screen.findByText('There was an error loading data.') + ).toBeInTheDocument(); - it('always enables the Merged PRs card, showing 0 when nothing is merged', async () => { - renderPage(); + // Re-arm the section with a success response, then retry: its card + // appears, proving the LoadingError's onRetry refetches the section. + mockSection(SECTION_QUERIES.code_changes_ready, {body: [codeIssue], hits: '1'}); + await userEvent.click(screen.getByRole('button', {name: 'Retry'})); - const mergedCard = await screen.findByRole('button', {name: /Merged PRs/}); - expect(mergedCard).toBeEnabled(); - expect(mergedCard).toHaveTextContent('0'); + expect( + await screen.findByRole('link', {name: 'Retry succeeded issue'}) + ).toBeInTheDocument(); }); - it('surfaces the blocking question when a run awaits user input', async () => { + it('renders an error state only when every section fails', async () => { + // An unmatched mock is newest, so it answers all five section requests. MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/2/autofix/`, - body: { - autofix: { - run_id: 1, - status: 'awaiting_user_input', - updated_at: '2026-07-14T10:00:00Z', - blocks: [ - { - id: 'b1', - timestamp: '2026-07-14T09:00:00Z', - message: { - role: 'assistant', - content: 'rca', - metadata: {step: 'root_cause'}, - }, - }, - ], - // Canonical ask_user_question shape: the text is nested under - // questions[0].question, not a flat key. - pending_user_input: { - id: 'input-1', - input_type: 'ask_user_question', - data: { - questions: [{question: 'Which environment should I target?', options: []}], - }, - }, - }, - }, + url: `/organizations/${organization.slug}/issues/`, + statusCode: 500, + body: {detail: 'boom'}, }); renderPage(); expect( - await screen.findByText('Seer asked: Which environment should I target?') + await screen.findByText('There was an error loading data.') ).toBeInTheDocument(); }); - it('normalizes space-less • bullets into a markdown list', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/runs/`, - body: [ - { - id: 'run-1', - type: 'explorer', - groupId: '2', - source: 'autofix', - lastTriggeredAt: '2026-07-14T09:00:00Z', - dateCreated: '2026-07-14T09:00:00Z', - outputs: [ - { - key: 'user_3', - question: RUN_QUESTIONS[3]!.prompt, - // No space after the • — the normalizer must still split these. - answer: '•Confirm the header is not leaked. •Verify both headers work.', - }, - ], - }, - ], - }); + it('falls back to the body length for the count when X-Hits is absent', async () => { + // No X-Hits header, so the badge reports the returned body length. + mockSection(SECTION_QUERIES.review_pr, {body: [issue]}); renderPage(); - await userEvent.click(await screen.findByRole('button', {name: 'Full analysis'})); + expect( + await screen.findByRole('button', {name: 'Awaiting your review 1'}) + ).toBeInTheDocument(); + }); - expect(screen.getByText('Confirm the header is not leaked.')).toBeVisible(); - expect(screen.getByText('Verify both headers work.')).toBeVisible(); - expect(screen.queryByText(/•/)).not.toBeInTheDocument(); + it('shows a not-found message when the focused issue resolves empty', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/issues/`, + match: [MockApiClient.matchQuery({group: ['2']})], + body: [], + }); + + renderPage({id: '2'}); + + expect(await screen.findByText('Issue not found.')).toBeInTheDocument(); }); - it('renders an error state and can retry', async () => { + it('shows an error state when the focused issue request fails', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/issues/`, + match: [MockApiClient.matchQuery({group: ['2']})], statusCode: 500, body: {detail: 'boom'}, }); - renderPage(); + renderPage({id: '2'}); expect( await screen.findByText('There was an error loading data.') diff --git a/static/app/views/seerWorkflows/overview/index.tsx b/static/app/views/seerWorkflows/overview/index.tsx index 1bb3a8d9b70f..deab05ac12fe 100644 --- a/static/app/views/seerWorkflows/overview/index.tsx +++ b/static/app/views/seerWorkflows/overview/index.tsx @@ -1,224 +1,66 @@ -import {useMemo} from 'react'; -import styled from '@emotion/styled'; +import {Fragment} from 'react'; import {Alert} from '@sentry/scraps/alert'; -import {Button, LinkButton} from '@sentry/scraps/button'; -import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; -import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; -import {Pagination} from '@sentry/scraps/pagination'; -import {Heading, Text} from '@sentry/scraps/text'; +import {InfoTip} from '@sentry/scraps/info'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; -import {LoadingError} from 'sentry/components/loadingError'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import * as Layout from 'sentry/components/layouts/thirds'; +import {PageFiltersContainer} from 'sentry/components/pageFilters/container'; +import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; -import {IconFilter, IconFix, IconMerge, IconPullRequest, IconUser} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {decodeList, decodeScalar} from 'sentry/utils/queryString'; +import {decodeScalar} from 'sentry/utils/queryString'; +import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {useAutofixIssues} from 'sentry/views/autofixIssuesDemo/useAutofixIssues'; -import { - ATTENTION_META, - ATTENTION_REASONS, - getAttentionReason, - getTriageRank, -} from './attentionBadge'; -import {buildOverviewRows} from './buildOverviewRows'; -import {IssueCard} from './issueCard'; -import {RUN_QUESTION_PROMPTS} from './runQuestions'; -import type {AttentionReason, AutofixOutcome} from './types'; - -// Only autofix runs. `source` is the run's origin surface (autofix, chat, -// night_shift orchestration, ...), not the autofix trigger — so this keeps -// autofixes (including night-shift-triggered ones, which are source=autofix) -// and drops non-autofix runs like the night-shift triage feature run. How the -// autofix was triggered lives in the run's referrer/auto_run_source, which the -// API doesn't expose yet; badging/filtering by it is a follow-up. -const OVERVIEW_RUNS_QUERY = 'type:explorer source:autofix'; - -const OUTCOME_FILTER_OPTIONS: Array<{label: string; value: AutofixOutcome}> = [ - {value: 'root_cause', label: t('Root cause')}, - {value: 'solution', label: t('Solution')}, - {value: 'code_changes', label: t('Code changes')}, - {value: 'pr_opened', label: t('PR opened')}, -]; - -// TODO(seer): Re-enable the "Triggered by" filter once the backend exposes the -// autofix trigger. A run's `source` is its origin surface (always "autofix" -// here after the source:autofix filter), not how the autofix was triggered — -// that lives in the referrer / auto_run_source, which the runs API does not -// return yet. Until it does, this filter can only ever resolve to "manual", so -// it (and its options/parse/check/dropdown below) is disabled. -// const TRIGGER_FILTER_OPTIONS: Array<{label: string; value: AutofixTrigger}> = -// SELECTABLE_TRIGGERS.map(value => ({ -// value, -// label: TRIGGER_META[value].label, -// })); - -const ATTENTION_FILTER_OPTIONS: Array<{ - label: string; - value: AttentionReason; -}> = ATTENTION_REASONS.map(value => ({ - value, - label: ATTENTION_META[value].label, -})); - -type QuickFilterValue = 'review_pr' | 'awaiting_input' | 'code_changes_ready' | 'merged'; - -type SortValue = 'triage' | 'activity' | 'events'; - -const SORT_OPTIONS: Array<{label: string; value: SortValue}> = [ - {value: 'triage', label: t('Needs you first')}, - {value: 'activity', label: t('Recent activity')}, - {value: 'events', label: t('Most events')}, -]; - -const PERIOD_FILTER_OPTIONS: Array<{label: string; value: string}> = [ - {value: '', label: t('All time')}, - {value: '24h', label: t('Last 24 hours')}, - {value: '7d', label: t('Last 7 days')}, - {value: '30d', label: t('Last 30 days')}, -]; - -const PERIOD_TO_DAYS: Record = { - '24h': 1, - '7d': 7, - '30d': 30, -}; +import {FocusedIssue} from './focusedIssue'; +import {OverviewFilters} from './overviewFilters'; +import {DEFAULT_STATS_PERIOD} from './periods'; +import {SectionList} from './sectionList'; +import type {StatusGroupKey} from './statusGroups'; +import {type OverviewView, SECTION_ORDER} from './types'; export default function AutofixOverview() { const organization = useOrganization(); const location = useLocation(); const navigate = useNavigate(); - const cursor = decodeScalar(location.query.cursor); - const outcomeFilter = decodeList(location.query.outcome) as AutofixOutcome[]; - // TODO(seer): trigger filter disabled — see TRIGGER_FILTER_OPTIONS above. - // const triggerFilter = decodeList(location.query.trigger) as AutofixTrigger[]; - const attentionFilter = decodeList(location.query.attention) as AttentionReason[]; - const quickFilter = decodeScalar(location.query.quick) as QuickFilterValue | undefined; - const period = decodeScalar(location.query.period); - const sort = (decodeScalar(location.query.sort) as SortValue | undefined) ?? 'triage'; + const selectedId = decodeScalar(location.query.id); + const period = decodeScalar(location.query.period) ?? DEFAULT_STATS_PERIOD; + // Unknown or legacy sort values fall back to the default. + const sort = decodeScalar(location.query.sort) === 'events' ? 'events' : 'activity'; + + // Project scoping comes from the canonical page-filters selection; the + // section requests are gated until the persisted selection is restored so + // the first fetch doesn't race it with an all-projects query. + const {selection, isReady: pageFiltersReady} = usePageFilters(); - const {issues, isPending, isError, refetch, pageLinks} = useAutofixIssues({ - query: '', - cursor, - runsQuery: OVERVIEW_RUNS_QUERY, - questions: RUN_QUESTION_PROMPTS, - }); + const [collapsedGroups, setCollapsedGroups] = useLocalStorageState( + 'seer-autofix-overview:collapsed-groups', + [] + ); + const [view, setView] = useLocalStorageState( + 'seer-autofix-overview:view', + storedValue => (storedValue === 'table' ? 'table' : 'cards') + ); const updateQuery = (patch: Record) => { navigate( - { - pathname: location.pathname, - query: {...location.query, ...patch}, - }, + {pathname: location.pathname, query: {...location.query, ...patch}}, {replace: true} ); }; - - const toggleQuickFilter = (value: QuickFilterValue) => { - updateQuery({quick: quickFilter === value ? undefined : value}); - }; - - const periodCutoffMs = useMemo(() => { - const days = PERIOD_TO_DAYS[period ?? '']; - return days === undefined ? null : Date.now() - days * 24 * 60 * 60 * 1000; - }, [period]); - - // Computed each render (not memoized): the hook's enriched issues array is a - // new reference every render and there is at most a page's worth of rows. - const rowsWithAttention = buildOverviewRows(issues).map(row => ({ - row, - attention: getAttentionReason(row), - })); - - const filteredRows = rowsWithAttention.filter(({row, attention}) => { - if (outcomeFilter.length && !outcomeFilter.every(o => row.outcomes.includes(o))) { - return false; - } - // TODO(seer): trigger filter disabled — see TRIGGER_FILTER_OPTIONS above. - // if (triggerFilter.length && (!row.trigger || !triggerFilter.includes(row.trigger))) { - // return false; - // } - if (attentionFilter.length) { - if (!attention || !attentionFilter.includes(attention)) { - return false; - } - } - if (quickFilter === 'merged') { - if (!row.prMerged) { - return false; - } - } else if (quickFilter && attention !== quickFilter) { - return false; - } - if (periodCutoffMs !== null && Date.parse(row.lastActivityAt) < periodCutoffMs) { - return false; - } - return true; - }); - - // Default is the triage-queue order, what needs a human first - // (by urgency tier), highest impact within a tier, run recency as - // the tiebreak. - const byActivity = ( - a: (typeof filteredRows)[number], - b: (typeof filteredRows)[number] - ) => Date.parse(b.row.lastActivityAt) - Date.parse(a.row.lastActivityAt); - const sortedRows = [...filteredRows].sort((a, b) => { - if (sort === 'activity') { - return byActivity(a, b); - } - if (sort === 'events') { - return b.row.eventCount - a.row.eventCount || byActivity(a, b); - } - return ( - getTriageRank(a.row, a.attention) - getTriageRank(b.row, b.attention) || - b.row.eventCount - a.row.eventCount || - byActivity(a, b) + const toggleGroup = (groupKey: StatusGroupKey, expanded: boolean) => { + setCollapsedGroups(previous => + expanded + ? previous.filter(key => key !== groupKey) + : [...previous.filter(key => key !== groupKey), groupKey] ); - }); - - const stats = { - reviewPr: 0, - awaitingInput: 0, - codeChangesReady: 0, - merged: 0, - }; - for (const {row, attention} of filteredRows) { - if (row.prMerged) { - stats.merged++; - } - if (attention === 'review_pr') { - stats.reviewPr++; - } - if (attention === 'awaiting_input') { - stats.awaitingInput++; - } - if (attention === 'code_changes_ready') { - stats.codeChangesReady++; - } - } - - const hasActiveFilters = - outcomeFilter.length > 0 || - attentionFilter.length > 0 || - quickFilter !== undefined || - (period !== undefined && period !== ''); - - const clearAllFilters = () => { - updateQuery({ - outcome: undefined, - attention: undefined, - quick: undefined, - period: undefined, - }); }; + const allGroupsCollapsed = SECTION_ORDER.every(key => collapsedGroups.includes(key)); return ( } > - - - - - {t('Autofix Overview')} - - {t( - 'Issues where Autofix has produced a root cause, solution, code changes, or pull request.' - )} - - - - - {t('Workflow runs')} - - - {t('Runs demo')} - - - - - - - toggleQuickFilter('awaiting_input')} - /> - toggleQuickFilter('code_changes_ready')} - /> - toggleQuickFilter('review_pr')} - /> - toggleQuickFilter('merged')} - /> - - - - - - - updateQuery({ - outcome: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> - {/* TODO(seer): "Triggered by" filter disabled until the runs - API exposes the autofix trigger (referrer/auto_run_source); - see TRIGGER_FILTER_OPTIONS above. - - updateQuery({ - trigger: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> */} - - updateQuery({ - attention: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> - - updateQuery({ - period: - selected.value === '' ? undefined : String(selected.value), - }) - } - trigger={triggerProps => ( - - )} - /> - - updateQuery({ - // Default sort keeps the URL clean. - sort: - selected.value === 'triage' - ? undefined - : String(selected.value), - }) - } - trigger={triggerProps => ( - - )} - /> - - {hasActiveFilters ? ( - - ) : null} - - - - {isError ? ( - - ) : isPending ? ( - - ) : sortedRows.length === 0 ? ( - - - {hasActiveFilters - ? t('No issues match your filters.') - : t('No completed autofix runs yet.')} - - + + + + {t('Autofix Overview')} + + + + {selectedId ? ( + ) : ( - - {sortedRows.map(({row}) => ( - - ))} - + + + setCollapsedGroups(allGroupsCollapsed ? [] : [...SECTION_ORDER]) + } + /> + + )} - - {!isPending && !isError && } - - - + + + ); } @@ -435,68 +125,3 @@ function NoAccess() { ); } - -type StatIconVariant = 'success' | 'warning' | 'primary' | 'secondary'; - -const StatCardButton = styled('button')<{isActive: boolean}>` - cursor: ${p => (p.disabled ? 'default' : 'pointer')}; - background: ${p => - p.isActive ? p.theme.tokens.background.secondary : p.theme.tokens.background.primary}; - border: 1px solid - ${p => (p.isActive ? p.theme.tokens.border.accent : p.theme.tokens.border.primary)}; - border-radius: ${p => p.theme.radius.md}; - padding: ${p => `${p.theme.space.md} ${p.theme.space.lg}`}; - text-align: left; - transition: - border-color 0.15s ease, - background 0.15s ease; - &:hover { - border-color: ${p => - p.disabled ? p.theme.tokens.border.primary : p.theme.tokens.border.accent}; - } - &:focus-visible { - outline: 2px solid ${p => p.theme.tokens.focus.default}; - outline-offset: 1px; - } -`; - -function StatCard({ - Icon, - iconVariant, - label, - value, - isActive, - onClick, - extra, -}: { - Icon: typeof IconUser; - iconVariant: StatIconVariant; - isActive: boolean; - label: string; - value: number | string; - extra?: React.ReactNode; - onClick?: () => void; -}) { - return ( - - - - - - {label} - - {extra} - - - {value} - - - - ); -} diff --git a/static/app/views/seerWorkflows/overview/issueCard.tsx b/static/app/views/seerWorkflows/overview/issueCard.tsx index 14d62aec55cb..fd2981488bf8 100644 --- a/static/app/views/seerWorkflows/overview/issueCard.tsx +++ b/static/app/views/seerWorkflows/overview/issueCard.tsx @@ -1,10 +1,8 @@ import styled from '@emotion/styled'; -import {Tag} from '@sentry/scraps/badge'; import {LinkButton} from '@sentry/scraps/button'; -import {Disclosure} from '@sentry/scraps/disclosure'; import {InfoText} from '@sentry/scraps/info'; -import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -13,22 +11,16 @@ import {ErrorLevel} from 'sentry/components/events/errorLevel'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; import {TimeSince} from 'sentry/components/timeSince'; -import { - IconArrow, - IconCircleCheckmark, - IconCommit, - IconFocus, - IconMerge, - IconPullRequest, - IconSearch, -} from 'sentry/icons'; +import {IconArrow, IconCommit, IconFocus, IconPullRequest} from 'sentry/icons'; import {t, tn} from 'sentry/locale'; import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; import {ellipsize} from 'sentry/utils/string/ellipsize'; +import {FileDiffViewer} from 'sentry/views/seerExplorer/components/fileDiffViewer'; -import {ATTENTION_META, AttentionBadge, getAttentionReason} from './attentionBadge'; +import {deriveCardAction, IssuePrimaryAction} from './cardAction'; +import {periodWindowLabel} from './periods'; import {TriggerBadge} from './triggerBadge'; -import type {OverviewRow, PatchStats} from './types'; +import type {AutofixStateKey, OverviewRow, PatchStats} from './types'; const TitleLink = styled(Link)` color: inherit; @@ -41,6 +33,18 @@ const TitleLink = styled(Link)` // The most-changed files shown on hover before collapsing into "+N more". const MAX_TOOLTIP_FILES = 5; +// Paths have no spaces to wrap on, so a long one would push the +/− counts +// out of the tooltip's max width. Truncate from the LEFT (rtl trick, like the +// diff viewer's file header) so the filename end stays visible; overflow +// hidden also gives the flex item its min-width of 0. +const TooltipPath = styled(Text)` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; +`; + // Per-file breakdown for the diff pill's tooltip: path left, churn right, // biggest files first (fileList is pre-sorted by churn). function PatchFilesTooltip({stats}: {stats: PatchStats}) { @@ -50,9 +54,9 @@ function PatchFilesTooltip({stats}: {stats: PatchStats}) { {shown.map(file => ( - + {file.path} - + +{file.added} @@ -72,94 +76,170 @@ function PatchFilesTooltip({stats}: {stats: PatchStats}) { ); } -// Buckets the raw 0–1 score into a scannable label; the 0.7 threshold matches -// isIssueQuickFixable (sentry/components/events/autofix/utils). -function FixabilityTag({score}: {score: number}) { - const high = score > 0.7; - const label = high - ? t('High fixability') - : score > 0.4 - ? t('Medium fixability') - : t('Low fixability'); +function issueCountLabels(row: OverviewRow) { + return { + eventCountLabel: + row.eventCount === 1 + ? t('1 event') + : t('%s events', formatAbbreviatedNumber(row.eventCount)), + userCountLabel: + row.userCount === 1 + ? t('1 user') + : t('%s users', formatAbbreviatedNumber(row.userCount)), + }; +} + +function IssueTitleLink({row, to, size}: {row: OverviewRow; to: string; size?: 'lg'}) { + // The ellipsis Text is the shrinking flex item (overflow:hidden resolves its + // min-width to 0); the Link must nest inside it or the anchor refuses to + // shrink and the title overflows the card. When Seer produced a + // plain-language headline it replaces the raw issue title, which stays + // reachable via the tooltip and the expanded details. return ( - - {label} - + + {row.headline ? ( + + + {t('Raw issue title')} + + + {ellipsize(row.title, 200)} + + + } + > + {row.headline} + + ) : ( + {row.title} + )} + ); } -export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { +export function IssueCard({ + orgSlug, + row, + sectionKey, + defaultExpanded = false, + minHeight, +}: { + orgSlug: string; + row: OverviewRow; + sectionKey: AutofixStateKey; + // Open the inline diffs on mount — the overview's ?id= focus mode wants + // the whole card readable at once. + defaultExpanded?: boolean; + minHeight?: string; +}) { const issueUrl = `/organizations/${orgSlug}/issues/${row.id}/`; // Deep-link into the issue page with the Seer drawer already open, so the // run itself is one click away (matches the issue details ?seerDrawer param). const runUrl = {pathname: issueUrl, query: {seerDrawer: 'true'}}; - const attention = getAttentionReason(row); - // The body shows exactly one block: the proposed fix when the run drafted - // code (the fix prompt returns an empty answer otherwise, and empty answers - // never become entries), else the diagnosis summary. - const summary = row.analysis.find(entry => entry.key === 'summary'); + const cardAction = deriveCardAction(sectionKey, row); + const rootCause = row.analysis.find(entry => entry.key === 'root_cause'); const proposedFix = row.analysis.find(entry => entry.key === 'fix_summary'); - const bodyEntry = proposedFix ?? summary; - const isFixBody = bodyEntry?.key === 'fix_summary'; - const detailEntries = row.analysis.filter(entry => entry.placement === 'details'); + const nextSteps = row.analysis.find(entry => entry.key === 'next_steps'); - const eventCountLabel = - row.eventCount === 1 - ? t('1 event') - : t('%s events', formatAbbreviatedNumber(row.eventCount)); - const userCountLabel = - row.userCount === 1 - ? t('1 user') - : t('%s users', formatAbbreviatedNumber(row.userCount)); + // Thought order: what broke → what Seer changed → what the human does + // next. The fix and next-step prompts return empty answers when they don't + // apply, and empty answers never become entries. + const sections = [ + rootCause && { + key: 'root_cause', + label: t('Root cause'), + icon: , + variant: 'muted' as const, + answer: rootCause.answer, + }, + proposedFix && { + key: 'fix_summary', + label: t('Proposed fix'), + icon: , + variant: 'success' as const, + answer: proposedFix.answer, + }, + nextSteps && { + key: 'next_steps', + label: t('Next steps'), + icon: , + variant: 'muted' as const, + answer: nextSteps.answer, + }, + ].filter(section => !!section); + const {eventCountLabel, userCountLabel} = issueCountLabels(row); return ( - - - {/* Header: title + change size + action */} + + + {/* Header: title over metadata subline, diff size pinned right */} - - - {/* The ellipsis Text is the shrinking flex item (overflow:hidden - resolves its min-width to 0); the Link must nest inside it or - the anchor refuses to shrink and the title overflows the card. - When Seer produced a plain-language headline it replaces the - raw issue title, which stays reachable via the tooltip and - the expanded details. */} - - {row.headline ? ( - - - {t('Raw issue title')} - - - {ellipsize(row.title, 200)} - - - } - > - {row.headline} - - ) : ( - {row.title} + + {/* lg matches the issues feed's row titles */} + + {/* Only non-default triggers get a badge; "manual" is the default. */} + + 0 + ? t( + '%s events and %s affected users %s', + row.eventCount.toLocaleString(), + row.userCount.toLocaleString(), + periodWindowLabel(row.statsPeriod) + ) + : t( + '%s events %s', + row.eventCount.toLocaleString(), + periodWindowLabel(row.statsPeriod) + ) + } + size="sm" + variant="muted" + > + {eventCountLabel} + {row.userCount > 0 && ` · ${userCountLabel}`} + + + {'·'} + + + + + {row.trigger !== 'manual' && ( + )} - - + + + {/* Right cluster: just the diff-size fact — the action lives in + the card's tail, the pipeline story on the group header */} - {/* No stage chip here: the action verb already encodes the stage - (Review PR ⇒ PR opened, Open PR ⇒ code drafted, …) and the - Outcome filter covers querying by it. One fact + one action. */} {row.patchStats && ( } maxWidth={480} skipWrapper > - {/* Contained like its Tag/button neighbors so the diff size - doesn't read as floating text */} )} - {row.statePending ? ( - {'…'} - ) : row.isProcessing ? ( - {t('Running')} - ) : row.prMerged ? ( - - }> - {t('Merged')} - - - ) : attention === 'review_pr' && row.prUrl ? ( - - } - href={row.prUrl} - external - > - {ATTENTION_META.review_pr.label} - - - ) : attention ? ( - - ) : ( - - - {t('View run')} - - - )} - {row.prUrl && attention !== 'review_pr' && ( - } - href={row.prUrl} - external - > - {row.prNumber ? `#${row.prNumber}` : t('PR')} - - )} {/* The question autofix is blocked on, surfaced right on the card */} {row.pendingQuestion && ( - + {t('Seer asked: %s', row.pendingQuestion)} )} - {/* The body is exactly ONE block, either/or: the proposed fix when the - run drafted code (the fix text supersedes the summary, which would - describe the same change twice), otherwise the diagnosis summary. - Same anatomy for both; icon + label color tell them apart. */} - {bodyEntry && ( - - - - {isFixBody ? ( - - ) : ( - - )} - - {isFixBody ? t('Proposed fix') : t('Diagnosis')} - - - - + {/* The analysis sections, one shared voice (eyebrow icon + + uppercase label + prose), in thought order */} + {sections.map(section => ( + + + {section.icon} + + {section.label} - - + + + + + + ))} + + {/* The drafted diff itself, but only when it's small enough to read + on a card (see the INLINE_DIFF_* limits): collapsed file headers + that expand in place, aligned with the body's text column */} + {row.inlinePatches && ( + + {row.inlinePatches.map(({patch, repoName}) => ( + + ))} + )} - {/* Footer: the collapsed analysis on the left, project pinned in the - card's bottom-right corner */} - - - {detailEntries.length > 0 && ( - - {t('Full analysis')} - - - {/* Compact identity strip: the short id and Seer's - fixability read — the raw title lives in the headline - tooltip, not here */} - - - {row.shortId} - - {typeof row.fixabilityScore === 'number' && ( - - )} - - {/* Sections share the body blocks' icon+label voice and - sit side by side on wide screens instead of leaving - the card's right half empty */} - - {detailEntries.map(entry => { - const section = - entry.key === 'reviewer_notes' - ? isFixBody - ? { - label: t('Review checklist'), - icon: ( - - ), - } - : { - label: t('Next steps'), - icon: ( - - ), - } - : { - label: entry.label, - icon: , - }; - return ( - - - {section.icon} - - {section.label} - - - - - - - ); - })} - - - - - )} - - {/* Provenance + vitals read as one quiet metadata line */} - - {/* "Manual" is the default trigger and reads as noise on every - card; only non-default triggers earn a badge */} - {row.trigger !== 'manual' && ( - - )} - - 0 - ? t( - '%s events and %s affected users in the last 90 days', - row.eventCount.toLocaleString(), - row.userCount.toLocaleString() - ) - : t('%s events in the last 90 days', row.eventCount.toLocaleString()) - } - size="xs" - variant="muted" + {/* Tail: primary action left, issue identity right */} + + + + {row.prUrl && cardAction.type !== 'review_pr' && ( + } + href={row.prUrl} + external > - {eventCountLabel} - {row.userCount > 0 && ` · ${userCountLabel}`} - - - {'·'} - - - - - - - + {row.prNumber ? `#${row.prNumber}` : t('PR')} + + )} + + + + + {row.shortId} + + + @@ -390,3 +331,66 @@ export function IssueCard({orgSlug, row}: {orgSlug: string; row: OverviewRow}) { ); } + +/** + * The compact, Linear-style rendering used by the overview's table mode. + * Keep this deliberately sparse: the full analysis and diff stay in card + * mode, while this row is optimized for scanning and taking the next action. + */ +export function IssueTableRow({ + orgSlug, + row, + sectionKey, + minHeight, +}: { + orgSlug: string; + row: OverviewRow; + sectionKey: AutofixStateKey; + minHeight?: string; +}) { + const issueUrl = `/organizations/${orgSlug}/issues/${row.id}/`; + const runUrl = {pathname: issueUrl, query: {seerDrawer: 'true'}}; + const cardAction = deriveCardAction(sectionKey, row); + const {eventCountLabel, userCountLabel} = issueCountLabels(row); + + return ( + + + + + + + {row.shortId} + + + {'·'} + + + {eventCountLabel} + {row.userCount > 0 && ` · ${userCountLabel}`} + + + {'·'} + + + + + + + + + + + ); +} diff --git a/static/app/views/seerWorkflows/overview/overviewFilters.tsx b/static/app/views/seerWorkflows/overview/overviewFilters.tsx new file mode 100644 index 000000000000..d98906433275 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/overviewFilters.tsx @@ -0,0 +1,105 @@ +import {Button} from '@sentry/scraps/button'; +import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {Flex} from '@sentry/scraps/layout'; +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; +import {SegmentedControl} from '@sentry/scraps/segmentedControl'; + +import {PageFilterBar} from 'sentry/components/pageFilters/pageFilterBar'; +import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; +import {IconChevron, IconGrid, IconTable} from 'sentry/icons'; +import {t} from 'sentry/locale'; + +import {DEFAULT_STATS_PERIOD, PERIOD_FILTER_OPTIONS} from './periods'; +import type {OverviewView, SortValue} from './types'; + +const SORT_OPTIONS: Array<{label: string; value: SortValue}> = [ + {value: 'activity', label: t('Recent activity')}, + {value: 'events', label: t('Most events')}, +]; + +export function OverviewFilters({ + allCollapsed, + onToggleAll, + onUpdateQuery, + onViewChange, + period, + sort, + view, +}: { + allCollapsed: boolean; + onToggleAll: () => void; + onUpdateQuery: (patch: Record) => void; + onViewChange: (view: OverviewView) => void; + period: string; + sort: SortValue; + view: OverviewView; +}) { + return ( + + + + + + + onUpdateQuery({ + period: + selected.value === DEFAULT_STATS_PERIOD + ? undefined + : String(selected.value), + }) + } + trigger={triggerProps => ( + + )} + /> + + onUpdateQuery({ + // Default sort keeps the URL clean. + sort: selected.value === 'activity' ? undefined : String(selected.value), + }) + } + trigger={triggerProps => ( + + )} + /> + + + + + size="xs" + value={view} + onChange={onViewChange} + aria-label={t('View mode')} + > + } + aria-label={t('Card view')} + tooltip={t('Card view')} + /> + } + aria-label={t('Table view')} + tooltip={t('Table view')} + /> + + + + ); +} diff --git a/static/app/views/seerWorkflows/overview/periods.spec.ts b/static/app/views/seerWorkflows/overview/periods.spec.ts new file mode 100644 index 000000000000..31eb1f470917 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/periods.spec.ts @@ -0,0 +1,27 @@ +import { + DEFAULT_STATS_PERIOD, + PERIOD_FILTER_OPTIONS, + periodWindowLabel, +} from 'sentry/views/seerWorkflows/overview/periods'; + +describe('periodWindowLabel', () => { + it('returns the window phrase for a known period', () => { + expect(periodWindowLabel('24h')).toBe('in the last 24 hours'); + expect(periodWindowLabel('7d')).toBe('in the last 7 days'); + }); + + it('falls back to the default window phrase for an unknown period', () => { + const defaultOption = PERIOD_FILTER_OPTIONS.find( + option => option.value === DEFAULT_STATS_PERIOD + ); + expect(periodWindowLabel('not-a-period')).toBe(defaultOption?.windowLabel); + }); +}); + +describe('PERIOD_FILTER_OPTIONS', () => { + it('includes the default stats period', () => { + expect( + PERIOD_FILTER_OPTIONS.some(option => option.value === DEFAULT_STATS_PERIOD) + ).toBe(true); + }); +}); diff --git a/static/app/views/seerWorkflows/overview/periods.ts b/static/app/views/seerWorkflows/overview/periods.ts new file mode 100644 index 000000000000..a11ac616b72c --- /dev/null +++ b/static/app/views/seerWorkflows/overview/periods.ts @@ -0,0 +1,21 @@ +import {t} from 'sentry/locale'; + +export const DEFAULT_STATS_PERIOD = '90d'; + +export const PERIOD_FILTER_OPTIONS: Array<{ + label: string; + value: string; + windowLabel: string; +}> = [ + {value: '24h', label: t('Last 24 hours'), windowLabel: t('in the last 24 hours')}, + {value: '7d', label: t('Last 7 days'), windowLabel: t('in the last 7 days')}, + {value: '30d', label: t('Last 30 days'), windowLabel: t('in the last 30 days')}, + {value: '90d', label: t('Last 90 days'), windowLabel: t('in the last 90 days')}, +]; + +export function periodWindowLabel(statsPeriod: string): string { + const option = + PERIOD_FILTER_OPTIONS.find(candidate => candidate.value === statsPeriod) ?? + PERIOD_FILTER_OPTIONS.find(candidate => candidate.value === DEFAULT_STATS_PERIOD); + return option?.windowLabel ?? ''; +} diff --git a/static/app/views/seerWorkflows/overview/runQuestions.tsx b/static/app/views/seerWorkflows/overview/runQuestions.tsx index 6ffa35460f5f..45483102e31e 100644 --- a/static/app/views/seerWorkflows/overview/runQuestions.tsx +++ b/static/app/views/seerWorkflows/overview/runQuestions.tsx @@ -1,11 +1,8 @@ import {t} from 'sentry/locale'; -import type {AnswerPlacement} from './types'; - interface RunQuestionConfig { key: string; label: string; - placement: AnswerPlacement; // The prompt sent to the runs endpoint as a `question` param. Answered per // run over its full history by Seer's agent_question one-shot; editing the // text invalidates that (run, question) cache entry, so fresh answers show @@ -20,38 +17,23 @@ export const RUN_QUESTIONS: RunQuestionConfig[] = [ { key: 'root_cause', label: t('Root cause'), - placement: 'details', prompt: 'Answer in two parts separated by a single pipe character. First a ' + 'headline: at most 14 words of plain language describing what is broken ' + 'and where, the way a good bug-report title would — no error class names ' + 'unless essential, no markdown or code formatting, no pipe characters in ' + 'it. ' + - 'Then the pipe. Then the root cause: what actually breaks and why, in ' + - 'one or two short sentences (at most 40 words), naming the responsible ' + - 'function, commit, or configuration — inline code is allowed in this ' + - 'part, but no headers, bullets, or code blocks. If the run did not ' + - 'establish a root cause, still give the headline, then the pipe, then ' + - 'one sentence on why it could not (e.g. could not reproduce, missing ' + - 'context).', - }, - { - key: 'summary', - label: t('Summary'), - placement: 'face', - prompt: - 'One sentence of at most 25 words (a semicolon is fine): the failure ' + - "mechanism, then the run's concrete outcome — what the opened PR or " + - 'drafted fix changes and where, or the issue-specific reason the run ' + - 'skipped or stopped early. Name files and identifiers, not activities: ' + - 'never content-free phrases like "this run produced a diagnosis" or ' + - '"implemented code changes". No first person, no filler. Inline code ' + - 'allowed; no headers, bullets, or code blocks.', + 'Then the pipe. Then the root cause: ONE sentence of at most 25 words on ' + + 'why it breaks, naming the responsible function, commit, or ' + + 'configuration — the headline already covers what and where, so do not ' + + 'repeat it. Inline code is allowed in this part, but no headers, ' + + 'bullets, or code blocks. If the run did not establish a root cause, ' + + 'still give the headline, then the pipe, then one sentence on why it ' + + 'could not (e.g. could not reproduce, missing context).', }, { key: 'fix_summary', label: t('Proposed fix'), - placement: 'face', prompt: 'If this run drafted code changes or opened a pull request, describe in ' + 'at most two sentences (max 45 words) what the changes are and why they ' + @@ -64,23 +46,17 @@ export const RUN_QUESTIONS: RunQuestionConfig[] = [ 'file or function names; no markdown headers, bullets, or code blocks.', }, { - key: 'reviewer_notes', - // Placeholder — the card derives the display label from whether the run - // drafted code: "Review checklist" vs "Next steps". - label: t('Notes'), - placement: 'details', + key: 'next_steps', + label: t('Next steps'), prompt: - 'If this run drafted code changes or opened a pull request: a review ' + - 'checklist — three to five markdown bullets a reviewer should verify ' + - 'before trusting or merging the change, each naming the specific risk, ' + - 'assumption, or untested path and why it matters (at most 25 words per ' + - 'bullet). If the run produced no code: next steps instead — two to four ' + - 'markdown bullets on how an engineer should take this forward (what to ' + - 'confirm in the codebase, what decision to make, whether to have Seer ' + - 'generate code), concrete and specific to this issue, never generic ' + - 'advice. Every bullet must start on its own line with "- " (hyphen, ' + - 'space) — never use the "•" character or run bullets together in one ' + - 'paragraph. No first person; inline code allowed; no markdown headers.', + 'If this run drafted code changes or opened a pull request, return an ' + + 'empty string. Otherwise: ONE sentence of at most 25 words — the single ' + + 'highest-leverage next step for an engineer (what to confirm, decide, ' + + 'or investigate), concrete and specific to this issue. Never tell the ' + + 'reader to have Seer or an AI generate the fix — the product already ' + + 'offers that as a button next to these notes; if generating code is the ' + + 'obvious next move, state what the fix should do instead. No first ' + + 'person; no markdown bullets or headers; inline code allowed.', }, ]; diff --git a/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx b/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx new file mode 100644 index 000000000000..16d8d3b8d568 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/sectionIssueCard.tsx @@ -0,0 +1,78 @@ +import {LazyRender} from 'sentry/components/lazyRender'; + +import {buildOverviewRow, deriveSectionKey} from './buildOverviewRows'; +import {IssueCard, IssueTableRow} from './issueCard'; +import type {AutofixStateKey, OverviewIssue} from './types'; +import {useIssueAutofixEnrichment} from './useIssueAutofixEnrichment'; + +const CARD_PLACEHOLDER_HEIGHT = 180; +const TABLE_ROW_PLACEHOLDER_HEIGHT = 48; +const LAZY_OBSERVER_OPTIONS = {rootMargin: '200px 0px'}; + +function HydratedCard({ + defaultExpanded, + issue, + orgSlug, + sectionKey, + view, + statsPeriod, +}: { + issue: OverviewIssue; + orgSlug: string; + statsPeriod: string; + view: 'cards' | 'table'; + defaultExpanded?: boolean; + // The server-bucketed section. Absent in focus mode, where the issues + // endpoint omits issue.autofix_state, so we reconstruct it from enrichment. + sectionKey?: AutofixStateKey; +}) { + const {run, state, enrichmentPending} = useIssueAutofixEnrichment(issue.id); + const row = buildOverviewRow(issue, run, state, enrichmentPending, statsPeriod); + const resolvedSectionKey = sectionKey ?? deriveSectionKey(run, state); + const minHeight = enrichmentPending + ? `${view === 'cards' ? CARD_PLACEHOLDER_HEIGHT : TABLE_ROW_PLACEHOLDER_HEIGHT}px` + : undefined; + + return view === 'cards' ? ( + + ) : ( + + ); +} + +export function SectionIssueCard({ + lazy = true, + ...props +}: { + issue: OverviewIssue; + orgSlug: string; + statsPeriod: string; + view: 'cards' | 'table'; + defaultExpanded?: boolean; + lazy?: boolean; + sectionKey?: AutofixStateKey; +}) { + return ( + + + + ); +} diff --git a/static/app/views/seerWorkflows/overview/sectionList.tsx b/static/app/views/seerWorkflows/overview/sectionList.tsx new file mode 100644 index 000000000000..7d210ace0224 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/sectionList.tsx @@ -0,0 +1,166 @@ +import styled from '@emotion/styled'; + +import {Badge} from '@sentry/scraps/badge'; +import {Disclosure} from '@sentry/scraps/disclosure'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {LoadingError} from 'sentry/components/loadingError'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {Sticky} from 'sentry/components/sticky'; +import {t} from 'sentry/locale'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import {DEFAULT_STATS_PERIOD} from './periods'; +import {SectionIssueCard} from './sectionIssueCard'; +import {STATUS_GROUP_META, StatusGroupTooltip, type StatusGroupKey} from './statusGroups'; +import type {OverviewView, SortValue} from './types'; +import {SECTION_LIMIT, useAutofixSections} from './useAutofixSections'; + +function formatSectionCount(count: number | undefined) { + if (count === undefined) { + return '…'; + } + return count > SECTION_LIMIT ? `${SECTION_LIMIT}+` : count; +} + +export function SectionList({ + collapsedGroups, + enabled, + onToggleGroup, + period, + projects, + sort, + view, +}: { + collapsedGroups: StatusGroupKey[]; + enabled: boolean; + onToggleGroup: (groupKey: StatusGroupKey, expanded: boolean) => void; + period: string; + projects: number[]; + sort: SortValue; + view: OverviewView; +}) { + const organization = useOrganization(); + const {sections, isPending, isError, refetch} = useAutofixSections({ + enabled, + projects, + sort: sort === 'events' ? 'freq' : 'date', + statsPeriod: period, + }); + + const firstLoad = isPending && sections.every(section => section.isPending); + const allSectionsEmpty = sections.every( + section => !section.isPending && !section.isError && section.issues.length === 0 + ); + const hasNonDefaultFilters = projects.length > 0 || period !== DEFAULT_STATS_PERIOD; + + if (isError) { + return ; + } + if (firstLoad) { + return ; + } + if (allSectionsEmpty) { + return ( + + + {hasNonDefaultFilters + ? t('No autofix runs match your filters.') + : t('No completed autofix runs yet.')} + + + ); + } + + return ( + + {sections.map(section => { + const meta = STATUS_GROUP_META[section.key]; + return ( + onToggleGroup(section.key, next)} + > + + + + } + skipWrapper + > + + + {meta.label} + {formatSectionCount(section.count)} + + + + + {section.isError ? ( + + ) : section.isPending ? ( + + ) : section.issues.length === 0 ? ( + + + {t('No issues')} + + + ) : ( + + {section.issues.map(issue => ( + + ))} + + )} + + + ); + })} + + ); +} + +const SectionRows = styled(Stack)` + &[data-view='table'] > *:last-child { + border-bottom: none; + } +`; + +// Disclosure.Content hardcodes a padding-left to indent its panel under the +// title; the `> * + *` sibling selector drops it so the full-width cards line +// up flush with their group header. +const StatusGroup = styled(Disclosure)` + && > * + * { + padding-left: 0; + } +`; + +// Sticky group header; z-index isn't a layout-primitive prop so it lives here. +// Opaque background so cards scroll under it. +const GroupHeader = styled(Sticky)` + z-index: ${p => p.theme.zIndex.initial}; + width: 100%; + background: ${p => p.theme.tokens.background.secondary}; + border-radius: ${p => p.theme.radius.md}; + + &[data-stuck] { + border-radius: 0; + border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; + } +`; diff --git a/static/app/views/seerWorkflows/overview/statusGroups.tsx b/static/app/views/seerWorkflows/overview/statusGroups.tsx new file mode 100644 index 000000000000..0cead1dabafa --- /dev/null +++ b/static/app/views/seerWorkflows/overview/statusGroups.tsx @@ -0,0 +1,75 @@ +import {Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {IconCode, IconCommit, IconMerge, IconPullRequest, IconSearch} from 'sentry/icons'; +import type {SVGIconProps} from 'sentry/icons/svgIcon'; +import {t, tn} from 'sentry/locale'; + +import {type AutofixStateKey, PIPELINE} from './types'; + +// The list's status sections. needs_investigation covers settled +// diagnosis-only runs (manual next steps, no one-click pipeline action); +// merged covers rows with nothing left to do. +export type StatusGroupKey = AutofixStateKey; + +interface StatusGroupMeta { + Icon: React.ComponentType; + label: string; +} + +export const STATUS_GROUP_META: Record = { + review_pr: {Icon: IconPullRequest, label: t('Awaiting your review')}, + code_changes_ready: {Icon: IconCommit, label: t('Code changes ready')}, + solution_ready: {Icon: IconCode, label: t('Ready to generate code')}, + // Same magnifier as the card's Investigate action: these runs stopped at a + // root cause, and their next steps are manual verify/decide work. + needs_investigation: {Icon: IconSearch, label: t('Needs investigation')}, + merged: {Icon: IconMerge, label: t('Merged')}, +}; + +const FILL_BY_KEY: Record = Object.fromEntries( + PIPELINE.map(stage => [stage.key, stage.fill]) +) as Record; + +const STEP_LABELS = [ + t('Root cause'), + t('Plan'), + t('Code changes'), + t('PR opened'), + t('Merged'), +]; + +/** + * The pipeline checklist for a group's header icon: where every card in the + * group is and how many steps remain until the fix lands. Staged groups get + * the ✓/○ rows; variable-stage groups get their one-line description. + */ +export function StatusGroupTooltip({groupKey}: {groupKey: StatusGroupKey}) { + const merged = groupKey === 'merged'; + const fill = FILL_BY_KEY[groupKey]; + + return ( + + + {merged + ? t('Issue fixed') + : tn( + '%s step until issue fix', + '%s steps until issue fix', + STEP_LABELS.length - fill + )} + + {STEP_LABELS.map((label, index) => + index < fill ? ( + + {`✓ ${label}`} + + ) : ( + + {`○ ${label}`} + + ) + )} + + ); +} diff --git a/static/app/views/seerWorkflows/overview/triggerBadge.spec.tsx b/static/app/views/seerWorkflows/overview/triggerBadge.spec.tsx new file mode 100644 index 000000000000..ad54af783ec3 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/triggerBadge.spec.tsx @@ -0,0 +1,43 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import { + mapRunSourceToTrigger, + TriggerBadge, +} from 'sentry/views/seerWorkflows/overview/triggerBadge'; + +describe('mapRunSourceToTrigger', () => { + it.each(['autofix', 'slack_thread', 'chat'])( + 'maps %s to the manual trigger', + source => { + expect(mapRunSourceToTrigger(source)).toBe('manual'); + } + ); + + it('maps night_shift to the workflow trigger', () => { + expect(mapRunSourceToTrigger('night_shift')).toBe('night_shift'); + }); + + it.each(['bug_fixer', 'dashboard_generate', null])( + 'leaves an unmapped source (%s) unclassified', + source => { + expect(mapRunSourceToTrigger(source)).toBeNull(); + } + ); +}); + +describe('TriggerBadge', () => { + it('renders the mapped trigger label', () => { + render(); + expect(screen.getByText('Workflow')).toBeInTheDocument(); + }); + + it('falls back to the raw source verbatim when the trigger is unmapped', () => { + render(); + expect(screen.getByText('bug_fixer')).toBeInTheDocument(); + }); + + it('renders nothing when neither a trigger nor a raw source is known', () => { + const {container} = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/static/app/views/seerWorkflows/overview/types.ts b/static/app/views/seerWorkflows/overview/types.ts index 171583b7721e..c5b22c6334bd 100644 --- a/static/app/views/seerWorkflows/overview/types.ts +++ b/static/app/views/seerWorkflows/overview/types.ts @@ -1,12 +1,108 @@ +import type {FilePatch} from 'sentry/components/events/autofix/types'; import type {Level} from 'sentry/types/event'; import type {PlatformKey} from 'sentry/types/platform'; -export type AutofixOutcome = 'root_cause' | 'solution' | 'code_changes' | 'pr_opened'; +// Shared staleTime for the overview's issue/run/state queries. +export const QUERY_STALE_TIME = 30_000; -// Terminal-ish run status buckets the overview cares about, mapped from -// ExplorerAutofixState.status. A run that is still 'processing' is reported -// as COMPLETED here with `isProcessing` set on the row instead. -export type AutofixRunStatus = 'COMPLETED' | 'ERROR' | 'NEED_MORE_INFORMATION'; +// Runs filter: the explorer runs autofix creates. Combined with a +// ``group:[...]`` filter so we only fetch runs for the issues on the page. +export const RUNS_QUERY = 'type:explorer source:autofix'; + +// Always applied to the issue query: only issues Seer has run autofix on. +export const REQUIRED_ISSUE_FILTER = 'has:issue.seer_last_run'; + +// The section an issue is bucketed into, from the ``issue.autofix_state`` +// search key (server-authoritative) or, in focus mode, deriveSectionKey. +export type AutofixStateKey = + | 'review_pr' + | 'code_changes_ready' + | 'solution_ready' + | 'needs_investigation' + | 'merged'; + +// One pipeline stage. `fill` is how many of the five checklist steps +// (root cause → plan → code → PR → merge) a card in this stage has reached; it +// is the single source of stage precedence, driving the section-header +// checklist and the focus-mode fallback (which walks stages furthest-first). +export interface PipelineStage { + fill: number; + key: AutofixStateKey; +} + +// The whole pipeline, in display order. Every hand-encoded stage ordering in +// the overview derives from this table. +export const PIPELINE: PipelineStage[] = [ + {key: 'review_pr', fill: 4}, + {key: 'code_changes_ready', fill: 3}, + {key: 'solution_ready', fill: 2}, + {key: 'needs_investigation', fill: 1}, + {key: 'merged', fill: 5}, +]; + +export const SECTION_ORDER: AutofixStateKey[] = PIPELINE.map(stage => stage.key); + +// Toolbar controls, persisted in the URL (sort) and localStorage (view). +export type SortValue = 'activity' | 'events'; +export type OverviewView = 'cards' | 'table'; + +// Live run status, mirrored straight from ExplorerAutofixState.status. Drives +// the transient card overlays (Running / Retry / Add context), never the +// section-driven primary action. +export type RunStatus = 'processing' | 'completed' | 'error' | 'awaiting_user_input'; + +// The primary action a card offers, derived from its section. review_pr carries +// the linked PR so it can offer the external review button. +export type CardAction = + | {prNumber: number | undefined; prUrl: string | undefined; type: 'review_pr'} + | {type: 'code_changes_ready'} + | {type: 'solution_ready'} + | {type: 'needs_investigation'} + | {type: 'merged'}; + +// One answered question, mirrors the run output in +// src/sentry/api/serializers/models/seer_run.py +export interface RunQuestion { + answer: string; + key: string; + // The question text, echoed back only for user-supplied questions. + question?: string; +} + +// A pull request linked to a run, serialized by PullRequestSerializer +// src/sentry/api/serializers/models/pullrequest.py +// `status` is 'open' | 'merged' | 'closed' | 'draft' | 'unknown'. +interface RunPullRequest { + status: string | null; + mergedAt?: string | null; +} + +// Subset of the runs list response we consume +// src/sentry/api/serializers/models/seer_run.py +export interface SeerRun { + groupId: string | null; + id: string; + lastTriggeredAt: string; + source: string | null; + // Present only when ?outputs is requested. + outputs?: RunQuestion[]; + // Linked PRs with merge status. + pullRequests?: RunPullRequest[]; +} + +// One issue from the issue stream, as the overview cards consume it. +export interface OverviewIssue { + // Event count over the stats period; the endpoint returns it as a string. + count: string; + id: string; + lastSeen: string; + level: Level; + project: {slug: string; platform?: PlatformKey}; + seerAutofixLastTriggered: string | null; + shortId: string; + title: string; + userCount: number; +} // How the run was started. Sources without a mapping render a fallback // badge with the raw source text. @@ -17,24 +113,12 @@ export type AutofixTrigger = | 'post_process' | 'night_shift'; -export type AttentionReason = - | 'awaiting_input' - | 'solution_ready' - | 'code_changes_ready' - | 'review_pr' - | 'errored'; - -// Where an answered run question renders on the card: on the face (always -// visible) or inside the collapsed "Full analysis" disclosure. -export type AnswerPlacement = 'face' | 'details'; - // One answered run question joined to its question config // See ./runQuestions.ts export interface RunAnalysisEntry { answer: string; key: string; label: string; - placement: AnswerPlacement; } // One changed file within the run's drafted diff. @@ -57,27 +141,31 @@ export interface PatchStats { // One issue + its latest autofix run, flattened for the overview cards. export interface OverviewRow { analysis: RunAnalysisEntry[]; - autofixRunStatus: AutofixRunStatus; eventCount: number; id: string; // Most recent activity on the run (state update, trigger, or issue-level - // last-trigger timestamp) - drives sorting and the period filter. + // last-trigger timestamp); labels the card's "updated" TimeSince. lastActivityAt: string; - lastSeen: string; level: Level; - outcomes: AutofixOutcome[]; - prMerged: boolean; project: {slug: string; platform?: PlatformKey}; + // Live run status, mirrored straight from the state payload; drives the + // transient overlays only. Null until the state request resolves. + runStatus: RunStatus | null; shortId: string; // Whether the per-issue autofix state request is still in flight. statePending: boolean; + // The stats period the event/user counts were fetched over; labels the + // count tooltip so it matches the active period filter. + statsPeriod: string; title: string; userCount: number; - fixabilityScore?: number | null; // Plain-language title from the run's root-cause answer (see runQuestions). // Falls back to the raw issue title. headline?: string; - isProcessing?: boolean; + // Structured patches for the on-card differ; present only when the diff is + // small enough to render inline (see the INLINE_DIFF_* limits in + // buildOverviewRows). + inlinePatches?: Array<{patch: FilePatch; repoName?: string}>; patchStats?: PatchStats; // The question autofix paused on, when status is NEED_MORE_INFORMATION and // the pending input payload carries readable text. diff --git a/static/app/views/seerWorkflows/overview/useAutofixSections.tsx b/static/app/views/seerWorkflows/overview/useAutofixSections.tsx new file mode 100644 index 000000000000..b01d851d4ff3 --- /dev/null +++ b/static/app/views/seerWorkflows/overview/useAutofixSections.tsx @@ -0,0 +1,79 @@ +import {useQueries} from '@tanstack/react-query'; + +import type {ApiResponse} from 'sentry/utils/api/apiFetch'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import { + type AutofixStateKey, + type OverviewIssue, + QUERY_STALE_TIME, + REQUIRED_ISSUE_FILTER, + SECTION_ORDER, +} from './types'; + +export const SECTION_LIMIT = 100; + +interface SectionResult { + count: number | undefined; + isError: boolean; + isPending: boolean; + issues: OverviewIssue[]; + key: AutofixStateKey; + refetch: () => void; +} + +export function useAutofixSections({ + enabled, + projects, + sort, + statsPeriod, +}: { + enabled: boolean; + projects: number[]; + sort: 'date' | 'freq'; + statsPeriod: string; +}) { + const organization = useOrganization(); + + const results = useQueries({ + queries: SECTION_ORDER.map(key => ({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/issues/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + query: `${REQUIRED_ISSUE_FILTER} issue.autofix_state:${key}`, + project: projects, + statsPeriod, + sort, + limit: SECTION_LIMIT, + }, + staleTime: QUERY_STALE_TIME, + } + ), + enabled, + select: (data: ApiResponse) => selectJsonWithHeaders(data), + })), + }); + + const sections: SectionResult[] = SECTION_ORDER.map((key, index) => { + const result = results[index]!; + const issues = result.data?.json ?? []; + return { + key, + issues, + count: result.data?.headers['X-Hits'] ?? (result.data ? issues.length : undefined), + isPending: result.isPending, + isError: result.isError, + refetch: () => result.refetch(), + }; + }); + + return { + sections, + isPending: results.some(result => result.isPending), + isError: results.every(result => result.isError), + refetch: () => results.forEach(result => result.refetch()), + }; +} diff --git a/static/app/views/seerWorkflows/overview/useIssueAutofixEnrichment.tsx b/static/app/views/seerWorkflows/overview/useIssueAutofixEnrichment.tsx new file mode 100644 index 000000000000..11271c70455d --- /dev/null +++ b/static/app/views/seerWorkflows/overview/useIssueAutofixEnrichment.tsx @@ -0,0 +1,49 @@ +import {useQuery} from '@tanstack/react-query'; + +import type {ExplorerAutofixState} from 'sentry/components/events/autofix/useExplorerAutofix'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import {RUN_QUESTION_PROMPTS} from './runQuestions'; +import {QUERY_STALE_TIME, RUNS_QUERY, type SeerRun} from './types'; + +interface IssueAutofixEnrichment { + enrichmentPending: boolean; + run: SeerRun | null; + state: ExplorerAutofixState | null; + statePending: boolean; +} + +export function useIssueAutofixEnrichment(issueId: string): IssueAutofixEnrichment { + const organization = useOrganization(); + + const runsQuery = useQuery({ + ...apiOptions.as()('/organizations/$organizationIdOrSlug/seer/runs/', { + path: {organizationIdOrSlug: organization.slug}, + query: { + query: `${RUNS_QUERY} group:${issueId}`, + question: RUN_QUESTION_PROMPTS, + per_page: 1, + }, + staleTime: QUERY_STALE_TIME, + }), + }); + + const stateQuery = useQuery({ + ...apiOptions.as<{autofix: ExplorerAutofixState | null}>()( + '/organizations/$organizationIdOrSlug/issues/$issueId/autofix/', + { + path: {organizationIdOrSlug: organization.slug, issueId}, + query: {mode: 'explorer'}, + staleTime: QUERY_STALE_TIME, + } + ), + }); + + return { + run: runsQuery.data?.find(run => run.groupId === issueId) ?? null, + state: stateQuery.data?.autofix ?? null, + statePending: stateQuery.isPending, + enrichmentPending: stateQuery.isPending || runsQuery.isPending, + }; +}