addons/isl/src/codeReview/CodeReviewInfo.tsblame
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
083fd5f8import type {DiffId, DiffSignalSummary, DiffSummary, Hash, PageVisibility, Result, ValidatedRepoInfo} from '../types';
b69ab319import type {UICodeReviewProvider} from './UICodeReviewProvider';
b69ab3110
cd704b911import {startTransition} from 'react';
b69ab3112import {atom} from 'jotai';
b69ab3113import {clearTrackedCache} from 'shared/LRU';
b69ab3114import {debounce} from 'shared/debounce';
b69ab3115import {firstLine, nullthrows} from 'shared/utils';
b69ab3116import serverAPI from '../ClientToServerAPI';
b69ab3117import {commitMessageTemplate} from '../CommitInfoView/CommitInfoState';
b69ab3118import {
b69ab3119 applyEditedFields,
b69ab3120 commitMessageFieldsSchema,
b69ab3121 commitMessageFieldsToString,
b69ab3122 emptyCommitMessageFields,
b69ab3123 parseCommitMessageFields,
b69ab3124} from '../CommitInfoView/CommitMessageFields';
b69ab3125import {Internal} from '../Internal';
b69ab3126import {getTracker} from '../analytics/globalTracker';
b69ab3127import {atomFamilyWeak, atomWithOnChange, writeAtom} from '../jotaiUtils';
b69ab3128import {messageSyncingEnabledState} from '../messageSyncing';
b69ab3129import {dagWithPreviews} from '../previews';
b69ab3130import {commitByHash, repositoryInfo} from '../serverAPIState';
b69ab3131import {registerCleanup, registerDisposable} from '../utils';
b69ab3132import {GithubUICodeReviewProvider} from './github/github';
6c63fb633import {GroveUICodeReviewProvider} from './grove/grove';
b69ab3134
b69ab3135export const codeReviewProvider = atom<UICodeReviewProvider | null>(get => {
b69ab3136 const repoInfo = get(repositoryInfo);
b69ab3137 return repoInfoToCodeReviewProvider(repoInfo);
b69ab3138});
b69ab3139
b69ab3140function repoInfoToCodeReviewProvider(repoInfo?: ValidatedRepoInfo): UICodeReviewProvider | null {
b69ab3141 if (repoInfo == null) {
b69ab3142 return null;
b69ab3143 }
b69ab3144 if (repoInfo.codeReviewSystem.type === 'github') {
b69ab3145 return new GithubUICodeReviewProvider(
b69ab3146 repoInfo.codeReviewSystem,
b69ab3147 repoInfo.preferredSubmitCommand ?? 'pr',
b69ab3148 );
b69ab3149 }
6c63fb650 if (repoInfo.codeReviewSystem.type === 'grove') {
6c63fb651 return new GroveUICodeReviewProvider(repoInfo.codeReviewSystem);
6c63fb652 }
b69ab3153 if (
b69ab3154 repoInfo.codeReviewSystem.type === 'phabricator' &&
b69ab3155 Internal.PhabricatorUICodeReviewProvider != null
b69ab3156 ) {
b69ab3157 return new Internal.PhabricatorUICodeReviewProvider(repoInfo.codeReviewSystem);
b69ab3158 }
b69ab3159 return null;
b69ab3160}
b69ab3161
b69ab3162export const diffSummary = atomFamilyWeak((diffId: DiffId | undefined) =>
b69ab3163 atom<Result<DiffSummary | undefined>>(get => {
b69ab3164 if (diffId == null) {
b69ab3165 return {value: undefined};
b69ab3166 }
b69ab3167 const all = get(allDiffSummaries);
b69ab3168 if (all == null) {
b69ab3169 return {value: undefined};
b69ab3170 }
b69ab3171 if (all.error) {
b69ab3172 return {error: all.error};
b69ab3173 }
b69ab3174 return {value: all.value?.get(diffId)};
b69ab3175 }),
b69ab3176);
b69ab3177
e62555278/**
e62555279 * Resolve a commit's diffId: use the template-parsed value if available,
e62555280 * otherwise fall back to the commitHash→diffId reverse index
e62555281 * (populated from GroveDiffSummary.commitHash).
e62555282 */
e62555283export const diffIdForCommit = atomFamilyWeak((hash: Hash) =>
e62555284 atom<DiffId | undefined>(get => {
e62555285 const commit = get(commitByHash(hash));
e62555286 if (commit?.diffId != null) {
e62555287 return commit.diffId;
e62555288 }
e62555289 return get(diffIdsByCommitHash).get(hash);
e62555290 }),
e62555291);
e62555292
b69ab3193export const branchingDiffInfos = atomFamilyWeak((branchName: string) =>
b69ab3194 atom<Result<DiffSummary | undefined>>(get => {
b69ab3195 const all = get(allDiffSummaries);
b69ab3196 if (all == null) {
b69ab3197 return {value: undefined};
b69ab3198 }
b69ab3199 if (all.error) {
b69ab31100 return {error: all.error};
b69ab31101 }
b69ab31102 const idMap = get(diffIdsByBranchName);
b69ab31103 const idForBranchName = idMap.get(branchName);
b69ab31104 if (idForBranchName) {
b69ab31105 return {value: all.value?.get(idForBranchName)};
b69ab31106 }
b69ab31107 return {value: undefined};
b69ab31108 }),
b69ab31109);
b69ab31110
b69ab31111export const allDiffSummaries = atom<Result<Map<DiffId, DiffSummary> | null>>({value: null});
b69ab31112export const diffIdsByBranchName = atom<Map<string, DiffId>>(new Map());
e625552113export const diffIdsByCommitHash = atom<Map<Hash, DiffId>>(new Map());
b69ab31114
b69ab31115registerDisposable(
b69ab31116 allDiffSummaries,
b69ab31117 serverAPI.onMessageOfType('fetchedDiffSummaries', event => {
cd704b9118 // Defer off the current task so ongoing user interactions (e.g. drag) aren't blocked.
cd704b9119 setTimeout(() => {
cd704b9120 startTransition(() => {
cd704b9121 writeAtom(diffIdsByBranchName, existing => {
cd704b9122 if (event.summaries.error) {
cd704b9123 return existing;
cd704b9124 }
b69ab31125
cd704b9126 const map = new Map<string, DiffId>(existing);
cd704b9127 for (const [diffId, summary] of event.summaries.value.entries()) {
cd704b9128 if (summary.branchName) {
cd704b9129 map.set(summary.branchName, diffId);
cd704b9130 }
cd704b9131 }
cd704b9132 return map;
cd704b9133 });
b69ab31134
cd704b9135 writeAtom(diffIdsByCommitHash, existing => {
cd704b9136 if (event.summaries.error) {
cd704b9137 return existing;
cd704b9138 }
e625552139
cd704b9140 const map = new Map<Hash, DiffId>(existing);
cd704b9141 for (const [diffId, summary] of event.summaries.value.entries()) {
cd704b9142 if (summary.type === 'grove' && summary.commitHash) {
cd704b9143 map.set(summary.commitHash, diffId);
cd704b9144 }
cd704b9145 }
cd704b9146 return map;
cd704b9147 });
e625552148
cd704b9149 writeAtom(allDiffSummaries, existing => {
cd704b9150 if (existing.error) {
cd704b9151 // TODO: if we only fetch one diff, but had an error on the overall fetch... should we still somehow show that error...?
cd704b9152 // Right now, this will reset all other diffs to "loading" instead of error
cd704b9153 // Probably, if all diffs fail to fetch, so will individual diffs.
cd704b9154 return event.summaries;
cd704b9155 }
b69ab31156
cd704b9157 if (event.summaries.error || existing.value == null) {
cd704b9158 return event.summaries;
cd704b9159 }
b69ab31160
cd704b9161 // merge old values with newly fetched ones
cd704b9162 return {
cd704b9163 value: new Map([
cd704b9164 ...nullthrows(existing.value).entries(),
cd704b9165 ...event.summaries.value.entries(),
cd704b9166 ]),
cd704b9167 };
cd704b9168 });
cd704b9169 });
cd704b9170 }, 0);
b69ab31171 }),
b69ab31172 import.meta.hot,
b69ab31173);
b69ab31174
b69ab31175registerCleanup(
b69ab31176 allDiffSummaries,
b69ab31177 serverAPI.onSetup(() => {
b69ab31178 serverAPI.postMessage({
b69ab31179 type: 'fetchDiffSummaries',
b69ab31180 });
b69ab31181 getTracker()?.track('DiffFetchSource', {extras: {source: 'webview_startup'}});
b69ab31182 }),
b69ab31183 import.meta.hot,
b69ab31184);
b69ab31185
e7069e1186export type CanopySignalInfo = {signal: DiffSignalSummary; runId?: number; commitId?: string};
9e488cc187
083fd5f188/** Canopy CI/CD build signals keyed by commit message title. */
9e488cc189export const canopySignalsByTitle = atom<Map<string, CanopySignalInfo>>(new Map());
083fd5f190
083fd5f191registerDisposable(
083fd5f192 canopySignalsByTitle,
083fd5f193 serverAPI.onMessageOfType('fetchedCanopySignals', event => {
083fd5f194 setTimeout(() => {
083fd5f195 startTransition(() => {
30a382c196 writeAtom(canopySignalsByTitle, _existing => {
30a382c197 // Replace the entire map with fresh data from the server.
30a382c198 // The server returns runs newest-first, keeping only the most recent per commit message,
30a382c199 // so we always want to use the latest signal (e.g. running → passed).
30a382c200 const next = new Map<string, CanopySignalInfo>();
083fd5f201 for (const run of event.runs) {
083fd5f202 if (!next.has(run.commitMessage)) {
e7069e1203 next.set(run.commitMessage, {signal: run.signal, runId: run.runId, commitId: run.commitId});
083fd5f204 }
083fd5f205 }
083fd5f206 return next;
083fd5f207 });
083fd5f208 });
083fd5f209 }, 0);
083fd5f210 }),
083fd5f211 import.meta.hot,
083fd5f212);
083fd5f213
e7069e1214let canopySignalsFetchRequested = false;
e7069e1215/** Request Canopy signals from the server. Only sends the request once. */
e7069e1216export function ensureCanopySignalsFetched(): void {
e7069e1217 if (canopySignalsFetchRequested) {
e7069e1218 return;
e7069e1219 }
e7069e1220 canopySignalsFetchRequested = true;
e7069e1221 serverAPI.postMessage({type: 'fetchCanopySignals'});
e7069e1222}
e7069e1223
083fd5f224/** Look up Canopy CI signal for a specific commit by matching its title to Canopy run messages. */
083fd5f225export const canopySignalForCommit = atomFamilyWeak((hash: Hash) =>
9e488cc226 atom<CanopySignalInfo | undefined>(get => {
083fd5f227 const dag = get(dagWithPreviews);
083fd5f228 const commit = dag.get(hash);
083fd5f229 if (!commit) {
083fd5f230 return undefined;
083fd5f231 }
083fd5f232 const title = commit.title;
083fd5f233 return get(canopySignalsByTitle).get(title);
083fd5f234 }),
083fd5f235);
083fd5f236
b69ab31237/**
b69ab31238 * Latest commit message (title,description) for a hash.
b69ab31239 * There's multiple competing values, in order of priority:
b69ab31240 * (1) the optimistic commit's message
b69ab31241 * (2) the latest commit message on the server (phabricator/github)
b69ab31242 * (3) the local commit's message
b69ab31243 *
b69ab31244 * Remote messages preferred above local messages, so you see remote changes accounted for.
b69ab31245 * Optimistic changes preferred above remote changes, since we should always
b69ab31246 * async update the remote message to match the optimistic state anyway, but the UI will
b69ab31247 * be smoother if we use the optimistic one before the remote has gotten the update propagated.
b69ab31248 * This is only necessary if the optimistic message is different than the local message.
b69ab31249 */
b69ab31250export const latestCommitMessage = atomFamilyWeak((hash: Hash | 'head') =>
b69ab31251 atom((get): [title: string, description: string] => {
b69ab31252 if (hash === 'head') {
b69ab31253 const template = get(commitMessageTemplate);
b69ab31254 if (template) {
b69ab31255 const schema = get(commitMessageFieldsSchema);
b69ab31256 const result = applyEditedFields(emptyCommitMessageFields(schema), template);
b69ab31257 const templateString = commitMessageFieldsToString(
b69ab31258 schema,
b69ab31259 result,
b69ab31260 /* allowEmptyTitle */ true,
b69ab31261 );
b69ab31262 const title = firstLine(templateString);
b69ab31263 const description = templateString.slice(title.length);
b69ab31264 return [title, description];
b69ab31265 }
b69ab31266 return ['', ''];
b69ab31267 }
b69ab31268 const commit = get(commitByHash(hash));
b69ab31269 const preview = get(dagWithPreviews).get(hash);
b69ab31270
b69ab31271 if (
b69ab31272 preview != null &&
b69ab31273 (preview.title !== commit?.title || preview.description !== commit?.description)
b69ab31274 ) {
b69ab31275 return [preview.title, preview.description];
b69ab31276 }
b69ab31277
b69ab31278 if (!commit) {
b69ab31279 return ['', ''];
b69ab31280 }
b69ab31281
b69ab31282 const syncEnabled = get(messageSyncingEnabledState);
b69ab31283
b69ab31284 let remoteTitle = commit.title;
b69ab31285 let remoteDescription = commit.description;
b69ab31286 if (syncEnabled && commit.diffId) {
b69ab31287 // use the diff's commit message instead of the local one, if available
b69ab31288 const summary = get(diffSummary(commit.diffId));
b69ab31289 if (summary?.value) {
b69ab31290 remoteTitle = summary.value.title;
b69ab31291 remoteDescription = summary.value.commitMessage;
b69ab31292 }
b69ab31293 }
b69ab31294
b69ab31295 return [remoteTitle, remoteDescription];
b69ab31296 }),
b69ab31297);
b69ab31298
b69ab31299export const latestCommitMessageTitle = atomFamilyWeak((hashOrHead: Hash | 'head') =>
b69ab31300 atom(get => {
b69ab31301 const [title] = get(latestCommitMessage(hashOrHead));
b69ab31302 return title;
b69ab31303 }),
b69ab31304);
b69ab31305
b69ab31306export const latestCommitMessageFields = atomFamilyWeak((hashOrHead: Hash | 'head') =>
b69ab31307 atom(get => {
b69ab31308 const [title, description] = get(latestCommitMessage(hashOrHead));
b69ab31309 const schema = get(commitMessageFieldsSchema);
b69ab31310 return parseCommitMessageFields(schema, title, description);
b69ab31311 }),
b69ab31312);
b69ab31313
b69ab31314export const pageVisibility = atomWithOnChange(
b69ab31315 atom<PageVisibility>(document.hasFocus() ? 'focused' : document.visibilityState),
b69ab31316 debounce(state => {
b69ab31317 serverAPI.postMessage({
b69ab31318 type: 'pageVisibility',
b69ab31319 state,
b69ab31320 });
b69ab31321 }, 50),
b69ab31322);
b69ab31323
b69ab31324const handleVisibilityChange = () => {
b69ab31325 const newValue = document.hasFocus() ? 'focused' : document.visibilityState;
b69ab31326 writeAtom(pageVisibility, oldValue => {
b69ab31327 if (oldValue !== newValue && newValue === 'hidden') {
b69ab31328 clearTrackedCache();
b69ab31329 }
b69ab31330 return newValue;
b69ab31331 });
b69ab31332};
b69ab31333
b69ab31334window.addEventListener('focus', handleVisibilityChange);
b69ab31335window.addEventListener('blur', handleVisibilityChange);
b69ab31336document.addEventListener('visibilitychange', handleVisibilityChange);
b69ab31337registerCleanup(
b69ab31338 pageVisibility,
b69ab31339 () => {
b69ab31340 document.removeEventListener('visibilitychange', handleVisibilityChange);
b69ab31341 window.removeEventListener('focus', handleVisibilityChange);
b69ab31342 window.removeEventListener('blur', handleVisibilityChange);
b69ab31343 },
b69ab31344 import.meta.hot,
b69ab31345);