addons/isl-server/src/templates.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
b69ab318import type {
b69ab319 ChangedFile,
b69ab3110 CodeReviewSystem,
b69ab3111 CommitInfo,
b69ab3112 CommitPhaseType,
b69ab3113 Hash,
b69ab3114 RepoRelativePath,
b69ab3115 ShelvedChange,
b69ab3116 SmartlogCommits,
b69ab3117 StableCommitFetchConfig,
b69ab3118 StableInfo,
b69ab3119 SuccessorInfo,
b69ab3120} from 'isl/src/types';
b69ab3121import type {Logger} from './logger';
b69ab3122
b69ab3123import path from 'path';
b69ab3124import {Internal} from './Internal';
b69ab3125import {MAX_FETCHED_FILES_PER_COMMIT} from './commands';
b69ab3126import {fromEntries} from './utils';
b69ab3127
b69ab3128export const COMMIT_END_MARK = '<<COMMIT_END_MARK>>';
b69ab3129export const NULL_CHAR = '\0';
b69ab3130export const ESCAPED_NULL_CHAR = '\\0';
b69ab3131export const WDIR_PARENT_MARKER = '@';
b69ab3132
b69ab3133///// Main commits fetch /////
b69ab3134
b69ab3135export const mainFetchTemplateFields = (codeReviewSystem: CodeReviewSystem) => ({
b69ab3136 hash: '{node}',
b69ab3137 title: '{desc|firstline}',
b69ab3138 author: '{author}',
b69ab3139 // We prefer committerdate over authordate as authordate sometimes makes
b69ab3140 // amended or rebased commits look stale
b69ab3141 date: '{committerdate|isodatesec}',
b69ab3142 phase: '{phase}',
b69ab3143 bookmarks: `{bookmarks % '{bookmark}${ESCAPED_NULL_CHAR}'}`,
b69ab3144 remoteBookmarks: `{remotenames % '{remotename}${ESCAPED_NULL_CHAR}'}`,
b69ab3145 parents: `{parents % "{node}${ESCAPED_NULL_CHAR}"}`,
b69ab3146 grandparents: `{grandparents % "{node}${ESCAPED_NULL_CHAR}"}`,
b69ab3147 isDot: `{ifcontains(rev, revset('.'), '${WDIR_PARENT_MARKER}')}`,
b69ab3148 // We don't need files for public commits, and public commits are sometimes gigantic codemods without you realizing.
b69ab3149 // No need to fetch if not draft.
b69ab3150 files: `{ifeq(phase, 'draft', join(files,'${ESCAPED_NULL_CHAR}'), '')}`,
b69ab3151 totalFileCount: '{files|count}', // We skip getting files for public commits, but we still want to know how many files there would be
b69ab3152 successorInfo: '{mutations % "{operation}:{successors % "{node}"},"}',
b69ab3153 closestPredecessors: '{predecessors % "{node},"}',
b69ab3154 // This would be more elegant as a new built-in template
b69ab3155 diffId:
b69ab3156 codeReviewSystem.type === 'phabricator'
b69ab3157 ? '{phabdiff}'
b69ab3158 : codeReviewSystem.type === 'github'
b69ab3159 ? '{github_pull_request_number}'
b69ab3160 : '',
b69ab3161 isFollower: '{sapling_pr_follower|json}',
b69ab3162 stableCommitMetadata: Internal.stableCommitConfig?.template ?? '',
b69ab3163 // Description must be last
b69ab3164 description: '{desc}',
b69ab3165});
b69ab3166
b69ab3167export function getMainFetchTemplate(codeReviewSystem: CodeReviewSystem): string {
b69ab3168 return [...Object.values(mainFetchTemplateFields(codeReviewSystem)), COMMIT_END_MARK].join('\n');
b69ab3169}
b69ab3170
b69ab3171/**
b69ab3172 * Extract CommitInfos from log calls that use FETCH_TEMPLATE.
b69ab3173 */
b69ab3174export function parseCommitInfoOutput(
b69ab3175 logger: Logger,
b69ab3176 output: string,
b69ab3177 reviewSystem: CodeReviewSystem,
b69ab3178 stableCommitConfig = Internal.stableCommitConfig as StableCommitFetchConfig | null,
b69ab3179): SmartlogCommits {
b69ab3180 const fields = mainFetchTemplateFields(reviewSystem);
b69ab3181 const index = fromEntries(Object.keys(fields).map((key, i) => [key, i])) as {
b69ab3182 [key in Required<keyof typeof fields>]: number;
b69ab3183 };
b69ab3184
b69ab3185 const revisions = output.split(COMMIT_END_MARK);
b69ab3186 const commitInfos: Array<CommitInfo> = [];
b69ab3187 for (const chunk of revisions) {
b69ab3188 try {
b69ab3189 const lines = chunk.trimStart().split('\n');
b69ab3190 if (lines.length < Object.keys(fields).length) {
b69ab3191 continue;
b69ab3192 }
b69ab3193 const files = lines[index.files].split(NULL_CHAR).filter(e => e.length > 0);
b69ab3194
b69ab3195 // Find if the commit is entirely within the cwd and therefore more relevant to the user.
b69ab3196 // Note: this must be done on the server using the full list of files, not just the sample that the client gets.
b69ab3197 // TODO: should we cache this by commit hash to avoid iterating all files on the same commits every time?
b69ab3198 const maxCommonPathPrefix = findMaxCommonPathPrefix(files);
b69ab3199
b69ab31100 commitInfos.push({
b69ab31101 hash: lines[index.hash],
b69ab31102 title: lines[index.title],
b69ab31103 author: lines[index.author],
b69ab31104 date: new Date(lines[index.date]),
b69ab31105 parents: splitLine(lines[index.parents]) as string[],
b69ab31106 grandparents: splitLine(lines[index.grandparents]) as string[],
b69ab31107 phase: lines[index.phase] as CommitPhaseType,
b69ab31108 bookmarks: splitLine(lines[index.bookmarks]),
b69ab31109 remoteBookmarks: splitLine(lines[index.remoteBookmarks]),
b69ab31110 isDot: lines[index.isDot] === WDIR_PARENT_MARKER,
b69ab31111 filePathsSample: files.slice(0, MAX_FETCHED_FILES_PER_COMMIT),
b69ab31112 totalFileCount: parseInt(lines[index.totalFileCount], 10),
b69ab31113 successorInfo: parseSuccessorData(lines[index.successorInfo]),
b69ab31114 closestPredecessors: splitLine(lines[index.closestPredecessors], ','),
b69ab31115 description: lines
b69ab31116 .slice(index.description + 1 /* first field of description is title; skip it */)
b69ab31117 .join('\n')
b69ab31118 .trim(),
b69ab31119 diffId: lines[index.diffId] != '' ? lines[index.diffId] : undefined,
ab83ad3120 isFollower: lines[index.isFollower] !== '' ? (JSON.parse(lines[index.isFollower]) as boolean) : false,
b69ab31121 stableCommitMetadata:
b69ab31122 lines[index.stableCommitMetadata] != ''
b69ab31123 ? stableCommitConfig?.parse(lines[index.stableCommitMetadata])
b69ab31124 : undefined,
b69ab31125 maxCommonPathPrefix,
b69ab31126 });
b69ab31127 } catch (err) {
b69ab31128 logger.error('failed to parse commit', err);
b69ab31129 }
b69ab31130 }
b69ab31131 return commitInfos;
b69ab31132}
b69ab31133
b69ab31134/**
b69ab31135 * Given a set of changed files, find the longest common path prefix.
b69ab31136 * See {@link CommitInfo}.maxCommonPathPrefix
b69ab31137 * TODO: This could be cached by commit hash
b69ab31138 */
b69ab31139export function findMaxCommonPathPrefix(filePaths: Array<RepoRelativePath>): RepoRelativePath {
b69ab31140 let max: null | Array<string> = null;
b69ab31141 let maxLength = 0;
b69ab31142
b69ab31143 // Path module separator should match what `sl` gives us
b69ab31144 const sep = path.sep;
b69ab31145
b69ab31146 for (const path of filePaths) {
b69ab31147 if (max == null) {
b69ab31148 max = path.split(sep);
b69ab31149 max.pop(); // ignore file part, only care about directory
b69ab31150 maxLength = max.reduce((acc, part) => acc + part.length + 1, 0); // +1 for slash
b69ab31151 continue;
b69ab31152 }
b69ab31153 // small optimization: we only need to look as long as the max so far, max common path will always be shorter
b69ab31154 const parts = path.slice(0, maxLength).split(sep);
b69ab31155 for (const [i, part] of parts.entries()) {
b69ab31156 if (part !== max[i]) {
b69ab31157 max = max.slice(0, i);
b69ab31158 maxLength = max.reduce((acc, part) => acc + part.length + 1, 0); // +1 for slash
b69ab31159 break;
b69ab31160 }
b69ab31161 }
b69ab31162 if (max.length === 0) {
b69ab31163 return ''; // we'll never get *more* specific, early exit
b69ab31164 }
b69ab31165 }
b69ab31166
b69ab31167 const result = (max ?? []).join(sep);
b69ab31168 if (result == '') {
b69ab31169 return result;
b69ab31170 }
b69ab31171 return result + sep;
b69ab31172}
b69ab31173
b69ab31174/**
b69ab31175 * Additional stable locations in the commit fetch will not automatically
b69ab31176 * include "stableCommitMetadata". Insert this data onto the commits.
b69ab31177 */
b69ab31178export function attachStableLocations(commits: Array<CommitInfo>, locations: Array<StableInfo>) {
b69ab31179 const map: Record<Hash, Array<StableInfo>> = {};
b69ab31180 for (const location of locations) {
b69ab31181 const existing = map[location.hash] ?? [];
b69ab31182 map[location.hash] = [...existing, location];
b69ab31183 }
b69ab31184
b69ab31185 for (const commit of commits) {
b69ab31186 if (commit.hash in map) {
b69ab31187 commit.stableCommitMetadata = [
b69ab31188 ...(commit.stableCommitMetadata ?? []),
b69ab31189 ...map[commit.hash].map(location => ({
b69ab31190 value: location.name,
b69ab31191 description: location.info ?? '',
b69ab31192 })),
b69ab31193 ];
b69ab31194 }
b69ab31195 }
b69ab31196}
b69ab31197
b69ab31198///// Shelve /////
b69ab31199
b69ab31200export const SHELVE_FIELDS = {
b69ab31201 hash: '{node}',
b69ab31202 name: '{shelvename}',
b69ab31203 author: '{author}',
b69ab31204 date: '{date|isodatesec}',
b69ab31205 filesAdded: '{file_adds|json}',
b69ab31206 filesModified: '{file_mods|json}',
b69ab31207 filesRemoved: '{file_dels|json}',
b69ab31208 description: '{desc}',
b69ab31209};
b69ab31210export const SHELVE_FIELD_INDEX = fromEntries(
b69ab31211 Object.keys(SHELVE_FIELDS).map((key, i) => [key, i]),
b69ab31212) as {
b69ab31213 [key in Required<keyof typeof SHELVE_FIELDS>]: number;
b69ab31214};
b69ab31215export const SHELVE_FETCH_TEMPLATE = [...Object.values(SHELVE_FIELDS), COMMIT_END_MARK].join('\n');
b69ab31216
b69ab31217export function parseShelvedCommitsOutput(logger: Logger, output: string): Array<ShelvedChange> {
b69ab31218 const shelves = output.split(COMMIT_END_MARK);
b69ab31219 const commitInfos: Array<ShelvedChange> = [];
b69ab31220 for (const chunk of shelves) {
b69ab31221 try {
b69ab31222 const lines = chunk.trim().split('\n');
b69ab31223 if (lines.length < Object.keys(SHELVE_FIELDS).length) {
b69ab31224 continue;
b69ab31225 }
b69ab31226 const files: Array<ChangedFile> = [
b69ab31227 ...(JSON.parse(lines[SHELVE_FIELD_INDEX.filesModified]) as Array<string>).map(path => ({
b69ab31228 path,
b69ab31229 status: 'M' as const,
b69ab31230 })),
b69ab31231 ...(JSON.parse(lines[SHELVE_FIELD_INDEX.filesAdded]) as Array<string>).map(path => ({
b69ab31232 path,
b69ab31233 status: 'A' as const,
b69ab31234 })),
b69ab31235 ...(JSON.parse(lines[SHELVE_FIELD_INDEX.filesRemoved]) as Array<string>).map(path => ({
b69ab31236 path,
b69ab31237 status: 'R' as const,
b69ab31238 })),
b69ab31239 ];
b69ab31240 commitInfos.push({
b69ab31241 hash: lines[SHELVE_FIELD_INDEX.hash],
b69ab31242 name: lines[SHELVE_FIELD_INDEX.name],
b69ab31243 date: new Date(lines[SHELVE_FIELD_INDEX.date]),
b69ab31244 filesSample: files.slice(0, MAX_FETCHED_FILES_PER_COMMIT),
b69ab31245 totalFileCount: files.length,
b69ab31246 description: lines.slice(SHELVE_FIELD_INDEX.description).join('\n'),
b69ab31247 });
b69ab31248 } catch (err) {
b69ab31249 logger.error('failed to parse shelved change');
b69ab31250 }
b69ab31251 }
b69ab31252 return commitInfos;
b69ab31253}
b69ab31254
b69ab31255///// Changed Files /////
b69ab31256
b69ab31257export const CHANGED_FILES_FIELDS = {
b69ab31258 hash: '{node}',
b69ab31259 filesAdded: '{file_adds|json}',
b69ab31260 filesModified: '{file_mods|json}',
b69ab31261 filesRemoved: '{file_dels|json}',
b69ab31262};
b69ab31263export const CHANGED_FILES_INDEX = fromEntries(
b69ab31264 Object.keys(CHANGED_FILES_FIELDS).map((key, i) => [key, i]),
b69ab31265) as {
b69ab31266 [key in Required<keyof typeof CHANGED_FILES_FIELDS>]: number;
b69ab31267};
b69ab31268export const CHANGED_FILES_TEMPLATE = [
b69ab31269 ...Object.values(CHANGED_FILES_FIELDS),
b69ab31270 COMMIT_END_MARK,
b69ab31271].join('\n');
b69ab31272
b69ab31273///// Helpers /////
b69ab31274
b69ab31275function parseSuccessorData(successorData: string): SuccessorInfo | undefined {
b69ab31276 const [successorString] = successorData.split(',', 1); // we're only interested in the first available mutation
b69ab31277 if (!successorString) {
b69ab31278 return undefined;
b69ab31279 }
b69ab31280 const successor = successorString.split(':');
b69ab31281 return {
b69ab31282 hash: successor[1],
b69ab31283 type: successor[0],
b69ab31284 };
b69ab31285}
b69ab31286function splitLine(line: string, separator = NULL_CHAR): Array<string> {
b69ab31287 return line.split(separator).filter(e => e.length > 0);
b69ab31288}