8.4 KB253 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {ReactNode} from 'react';
9
10import {Icon} from 'isl-components/Icon';
11import {atom, useAtomValue} from 'jotai';
12import {loadable} from 'jotai/utils';
13import {tryJsonParse} from 'shared/utils';
14import serverAPI from '../ClientToServerAPI';
15import {tracker} from '../analytics';
16import {codeReviewProvider} from '../codeReview/CodeReviewInfo';
17import {T} from '../i18n';
18import {atomFamilyWeak} from '../jotaiUtils';
19import {uncommittedChangesWithPreviews} from '../previews';
20import {commitByHash} from '../serverAPIState';
21import {commitInfoViewCurrentCommits, commitMode} from './CommitInfoState';
22
23import './SuggestedReviewers.css';
24
25const MAX_VISIBLE_RECENT_REVIEWERS = 3;
26const RECENT_REVIEWERS_STORAGE_KEY = 'ISL_RECENT_REVIEWERS';
27/**
28 * Half-life for frecency decay in days. After this many days,
29 * the recency multiplier is halved.
30 */
31const FRECENCY_HALF_LIFE_DAYS = 14;
32/**
33 * Maximum age in days before a reviewer is pruned from storage.
34 * Reviewers not used within this period are removed to prevent
35 * unbounded localStorage growth.
36 */
37const MAX_REVIEWER_AGE_DAYS = 90;
38
39type ReviewerData = {count: number; lastUsed: number};
40
41/**
42 * Frecency-based recent reviewers, persisted to localStorage.
43 * Combines frequency (how often used) with recency (how recently used)
44 * using exponential decay. More recent usage has higher weight.
45 */
46class RecentReviewers {
47 private recent: Map<string, ReviewerData>;
48
49 constructor() {
50 try {
51 const stored = tryJsonParse(
52 localStorage.getItem(RECENT_REVIEWERS_STORAGE_KEY) ?? '[]',
53 ) as Array<[string, number | ReviewerData]> | null;
54 this.recent = new Map();
55 const maxAge = MAX_REVIEWER_AGE_DAYS * 24 * 60 * 60 * 1000;
56 const now = Date.now();
57 let needsPersist = false;
58 if (stored) {
59 for (const [key, value] of stored) {
60 if (typeof value === 'number') {
61 // Migrate from old format (count only) to new format
62 this.recent.set(key, {count: value, lastUsed: now});
63 needsPersist = true;
64 } else if (now - value.lastUsed <= maxAge) {
65 // Only keep reviewers used within MAX_REVIEWER_AGE_DAYS
66 this.recent.set(key, value);
67 } else {
68 needsPersist = true;
69 }
70 }
71 }
72 if (needsPersist) {
73 this.persist();
74 }
75 } catch {
76 this.recent = new Map();
77 }
78 }
79
80 private persist() {
81 try {
82 localStorage.setItem(
83 RECENT_REVIEWERS_STORAGE_KEY,
84 JSON.stringify([...this.recent.entries()]),
85 );
86 } catch {}
87 }
88
89 /**
90 * Calculate frecency score for a reviewer.
91 * Score = count * recencyMultiplier, where recencyMultiplier
92 * decays exponentially based on time since last use.
93 */
94 private getFrecencyScore(data: ReviewerData): number {
95 const daysSinceLastUse = (Date.now() - data.lastUsed) / (1000 * 60 * 60 * 24);
96 const recencyMultiplier = Math.pow(0.5, daysSinceLastUse / FRECENCY_HALF_LIFE_DAYS);
97 return data.count * recencyMultiplier;
98 }
99
100 public useReviewer(reviewer: string) {
101 const existing = this.recent.get(reviewer);
102 this.recent.set(reviewer, {
103 count: (existing?.count ?? 0) + 1,
104 lastUsed: Date.now(),
105 });
106 this.persist();
107 }
108
109 public getRecent(): Array<string> {
110 return [...this.recent.entries()]
111 .map(([name, data]) => ({name, score: this.getFrecencyScore(data)}))
112 .sort((a, b) => b.score - a.score)
113 .slice(0, MAX_VISIBLE_RECENT_REVIEWERS)
114 .map(({name}) => name);
115 }
116}
117
118export const recentReviewers = new RecentReviewers();
119
120/**
121 * Since we use a selector to fetch suggestions, it will attempt to refetch
122 * when any dependency (uncommitted changes, list of changed files) changes.
123 * While technically suggestions could change if any edited path changes,
124 * the UI flickers way to much. So let's cache the result within some time window.
125 * using a time window ensures we don't overcache (for example,
126 * in commit mode, where two commits may have totally different changes.)
127 */
128const cachedSuggestions = new Map<string, {lastFetch: number; reviewers: Array<string>}>();
129const MAX_SUGGESTION_CACHE_AGE = 2 * 60 * 1000;
130const suggestedReviewersForCommit = atomFamilyWeak((hashOrHead: string | 'head' | undefined) => {
131 return loadable(
132 atom(get => {
133 if (hashOrHead == null) {
134 return [];
135 }
136 const context = {
137 paths: [] as Array<string>,
138 };
139 const cached = cachedSuggestions.get(hashOrHead);
140 if (cached) {
141 if (Date.now() - cached.lastFetch < MAX_SUGGESTION_CACHE_AGE) {
142 return cached.reviewers;
143 }
144 }
145
146 if (hashOrHead === 'head') {
147 const uncommittedChanges = get(uncommittedChangesWithPreviews);
148 context.paths.push(...uncommittedChanges.slice(0, 10).map(change => change.path));
149 } else {
150 const commit = get(commitByHash(hashOrHead));
151 if (commit?.isDot) {
152 const uncommittedChanges = get(uncommittedChangesWithPreviews);
153 context.paths.push(...uncommittedChanges.slice(0, 10).map(change => change.path));
154 }
155 context.paths.push(...(commit?.filePathsSample.slice(0, 10) ?? []));
156 }
157
158 return tracker.operation('GetSuggestedReviewers', 'FetchError', undefined, async () => {
159 serverAPI.postMessage({
160 type: 'getSuggestedReviewers',
161 key: hashOrHead,
162 context,
163 });
164
165 const response = await serverAPI.nextMessageMatching(
166 'gotSuggestedReviewers',
167 message => message.key === hashOrHead,
168 );
169 cachedSuggestions.set(hashOrHead, {lastFetch: Date.now(), reviewers: response.reviewers});
170 return response.reviewers;
171 });
172 }),
173 );
174});
175
176export function SuggestedReviewers({
177 existingReviewers,
178 addReviewer,
179}: {
180 existingReviewers: Array<string>;
181 addReviewer: (value: string) => unknown;
182}) {
183 const provider = useAtomValue(codeReviewProvider);
184 const recent = recentReviewers.getRecent().filter(s => !existingReviewers.includes(s));
185 const mode = useAtomValue(commitMode);
186 const currentCommitInfoViewCommit = useAtomValue(commitInfoViewCurrentCommits);
187 const currentCommit = currentCommitInfoViewCommit?.[0]; // assume we only have one commit
188
189 const key = currentCommit?.isDot && mode === 'commit' ? 'head' : (currentCommit?.hash ?? '');
190 const suggestedReviewers = useAtomValue(suggestedReviewersForCommit(key));
191
192 const filteredSuggestions = (
193 suggestedReviewers.state === 'hasData' ? suggestedReviewers.data : []
194 ).filter(s => !existingReviewers.includes(s));
195
196 return (
197 <div className="suggested-reviewers" data-testid="suggested-reviewers">
198 {recent.length > 0 ? (
199 <div data-testid="recent-reviewers-list">
200 <div className="suggestion-header">
201 <T>Recent</T>
202 </div>
203 <div className="suggestions">
204 {recent.map(s => (
205 <Suggestion
206 key={s}
207 onClick={() => {
208 addReviewer(s);
209 tracker.track('AcceptSuggestedReviewer', {extras: {type: 'recent'}});
210 }}>
211 {s}
212 </Suggestion>
213 ))}
214 </div>
215 </div>
216 ) : null}
217 {provider?.supportsSuggestedReviewers &&
218 (filteredSuggestions == null || filteredSuggestions.length > 0) ? (
219 <div data-testid="suggested-reviewers-list">
220 <div className="suggestion-header">
221 <T>Suggested</T>
222 </div>
223 <div className="suggestions">
224 {suggestedReviewers.state === 'loading' && (
225 <div className="suggestions-loading">
226 <Icon icon="loading" />
227 </div>
228 )}
229 {filteredSuggestions?.map(s => (
230 <Suggestion
231 key={s}
232 onClick={() => {
233 addReviewer(s);
234 tracker.track('AcceptSuggestedReviewer', {extras: {type: 'suggested'}});
235 }}>
236 {s}
237 </Suggestion>
238 )) ?? null}
239 </div>
240 </div>
241 ) : null}
242 </div>
243 );
244}
245
246function Suggestion({children, onClick}: {children: ReactNode; onClick: () => unknown}) {
247 return (
248 <button className="suggestion token" onClick={onClick}>
249 {children}
250 </button>
251 );
252}
253