diff --git a/package.json b/package.json index c7d1da6..1fbfc31 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@microsoft/fetch-event-source": "^2.0.1", "@next/third-parties": "^15.5.4", - "radix-ui": "^1.4.3", "@radix-ui/themes": "^3.2.1", "@tanstack/react-query": "^5.76.0", "@tanstack/react-query-next-experimental": "^5.76.0", @@ -53,6 +53,7 @@ "next-auth": "^5.0.0-beta.29", "nuqs": "^2.6.0", "posthog-node": "^5.9.2", + "radix-ui": "^1.4.3", "react": "19.1.1", "react-day-picker": "^9.6.7", "react-dom": "19.1.1", diff --git a/src/app/(app)/settings/code-review/[repositoryId]/layout.tsx b/src/app/(app)/settings/code-review/[repositoryId]/layout.tsx index e779194..4475a95 100644 --- a/src/app/(app)/settings/code-review/[repositoryId]/layout.tsx +++ b/src/app/(app)/settings/code-review/[repositoryId]/layout.tsx @@ -1,14 +1,26 @@ "use client"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { useParams } from "next/navigation"; +import { GetStartedChecklist } from "@components/system/get-started-checklist"; +import { Button } from "@components/ui/button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@components/ui/sheet"; import { useSuspenseGetParameterByKey } from "@services/parameters/hooks"; import { LanguageValue, ParametersConfigKey } from "@services/parameters/types"; import { usePermission } from "@services/permissions/hooks"; import { Action, ResourceType } from "@services/permissions/types"; +import { Eye } from "lucide-react"; import { FormProvider, useForm } from "react-hook-form"; import { useSelectedTeamId } from "src/core/providers/selected-team-context"; +import { cn } from "src/core/utils/components"; +import { DryRunSidebar } from "../_components/dry-run-sidebar"; import { type CodeReviewFormType } from "../_types"; import { useCodeReviewConfig } from "../../_components/context"; @@ -28,11 +40,12 @@ export default function Layout(props: React.PropsWithChildren) { ); const params = useParams(); + const repositoryId = params.repositoryId as string; const canEdit = usePermission( Action.Update, ResourceType.CodeReviewSettings, - params.repositoryId as string, + repositoryId, ); const form = useForm({ @@ -50,5 +63,31 @@ export default function Layout(props: React.PropsWithChildren) { form.reset({ ...config, language: parameters.configValue }); }, [config?.id]); - return {props.children}; + // const getStarted = GetStartedChecklist(); + const getStartedVisible = true; + + return ( + + {props.children} + + + + + + + + + + + + ); } diff --git a/src/app/(app)/settings/code-review/_components/dry-run-sidebar/code.tsx b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/code.tsx new file mode 100644 index 0000000..996a2bb --- /dev/null +++ b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/code.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { SyntaxHighlight } from "@components/ui/syntax-highlight"; + +export const CodeDiff = ({ + existingCode, + improvedCode, + language, +}: { + existingCode?: string; + improvedCode?: string; + language?: string; +}) => { + if (!existingCode && !improvedCode) { + return ( +
+ No code snippet available. +
+ ); + } + + return ( +
+            
+                
+
+

+ Existing Code: +

+ + {existingCode} + +
+ +
+

+ Improved Code: +

+ + {improvedCode} + +
+
+
+
+ ); +}; diff --git a/src/app/(app)/settings/code-review/_components/dry-run-sidebar/empty.tsx b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/empty.tsx new file mode 100644 index 0000000..d82e9f9 --- /dev/null +++ b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/empty.tsx @@ -0,0 +1,16 @@ +import { Eye } from "lucide-react"; + +export const EmptyState = () => ( +
+
+ +
+
+

Ready to preview?

+

+ Select a Pull Request and generate a preview to see how kodus + will analyze the code with current settings. +

+
+
+); diff --git a/src/app/(app)/settings/code-review/_components/dry-run-sidebar/history.tsx b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/history.tsx new file mode 100644 index 0000000..37d58ce --- /dev/null +++ b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/history.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import { Badge } from "@components/ui/badge"; +import { Button } from "@components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@components/ui/popover"; +import { listDryRuns } from "@services/dryRun/fetch"; +import { IDryRunData } from "@services/dryRun/types"; +import { ChevronsUpDown, Loader2 } from "lucide-react"; +import { useSelectedTeamId } from "src/core/providers/selected-team-context"; + +import { statusMap } from "."; +import { useCodeReviewRouteParams } from "../../../_hooks"; + +const formatHistoryDate = (dateString: string | Date) => { + const date = dateString instanceof Date ? dateString : new Date(dateString); + return date.toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "short", + }); +}; + +export const SelectHistoryItem = (props: { + id?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + disabled?: boolean; + value: string | null; // correlationId + onChange: (value: string) => void; // setCorrelationId +}) => { + const { + id = "select-history-item", + open, + onOpenChange, + disabled, + onChange, + value, + } = props; + + const { teamId } = useSelectedTeamId(); + const { repositoryId, directoryId } = useCodeReviewRouteParams(); + + const [history, setHistory] = useState([]); + const [isHistoryLoading, setIsHistoryLoading] = useState(false); + + useEffect(() => { + const fetchHistory = async () => { + if (!teamId || !repositoryId) return; + setIsHistoryLoading(true); + try { + const historyData = await listDryRuns(teamId, { + repositoryId, + directoryId, + }); + setHistory(historyData); + } catch (err) { + console.error("Failed to fetch dry run history:", err); + setHistory([]); + } finally { + setIsHistoryLoading(false); + } + }; + + fetchHistory(); + }, [teamId, repositoryId, directoryId]); + + const selectedItem = history.find((item) => item.id === value); + + const historyGroupedByRepository = history.reduce( + (acc, current) => { + if (!acc[current.repositoryName]) acc[current.repositoryName] = []; + acc[current.repositoryName].push(current); + return acc; + }, + {} as Record, + ); + + return ( + + + + + + + { + const item = history.find((h) => h.id === value); + if (item) { + const prNumberString = item.prNumber.toString(); + const repositoryName = + item.repositoryName.toLowerCase(); + const searchLower = search.toLowerCase(); + + if ( + prNumberString.includes(searchLower) || + `#${prNumberString}`.includes(searchLower) || + repositoryName.includes(searchLower) + ) { + return 1; + } + } + return 0; + }}> + + + + No past preview found. + +
+ {Object.entries(historyGroupedByRepository).map( + ([repoName, items]) => ( + + {items.map((item) => ( + + onChange(item.id) + } + className="flex flex-col items-start"> + + + {item.status + ? statusMap[ + item.status + ] + : "Unknown"} + + + #{item.prNumber} -{" "} + {item.prTitle} + + + + {formatHistoryDate( + item.createdAt, + )} + + + ))} + + ), + )} +
+
+
+
+
+ ); +}; diff --git a/src/app/(app)/settings/code-review/_components/dry-run-sidebar/index.tsx b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/index.tsx new file mode 100644 index 0000000..ee3a1b1 --- /dev/null +++ b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/index.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SelectPullRequest } from "@components/system/select-pull-requests"; +import { Button } from "@components/ui/button"; +import { Label } from "@components/ui/label"; +import { + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@components/ui/sheet"; +import { toast } from "@components/ui/toaster/use-toast"; +import { useEffectOnce } from "@hooks/use-effect-once"; +import { getPrsByRepository } from "@services/codeManagement/fetch"; +import { executeDryRun } from "@services/dryRun/fetch"; +import { PREVIEW_JOB_ID_KEY, useDryRun } from "@services/dryRun/hooks"; +import { DryRunStatus, IDryRunData } from "@services/dryRun/types"; +import { Eye, Info, Loader2, RefreshCw } from "lucide-react"; +import { useSelectedTeamId } from "src/core/providers/selected-team-context"; +import { AwaitedReturnType } from "src/core/types"; + +import { useCodeReviewRouteParams } from "../../../_hooks"; +import { EmptyState } from "./empty"; +import { SelectHistoryItem } from "./history"; +import { Results } from "./results"; + +export const statusMap: Record = { + [DryRunStatus.IN_PROGRESS]: "In Progress", + [DryRunStatus.COMPLETED]: "Completed", + [DryRunStatus.FAILED]: "Failed", +}; + +export const DryRunSidebar = () => { + const { teamId } = useSelectedTeamId(); + const { repositoryId } = useCodeReviewRouteParams(); + + const [pullRequests, setPullRequests] = useState< + AwaitedReturnType + >([]); + const [isPrsLoading, setIsPrsLoading] = useState(false); + + useEffect(() => { + const fetchPrs = async () => { + setIsPrsLoading(true); + try { + const prs = await getPrsByRepository(teamId, repositoryId, { + state: "closed", + }); + setPullRequests(prs); + } catch (err) { + console.error("Failed to fetch pull requests:", err); + } finally { + setIsPrsLoading(false); + } + }; + + if (teamId && repositoryId) { + fetchPrs(); + } + }, [teamId, repositoryId]); + + useEffectOnce(() => { + const activeJobId = sessionStorage.getItem(PREVIEW_JOB_ID_KEY); + if (activeJobId) { + setCorrelationId(activeJobId); + } + }); + + const [prsOpen, setPrsOpen] = useState(false); + const [selectedPR, setSelectedPR] = useState< + NonNullable[number] | undefined + >(); + + const [historyOpen, setHistoryOpen] = useState(false); + + const [correlationId, setCorrelationId] = useState(null); + const [isStarting, setIsStarting] = useState(false); + + const { + messages, + status, + description, + isLoading, + isConnected, + error, + files, + prLevelSuggestions, + } = useDryRun({ + correlationId, + teamId, + }); + + const handleGeneratePreview = async () => { + setIsStarting(true); + setCorrelationId(null); + + const prNumber = selectedPR ? selectedPR.pull_number : null; + const repositoryId = selectedPR ? selectedPR.repositoryId : null; + + if (!prNumber || !repositoryId) { + setIsStarting(false); + return; + } + + const response = await executeDryRun(teamId, repositoryId, prNumber); + + if (!response) { + setIsStarting(false); + + toast({ + variant: "warning", + title: "Failed to start preview", + description: "Failed to start preview. Please try again later.", + }); + + return; + } + + if (response.includes("Limit.Reached")) { + setIsStarting(false); + + toast({ + variant: "warning", + title: "Preview limit reached", + description: + "You have reached the maximum number of daily previews. Please try again later.", + }); + + return; + } + + const jobId = response; + sessionStorage.setItem(PREVIEW_JOB_ID_KEY, jobId); + setCorrelationId(jobId); + + setIsStarting(false); + }; + + const showLoading = isStarting || isLoading; + const hasData = messages.length > 0 || !!status; + const isComplete = status === DryRunStatus.COMPLETED && !isConnected; + + const loadingPrs = ( +
+ + Loading Pull Requests... +
+ ); + + return ( + + +
+
+ +
+
+ Preview + + Preview code review results + +
+
+
+ +
+
+
+ + {isPrsLoading ? ( + loadingPrs + ) : ( + { + setSelectedPR(v); + setPrsOpen(false); + }} + /> + )} +
+ + + +
+
+ + Or + +
+ +
+ + { + setCorrelationId(value); + setSelectedPR(undefined); + setHistoryOpen(false); + }} + /> +
+ +
+
+ +
+ {error && ( +
+ Error: {error} +
+ )} + {!hasData && !isLoading && !error ? : null} + {hasData ? ( + + ) : null} +
+
+ + +
+ + + This is a preview. No changes will be made to the actual + PR. + +
+
+
+ ); +}; diff --git a/src/app/(app)/settings/code-review/_components/dry-run-sidebar/results.tsx b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/results.tsx new file mode 100644 index 0000000..612ce80 --- /dev/null +++ b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/results.tsx @@ -0,0 +1,89 @@ +import { Markdown } from "@components/ui/markdown"; +import { + DryRunStatus, + IDryRunMessage, + IFile, + ISuggestionByPR, +} from "@services/dryRun/types"; + +import { SuggestionCard } from "./suggestion"; +import { PreviewSummary } from "./summary"; + +export const Results = ({ + messages, + files, + prLevelSuggestions, + description, + status, + isComplete, +}: { + messages: IDryRunMessage[]; + files: IFile[]; + prLevelSuggestions: ISuggestionByPR[]; + description: string | null; + status: DryRunStatus | null; + isComplete: boolean; +}) => { + const generalMessages = []; + const reviewMessages = []; + + for (const msg of messages) { + if (!msg.category || !msg.severity) { + generalMessages.push(msg); + } else { + reviewMessages.push(msg); + } + } + + let suggestions = prLevelSuggestions.length; + for (const file of files) { + suggestions += file.suggestions.length; + } + + return ( +
+ +
+

Description

+
+ {description ? ( + {description} + ) : ( +

+ No description provided. +

+ )} +
+

General Messages

+ {generalMessages.length === 0 && !isComplete && ( +

+ Waiting for general messages... +

+ )} +
+ {generalMessages.map((message) => ( + + ))} +
+
+
+

Suggestions

+ {reviewMessages.length === 0 && !isComplete && ( +

+ Waiting for suggestions... +

+ )} +
+ {reviewMessages.map((message) => ( + + ))} +
+
+
+ ); +}; diff --git a/src/app/(app)/settings/code-review/_components/dry-run-sidebar/suggestion.tsx b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/suggestion.tsx new file mode 100644 index 0000000..b5eaf96 --- /dev/null +++ b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/suggestion.tsx @@ -0,0 +1,57 @@ +import { IssueSeverityLevelBadge } from "@components/system/issue-severity-level-badge"; +import { SuggestionCategoryBadge } from "@components/system/suggestion-category-badge"; +import { Badge } from "@components/ui/badge"; +import { Markdown } from "@components/ui/markdown"; +import { IDryRunMessage } from "@services/dryRun/types"; +import { Bug, File, Info, Shield } from "lucide-react"; + +import { CodeDiff } from "./code"; + +export const SuggestionCard = ({ + suggestion, +}: { + suggestion: IDryRunMessage; +}) => { + return ( +
+
+ {suggestion.category && ( + + )} + {suggestion.severity && ( + + )} +
+ +
+ + {suggestion.content.replace( + /<\/details>\s*<\/details>/g, + "", + )} + +
+ + {suggestion.path && ( +
+
+ + {suggestion.path} + + (lines {suggestion.lines?.start} to{" "} + {suggestion.lines?.end}) + +
+ + +
+ )} +
+ ); +}; diff --git a/src/app/(app)/settings/code-review/_components/dry-run-sidebar/summary.tsx b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/summary.tsx new file mode 100644 index 0000000..e83e97a --- /dev/null +++ b/src/app/(app)/settings/code-review/_components/dry-run-sidebar/summary.tsx @@ -0,0 +1,53 @@ +import { CheckCircle2, Loader2 } from "lucide-react"; + +export const PreviewSummary = ({ + suggestionsSent, + suggestionsFound, + filesAnalyzed, + isComplete, +}: { + suggestionsSent: number; + suggestionsFound: number; + filesAnalyzed: number | null; + isComplete: boolean; +}) => ( +
+ {isComplete ? ( + + ) : ( + + )} + +
+

+ {isComplete ? "Preview Complete" : "Preview in Progress"} +

+

+ {isComplete + ? "Kody has finished analyzing the Pull Request." + : "Kody is analyzing the Pull Request..."} +

+
+
+
+
{suggestionsSent}
+
+ Suggestions sent +
+
+
+
{suggestionsFound}
+
+ Suggestions found +
+
+ +
+
+ {filesAnalyzed ?? "--"} +
+
Files analyzed
+
+
+
+); diff --git a/src/core/components/system/suggestion-category-badge.tsx b/src/core/components/system/suggestion-category-badge.tsx new file mode 100644 index 0000000..d5d8a2c --- /dev/null +++ b/src/core/components/system/suggestion-category-badge.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { Badge } from "@components/ui/badge"; +import { + AlertTriangle, + Bug, + LucideIcon, + Network, + Shield, + Tag, + Target, + Zap, +} from "lucide-react"; +import { cn } from "src/core/utils/components"; + +export const categoryClassnames = { + security: + "bg-danger/10 text-danger ring-danger/64 [--button-foreground:var(--color-danger)]", + breaking_changes: + "bg-danger/10 text-danger ring-danger/64 [--button-foreground:var(--color-danger)]", + bug: "bg-warning/10 text-warning ring-warning/64 [--button-foreground:var(--color-warning)]", + performance: + "bg-alert/10 text-alert ring-alert/64 [--button-foreground:var(--color-alert)]", + kody_rules: + "bg-info/10 text-info ring-info/64 [--button-foreground:var(--color-info)]", + cross_file: + "bg-info/10 text-info ring-info/64 [--button-foreground:var(--color-info)]", + default: + "bg-gray-100 text-gray-600 ring-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700", +}; +export type IssueCategory = keyof typeof categoryClassnames; + +const categoryIcons: Record = { + security: Shield, + bug: Bug, + performance: Zap, + kody_rules: Target, + breaking_changes: AlertTriangle, + cross_file: Network, + default: Tag, +}; + +const categoryDisplayNames: Record = { + security: "Security", + bug: "Bug", + performance: "Performance", + kody_rules: "Kody Rule", + breaking_changes: "Breaking Change", + cross_file: "Cross-File", + default: "Suggestion", +}; + +export const SuggestionCategoryBadge = ({ + category, + showIcon = true, + className, +}: { + category: string; + showIcon?: boolean; + className?: string; +}) => { + const categoryKey = + category in categoryClassnames + ? (category as IssueCategory) + : "default"; + + const Icon = categoryIcons[categoryKey]; + + const displayName = + categoryKey !== "default" + ? categoryDisplayNames[categoryKey] + : category + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()); + + const baseStyle = + "pointer-events-none h-6 min-h-auto rounded-lg text-[10px] leading-px uppercase ring-1 flex items-center gap-1"; + + return ( + + {showIcon && } + {displayName} + + ); +}; diff --git a/src/lib/services/codeManagement/fetch.ts b/src/lib/services/codeManagement/fetch.ts index d08b962..46b2758 100644 --- a/src/lib/services/codeManagement/fetch.ts +++ b/src/lib/services/codeManagement/fetch.ts @@ -5,7 +5,11 @@ import { } from "src/core/types"; import { axiosAuthorized } from "src/core/utils/axios"; -import { CODE_MANAGEMENT_API_PATHS, type Repository, type RepositoryUploadResult } from "./types"; +import { + CODE_MANAGEMENT_API_PATHS, + type Repository, + type RepositoryUploadResult, +} from "./types"; export const getRepositories = async ( teamId: string, @@ -37,21 +41,21 @@ export const createOrUpdateRepositories = ( export const createOrUpdateRepositoriesInChunks = async ( repositories: Repository[], teamId: string, - onProgress?: (current: number, total: number) => void + onProgress?: (current: number, total: number) => void, ): Promise => { const CHUNK_SIZE = 50; const chunks = []; - + for (let i = 0; i < repositories.length; i += CHUNK_SIZE) { chunks.push(repositories.slice(i, i + CHUNK_SIZE)); } - + const results: RepositoryUploadResult = { success: 0, failed: 0, - errors: [] + errors: [], }; - + for (let i = 0; i < chunks.length; i++) { const type = i === 0 ? "replace" : "append"; try { @@ -63,7 +67,7 @@ export const createOrUpdateRepositoriesInChunks = async ( } onProgress?.(results.success + results.failed, repositories.length); } - + return results; }; @@ -153,3 +157,49 @@ export const deleteIntegrationAndRepositories = async ( { params: { organizationId, teamId } }, ); }; + +export const getPrsByRepository = async ( + teamId: string, + repositoryId: string, + filters?: { + number?: string; + startDate?: string; + endDate?: string; + author?: string; + branch?: string; + title?: string; + state?: "open" | "closed" | "merged" | "all"; + }, +) => { + const { data: rawData } = (await axiosAuthorized.fetcher( + CODE_MANAGEMENT_API_PATHS.GET_PULL_REQUESTS, + { + params: { + teamId, + repositoryId, + ...filters, + }, + }, + )) as { + data: { + id: string; + pull_number: number; + repository: { + id: string; + name: string; + }; + title: string; + url: string; + }[]; + }; + + // Transform to legacy format for compatibility + return rawData.map((pr) => ({ + id: pr.id, + pull_number: pr.pull_number, + repository: pr.repository.name, // Extract name from repository object + repositoryId: pr.repository.id, + title: pr.title, + url: pr.url, + })); +}; diff --git a/src/lib/services/codeManagement/hooks.ts b/src/lib/services/codeManagement/hooks.ts index 80022d5..1d6d247 100644 --- a/src/lib/services/codeManagement/hooks.ts +++ b/src/lib/services/codeManagement/hooks.ts @@ -62,6 +62,49 @@ export function useSuspenseGetOnboardingPullRequests(teamId: string) { })); } +export function useSuspenseGetPullRequestsByRepository( + teamId: string, + repositoryId: string, + filters?: { + number?: string; + startDate?: string; + endDate?: string; + author?: string; + branch?: string; + title?: string; + state?: "open" | "closed" | "merged" | "all"; + }, +) { + const rwaData = useSuspenseFetch< + { + id: string; + pull_number: number; + repository: { + id: string; + name: string; + }; + title: string; + url: string; + }[] + >(CODE_MANAGEMENT_API_PATHS.GET_PULL_REQUESTS, { + params: { + teamId, + repositoryId, + ...filters, + }, + }); + + // Transform to legacy format for compatibility + return rwaData.map((pr) => ({ + id: pr.id, + pull_number: pr.pull_number, + repository: pr.repository.name, // Extract name from repository object + repositoryId: pr.repository.id, + title: pr.title, + url: pr.url, + })); +} + export function useSearchPullRequests( teamId: string, searchParams: { @@ -70,7 +113,6 @@ export function useSearchPullRequests( repositoryId?: string; } = {}, ) { - return useFetch< { id: string; diff --git a/src/lib/services/codeManagement/types.ts b/src/lib/services/codeManagement/types.ts index e5f6831..b365abe 100644 --- a/src/lib/services/codeManagement/types.ts +++ b/src/lib/services/codeManagement/types.ts @@ -55,6 +55,8 @@ export const CODE_MANAGEMENT_API_PATHS = { DELETE_INTEGRATION_AND_REPOSITORIES: pathToApiUrl( "/code-management/delete-integration-and-repositories", ), + + GET_PULL_REQUESTS: pathToApiUrl("/code-management/get-prs-repo"), } as const; export type RepositoryUploadResult = { diff --git a/src/lib/services/dryRun/fetch.ts b/src/lib/services/dryRun/fetch.ts new file mode 100644 index 0000000..feda0a5 --- /dev/null +++ b/src/lib/services/dryRun/fetch.ts @@ -0,0 +1,86 @@ +import { authorizedFetch } from "@services/fetch"; +import { axiosAuthorized } from "src/core/utils/axios"; + +import { DRY_RUN_PATHS } from "."; +import { DryRunStatus, IDryRunData } from "./types"; + +export const executeDryRun = async ( + teamId: string, + repositoryId: string, + prNumber: number, +): Promise => { + try { + const response = await axiosAuthorized.post<{ + data: string; + }>(DRY_RUN_PATHS.EXECUTE, { + teamId, + repositoryId, + prNumber, + }); + + return response.data; + } catch (error: any) { + throw new Error(error.response?.status || "Unknown error"); + } +}; + +export const fetchDryRunStatus = async ( + correlationId: string, + teamId: string, +): Promise => { + try { + const response = await authorizedFetch( + `${DRY_RUN_PATHS.GET_STATUS}/${correlationId}`, + { + params: { teamId }, + }, + ); + + return response; + } catch (error: any) { + throw new Error(error.response?.status || "Unknown error"); + } +}; + +export const fetchDryRunDetails = async ( + correlationId: string, + teamId: string, +): Promise => { + try { + const response = await authorizedFetch( + `${DRY_RUN_PATHS.GET}/${correlationId}`, + { + params: { teamId }, + }, + ); + + return response; + } catch (error: any) { + throw new Error(error.response?.status || "Unknown error"); + } +}; + +export const listDryRuns = async ( + teamId: string, + filters?: { + repositoryId?: string; + directoryId?: string; + status?: DryRunStatus; + startDate?: string; + endDate?: string; + prNumber?: number; + }, +): Promise => { + try { + const response = await authorizedFetch( + DRY_RUN_PATHS.LIST, + { + params: { teamId, ...filters }, + }, + ); + + return response; + } catch (error: any) { + throw new Error(error.response?.status || "Unknown error"); + } +}; diff --git a/src/lib/services/dryRun/hooks.ts b/src/lib/services/dryRun/hooks.ts new file mode 100644 index 0000000..2aa097b --- /dev/null +++ b/src/lib/services/dryRun/hooks.ts @@ -0,0 +1,295 @@ +import { useEffect, useRef, useState } from "react"; +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { useAuth } from "src/core/providers/auth.provider"; +import { getJWTToken } from "src/core/utils/session"; +import { addSearchParamsToUrl } from "src/core/utils/url"; + +import { DRY_RUN_PATHS } from "."; +import { fetchDryRunDetails, fetchDryRunStatus } from "./fetch"; +import { + DryRunEventType, + DryRunStatus, + IDryRunDescriptionUpdatedPayload, + IDryRunEvent, + IDryRunMessage, + IDryRunMessageAddedPayload, + IDryRunMessageUpdatedPayload, + IDryRunStatusUpdatedPayload, + IFile, + ISuggestionByPR, +} from "./types"; + +export const PREVIEW_JOB_ID_KEY = "activePreviewJobId"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const useDryRun = ({ + correlationId, + teamId, +}: { + correlationId: string | null; + teamId: string; +}) => { + const [messages, setMessages] = useState([]); + const [status, setStatus] = useState(null); + const [description, setDescription] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const [files, setFiles] = useState([]); + const [prLevelSuggestions, setPrLevelSuggestions] = useState< + ISuggestionByPR[] + >([]); + + const eventSourceRef = useRef(null); + + useEffect(() => { + if (!correlationId || !teamId) { + setIsLoading(false); + return; + } + + const checkAndConnect = async () => { + setIsLoading(true); + setError(null); + setMessages([]); + setStatus(null); + setDescription(null); + setFiles([]); + setPrLevelSuggestions([]); + + try { + let initialStatus: DryRunStatus | null = null; + const maxRetries = 5; + const initialDelay = 500; // 500ms + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + initialStatus = await fetchDryRunStatus( + correlationId, + teamId, + ); + + if (initialStatus !== null) { + break; + } + + console.warn( + `Attempt ${attempt} to fetch dry run status: Not found (null). Retrying...`, + ); + + if (attempt < maxRetries) { + await sleep(initialDelay * Math.pow(2, attempt - 1)); + } + } + + if (initialStatus === null) { + throw new Error( + "Failed to get dry run status after multiple attempts. Job not found.", + ); + } + + if ( + initialStatus === DryRunStatus.COMPLETED || + initialStatus === DryRunStatus.FAILED + ) { + await fetchFinalDetails(); + return; + } + + if (initialStatus === DryRunStatus.IN_PROGRESS) { + setStatus(initialStatus); + connectToStream(); + return; + } + + throw new Error(`Unexpected dry run status: ${initialStatus}`); + } catch (err: any) { + console.error("Error checking dry run status:", err); + setError(err.message || "An unknown error occurred"); + setIsLoading(false); + sessionStorage.removeItem(PREVIEW_JOB_ID_KEY); + } + }; + + const fetchFinalDetails = async () => { + try { + setIsLoading(true); + const details = await fetchDryRunDetails(correlationId, teamId); + + if (details) { + setMessages(details.messages || []); + setStatus(details.status); + setDescription(details.description || null); + setFiles(details.files || []); + setPrLevelSuggestions(details.prLevelSuggestions || []); + } + } catch (err: any) { + console.error("Failed to fetch final dry run details:", err); + setError(err.message || "Failed to get final details."); + } finally { + sessionStorage.removeItem(PREVIEW_JOB_ID_KEY); + setIsLoading(false); + setIsConnected(false); + } + }; + + const connectToStream = async () => { + eventSourceRef.current?.abort(); + + const controller = new AbortController(); + eventSourceRef.current = controller; + + const url = new URL(`${DRY_RUN_PATHS.SSE_EVENTS}/${correlationId}`); + const finalUrl = addSearchParamsToUrl(url.toString(), { + teamId, + }); + + const accessToken = await getJWTToken(); + + return fetchEventSource(finalUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + signal: controller.signal, + + onopen: async (response) => { + if (response.ok) { + setIsConnected(true); + } else { + controller.abort(); + setError( + `Failed to connect: ${response.status} ${response.statusText}`, + ); + setIsLoading(false); + setIsConnected(false); + } + }, + + onmessage: (event) => { + if (!event.data) { + return; + } + + console.log("Received SSE event:", event); + + let parsedEvent: IDryRunEvent | undefined; + try { + parsedEvent = JSON.parse(event.data) as IDryRunEvent; + } catch (err) { + console.error("Failed to parse SSE event data:", err); + return; + } + + if (!parsedEvent) { + console.warn("Received empty or invalid event:", event); + return; + } + + switch (parsedEvent.type) { + case DryRunEventType.MESSAGE_ADDED: { + const payload = + parsedEvent.payload as IDryRunMessageAddedPayload; + + console.log("Adding message:", payload.message); + setMessages((prev) => { + const index = prev.findIndex( + (msg) => msg.id === payload.message.id, + ); + + if (index !== -1) { + const newState = [...prev]; + newState[index] = payload.message; + return newState; + } + + return [...prev, payload.message]; + }); + break; + } + + case DryRunEventType.MESSAGE_UPDATED: { + const payload = parsedEvent.payload; + setMessages((prev) => + prev.map((msg) => { + if (msg.id === payload.messageId) { + return { + ...msg, + content: payload.content, + }; + } + return msg; + }), + ); + break; + } + + case DryRunEventType.STATUS_UPDATED: { + const payload = parsedEvent.payload; + setStatus(payload.status); + + if ( + payload.status === DryRunStatus.COMPLETED || + payload.status === DryRunStatus.FAILED + ) { + controller.abort(); // Gracefully close connection + setIsConnected(false); + fetchFinalDetails(); + } + break; + } + + case DryRunEventType.DESCRIPTION_UPDATED: { + const payload = parsedEvent.payload; + setDescription(payload.description); + break; + } + + case DryRunEventType.REMOVED: { + controller.abort(); + setIsConnected(false); + setIsLoading(false); + break; + } + } + }, + + onclose: () => { + console.log("SSE connection closed."); + setIsConnected(false); + setIsLoading(false); + }, + + onerror: (err) => { + console.error("SSE Error:", err); + setError("Connection to server lost."); + setIsConnected(false); + setIsLoading(false); + + if (err.name === "AbortError") { + return; + } + }, + }); + }; + + checkAndConnect(); + + return () => { + console.log("Aborting event source from effect cleanup."); + eventSourceRef.current?.abort(); + setIsConnected(false); + setIsLoading(false); + }; + }, [correlationId, teamId]); + + return { + messages, + status, + description, + isLoading, + isConnected, + error, + files, + prLevelSuggestions, + }; +}; diff --git a/src/lib/services/dryRun/index.ts b/src/lib/services/dryRun/index.ts new file mode 100644 index 0000000..d074046 --- /dev/null +++ b/src/lib/services/dryRun/index.ts @@ -0,0 +1,9 @@ +import { pathToApiUrl } from "src/core/utils/helpers"; + +export const DRY_RUN_PATHS = { + EXECUTE: pathToApiUrl("/dry-run/execute"), + GET_STATUS: pathToApiUrl("/dry-run/status"), + SSE_EVENTS: pathToApiUrl("/dry-run/events"), + GET: pathToApiUrl("/dry-run"), + LIST: pathToApiUrl("/dry-run"), +}; diff --git a/src/lib/services/dryRun/types.ts b/src/lib/services/dryRun/types.ts new file mode 100644 index 0000000..d64f28f --- /dev/null +++ b/src/lib/services/dryRun/types.ts @@ -0,0 +1,204 @@ +import { SeverityLevel } from "src/core/types"; + +export enum DryRunStatus { + IN_PROGRESS = "IN_PROGRESS", + COMPLETED = "COMPLETED", + FAILED = "FAILED", +} + +export interface ISuggestion { + id: string; + relevantFile: string; + language: string; + suggestionContent: string; + existingCode: string; + improvedCode: string; + oneSentenceSummary: string; + relevantLinesStart: number; + relevantLinesEnd: number; + label: string; + severity: string; + rankScore?: number; + brokenKodyRulesIds?: string[]; + clusteringInformation?: { + type?: string; + relatedSuggestionsIds?: string[]; + parentSuggestionId?: string; + problemDescription?: string; + actionStatement?: string; + }; + priorityStatus: string; + deliveryStatus: string; + implementationStatus?: string; + comment?: { + id: number; + pullRequestReviewId: number; + }; + type?: string; + createdAt: string; + updatedAt: string; + prNumber?: number; + prTitle?: string; + prUrl?: string; + repositoryId?: string; + repositoryFullName?: string; +} + +export interface IFile { + id: string; + sha?: string; + path: string; + filename: string; + previousName: string; + status: string; + createdAt: string; + updatedAt: string; + suggestions: ISuggestion[]; + added?: number; + deleted?: number; + changes?: number; + reviewMode?: string; + codeReviewModelUsed?: { + generateSuggestions: string; + safeguard: string; + }; +} + +export interface ISuggestionByPR { + id: string; + suggestionContent: string; + oneSentenceSummary: string; + label: string; + severity?: SeverityLevel; + brokenKodyRulesIds?: string[]; + priorityStatus?: string; + deliveryStatus: string; + comment?: { + id: number; + pullRequestReviewId: number; + }; + files?: { + violatedFileSha?: string[]; + relatedFileSha?: string[]; + }; + createdAt?: string; + updatedAt?: string; +} + +export interface IDryRunData { + id: string; + status: DryRunStatus; + + dependents: IDryRunData["id"][]; // Ids of dry runs that reference this one + createdAt: Date; + updatedAt: Date; + + provider: string; + prNumber: number; + prTitle: string; + repositoryId: string; + repositoryName: string; + directoryId?: string; + + description: string; + messages: IDryRunMessage[]; + files: IFile[]; // Changed files or reference to another dry run + prLevelSuggestions: ISuggestionByPR[]; // PR level suggestions or reference to another dry run + + config: string; // ID of the code review config used + pullRequestMessages: string; // ID of the pull request messages used + + configHashes: { + full: string; // Hash of the full config + basic: string; // Hash of configs that do not affect LLM behavior + llm: string; // Hash of configs that affect LLM behavior + }; +} + +export interface IDryRunMessage { + id: number; + content: string; + path?: string; + lines?: { + start: number; + end: number; + }; + severity?: string; + category?: string; + language?: string; + existingCode?: string; + improvedCode?: string; +} + +export enum DryRunEventType { + MESSAGE_ADDED = "MESSAGE_ADDED", + MESSAGE_UPDATED = "MESSAGE_UPDATED", + DESCRIPTION_UPDATED = "DESCRIPTION_UPDATED", + STATUS_UPDATED = "STATUS_UPDATED", + REMOVED = "REMOVED", +} + +export interface IDryRunBaseEvent { + id: string; + dryRunId: string; + organizationId: string; + teamId: string; + type: DryRunEventType; + payload: any; + timestamp: Date; +} + +export interface IDryRunMessageAddedEvent extends IDryRunBaseEvent { + type: DryRunEventType.MESSAGE_ADDED; + payload: IDryRunMessageAddedPayload; +} + +export interface IDryRunMessageAddedPayload { + message: IDryRunMessage; +} + +export interface IDryRunMessageUpdatedEvent extends IDryRunBaseEvent { + type: DryRunEventType.MESSAGE_UPDATED; + payload: IDryRunMessageUpdatedPayload; +} + +export interface IDryRunMessageUpdatedPayload { + messageId: number; + content: string; +} + +export interface IDryRunDescriptionUpdatedEvent extends IDryRunBaseEvent { + type: DryRunEventType.DESCRIPTION_UPDATED; + payload: IDryRunDescriptionUpdatedPayload; +} + +export interface IDryRunDescriptionUpdatedPayload { + description: string; +} + +export interface IDryRunStatusUpdatedEvent extends IDryRunBaseEvent { + type: DryRunEventType.STATUS_UPDATED; + payload: IDryRunStatusUpdatedPayload; +} + +export interface IDryRunStatusUpdatedPayload { + status: DryRunStatus; +} + +export interface IDryRunRemovedEvent extends IDryRunBaseEvent { + type: DryRunEventType.REMOVED; + payload: IDryRunRemovedPayload; +} + +export interface IDryRunRemovedPayload {} + +export type IDryRunEvent = + | IDryRunMessageAddedEvent + | IDryRunMessageUpdatedEvent + | IDryRunDescriptionUpdatedEvent + | IDryRunStatusUpdatedEvent + | IDryRunRemovedEvent; + +export type IDryRunPayloadMap = { + [T in DryRunEventType]: Extract["payload"]; +}; diff --git a/yarn.lock b/yarn.lock index 8a4366a..ae6ca6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -540,6 +540,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@microsoft/fetch-event-source@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz#9ceecc94b49fbaa15666e38ae8587f64acce007d" + integrity sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA== + "@napi-rs/wasm-runtime@^0.2.11": version "0.2.12" resolved "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2"