| /** |
| * @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; |
| } |