import * as fs from 'fs' ; import * as path from 'path' ; import stripIndent from 'strip-indent' ; import handlebars from 'handlebars' ; import { workspace , Uri , window , ViewColumn , TreeItemCollapsibleState , ExtensionContext , ThemeIcon , commands , } from 'vscode' ; const parseFile = require ( '@fast-csv/parse' ) . parseFile ; import { EOL } from 'os' ; import { encode , decode } from 'js-base64' ; import { toAbsolutePath , getFileContentForRange , sortCsvEntryForLines , sortLineSelections , rangeFromStringDefinition , escapeEndOfLineForCsv , standardizeFilename , splitStringDefinition , } from './utils/workspace-util' ; import { ReviewFileExportSection , GroupBy , ExportFormat , ExportMap , Group } from './interfaces' ; import { CsvEntry , CsvStructure } from './model' ; import { CommentListEntry } from './comment-list-entry' ; import { FileGenerator } from './file-generator' ; import { themeColorForPriority } from './utils/editor-utils' ; const gitCommitId = require ( 'git-commit-id' ) ; export class ExportFactory { private defaultFileName = 'code-review' ; private groupBy : GroupBy ; private includeCodeSelection = false ; private includePrivateComments = false ; private privateCommentIcon : string ; private filterByCommit : boolean = false ; private currentCommitId : string | null = null ; private filterByFilename : boolean = false ; private currentFilename : string | null = null ; param entry private isCommentEligible ( entry ) { return ( ( this . currentCommitId === null || entry . sha === this . currentCommitId ) && ( this . currentFilename === null || entry . filename === this . currentFilename ) ) ; } private exportHandlerMap = new Map < ExportFormat , ExportMap > ( [ [ 'html' , { fileExtension : 'html' , storeOutside : true , writeFileHeader : ( _outputFile ) => { return ; } , handleData : ( _outputFile , row ) : CsvEntry => { row . code = this . includeCodeSelection ? this . getCodeForFile ( row . filename , row . lines ) : '' ; return row ; } , handleEnd : ( outputFile , rows , template ) => { const templateData = fs . readFileSync ( template . fsPath , 'utf8' ) ; if ( ! templateData ) { window . showErrorMessage ( `Error when reading the template file: '${ template . fsPath }'` ) ; } let reviewExportData = [ ] ; reviewExportData = this . groupResults ( rows , this . groupBy ) ; if ( this . groupBy === Group . filename ) { reviewExportData . forEach ( ( group ) => { group . lines . sort ( sortCsvEntryForLines ) ; } ) ; } handlebars . registerHelper ( 'codeBlock' , ( code ) => decode ( code ) ) ; const templateCompiled = handlebars . compile ( templateData ) ; const htmlOut = templateCompiled ( reviewExportData ) ; fs . writeFileSync ( outputFile , htmlOut ) ; window . showInformationMessage ( `Code review file: '${ outputFile }' successfully created.` ) ; this . showPreview ( outputFile ) ; } , } , ] , [ 'gitlab' , { fileExtension : 'gitlab.csv' , storeOutside : false , writeFileHeader : ( outputFile ) => { fs . writeFileSync ( outputFile , `title,description${ EOL }` ) ; } , handleData : ( outputFile , row ) : CsvEntry => { row . comment = escapeEndOfLineForCsv ( row . comment ) ; this . includeCodeSelection ? ( row . code = this . getCodeForFile ( row . filename , row . lines ) ) : delete row . code ; const descShort = row . comment . length > 100 ? `${ row . comment . substring ( 0 , 100 ) }...` : row . comment ; const title = row . title ? row . title . substring ( 0 , 255 ) : descShort ; const fileRow = row . url ? `- file: [${ row . filename }](${ row . url })${ EOL }` : `${ row . filename }${ EOL }` ; const linesRow = `- lines: ${ row . lines }${ EOL }` ; const shaRow = row . sha ? `- SHA: ${ row . sha }${ EOL }${ EOL }` : '' ; const commentSection = `## Comment${ EOL }${ row . comment }${ EOL }` ; const additional = row . additional ? `## Additional information${ EOL }${ row . additional }${ EOL }` : '' ; const priority = row . priority ? `## Priority${ EOL }${ this . priorityName ( row . priority ) }${ EOL }${ EOL }` : '' ; const category = row . category ? `## Category${ EOL }${ row . category }${ EOL }${ EOL }` : '' ; const code = row . code ? `${ EOL }## Source Code${ EOL }${ EOL }\`\`\`${ EOL }${ row . code }\`\`\`${ EOL }` : '' ; const description = `${ priority }${ category }## Affected${ EOL }${ fileRow }${ linesRow }${ shaRow }${ commentSection }${ EOL }${ additional }${ code }` ; fs . appendFileSync ( outputFile , `"[code review] ${ title }","${ description }"${ EOL }` ) ; return row ; } , handleEnd : ( outputFile , _rows ) => { window . showInformationMessage ( `GitLab importable CSV file: '${ outputFile }' successfully created.` ) ; } , } , ] , [ 'github' , { fileExtension : 'github.csv' , storeOutside : false , writeFileHeader : ( outputFile ) => { fs . writeFileSync ( outputFile , `title,description,labels,state,assignee${ EOL }` ) ; } , handleData : ( outputFile , row ) : CsvEntry => { row . comment = escapeEndOfLineForCsv ( row . comment ) ; this . includeCodeSelection ? ( row . code = this . getCodeForFile ( row . filename , row . lines ) ) : delete row . code ; const descShort = row . comment . length > 100 ? `${ row . comment . substring ( 0 , 100 ) }...` : row . comment ; const title = row . title ? row . title . substring ( 0 , 255 ) : descShort ; const fileRow = row . url ? `- file: [${ row . filename }](${ row . url })${ EOL }` : `${ row . filename }${ EOL }` ; const linesRow = `- lines: ${ row . lines }${ EOL }` ; const shaRow = row . sha ? `- SHA: ${ row . sha }${ EOL }${ EOL }` : '' ; const commentSection = `## Comment${ EOL }${ row . comment }${ EOL }` ; const additional = row . additional ? `## Additional information${ EOL }${ row . additional }${ EOL }` : '' ; const priority = row . priority ? `## Priority${ EOL }${ this . priorityName ( row . priority ) }${ EOL }${ EOL }` : '' ; const category = row . category ? `## Category${ EOL }${ row . category }${ EOL }${ EOL }` : '' ; const code = row . code ? `${ EOL }## Source Code${ EOL }${ EOL }\`\`\`${ EOL }${ row . code }\`\`\`${ EOL }` : '' ; const description = `${ priority }${ category }## Affected${ EOL }${ fileRow }${ linesRow }${ shaRow }${ commentSection }${ EOL }${ additional }${ code }` ; fs . appendFileSync ( outputFile , `"[code review] ${ title }","${ description }","code-review","open",""${ EOL }` ) ; return row ; } , handleEnd : ( outputFile , _rows ) => { window . showInformationMessage ( `GitHub importable CSV file: '${ outputFile }' successfully created.` ) ; } , } , ] , [ 'jira' , { fileExtension : 'jira.csv' , storeOutside : false , writeFileHeader : ( outputFile ) => { fs . writeFileSync ( outputFile , `Summary,Description,Priority,sha,filename,url,lines,title,category,comment,additional${ EOL }` ) ; } , handleData : ( outputFile , row ) : CsvEntry => { row . comment = escapeEndOfLineForCsv ( row . comment ) ; this . includeCodeSelection ? ( row . code = this . getCodeForFile ( row . filename , row . lines ) ) : delete row . code ; const descShort = row . comment . length > 100 ? `${ row . comment . substring ( 0 , 100 ) }...` : row . comment ; const title = row . title ? row . title . substring ( 0 , 255 ) : descShort ; const fileRow = row . url ? `* file: [${ row . filename }|${ row . url }]${ EOL }` : `${ row . filename }${ EOL }` ; const linesRow = `* lines: ${ row . lines }${ EOL }` ; const shaRow = row . sha ? `* SHA: ${ row . sha }${ EOL }${ EOL }` : '' ; const categorySection = `h2. Category${ EOL }${ row . category }${ EOL }${ EOL }` ; const commentSection = `h2. Comment${ EOL }${ row . comment }${ EOL }` ; const additional = row . additional ? `h2. Additional information${ EOL }${ row . additional }${ EOL }` : '' ; const code = row . code ? `${ EOL }h2. Source Code${ EOL }${ EOL }{code}${ EOL }${ row . code }{code}${ EOL }` : '' ; const description = `h2. Affected${ EOL }${ fileRow }${ linesRow }${ shaRow }${ categorySection }${ commentSection }${ EOL }${ additional }${ code }` ; fs . appendFileSync ( outputFile , `"[code review] ${ title }","${ description }","${ this . priorityName ( row . priority ) }","${ row . sha }","${ row . filename }","${ row . url }","${ row . lines }","${ row . title }","${ row . category }","${ row . comment }","${ row . additional }"${ EOL }` ) ; return row ; } , handleEnd : ( outputFile , _rows ) => { window . showInformationMessage ( `JIRA importable file: '${ outputFile }' successfully created.` ) ; } , } , ] , [ 'json' , { fileExtension : 'json' , storeOutside : true , writeFileHeader : ( _outputFile ) => { return ; } , handleData : ( _outputFile , row ) : CsvEntry => { this . includeCodeSelection ? ( row . code = this . getCodeForFile ( row . filename , row . lines ) ) : delete row . code ; return row ; } , handleEnd : ( outputFile , rows ) => { fs . writeFileSync ( outputFile , JSON . stringify ( rows , null , 2 ) ) ; window . showInformationMessage ( `GitHub importable CSV file: '${ outputFile }' successfully created.` ) ; } , } , ] , ] ) ; /**
     * for trying out: https://stackblitz.com/edit/code-review-template
     */ constructor ( private context , private workspaceRoot , private generator ) { const configFileName = workspace . getConfiguration ( ) . get ( 'code-review.filename' ) as string ; if ( configFileName ) { this . defaultFileName = configFileName ; } let groupByConfig = workspace . getConfiguration ( ) . get ( 'code-review.groupBy' ) as string ; if ( ! groupByConfig || groupByConfig === '-' ) { groupByConfig = Group . filename ; } this . groupBy = groupByConfig as GroupBy ; this . includeCodeSelection = workspace . getConfiguration ( ) . get ( 'code-review.reportWithCodeSelection' ) as boolean ; this . includePrivateComments = workspace . getConfiguration ( ) . get ( 'code-review.reportWithPrivateComments' ) as boolean ; this . privateCommentIcon = workspace . getConfiguration ( ) . get ( 'code-review.privateCommentIcon' ) as string ; this . filterByCommit = workspace . getConfiguration ( ) . get ( 'code-review.filterCommentsByCommit' ) as boolean ; this . setFilterByCommit ( this . filterByCommit ) ; this . filterByFilename = workspace . getConfiguration ( ) . get ( 'code-review.filterCommentsByFilename' ) as boolean ; this . setFilterByFilename ( this . filterByFilename , true ) ; } get basePath ( ) : string { return toAbsolutePath ( this . workspaceRoot , this . defaultFileName ) ; } get inputFile ( ) : string { return `${ this . basePath }.csv` ; } param format exportForFormat ( format , template ? ) { const exporter = this . exportHandlerMap . get ( format ) ; const outputFile = `${ this . basePath }.${ exporter ?. fileExtension }` ; exporter ?. writeFileHeader ( outputFile ) ; const data = [ ] ; parseFile ( this . inputFile , { delimiter : ',' , ignoreEmpty : true , headers : true } ) . on ( 'error' , this . handleError ) . on ( 'data' , ( comment ) => { comment = CsvStructure . finalizeParse ( comment ) ; if ( this . isCommentEligible ( comment ) ) { if ( this . includePrivateComments || comment . private === 0 ) { if ( exporter ?. storeOutside ) { const tmp = exporter . handleData ( outputFile , comment ) ; data . push ( tmp ) ; } exporter ?. handleData ( outputFile , comment ) ; } } } ) . on ( 'end' , ( _rows ) => { return exporter ?. handleEnd ( outputFile , exporter ?. storeOutside ? data : [ ] , template ) ; } ) ; } /**
     * get the comments as CommentListEntry for VSCode view
     */ getComments ( commentGroupedInFile ) { const result = commentGroupedInFile . data . lines . filter ( ( entry ) => this . isCommentEligible ( entry ) ) . map ( ( entry ) => { entry = CsvStructure . finalizeParse ( entry ) ; const item = new CommentListEntry ( entry . id , entry . title , entry . comment , entry . comment , TreeItemCollapsibleState . None , commentGroupedInFile . data , entry . priority , entry . private ) ; item . contextValue = 'comment' ; item . command = { command : 'codeReview.openSelection' , title : 'Open comment' , arguments : [ commentGroupedInFile . data , entry ] , } ; item . iconPath = this . getIcon ( entry . priority , entry . private ) ; return item ; } ) ; return Promise . resolve ( result ) ; } private getIcon ( prio , priv ) { switch ( priv ) { default : { let icon = '' ; switch ( prio ) { case 3 : icon = 'red.svg' ; break ; case 2 : icon = 'yellow.svg' ; break ; case 1 : icon = 'green.svg' ; break ; default : icon = 'unset.svg' ; break ; } const iPath = this . context . asAbsolutePath ( path . join ( 'dist' , icon ) ) ; return { light : iPath , dark : iPath } ; } case 1 : { return new ThemeIcon ( this . privateCommentIcon , themeColorForPriority ( prio ) ) ; } } } public getFilesContainingComments ( ) { if ( ! fs . existsSync ( this . inputFile ) || ! this . generator . check ( ) ) { return Promise . resolve ( [ ] ) ; } const entries = [ ] ; return new Promise ( ( resolve ) => { parseFile ( this . inputFile , { delimiter : ',' , ignoreEmpty : true , headers : true } ) . on ( 'error' , ( ) => this . handleError ) . on ( 'data' , ( row ) => { if ( this . isCommentEligible ( row ) ) { entries . push ( row ) ; } } ) . on ( 'end' , ( ) => { const sortedByFile = this . groupResults ( entries , Group . filename ) ; const listEntries = sortedByFile . map ( ( el , index ) => { const item = new CommentListEntry ( '' , el . group , `(${ el . lines . length })` , `${ el . lines . length } comments` , this . filterByFilename && index === 0 ? TreeItemCollapsibleState . Expanded : TreeItemCollapsibleState . Collapsed , el ) ; item . command = { command : 'codeReview.openSelection' , title : 'reveal comment' , arguments : [ el ] , } ; item . contextValue = 'file' ; item . iconPath = { light : this . context . asAbsolutePath ( path . join ( 'dist' , 'document-light.svg' ) ) , dark : this . context . asAbsolutePath ( path . join ( 'dist' , 'document-dark.svg' ) ) , } ; return item ; } ) ; resolve ( listEntries ) ; } ) ; } ) ; } private handleError ( error ) { console . error ( error ) ; } private groupResults ( rows , groupAttribute ) { const reviewExportData = [ ] ; rows . forEach ( ( row ) => { row . category = row . category || 'Other' ; row . lines = splitStringDefinition ( row . lines ) . sort ( sortLineSelections ) . join ( '|' ) ; const match = reviewExportData . find ( ( fileRef ) => fileRef . group === row [ groupAttribute ] ) ; if ( match ) { match . lines . push ( row ) ; } else { reviewExportData . push ( { group : row [ groupAttribute ] . toString ( ) , lines : [ row ] , } ) ; } } ) ; return reviewExportData ; } private getCodeForFile ( filename , lines ) { if ( ! filename ) { filename = '' ; } if ( ! lines ) { lines = '' ; } let result = '' ; const lineRanges = splitStringDefinition ( lines ) ; const filePath = toAbsolutePath ( this . workspaceRoot , filename ) ; if ( lineRanges ) { lineRanges . forEach ( ( rangeString ) => { if ( rangeString ) { const range = rangeFromStringDefinition ( rangeString , 1 ) ; const fileContent = stripIndent ( getFileContentForRange ( filePath , range ) ) ; if ( result ) { result = `${ result }${ EOL }...${ EOL }${ EOL }${ fileContent }` ; } else { result = fileContent ; } } } ) ; } return encode ( result ) ; } private priorityName ( priority ) { const priorityMap = workspace . getConfiguration ( ) . get ( 'code-review.priorities' ) as string [ ] ; return priorityMap [ priority ] ; } private showPreview ( outputFile ) { const panel = window . createWebviewPanel ( 'text' , 'Code Review HTML Report' , ViewColumn . Beside , { enableScripts : true , } ) ; panel . webview . html = fs . readFileSync ( outputFile , 'utf8' ) ; } /**
     * Refresh comments filtering state
     */ public refreshFilterByCommit ( ) { this . setFilterByCommit ( this . filterByCommit ) ; } param state returns public setFilterByCommit ( state ) { this . filterByCommit = state ; if ( this . filterByCommit ) { try { const gitDirectory = workspace . getConfiguration ( ) . get ( 'code-review.gitDirectory' ) as string ; const gitRepositoryPath = path . resolve ( this . workspaceRoot , gitDirectory ) ; this . currentCommitId = gitCommitId ( { cwd : gitRepositoryPath } ) ; } catch ( error ) { this . filterByCommit = false ; this . currentCommitId = null ; console . log ( 'Not in a git repository. Disabling filter by commit' , error ) ; } } else { this . currentCommitId = null ; } commands . executeCommand ( 'setContext' , 'isFilteredByCommit' , this . filterByCommit ) ; return this . filterByCommit ; } returns public refreshFilterByFilename ( ) { return this . setFilterByFilename ( this . filterByFilename ) ; } param state param force returns public setFilterByFilename ( state , force = false ) { let changedState = this . filterByFilename !== state || force ; this . filterByFilename = state ; let changedFile = false ; if ( this . filterByFilename ) { let filename = window . activeTextEditor ?. document . fileName ; if ( filename ) { filename = standardizeFilename ( this . workspaceRoot , filename ) ; if ( this . currentFilename !== filename ) { changedFile = true ; this . currentFilename = filename ; } } } else { this . currentFilename = null ; } if ( changedState ) { commands . executeCommand ( 'setContext' , 'isFilteredByFilename' , this . filterByFilename ) ; } return changedState || changedFile ; } }