blob: 34a90de6ac1b3f87f08c81654b6589843bfcd3ad [file] [log] [blame]
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
CommentBasics,
CommentInfo,
PatchSetNum,
RobotCommentInfo,
Timestamp,
UrlEncodedCommentId,
CommentRange,
PatchRange,
PARENT,
ContextLine,
BasePatchSetNum,
RevisionPatchSetNum,
AccountInfo,
AccountDetailInfo,
ChangeMessageInfo,
VotingRangeInfo,
FixSuggestionInfo,
FixId,
PatchSetNumber,
} from '../types/common';
import {CommentSide, SpecialFilePath} from '../constants/constants';
import {parseDate} from './date-util';
import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
import {isMergeParent, getParentIndex} from './patch-set-util';
import {DiffInfo} from '../types/diff';
import {LineNumber} from '../api/diff';
import {FormattedReviewerUpdateInfo} from '../types/types';
import {extractMentionedUsers} from './account-util';
export interface DraftCommentProps {
// This must be true for all drafts. Drafts received from the backend will be
// modified immediately with __draft:true before allowing them to get into
// the application state.
__draft: boolean;
}
export interface UnsavedCommentProps {
// This must be true for all unsaved comment drafts. An unsaved draft is
// always just local to a comment component like <gr-comment> or
// <gr-comment-thread>. Unsaved drafts will never appear in the application
// state.
__unsaved: boolean;
}
export type DraftInfo = CommentInfo & DraftCommentProps;
export type UnsavedInfo = CommentBasics & UnsavedCommentProps;
export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
// TODO: Replace the CommentMap type with just an array of paths.
export type CommentMap = {[path: string]: boolean};
export function isRobot<T extends CommentBasics>(
x: T | DraftInfo | RobotCommentInfo | undefined
): x is RobotCommentInfo {
return !!x && !!(x as RobotCommentInfo).robot_id;
}
export function isDraft<T extends CommentBasics>(
x: T | DraftInfo | undefined
): x is DraftInfo {
return !!x && !!(x as DraftInfo).__draft;
}
export function isUnsaved<T extends CommentBasics>(
x: T | UnsavedInfo | undefined
): x is UnsavedInfo {
return !!x && !!(x as UnsavedInfo).__unsaved;
}
export function isDraftOrUnsaved<T extends CommentBasics>(
x: T | DraftInfo | UnsavedInfo | undefined
): x is UnsavedInfo | DraftInfo {
return isDraft(x) || isUnsaved(x);
}
interface SortableComment {
updated: Timestamp;
id: UrlEncodedCommentId;
}
export interface ChangeMessage extends ChangeMessageInfo {
// TODO(TS): maybe should be an enum instead
type: string;
expanded: boolean;
commentThreads: CommentThread[];
}
export function isFormattedReviewerUpdate(
message: ChangeMessage
): message is ChangeMessage & FormattedReviewerUpdateInfo {
return message.type === 'REVIEWER_UPDATE';
}
export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
export const NEWLINE_PATTERN = /\n/g;
export const PATCH_SET_PREFIX_PATTERN =
/^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
export function sortComments<T extends SortableComment>(comments: T[]): T[] {
return comments.slice(0).sort((c1, c2) => {
const d1 = isDraft(c1);
const d2 = isDraft(c2);
if (d1 !== d2) return d1 ? 1 : -1;
const date1 = parseDate(c1.updated);
const date2 = parseDate(c2.updated);
const dateDiff = date1.valueOf() - date2.valueOf();
if (dateDiff !== 0) return dateDiff;
const id1 = c1.id;
const id2 = c2.id;
return id1.localeCompare(id2);
});
}
export function createUnsavedComment(thread: CommentThread): UnsavedInfo {
return {
path: thread.path,
patch_set: thread.patchNum,
side: thread.commentSide ?? CommentSide.REVISION,
line: typeof thread.line === 'number' ? thread.line : undefined,
range: thread.range,
parent: thread.mergeParentNum,
message: '',
unresolved: true,
__unsaved: true,
};
}
export function createPatchsetLevelUnsavedDraft(
patchNum?: PatchSetNumber,
message?: string,
unresolved?: boolean
): UnsavedInfo {
return {
patch_set: patchNum,
message,
unresolved,
path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
__unsaved: true,
};
}
export function createUnsavedReply(
replyingTo: CommentInfo,
message: string,
unresolved: boolean
): UnsavedInfo {
return {
path: replyingTo.path,
patch_set: replyingTo.patch_set,
side: replyingTo.side,
line: replyingTo.line,
range: replyingTo.range,
parent: replyingTo.parent,
in_reply_to: replyingTo.id,
message,
unresolved,
__unsaved: true,
};
}
export function createCommentThreads(comments: CommentInfo[]) {
const sortedComments = sortComments(comments);
const threads: CommentThread[] = [];
const idThreadMap: CommentIdToCommentThreadMap = {};
for (const comment of sortedComments) {
// thread and append to it.
if (comment.in_reply_to) {
const thread = idThreadMap[comment.in_reply_to];
if (thread) {
thread.comments.push(comment);
if (comment.id) idThreadMap[comment.id] = thread;
continue;
}
}
// Otherwise, this comment starts its own thread.
if (!comment.path) {
throw new Error('Comment missing required "path".');
}
const newThread: CommentThread = {
comments: [comment],
patchNum: comment.patch_set,
commentSide: comment.side ?? CommentSide.REVISION,
mergeParentNum: comment.parent,
path: comment.path,
line: comment.line,
range: comment.range,
rootId: comment.id,
};
if (!comment.line && !comment.range) {
newThread.line = 'FILE';
}
threads.push(newThread);
if (comment.id) idThreadMap[comment.id] = newThread;
}
return threads;
}
export interface CommentThread {
/**
* This can only contain at most one draft. And if so, then it is the last
* comment in this list. This must not contain unsaved drafts.
*/
comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>;
/**
* Identical to the id of the first comment. If this is undefined, then the
* thread only contains an unsaved draft.
*/
rootId?: UrlEncodedCommentId;
/**
* Note that all location information is typically identical to that of the
* first comment, but not for ported comments!
*/
path: string;
commentSide: CommentSide;
/* mergeParentNum is the merge parent number only valid for merge commits
when commentSide is PARENT.
mergeParentNum is undefined for auto merge commits
Same as `parent` in CommentInfo.
*/
mergeParentNum?: number;
patchNum?: RevisionPatchSetNum;
/* Different from CommentInfo, which just keeps the line undefined for
FILE comments. */
line?: LineNumber;
range?: CommentRange;
/**
* Was the thread ported over from its original location to a newer patchset?
* If yes, then the location information above contains the ported location,
* but the comments still have the original location set.
*/
ported?: boolean;
/**
* Only relevant when ported:true. Means that no ported range could be
* computed. `line` and `range` can be undefined then.
*/
rangeInfoLost?: boolean;
}
export function equalLocation(t1?: CommentThread, t2?: CommentThread) {
if (t1 === t2) return true;
if (t1 === undefined || t2 === undefined) return false;
return (
t1.path === t2.path &&
t1.patchNum === t2.patchNum &&
t1.commentSide === t2.commentSide &&
t1.line === t2.line &&
t1.range?.start_line === t2.range?.start_line &&
t1.range?.start_character === t2.range?.start_character &&
t1.range?.end_line === t2.range?.end_line &&
t1.range?.end_character === t2.range?.end_character
);
}
export function getLastComment(thread: CommentThread): CommentInfo | undefined {
const len = thread.comments.length;
return thread.comments[len - 1];
}
export function getLastPublishedComment(
thread: CommentThread
): CommentInfo | undefined {
const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
const len = publishedComments.length;
return publishedComments[len - 1];
}
export function getFirstComment(
thread: CommentThread
): CommentInfo | undefined {
return thread.comments[0];
}
export function countComments(thread: CommentThread) {
return thread.comments.length;
}
export function isPatchsetLevel(thread: CommentThread): boolean {
return thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
}
export function isUnresolved(thread: CommentThread): boolean {
return !isResolved(thread);
}
export function isResolved(thread: CommentThread): boolean {
const lastUnresolved = getLastComment(thread)?.unresolved;
return !lastUnresolved ?? false;
}
export function isDraftThread(thread: CommentThread): boolean {
return isDraft(getLastComment(thread));
}
export function isMentionedThread(
thread: CommentThread,
account?: AccountInfo
) {
if (!account?.email) return false;
return getMentionedUsers(thread)
.map(v => v.email)
.includes(account.email);
}
export function isRobotThread(thread: CommentThread): boolean {
return isRobot(getFirstComment(thread));
}
export function hasHumanReply(thread: CommentThread): boolean {
return countComments(thread) > 1 && !isRobot(getLastComment(thread));
}
export function lastUpdated(thread: CommentThread): Date | undefined {
// We don't want to re-sort comments when you save a draft reply, so
// we stick to the timestampe of the last *published* comment.
const lastUpdated =
getLastPublishedComment(thread)?.updated ?? getLastComment(thread)?.updated;
return lastUpdated !== undefined ? parseDate(lastUpdated) : undefined;
}
/**
* Whether the given comment should be included in the base side of the
* given patch range.
*/
export function isInBaseOfPatchRange(
comment: {
patch_set?: PatchSetNum;
side?: CommentSide;
parent?: number;
},
range: PatchRange
) {
// If the base of the patch range is a parent of a merge, and the comment
// appears on a specific parent then only show the comment if the parent
// index of the comment matches that of the range.
if (comment.parent && comment.side === CommentSide.PARENT) {
return (
isMergeParent(range.basePatchNum) &&
comment.parent === getParentIndex(range.basePatchNum)
);
}
// If the base of the range is the parent of the patch:
if (
range.basePatchNum === PARENT &&
comment.side === CommentSide.PARENT &&
comment.patch_set === range.patchNum
) {
return true;
}
// If the base of the range is not the parent of the patch:
return (
range.basePatchNum !== PARENT &&
comment.side !== CommentSide.PARENT &&
comment.patch_set === range.basePatchNum
);
}
/**
* Whether the given comment should be included in the revision side of the
* given patch range.
*/
export function isInRevisionOfPatchRange(
comment: {
patch_set?: PatchSetNum;
side?: CommentSide;
},
range: PatchRange
) {
return (
comment.side !== CommentSide.PARENT && comment.patch_set === range.patchNum
);
}
/**
* Whether the given comment should be included in the given patch range.
*/
export function isInPatchRange(
comment: CommentBasics,
range: PatchRange
): boolean {
return (
isInBaseOfPatchRange(comment, range) ||
isInRevisionOfPatchRange(comment, range)
);
}
export function getPatchRangeForCommentUrl(
comment: Comment,
latestPatchNum: RevisionPatchSetNum
) {
if (!comment.patch_set) throw new Error('Missing comment.patch_set');
// TODO(dhruvsri): Add handling for comment left on parents of merge commits
if (comment.side === CommentSide.PARENT) {
return {
patchNum: comment.patch_set,
basePatchNum: PARENT,
};
} else if (latestPatchNum === comment.patch_set) {
return {
patchNum: latestPatchNum,
basePatchNum: PARENT,
};
} else {
return {
patchNum: latestPatchNum,
basePatchNum: comment.patch_set as BasePatchSetNum,
};
}
}
export function computeDiffFromContext(
context: ContextLine[],
path: string,
content_type?: string
) {
// do not render more than 20 lines of context
context = context.slice(0, 20);
const diff: DiffInfo = {
meta_a: {
name: '',
content_type: '',
lines: 0,
web_links: [],
},
meta_b: {
name: path,
content_type: content_type || '',
lines: context.length + context?.[0].line_number,
web_links: [],
},
change_type: 'MODIFIED',
intraline_status: 'OK',
diff_header: [],
content: [
{
skip: context[0].line_number - 1,
},
{
b: context.map(line => line.context_line),
},
],
};
return diff;
}
export function getCommentAuthors(
threads?: CommentThread[],
user?: AccountDetailInfo
) {
if (!threads || !user) return [];
const ids = new Set();
const authors: AccountInfo[] = [];
threads.forEach(t =>
t.comments.forEach(c => {
if (isDraft(c) && !ids.has(user._account_id)) {
ids.add(user._account_id);
authors.push(user);
return;
}
if (c.author && !ids.has(c.author._account_id)) {
ids.add(c.author._account_id);
authors.push(c.author);
}
})
);
return authors;
}
/**
* Add path info to every comment as CommentInfo returned from server does not
* have that.
*/
export function addPath<T>(comments: {[path: string]: T[]} = {}): {
[path: string]: Array<T & {path: string}>;
} {
const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
for (const filePath of Object.keys(comments)) {
updatedComments[filePath] = (comments[filePath] || []).map(comment => {
return {...comment, path: filePath};
});
}
return updatedComments;
}
/**
* Add __draft:true to all drafts returned from server so that they can be told
* apart from published comments easily.
*/
export function addDraftProp(
draftsByPath: {[path: string]: CommentInfo[]} = {}
) {
const updated: {[path: string]: DraftInfo[]} = {};
for (const filePath of Object.keys(draftsByPath)) {
updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
return {...draft, __draft: true};
});
}
return updated;
}
export function reportingDetails(comment: CommentBasics) {
return {
id: comment?.id,
message_length: comment?.message?.trim().length,
in_reply_to: comment?.in_reply_to,
unresolved: comment?.unresolved,
path_length: comment?.path?.length,
line: comment?.range?.start_line ?? comment?.line,
unsaved: isUnsaved(comment),
};
}
export const USER_SUGGESTION_INFO_STRING = 'suggestion';
export const USER_SUGGESTION_START_PATTERN = `\`\`\`${USER_SUGGESTION_INFO_STRING}\n`;
// This can either mean a user or a checks provided fix.
// "Provided" means that the fix is sent along with the request
// when previewing and applying the fix. This is in contrast to
// robot comment fixes, which are stored in the backend, and they
// are referenced by a unique `FixId`;
export const PROVIDED_FIX_ID = 'provided_fix' as FixId;
export function hasUserSuggestion(comment: Comment) {
return comment.message?.includes(USER_SUGGESTION_START_PATTERN) ?? false;
}
export function getUserSuggestion(comment: Comment) {
if (!comment.message) return;
const start =
comment.message.indexOf(USER_SUGGESTION_START_PATTERN) +
USER_SUGGESTION_START_PATTERN.length;
const end = comment.message.indexOf('\n```', start);
return comment.message.substring(start, end);
}
export function getContentInCommentRange(
fileContent: string,
comment: Comment
) {
const lines = fileContent.split('\n');
if (comment.range) {
const range = comment.range;
return lines.slice(range.start_line - 1, range.end_line).join('\n');
}
return lines[comment.line! - 1];
}
export function createUserFixSuggestion(
comment: Comment,
line: string,
replacement: string
): FixSuggestionInfo[] {
const lastLine = line.split('\n').pop();
return [
{
fix_id: PROVIDED_FIX_ID,
description: 'User suggestion',
replacements: [
{
path: comment.path!,
range: {
start_line: comment.range?.start_line ?? comment.line!,
start_character: 0,
end_line: comment.range?.end_line ?? comment.line!,
end_character: lastLine!.length,
},
replacement,
},
],
},
];
}
function getMentionedUsers(thread: CommentThread) {
return thread.comments.map(c => extractMentionedUsers(c.message)).flat();
}
export function getMentionedThreads(
threads: CommentThread[],
account: AccountInfo
) {
if (!account.email) return [];
return threads.filter(t =>
getMentionedUsers(t)
.map(v => v.email)
.includes(account.email)
);
}
export function findComment(
comments: {
[path: string]: (CommentInfo | DraftInfo)[];
},
commentId: UrlEncodedCommentId
) {
if (!commentId) return undefined;
let comment;
for (const path of Object.keys(comments)) {
comment = comment || comments[path].find(c => c.id === commentId);
}
return comment;
}