addons/isl/src/CommitInfoView/SuggestedReviewers.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {ReactNode} from 'react';
b69ab319
b69ab3110import {Icon} from 'isl-components/Icon';
b69ab3111import {atom, useAtomValue} from 'jotai';
b69ab3112import {loadable} from 'jotai/utils';
b69ab3113import {tryJsonParse} from 'shared/utils';
b69ab3114import serverAPI from '../ClientToServerAPI';
b69ab3115import {tracker} from '../analytics';
b69ab3116import {codeReviewProvider} from '../codeReview/CodeReviewInfo';
b69ab3117import {T} from '../i18n';
b69ab3118import {atomFamilyWeak} from '../jotaiUtils';
b69ab3119import {uncommittedChangesWithPreviews} from '../previews';
b69ab3120import {commitByHash} from '../serverAPIState';
b69ab3121import {commitInfoViewCurrentCommits, commitMode} from './CommitInfoState';
b69ab3122
b69ab3123import './SuggestedReviewers.css';
b69ab3124
b69ab3125const MAX_VISIBLE_RECENT_REVIEWERS = 3;
b69ab3126const RECENT_REVIEWERS_STORAGE_KEY = 'ISL_RECENT_REVIEWERS';
b69ab3127/**
b69ab3128 * Half-life for frecency decay in days. After this many days,
b69ab3129 * the recency multiplier is halved.
b69ab3130 */
b69ab3131const FRECENCY_HALF_LIFE_DAYS = 14;
b69ab3132/**
b69ab3133 * Maximum age in days before a reviewer is pruned from storage.
b69ab3134 * Reviewers not used within this period are removed to prevent
b69ab3135 * unbounded localStorage growth.
b69ab3136 */
b69ab3137const MAX_REVIEWER_AGE_DAYS = 90;
b69ab3138
b69ab3139type ReviewerData = {count: number; lastUsed: number};
b69ab3140
b69ab3141/**
b69ab3142 * Frecency-based recent reviewers, persisted to localStorage.
b69ab3143 * Combines frequency (how often used) with recency (how recently used)
b69ab3144 * using exponential decay. More recent usage has higher weight.
b69ab3145 */
b69ab3146class RecentReviewers {
b69ab3147 private recent: Map<string, ReviewerData>;
b69ab3148
b69ab3149 constructor() {
b69ab3150 try {
b69ab3151 const stored = tryJsonParse(
b69ab3152 localStorage.getItem(RECENT_REVIEWERS_STORAGE_KEY) ?? '[]',
b69ab3153 ) as Array<[string, number | ReviewerData]> | null;
b69ab3154 this.recent = new Map();
b69ab3155 const maxAge = MAX_REVIEWER_AGE_DAYS * 24 * 60 * 60 * 1000;
b69ab3156 const now = Date.now();
b69ab3157 let needsPersist = false;
b69ab3158 if (stored) {
b69ab3159 for (const [key, value] of stored) {
b69ab3160 if (typeof value === 'number') {
b69ab3161 // Migrate from old format (count only) to new format
b69ab3162 this.recent.set(key, {count: value, lastUsed: now});
b69ab3163 needsPersist = true;
b69ab3164 } else if (now - value.lastUsed <= maxAge) {
b69ab3165 // Only keep reviewers used within MAX_REVIEWER_AGE_DAYS
b69ab3166 this.recent.set(key, value);
b69ab3167 } else {
b69ab3168 needsPersist = true;
b69ab3169 }
b69ab3170 }
b69ab3171 }
b69ab3172 if (needsPersist) {
b69ab3173 this.persist();
b69ab3174 }
b69ab3175 } catch {
b69ab3176 this.recent = new Map();
b69ab3177 }
b69ab3178 }
b69ab3179
b69ab3180 private persist() {
b69ab3181 try {
b69ab3182 localStorage.setItem(
b69ab3183 RECENT_REVIEWERS_STORAGE_KEY,
b69ab3184 JSON.stringify([...this.recent.entries()]),
b69ab3185 );
b69ab3186 } catch {}
b69ab3187 }
b69ab3188
b69ab3189 /**
b69ab3190 * Calculate frecency score for a reviewer.
b69ab3191 * Score = count * recencyMultiplier, where recencyMultiplier
b69ab3192 * decays exponentially based on time since last use.
b69ab3193 */
b69ab3194 private getFrecencyScore(data: ReviewerData): number {
b69ab3195 const daysSinceLastUse = (Date.now() - data.lastUsed) / (1000 * 60 * 60 * 24);
b69ab3196 const recencyMultiplier = Math.pow(0.5, daysSinceLastUse / FRECENCY_HALF_LIFE_DAYS);
b69ab3197 return data.count * recencyMultiplier;
b69ab3198 }
b69ab3199
b69ab31100 public useReviewer(reviewer: string) {
b69ab31101 const existing = this.recent.get(reviewer);
b69ab31102 this.recent.set(reviewer, {
b69ab31103 count: (existing?.count ?? 0) + 1,
b69ab31104 lastUsed: Date.now(),
b69ab31105 });
b69ab31106 this.persist();
b69ab31107 }
b69ab31108
b69ab31109 public getRecent(): Array<string> {
b69ab31110 return [...this.recent.entries()]
b69ab31111 .map(([name, data]) => ({name, score: this.getFrecencyScore(data)}))
b69ab31112 .sort((a, b) => b.score - a.score)
b69ab31113 .slice(0, MAX_VISIBLE_RECENT_REVIEWERS)
b69ab31114 .map(({name}) => name);
b69ab31115 }
b69ab31116}
b69ab31117
b69ab31118export const recentReviewers = new RecentReviewers();
b69ab31119
b69ab31120/**
b69ab31121 * Since we use a selector to fetch suggestions, it will attempt to refetch
b69ab31122 * when any dependency (uncommitted changes, list of changed files) changes.
b69ab31123 * While technically suggestions could change if any edited path changes,
b69ab31124 * the UI flickers way to much. So let's cache the result within some time window.
b69ab31125 * using a time window ensures we don't overcache (for example,
b69ab31126 * in commit mode, where two commits may have totally different changes.)
b69ab31127 */
b69ab31128const cachedSuggestions = new Map<string, {lastFetch: number; reviewers: Array<string>}>();
b69ab31129const MAX_SUGGESTION_CACHE_AGE = 2 * 60 * 1000;
b69ab31130const suggestedReviewersForCommit = atomFamilyWeak((hashOrHead: string | 'head' | undefined) => {
b69ab31131 return loadable(
b69ab31132 atom(get => {
b69ab31133 if (hashOrHead == null) {
b69ab31134 return [];
b69ab31135 }
b69ab31136 const context = {
b69ab31137 paths: [] as Array<string>,
b69ab31138 };
b69ab31139 const cached = cachedSuggestions.get(hashOrHead);
b69ab31140 if (cached) {
b69ab31141 if (Date.now() - cached.lastFetch < MAX_SUGGESTION_CACHE_AGE) {
b69ab31142 return cached.reviewers;
b69ab31143 }
b69ab31144 }
b69ab31145
b69ab31146 if (hashOrHead === 'head') {
b69ab31147 const uncommittedChanges = get(uncommittedChangesWithPreviews);
b69ab31148 context.paths.push(...uncommittedChanges.slice(0, 10).map(change => change.path));
b69ab31149 } else {
b69ab31150 const commit = get(commitByHash(hashOrHead));
b69ab31151 if (commit?.isDot) {
b69ab31152 const uncommittedChanges = get(uncommittedChangesWithPreviews);
b69ab31153 context.paths.push(...uncommittedChanges.slice(0, 10).map(change => change.path));
b69ab31154 }
b69ab31155 context.paths.push(...(commit?.filePathsSample.slice(0, 10) ?? []));
b69ab31156 }
b69ab31157
b69ab31158 return tracker.operation('GetSuggestedReviewers', 'FetchError', undefined, async () => {
b69ab31159 serverAPI.postMessage({
b69ab31160 type: 'getSuggestedReviewers',
b69ab31161 key: hashOrHead,
b69ab31162 context,
b69ab31163 });
b69ab31164
b69ab31165 const response = await serverAPI.nextMessageMatching(
b69ab31166 'gotSuggestedReviewers',
b69ab31167 message => message.key === hashOrHead,
b69ab31168 );
b69ab31169 cachedSuggestions.set(hashOrHead, {lastFetch: Date.now(), reviewers: response.reviewers});
b69ab31170 return response.reviewers;
b69ab31171 });
b69ab31172 }),
b69ab31173 );
b69ab31174});
b69ab31175
b69ab31176export function SuggestedReviewers({
b69ab31177 existingReviewers,
b69ab31178 addReviewer,
b69ab31179}: {
b69ab31180 existingReviewers: Array<string>;
b69ab31181 addReviewer: (value: string) => unknown;
b69ab31182}) {
b69ab31183 const provider = useAtomValue(codeReviewProvider);
b69ab31184 const recent = recentReviewers.getRecent().filter(s => !existingReviewers.includes(s));
b69ab31185 const mode = useAtomValue(commitMode);
b69ab31186 const currentCommitInfoViewCommit = useAtomValue(commitInfoViewCurrentCommits);
b69ab31187 const currentCommit = currentCommitInfoViewCommit?.[0]; // assume we only have one commit
b69ab31188
b69ab31189 const key = currentCommit?.isDot && mode === 'commit' ? 'head' : (currentCommit?.hash ?? '');
b69ab31190 const suggestedReviewers = useAtomValue(suggestedReviewersForCommit(key));
b69ab31191
b69ab31192 const filteredSuggestions = (
b69ab31193 suggestedReviewers.state === 'hasData' ? suggestedReviewers.data : []
b69ab31194 ).filter(s => !existingReviewers.includes(s));
b69ab31195
b69ab31196 return (
b69ab31197 <div className="suggested-reviewers" data-testid="suggested-reviewers">
b69ab31198 {recent.length > 0 ? (
b69ab31199 <div data-testid="recent-reviewers-list">
b69ab31200 <div className="suggestion-header">
b69ab31201 <T>Recent</T>
b69ab31202 </div>
b69ab31203 <div className="suggestions">
b69ab31204 {recent.map(s => (
b69ab31205 <Suggestion
b69ab31206 key={s}
b69ab31207 onClick={() => {
b69ab31208 addReviewer(s);
b69ab31209 tracker.track('AcceptSuggestedReviewer', {extras: {type: 'recent'}});
b69ab31210 }}>
b69ab31211 {s}
b69ab31212 </Suggestion>
b69ab31213 ))}
b69ab31214 </div>
b69ab31215 </div>
b69ab31216 ) : null}
b69ab31217 {provider?.supportsSuggestedReviewers &&
b69ab31218 (filteredSuggestions == null || filteredSuggestions.length > 0) ? (
b69ab31219 <div data-testid="suggested-reviewers-list">
b69ab31220 <div className="suggestion-header">
b69ab31221 <T>Suggested</T>
b69ab31222 </div>
b69ab31223 <div className="suggestions">
b69ab31224 {suggestedReviewers.state === 'loading' && (
b69ab31225 <div className="suggestions-loading">
b69ab31226 <Icon icon="loading" />
b69ab31227 </div>
b69ab31228 )}
b69ab31229 {filteredSuggestions?.map(s => (
b69ab31230 <Suggestion
b69ab31231 key={s}
b69ab31232 onClick={() => {
b69ab31233 addReviewer(s);
b69ab31234 tracker.track('AcceptSuggestedReviewer', {extras: {type: 'suggested'}});
b69ab31235 }}>
b69ab31236 {s}
b69ab31237 </Suggestion>
b69ab31238 )) ?? null}
b69ab31239 </div>
b69ab31240 </div>
b69ab31241 ) : null}
b69ab31242 </div>
b69ab31243 );
b69ab31244}
b69ab31245
b69ab31246function Suggestion({children, onClick}: {children: ReactNode; onClick: () => unknown}) {
b69ab31247 return (
b69ab31248 <button className="suggestion token" onClick={onClick}>
b69ab31249 {children}
b69ab31250 </button>
b69ab31251 );
b69ab31252}