Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7b8494e
feat(signals): wrong-repo dismissals feed repo selection
sortafreel Aug 27, 2026
f62155e
fix(signals): harden the wrong-repo correction loop after review
sortafreel Aug 27, 2026
f9449f5
test(mcp): update unit test snapshots
tests-posthog[bot] Aug 27, 2026
96547a5
chore: update OpenAPI generated types
tests-posthog[bot] Aug 27, 2026
ea15557
Merge branch 'master' into posthog/wrong-repo-dismissal-feedback
sortafreel Aug 27, 2026
9d0d460
test(mcp): update unit test snapshots
tests-posthog[bot] Aug 27, 2026
b7f5ff4
fix(signals): publish corrected_repository non-empty rule in schema
posthog[bot] Aug 27, 2026
b04466f
fix(signals): load wrong-repo picker on reason, not popover state
posthog[bot] Aug 27, 2026
259bf0b
fix(signals): surface repo lookup errors in wrong-repo picker
posthog[bot] Aug 27, 2026
2709407
fix(signals): align wrong_repo help text with recorded selection
posthog[bot] Aug 27, 2026
0a337f1
fix(signals): stop Enter in repo picker from submitting dismiss form
posthog[bot] Aug 27, 2026
02a84e4
docs(signals): correct wrong-repo restore re-research wording
posthog[bot] Aug 27, 2026
242a116
docs(signals): correct unconnected-correction gate rationale
posthog[bot] Aug 27, 2026
43fdc38
test(mcp): update unit test snapshots
tests-posthog[bot] Aug 27, 2026
b386149
fix(signals): gate corrected_repository on reason and normalize casing
sortafreel Aug 28, 2026
b9879ae
fix(desktop): keep dismiss dialog open while repo picker is open
sortafreel Aug 28, 2026
53250b1
fix(signals): clear repo selection on uncorrected wrong-repo dismissal
sortafreel Aug 28, 2026
8750da7
fix(signals): stop in-flight runs burying mid-run repo corrections
sortafreel Aug 28, 2026
ca25262
perf(signals): index artefacts for team-wide corrections reads
sortafreel Aug 28, 2026
2cb4018
fix(signals): drop deleted reports from the corrections feed
sortafreel Aug 28, 2026
eb32bd8
chore(signals): document wrong_repo dismissal in MCP tool descriptions
posthog[bot] Aug 28, 2026
5affc6a
chore: update OpenAPI generated types
tests-posthog[bot] Aug 28, 2026
ddf73ec
fix(signals): stop auto-start when a reviewer supersedes the repository
posthog[bot] Aug 28, 2026
b069b0d
fix(signals): detect task-attributed wrong-repo corrections mid-run
posthog[bot] Aug 28, 2026
e6a27f5
fix(signals): add a clear action to the wrong-repo correction picker
posthog[bot] Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,11 @@ import type {
ColumnConfigurationApi,
PaginatedColumnConfigurationListApi,
} from 'products/product_analytics/frontend/generated/api.schemas'
import type { SignalReportStateRequestApi } from 'products/signals/frontend/generated/api.schemas'
import {
SignalReport,
SignalReportArtefact,
SignalReportArtefactResponse,
SignalReportStateRequest,
SignalScoutEmission,
SignalScoutEmissionReportLink,
SignalScoutRunSummary,
Expand Down Expand Up @@ -5208,7 +5208,7 @@ const api = {
return await new ApiRequest().signalReport(id).withAction('reingest').create()
},
// State transitions: suppress (dismiss) or snooze back to potential. Backend: `state` action.
async setState(id: SignalReport['id'], data: SignalReportStateRequest): Promise<SignalReport> {
async setState(id: SignalReport['id'], data: SignalReportStateRequestApi): Promise<SignalReport> {
return await new ApiRequest().signalReport(id).withAction('state').create({ data })
},
// Backend returns a flat `{ [user_uuid]: { name, email } }` map (not paginated).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function FeedTabContent(): JSX.Element {
key={report.id}
report={report}
backUrl={urls.customerAnalyticsFeed()}
onArchive={(reason, note) => archiveReport(report.id, reason, note)}
onArchive={(dismissal) => archiveReport(report.id, dismissal)}
/>
))}
</div>
Expand Down
21 changes: 6 additions & 15 deletions products/customer_analytics/frontend/components/Feed/feedLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
InboxSortField,
} from 'products/signals/frontend/inbox/logics/inboxFiltersLogic'
import { SignalReport, SignalReportPriority, SignalReportStatus } from 'products/signals/frontend/inbox/types'
import { DismissalReasonValue } from 'products/signals/frontend/inbox/utils/dismissalReasons'
import { DismissalFeedback, suppressDismissalPayload } from 'products/signals/frontend/inbox/utils/dismissalReasons'

import type { UserType } from '../../../../../frontend/src/types'

Expand Down Expand Up @@ -52,17 +52,9 @@ export interface feedLogicValues {
export interface feedLogicActions {
archiveReport: (
reportId: string,
reason: DismissalReasonValue,
note: string
dismissal: DismissalFeedback
) => {
note: string
reason:
| 'already_fixed'
| 'analysis_wrong'
| 'other'
| 'report_unclear'
| 'wontfix_intentional'
| 'wontfix_irrelevant'
dismissal: DismissalFeedback
reportId: string
}
clearFilters: () => {
Expand Down Expand Up @@ -156,7 +148,7 @@ export const feedLogic = kea<feedLogicType>([
toggleScout: (scout: string) => ({ scout }),
clearScoutFilter: true,
clearFilters: true,
archiveReport: (reportId: string, reason: DismissalReasonValue, note: string) => ({ reportId, reason, note }),
archiveReport: (reportId: string, dismissal: DismissalFeedback) => ({ reportId, dismissal }),
}),
loaders(({ values }) => ({
reportsResponse: [
Expand Down Expand Up @@ -272,12 +264,11 @@ export const feedLogic = kea<feedLogicType>([
await breakpoint(300)
actions.loadReports(null)
},
archiveReport: async ({ reportId, reason, note }) => {
archiveReport: async ({ reportId, dismissal }) => {
try {
await api.signalReports.setState(reportId, {
state: 'suppressed',
dismissal_reason: reason,
...(note ? { dismissal_note: note } : {}),
...suppressDismissalPayload(dismissal),
})
} catch (error: any) {
lemonToast.error(error?.detail || error?.message || 'Failed to archive report')
Expand Down
2 changes: 2 additions & 0 deletions products/desktop/packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4980,6 +4980,8 @@ export class PostHogAPIClient {
/** When omitted, the server suppresses without creating a dismissal artefact. */
dismissal_reason?: DismissalReasonOptionValue;
dismissal_note?: string;
/** 'owner/repo' the report should have targeted; only allowed with dismissal_reason 'wrong_repo'. */
corrected_repository?: string;
reset_weight?: boolean;
error?: string;
},
Expand Down
8 changes: 8 additions & 0 deletions products/desktop/packages/core/src/inbox/bulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,15 @@ export function bulkSelectionKey(selection: InboxBulkSelection): string {
export interface DismissReportInput {
reason: DismissalReasonOptionValue;
note: string;
/** 'owner/repo' the reports should have targeted; only set when reason is 'wrong_repo'. */
correctedRepository?: string | null;
}

export type SuppressStateRequest = {
state: "suppressed";
dismissal_reason?: DismissalReasonOptionValue;
dismissal_note?: string;
corrected_repository?: string;
};

/** Body for `updateSignalReportState` when suppressing/dismissing. Notes are clamped to 4000 chars. */
Expand All @@ -150,6 +153,11 @@ export function buildSuppressRequest(
state: "suppressed",
dismissal_reason: dismissal.reason,
dismissal_note: dismissal.note.slice(0, 4000),
// The API rejects corrected_repository with any other reason, so the gate lives here
// rather than in every caller that builds a DismissReportInput.
...(dismissal.reason === "wrong_repo" && dismissal.correctedRepository
? { corrected_repository: dismissal.correctedRepository }
: {}),
};
}

Expand Down
9 changes: 8 additions & 1 deletion products/desktop/packages/core/src/inbox/engagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ export interface BuildBulkActionEventsInput {
actionType: InboxBulkActionType;
surface: InboxReportActionSurface;
/** Dismissal metadata, only meaningful for `dismiss`. Note is truncated to 500 chars. */
dismissal?: { reason?: string; note?: string };
dismissal?: {
reason?: string;
note?: string;
correctedRepository?: string | null;
};
}

/**
Expand Down Expand Up @@ -168,6 +172,9 @@ export function buildBulkActionEvents(
...(actionType === "dismiss" && dismissal?.note
? { dismissal_note: dismissal.note.slice(0, 500) }
: {}),
...(actionType === "dismiss" && dismissal?.correctedRepository
? { dismissal_corrected_repository: dismissal.correctedRepository }
: {}),
}));
}

Expand Down
2 changes: 2 additions & 0 deletions products/desktop/packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,8 @@ export interface InboxReportActionProperties {
list_size: number;
dismissal_reason?: string;
dismissal_note?: string;
// 'owner/repo' correction from a wrong_repo dismissal.
dismissal_corrected_repository?: string;
signal_id?: string;
signal_source_product?: string;
signal_source_type?: string;
Expand Down
4 changes: 4 additions & 0 deletions products/desktop/packages/shared/src/dismissal-reasons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export const DISMISSAL_REASON_OPTIONS = [
value: "analysis_wrong",
label: "Agent's analysis is wrong",
},
{
value: "wrong_repo",
label: "Agent picked the wrong repository",
},
{
value: "wontfix_intentional",
label: "Won't fix - intentional behavior",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { SignalReport } from "@posthog/shared/types";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { DismissReportDialog } from "./DismissReportDialog";

const connectedRepositories = ["posthog/posthog", "posthog/posthog-js"];

// Mirrors the real hook's cold-cache behavior: it yields repositories only while enabled
// (the query is gated off when disabled, so the list is empty). Capturing the enabled
// argument is what lets the test assert the fetch is driven by the reason, not the popover.
const useGithubRepositoriesSpy = vi.fn((_search: string, enabled: boolean) => ({
repositories: enabled ? connectedRepositories : [],
isPending: false,
isFetchingMore: false,
hasMore: false,
loadMore: vi.fn(),
}));

vi.mock("@posthog/ui/features/integrations/useIntegrations", () => ({
useIntegrations: vi.fn(),
useGithubRepositories: (search: string, enabled: boolean) =>
useGithubRepositoriesSpy(search, enabled),
}));

vi.mock("@posthog/ui/features/integrations/store", () => ({
useIntegrationSelectors: () => ({ hasGithubIntegration: true }),
}));

const report = { title: "Something broke" } as SignalReport;

describe("DismissReportDialog", () => {
afterEach(() => {
vi.clearAllMocks();
});

it("offers an openable repository picker on a cold cache once wrong-repo is chosen", () => {
render(
<DismissReportDialog
open
onOpenChange={vi.fn()}
report={report}
isSubmitting={false}
snoozeDisabledReason={null}
onConfirm={vi.fn()}
/>,
);

const wrongRepoRadio = screen
.getByText("Agent picked the wrong repository")
.closest("div")
?.querySelector("#dismiss-report-dialog-reason-wrong_repo");
if (!wrongRepoRadio) {
throw new Error("Expected the wrong-repo reason radio to render");
}
fireEvent.click(wrongRepoRadio);

// The repositories load on the reason alone, so with the picker still closed it renders
// its openable trigger, not the dead-end disabled "No GitHub repos" button that a
// fetch gated on the popover state would leave the reviewer stuck on.
expect(screen.getByText("Search repositories")).toBeInTheDocument();
expect(screen.queryByText("No GitHub repos")).not.toBeInTheDocument();
expect(useGithubRepositoriesSpy).toHaveBeenLastCalledWith("", true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,25 @@ import {
isDismissalReasonSnooze,
} from "@posthog/shared/dismissalReasons";
import type { SignalReport } from "@posthog/shared/types";
import { GitHubRepoPicker } from "@posthog/ui/features/folder-picker/GitHubRepoPicker";
import {
ExplainedPauseLabel,
ExplainedSuppressLabel,
} from "@posthog/ui/features/inbox/components/utils/ExplainedDismissOptionLabels";
import { useIntegrationSelectors } from "@posthog/ui/features/integrations/store";
import {
useGithubRepositories,
useIntegrations,
} from "@posthog/ui/features/integrations/useIntegrations";
import { Button } from "@posthog/ui/primitives/Button";
import { Dialog, Flex, RadioGroup, Text, TextArea } from "@radix-ui/themes";
import { useEffect, useRef, useState } from "react";

export interface DismissReportDialogResult {
reason: DismissalReasonOptionValue;
note: string;
/** 'owner/repo' the reports should have targeted; only set when reason is 'wrong_repo'. */
correctedRepository: string | null;
}

export interface DismissReportDialogProps {
Expand Down Expand Up @@ -44,10 +52,20 @@ export function DismissReportDialog({
const onOpenChangeRef = useRef(onOpenChange);
onOpenChangeRef.current = onOpenChange;

// Quill's combobox portals its popup to document.body, outside Dialog.Content, so Radix
// treats clicks on the popup as "outside the dialog". While the picker is open, every
// dismiss path below must close only the popup — otherwise selecting a repository slams
// the dialog shut and drops the reason and note already entered.
const [isRepoPickerOpen, setIsRepoPickerOpen] = useState(false);

useEffect(() => {
if (!open) setIsRepoPickerOpen(false);
}, [open]);

// Radix Themes nests Content inside the overlay scroll area, so backdrop clicks
// often land on padding/overlay nodes that never reach Content's dismiss layer.
useEffect(() => {
if (!open || isSubmitting) return;
if (!open || isSubmitting || isRepoPickerOpen) return;

const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
Expand All @@ -67,17 +85,27 @@ export function DismissReportDialog({
document.addEventListener("pointerdown", handlePointerDown, true);
return () =>
document.removeEventListener("pointerdown", handlePointerDown, true);
}, [open, isSubmitting]);
}, [open, isSubmitting, isRepoPickerOpen]);

return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content
maxWidth="480px"
onPointerDownOutside={() => {
if (!isSubmitting) onOpenChange(false);
onPointerDownOutside={(event) => {
// preventDefault, not just skipping onOpenChange: Radix also dismisses a controlled
// dialog through Root's own onOpenChange unless the event is cancelled.
if (isSubmitting || isRepoPickerOpen) {
event.preventDefault();
return;
}
onOpenChange(false);
}}
onEscapeKeyDown={() => {
if (!isSubmitting) onOpenChange(false);
onEscapeKeyDown={(event) => {
if (isSubmitting || isRepoPickerOpen) {
event.preventDefault();
return;
}
onOpenChange(false);
}}
>
<DismissReportDialogBody
Expand All @@ -86,6 +114,8 @@ export function DismissReportDialog({
isSubmitting={isSubmitting}
snoozeDisabledReason={snoozeDisabledReason}
onConfirm={onConfirm}
isRepoPickerOpen={isRepoPickerOpen}
onRepoPickerOpenChange={setIsRepoPickerOpen}
/>
</Dialog.Content>
</Dialog.Root>
Expand All @@ -98,15 +128,38 @@ function DismissReportDialogBody({
isSubmitting,
snoozeDisabledReason,
onConfirm,
isRepoPickerOpen,
onRepoPickerOpenChange,
}: Omit<DismissReportDialogProps, "open" | "onOpenChange"> & {
selectedCount: number;
/** Owned by the dialog wrapper, which suppresses its dismiss paths while the picker is open. */
isRepoPickerOpen: boolean;
onRepoPickerOpenChange: (open: boolean) => void;
}) {
const [reason, setReason] = useState<DismissalReasonOptionValue | null>(null);
const [note, setNote] = useState("");
const [correctedRepository, setCorrectedRepository] = useState<string | null>(
null,
);
const [repoSearch, setRepoSearch] = useState("");

const isWrongRepo = reason === "wrong_repo";
// Populates the integration store in case no other surface loaded it yet; react-query dedupes.
useIntegrations();
const { hasGithubIntegration } = useIntegrationSelectors();
// Enabled on the reason alone, not on isRepoPickerOpen: when the list is empty the picker
// renders a disabled "No GitHub repos" trigger that cannot be opened, so gating the fetch on
// the open state would deadlock on a cold cache (never open -> never fetch -> never open).
const repoPage = useGithubRepositories(repoSearch, isWrongRepo);

const handleConfirm = () => {
if (!reason) return;
onConfirm({ reason, note: note.trim() });
onConfirm({
reason,
note: note.trim(),
// A correction picked and then abandoned for another reason must not ride along.
correctedRepository: isWrongRepo ? correctedRepository : null,
});
};

const alreadyFixedDisabled = snoozeDisabledReason !== null;
Expand Down Expand Up @@ -160,6 +213,34 @@ function DismissReportDialogBody({
</Flex>
</RadioGroup.Root>

{isWrongRepo && hasGithubIntegration ? (
<div className="flex flex-col gap-1">
<span className="text-(--gray-12) text-xs">
Which repository should it have been?
</span>
<GitHubRepoPicker
value={correctedRepository}
onChange={setCorrectedRepository}
repositories={repoPage.repositories}
isLoading={repoPage.isPending}
isLoadingMore={repoPage.isFetchingMore}
open={isRepoPickerOpen}
onOpenChange={onRepoPickerOpenChange}
searchQuery={repoSearch}
onSearchQueryChange={setRepoSearch}
hasMore={repoPage.hasMore}
onLoadMore={repoPage.loadMore}
disabled={isSubmitting}
placeholder="Search repositories"
size="1"
/>
Comment thread
sortafreel marked this conversation as resolved.
Comment thread
sortafreel marked this conversation as resolved.
<span className="text-(--gray-10) text-xs">
Optional. The agent uses your correction when picking repositories
in the future.
</span>
</div>
) : null}

<TextArea
value={note}
onChange={(event) => setNote(event.target.value)}
Expand Down
Loading
Loading