diff --git a/artifactory/commands/buildinfo/publish.go b/artifactory/commands/buildinfo/publish.go index 360c95e74..4bb4cf127 100644 --- a/artifactory/commands/buildinfo/publish.go +++ b/artifactory/commands/buildinfo/publish.go @@ -3,6 +3,8 @@ package buildinfo import ( "errors" "fmt" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/commandssummaries" + "github.com/jfrog/jfrog-cli-core/v2/commandsummary" "net/url" "strconv" "strings" @@ -139,6 +141,10 @@ func (bpc *BuildPublishCommand) Run() error { return err } + if err = recordCommandSummary(buildInfo, buildLink); err != nil { + return err + } + logMsg := "Build info successfully deployed." if bpc.IsDetailedSummary() { log.Info(logMsg + " Browse it in Artifactory under " + buildLink) @@ -229,3 +235,15 @@ func (bpc *BuildPublishCommand) getNextBuildNumber(buildName string, servicesMan latestBuildNumber++ return strconv.Itoa(latestBuildNumber), nil } + +func recordCommandSummary(buildInfo *buildinfo.BuildInfo, buildLink string) (err error) { + if !commandsummary.ShouldRecordSummary() { + return + } + buildInfo.BuildUrl = buildLink + buildInfoSummary, err := commandsummary.New(commandssummaries.NewBuildInfo(), "build-info") + if err != nil { + return + } + return buildInfoSummary.Record(buildInfo) +} diff --git a/artifactory/commands/commandssummaries/buildinfosummary.go b/artifactory/commands/commandssummaries/buildinfosummary.go new file mode 100644 index 000000000..5624a432a --- /dev/null +++ b/artifactory/commands/commandssummaries/buildinfosummary.go @@ -0,0 +1,57 @@ +package commandssummaries + +import ( + "fmt" + buildInfo "github.com/jfrog/build-info-go/entities" + "github.com/jfrog/jfrog-cli-core/v2/commandsummary" + "strings" + "time" +) + +const timeFormat = "Jan 2, 2006 , 15:04:05" + +type BuildInfoSummary struct{} + +func NewBuildInfo() *BuildInfoSummary { + return &BuildInfoSummary{} +} + +func (bis *BuildInfoSummary) GenerateMarkdownFromFiles(dataFilePaths []string) (finalMarkdown string, err error) { + // Aggregate all the build info files into a slice + var builds []*buildInfo.BuildInfo + for _, path := range dataFilePaths { + var publishBuildInfo buildInfo.BuildInfo + if err = commandsummary.UnmarshalFromFilePath(path, &publishBuildInfo); err != nil { + return + } + builds = append(builds, &publishBuildInfo) + } + + if len(builds) > 0 { + finalMarkdown = bis.buildInfoTable(builds) + } + return +} + +func (bis *BuildInfoSummary) buildInfoTable(builds []*buildInfo.BuildInfo) string { + // Generate a string that represents a Markdown table + var tableBuilder strings.Builder + tableBuilder.WriteString("\n\n| Build Info | Time Stamp | \n") + tableBuilder.WriteString("|---------|------------| \n") + for _, build := range builds { + buildTime := parseBuildTime(build.Started) + tableBuilder.WriteString(fmt.Sprintf("| [%s](%s) | %s |\n", build.Name+" "+build.Number, build.BuildUrl, buildTime)) + } + tableBuilder.WriteString("\n\n") + return tableBuilder.String() +} + +func parseBuildTime(timestamp string) string { + // Parse the timestamp string into a time.Time object + buildInfoTime, err := time.Parse(buildInfo.TimeFormat, timestamp) + if err != nil { + return "N/A" + } + // Format the time in a more human-readable format and save it in a variable + return buildInfoTime.Format(timeFormat) +} diff --git a/artifactory/commands/commandssummaries/buildinfosummary_test.go b/artifactory/commands/commandssummaries/buildinfosummary_test.go new file mode 100644 index 000000000..9b8ac30aa --- /dev/null +++ b/artifactory/commands/commandssummaries/buildinfosummary_test.go @@ -0,0 +1,32 @@ +package commandssummaries + +import ( + buildinfo "github.com/jfrog/build-info-go/entities" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestBuildInfoTable(t *testing.T) { + gh := &BuildInfoSummary{} + var builds = []*buildinfo.BuildInfo{ + { + Name: "buildName", + Number: "123", + Started: "2024-05-05T12:47:20.803+0300", + BuildUrl: "http://myJFrogPlatform/builds/buildName/123", + }, + } + expected := "\n\n| Build Info | Time Stamp | \n|---------|------------| \n| [buildName 123](http://myJFrogPlatform/builds/buildName/123) | May 5, 2024 , 12:47:20 |\n\n\n" + assert.Equal(t, expected, gh.buildInfoTable(builds)) +} + +func TestParseBuildTime(t *testing.T) { + // Test format + actual := parseBuildTime("2006-01-02T15:04:05.000-0700") + expected := "Jan 2, 2006 , 15:04:05" + assert.Equal(t, expected, actual) + // Test invalid format + expected = "N/A" + actual = parseBuildTime("") + assert.Equal(t, expected, actual) +} diff --git a/artifactory/commands/commandssummaries/uploadsummary.go b/artifactory/commands/commandssummaries/uploadsummary.go new file mode 100644 index 000000000..b28d1ccbe --- /dev/null +++ b/artifactory/commands/commandssummaries/uploadsummary.go @@ -0,0 +1,66 @@ +package commandssummaries + +import ( + "fmt" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + "github.com/jfrog/jfrog-cli-core/v2/commandsummary" +) + +type UploadSummary struct { + uploadTree *utils.FileTree + uploadedArtifacts ResultsWrapper + PlatformUrl string + JfrogProjectKey string +} + +type UploadResult struct { + SourcePath string `json:"sourcePath"` + TargetPath string `json:"targetPath"` + RtUrl string `json:"rtUrl"` +} + +type ResultsWrapper struct { + Results []UploadResult `json:"results"` +} + +func NewUploadSummary(platformUrl, projectKey string) *UploadSummary { + return &UploadSummary{ + PlatformUrl: platformUrl, + JfrogProjectKey: projectKey, + } +} + +func (us *UploadSummary) GenerateMarkdownFromFiles(dataFilePaths []string) (markdown string, err error) { + if err = us.loadResults(dataFilePaths); err != nil { + return + } + // Wrap the markdown in a
 tags to preserve spaces
+	markdown = fmt.Sprintf("\n
\n\n\n" + us.generateFileTreeMarkdown() + "
\n\n") + return +} + +// Loads all the recorded results from the given file paths. +func (us *UploadSummary) loadResults(filePaths []string) error { + us.uploadedArtifacts = ResultsWrapper{} + for _, path := range filePaths { + var uploadResult ResultsWrapper + if err := commandsummary.UnmarshalFromFilePath(path, &uploadResult); err != nil { + return err + } + us.uploadedArtifacts.Results = append(us.uploadedArtifacts.Results, uploadResult.Results...) + } + return nil +} + +func (us *UploadSummary) generateFileTreeMarkdown() string { + us.uploadTree = utils.NewFileTree() + for _, uploadResult := range us.uploadedArtifacts.Results { + us.uploadTree.AddFile(uploadResult.TargetPath, us.buildUiUrl(uploadResult.TargetPath)) + } + return us.uploadTree.String() +} + +func (us *UploadSummary) buildUiUrl(targetPath string) string { + template := "%sui/repos/tree/General/%s/?projectKey=%s" + return fmt.Sprintf(template, us.PlatformUrl, targetPath, us.JfrogProjectKey) +} diff --git a/artifactory/commands/commandssummaries/uploadsummary_test.go b/artifactory/commands/commandssummaries/uploadsummary_test.go new file mode 100644 index 000000000..0a51eef6b --- /dev/null +++ b/artifactory/commands/commandssummaries/uploadsummary_test.go @@ -0,0 +1,24 @@ +package commandssummaries + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestBuildUiUrl(t *testing.T) { + gh := &UploadSummary{ + PlatformUrl: "https://myplatform.com/", + JfrogProjectKey: "myProject", + } + expected := "https://myplatform.com/ui/repos/tree/General/myPath/?projectKey=myProject" + actual := gh.buildUiUrl("myPath") + assert.Equal(t, expected, actual) + + gh = &UploadSummary{ + PlatformUrl: "https://myplatform.com/", + JfrogProjectKey: "", + } + expected = "https://myplatform.com/ui/repos/tree/General/myPath/?projectKey=" + actual = gh.buildUiUrl("myPath") + assert.Equal(t, expected, actual) +} diff --git a/artifactory/commands/generic/upload.go b/artifactory/commands/generic/upload.go index 082595956..e331e0db7 100644 --- a/artifactory/commands/generic/upload.go +++ b/artifactory/commands/generic/upload.go @@ -2,6 +2,9 @@ package generic import ( "errors" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/commandssummaries" + "github.com/jfrog/jfrog-cli-core/v2/commandsummary" + "os" buildInfo "github.com/jfrog/build-info-go/entities" @@ -153,6 +156,10 @@ func (uc *UploadCommand) upload() (err error) { } successCount = summary.TotalSucceeded failCount = summary.TotalFailed + + if err = recordCommandSummary(summary, serverDetails.Url, uc.buildConfiguration); err != nil { + return + } } } else { successCount, failCount, err = servicesManager.UploadFiles(uploadParamsArray...) @@ -188,6 +195,7 @@ func (uc *UploadCommand) upload() (err error) { } return build.PopulateBuildArtifactsAsPartials(buildArtifacts, uc.buildConfiguration, buildInfo.Generic) } + return } @@ -271,3 +279,39 @@ func createDeleteSpecForSync(deletePattern string, syncDeletesProp string) *spec Recursive(true). BuildSpec() } + +func recordCommandSummary(summary *rtServicesUtils.OperationSummary, platformUrl string, buildConfig *build.BuildConfiguration) (err error) { + if !commandsummary.ShouldRecordSummary() { + return + } + // Get project key if exists + var projectKey string + if buildConfig != nil { + projectKey = buildConfig.GetProject() + } + uploadSummary, err := commandsummary.New(commandssummaries.NewUploadSummary(platformUrl, projectKey), "upload") + if err != nil { + return + } + data, err := readDetailsFromReader(summary.TransferDetailsReader) + if err != nil { + return + } + return uploadSummary.Record(data) +} + +// Reads transfer details from the reader and return the content as bytes for further processing +func readDetailsFromReader(reader *content.ContentReader) (readContent []byte, err error) { + if reader == nil { + return + } + for _, file := range reader.GetFilesPaths() { + // Read source file + sourceBytes, err := os.ReadFile(file) + if err != nil { + return nil, errorutils.CheckError(err) + } + readContent = append(readContent, sourceBytes...) + } + return +} diff --git a/artifactory/utils/filetree.go b/artifactory/utils/filetree.go index 26fd553b2..88131f6c1 100644 --- a/artifactory/utils/filetree.go +++ b/artifactory/utils/filetree.go @@ -1,6 +1,10 @@ package utils import ( + "fmt" + "github.com/jfrog/jfrog-client-go/utils/log" + "golang.org/x/exp/maps" + "sort" "strings" ) @@ -17,16 +21,20 @@ func NewFileTree() *FileTree { return &FileTree{repos: map[string]*dirNode{}, size: 0} } -func (ft *FileTree) AddFile(path string) { +// Path - file structure path to artifact +// UploadedFileUrl - URL to the uploaded file in Artifactory, +// if UploadedFileUrl not provided, the file name will be displayed without a link. +func (ft *FileTree) AddFile(path, uploadedFileUrl string) { if ft.size >= maxFilesInTree { + log.Info("Exceeded maximum number of files in tree") ft.exceedsMax = true return } splitPath := strings.Split(path, "/") if _, exist := ft.repos[splitPath[0]]; !exist { - ft.repos[splitPath[0]] = &dirNode{name: splitPath[0], prefix: "šŸ“¦ ", subDirNodes: map[string]*dirNode{}, fileNames: map[string]bool{}} + ft.repos[splitPath[0]] = &dirNode{name: splitPath[0], prefix: "šŸ“¦ ", subDirNodes: map[string]*dirNode{}, fileNames: map[string]string{}} } - if ft.repos[splitPath[0]].addArtifact(splitPath[1:]) { + if ft.repos[splitPath[0]].addArtifact(splitPath[1:], uploadedFileUrl) { ft.size++ } } @@ -38,7 +46,7 @@ func (ft *FileTree) String() string { } treeStr := "" for _, repo := range ft.repos { - treeStr += strings.Join(repo.strings(), "\n") + "\n" + treeStr += strings.Join(repo.strings(), "\n") + "\n\n" } return treeStr } @@ -47,26 +55,26 @@ type dirNode struct { name string prefix string subDirNodes map[string]*dirNode - fileNames map[string]bool + fileNames map[string]string } -func (dn *dirNode) addArtifact(pathInDir []string) bool { +func (dn *dirNode) addArtifact(pathInDir []string, artifactUrl string) bool { if len(pathInDir) == 1 { if _, exist := dn.fileNames[pathInDir[0]]; exist { return false } - dn.fileNames[pathInDir[0]] = true + dn.fileNames[pathInDir[0]] = artifactUrl } else { if _, exist := dn.subDirNodes[pathInDir[0]]; !exist { - dn.subDirNodes[pathInDir[0]] = &dirNode{name: pathInDir[0], prefix: "šŸ“ ", subDirNodes: map[string]*dirNode{}, fileNames: map[string]bool{}} + dn.subDirNodes[pathInDir[0]] = &dirNode{name: pathInDir[0], prefix: "šŸ“ ", subDirNodes: map[string]*dirNode{}, fileNames: map[string]string{}} } - return dn.subDirNodes[pathInDir[0]].addArtifact(pathInDir[1:]) + return dn.subDirNodes[pathInDir[0]].addArtifact(pathInDir[1:], artifactUrl) } return true } func (dn *dirNode) strings() []string { - strs := []string{dn.prefix + dn.name} + repoAsString := []string{dn.prefix + dn.name} subDirIndex := 0 for subDirName := range dn.subDirNodes { var subDirPrefix string @@ -79,22 +87,34 @@ func (dn *dirNode) strings() []string { innerStrPrefix = "ā”‚ " } subDirStrs := dn.subDirNodes[subDirName].strings() - strs = append(strs, subDirPrefix+subDirStrs[0]) + repoAsString = append(repoAsString, subDirPrefix+subDirStrs[0]) for subDirStrIndex := 1; subDirStrIndex < len(subDirStrs); subDirStrIndex++ { - strs = append(strs, innerStrPrefix+subDirStrs[subDirStrIndex]) + repoAsString = append(repoAsString, innerStrPrefix+subDirStrs[subDirStrIndex]) } subDirIndex++ } fileIndex := 0 - for fileName := range dn.fileNames { + + // Sort File names inside each sub dir + sortedFileNames := maps.Keys(dn.fileNames) + sort.Slice(sortedFileNames, func(i, j int) bool { return sortedFileNames[i] < sortedFileNames[j] }) + + for _, fileNameKey := range sortedFileNames { var filePrefix string if fileIndex == len(dn.fileNames)-1 { - filePrefix = "ā””ā”€ā”€ šŸ“„ " + filePrefix = "ā””ā”€ā”€ " } else { - filePrefix = "ā”œā”€ā”€ šŸ“„ " + filePrefix = "ā”œā”€ā”€ " fileIndex++ } - strs = append(strs, filePrefix+fileName) + + var fullFileName string + if dn.fileNames[fileNameKey] != "" { + fullFileName = fmt.Sprintf("%s%s", filePrefix, dn.fileNames[fileNameKey], fileNameKey) + } else { + fullFileName = filePrefix + "šŸ“„ " + fileNameKey + } + repoAsString = append(repoAsString, fullFileName) } - return strs + return repoAsString } diff --git a/artifactory/utils/filetree_test.go b/artifactory/utils/filetree_test.go index 9fc825718..c00e22e9d 100644 --- a/artifactory/utils/filetree_test.go +++ b/artifactory/utils/filetree_test.go @@ -15,12 +15,73 @@ func TestFileTree(t *testing.T) { fileTree := NewFileTree() // Add a new file and check String() - fileTree.AddFile("repoName/path/to/first/artifact") - result, excpected := fileTree.String(), "šŸ“¦ repoName\nā””ā”€ā”€ šŸ“ path\n ā””ā”€ā”€ šŸ“ to\n ā””ā”€ā”€ šŸ“ first\n ā””ā”€ā”€ šŸ“„ artifact\n" + fileTree.AddFile("repoName/path/to/first/artifact", "") + result, excpected := fileTree.String(), "šŸ“¦ repoName\nā””ā”€ā”€ šŸ“ path\n ā””ā”€ā”€ šŸ“ to\n ā””ā”€ā”€ šŸ“ first\n ā””ā”€ā”€ šŸ“„ artifact\n\n" assert.Equal(t, excpected, result) // If maxFileInTree has exceeded, Check String() returns an empty string - fileTree.AddFile("repoName/path/to/second/artifact") + fileTree.AddFile("repoName/path/to/second/artifact", "") result, excpected = fileTree.String(), "" assert.Equal(t, excpected, result) } + +func TestFileTreeSort(t *testing.T) { + testCases := []struct { + name string + files []string + expected string + }{ + { + name: "Test Case 1", + files: []string{ + "repoName/path/to/fileC", + "repoName/path/to/fileA", + "repoName/path/to/fileB", + }, + expected: "šŸ“¦ repoName\nā””ā”€ā”€ šŸ“ path\n ā””ā”€ā”€ šŸ“ to\n ā”œā”€ā”€ šŸ“„ fileA\n ā”œā”€ā”€ šŸ“„ fileB\n ā””ā”€ā”€ šŸ“„ fileC\n\n", + }, + { + name: "Test Case 2", + files: []string{ + "repoName/path/to/file3", + "repoName/path/to/file1", + "repoName/path/to/file2", + }, + expected: "šŸ“¦ repoName\nā””ā”€ā”€ šŸ“ path\n ā””ā”€ā”€ šŸ“ to\n ā”œā”€ā”€ šŸ“„ file1\n ā”œā”€ā”€ šŸ“„ file2\n ā””ā”€ā”€ šŸ“„ file3\n\n", + }, + { + name: "Test Case 3", + files: []string{ + "repoName/path/to/fileZ", + "repoName/path/to/fileX", + "repoName/path/to/fileY", + }, + expected: "šŸ“¦ repoName\nā””ā”€ā”€ šŸ“ path\n ā””ā”€ā”€ šŸ“ to\n ā”œā”€ā”€ šŸ“„ fileX\n ā”œā”€ā”€ šŸ“„ fileY\n ā””ā”€ā”€ šŸ“„ fileZ\n\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fileTree := NewFileTree() + + // Add files + for _, file := range tc.files { + fileTree.AddFile(file, "") + } + + // Get the string representation of the FileTree + result := fileTree.String() + + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestFileTreeWithUrls(t *testing.T) { + fileTree := NewFileTree() + // Add a new file and check String() + fileTree.AddFile("repoName/path/to/first/artifact", "http://myJFrogPlatform/customLink/first/artifact") + result, expected := fileTree.String(), "šŸ“¦ repoName\nā””ā”€ā”€ šŸ“ path\n ā””ā”€ā”€ šŸ“ to\n ā””ā”€ā”€ šŸ“ first\n ā””ā”€ā”€ artifact\n\n" + assert.Equal(t, expected, result) + +} diff --git a/commandsummary/commandsummary.go b/commandsummary/commandsummary.go new file mode 100644 index 000000000..0337a2952 --- /dev/null +++ b/commandsummary/commandsummary.go @@ -0,0 +1,172 @@ +package commandsummary + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "os" + "path" + "path/filepath" + "strings" +) + +type CommandSummaryInterface interface { + GenerateMarkdownFromFiles(dataFilePaths []string) (finalMarkdown string, err error) +} + +const ( + // The name of the directory where all the commands summaries will be stored. + // Inside this directory, each command will have its own directory. + OutputDirName = "jfrog-command-summary" +) + +type CommandSummary struct { + CommandSummaryInterface + summaryOutputPath string + commandsName string +} + +// Create a new instance of CommandSummary. +// Notice to check if the command should record the summary before calling this function. +// You can do this by calling the helper function ShouldRecordSummary. +func New(userImplementation CommandSummaryInterface, commandsName string) (cs *CommandSummary, err error) { + outputDir := os.Getenv(coreutils.OutputDirPathEnv) + if outputDir == "" { + return nil, fmt.Errorf("output dir path is not defined,please set the JFROG_CLI_COMMAND_SUMMARY_OUTPUT_DIR environment variable") + } + cs = &CommandSummary{ + CommandSummaryInterface: userImplementation, + commandsName: commandsName, + summaryOutputPath: outputDir, + } + err = cs.prepareFileSystem() + return +} + +// This function stores the current data on the file system. +// It then invokes the GenerateMarkdownFromFiles function on all existing data files. +// Finally, it saves the generated markdown file to the file system. +func (cs *CommandSummary) Record(data any) (err error) { + if err = cs.saveDataToFileSystem(data); err != nil { + return + } + dataFilesPaths, err := cs.getAllDataFilesPaths() + if err != nil { + return fmt.Errorf("failed to load data files from directory %s, with error: %w", cs.commandsName, err) + } + markdown, err := cs.GenerateMarkdownFromFiles(dataFilesPaths) + if err != nil { + return fmt.Errorf("failed to render markdown: %w", err) + } + if err = cs.saveMarkdownToFileSystem(markdown); err != nil { + return fmt.Errorf("failed to save markdown to file system: %w", err) + } + return +} + +func (cs *CommandSummary) getAllDataFilesPaths() ([]string, error) { + entries, err := os.ReadDir(cs.summaryOutputPath) + if err != nil { + return nil, errorutils.CheckError(err) + } + // Exclude markdown files + var filePaths []string + for _, entry := range entries { + if !entry.IsDir() && !strings.HasSuffix(entry.Name(), ".md") { + filePaths = append(filePaths, path.Join(cs.summaryOutputPath, entry.Name())) + } + } + return filePaths, nil +} + +func (cs *CommandSummary) saveMarkdownToFileSystem(markdown string) (err error) { + file, err := os.OpenFile(path.Join(cs.summaryOutputPath, "markdown.md"), os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return errorutils.CheckError(err) + } + defer func() { + err = errors.Join(err, errorutils.CheckError(file.Close())) + }() + if _, err = file.WriteString(markdown); err != nil { + return errorutils.CheckError(err) + } + return +} + +// Saves the given data into a file in the specified directory. +func (cs *CommandSummary) saveDataToFileSystem(data interface{}) error { + // Create a random file name in the data file path. + fd, err := os.CreateTemp(cs.summaryOutputPath, "data-*") + if err != nil { + return errorutils.CheckError(err) + } + defer func() { + err = errors.Join(err, fd.Close()) + }() + + // Convert the data into bytes. + bytes, err := convertDataToBytes(data) + if err != nil { + return errorutils.CheckError(err) + } + + // Write the bytes to the file. + if _, err = fd.Write(bytes); err != nil { + return errorutils.CheckError(err) + } + + return nil +} + +// This function creates the base dir for the command summary inside +// the path the user has provided, userPath/OutputDirName. +// Then it creates a specific directory for the command, path/OutputDirName/commandsName. +// And set the summaryOutputPath to the specific command directory. +func (cs *CommandSummary) prepareFileSystem() (err error) { + summaryBaseDirPath := filepath.Join(cs.summaryOutputPath, OutputDirName) + if err = createDirIfNotExists(summaryBaseDirPath); err != nil { + return err + } + specificCommandOutputPath := filepath.Join(summaryBaseDirPath, cs.commandsName) + if err = createDirIfNotExists(specificCommandOutputPath); err != nil { + return err + } + // Sets the specific command output path + cs.summaryOutputPath = specificCommandOutputPath + return +} + +// If the output dir path is not defined, the command summary should not be recorded. +func ShouldRecordSummary() bool { + return os.Getenv(coreutils.OutputDirPathEnv) != "" +} + +// Helper function to unmarshal data from a file path into the target object. +func UnmarshalFromFilePath(dataFile string, target any) (err error) { + data, err := fileutils.ReadFile(dataFile) + if err != nil { + return + } + if err = json.Unmarshal(data, target); err != nil { + return errorutils.CheckError(err) + } + return +} + +// Converts the given data into a byte array. +// Handle specific conversion cases +func convertDataToBytes(data interface{}) ([]byte, error) { + switch v := data.(type) { + case []byte: + return v, nil + default: + return json.Marshal(data) + } +} + +func createDirIfNotExists(homeDir string) error { + return errorutils.CheckError(os.MkdirAll(homeDir, 0755)) +} diff --git a/commandsummary/commandsummary_test.go b/commandsummary/commandsummary_test.go new file mode 100644 index 000000000..a284ba34b --- /dev/null +++ b/commandsummary/commandsummary_test.go @@ -0,0 +1,141 @@ +package commandsummary + +import ( + "fmt" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/stretchr/testify/assert" + "os" + "path" + "testing" +) + +type mockCommandSummary struct { + CommandSummaryInterface +} + +type BasicStruct struct { + Field1 string + Field2 int +} + +func (tcs *mockCommandSummary) GenerateMarkdownFromFiles(dataFilePaths []string) (finalMarkdown string, err error) { + return "mockMarkdown", nil +} + +// Without output dir env, New should return an error. +func TestInitWithoutOutputDir(t *testing.T) { + _, err := New(&mockCommandSummary{}, "testsCommands") + assert.Error(t, err) +} + +func TestCommandSummaryFileSystemBehaviour(t *testing.T) { + cs, cleanUp := prepareTest(t) + defer func() { + cleanUp() + }() + + // Call GenerateMarkdownFromFiles + err := cs.Record("someData") + assert.NoError(t, err) + + // Verify that the directory contains two files + files, err := os.ReadDir(cs.summaryOutputPath) + assert.NoError(t, err, "Failed to read directory 'test'") + assert.Equal(t, 2, len(files), "Directory 'test' does not contain exactly two files") + + // Verify a markdown file has been created + assert.FileExists(t, path.Join(cs.summaryOutputPath, "markdown.md")) +} + +func TestDataPersistence(t *testing.T) { + // Define test cases + testCases := []struct { + name string + dirName string + originalData interface{} + }{ + { + name: "Test with a simple object", + dirName: "testDir", + originalData: map[string]string{"key": "value"}, + }, + { + name: "Test with a string", + dirName: "testDir3", + originalData: "test string", + }, + { + name: "Test with a basic struct", + dirName: "testDir4", + originalData: BasicStruct{ + Field1: "test string", + Field2: 123, + }, + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Prepare a new CommandSummary for each test case + cs, cleanUp := prepareTest(t) + defer func() { + cleanUp() + }() + // Save data to file + err := cs.saveDataToFileSystem(tc.originalData) + assert.NoError(t, err) + + // Verify file has been saved + dataFiles, err := cs.getAllDataFilesPaths() + assert.NoError(t, err) + assert.NotEqual(t, 0, len(dataFiles)) + + // Verify that data has not been corrupted + loadedData, err := unmarshalData(tc.originalData, dataFiles[0]) + assert.NoError(t, err) + assert.EqualValues(t, tc.originalData, loadedData) + }) + } +} + +func unmarshalData(expected interface{}, filePath string) (interface{}, error) { + switch expected := expected.(type) { + case map[string]string: + var loadedData map[string]string + err := UnmarshalFromFilePath(filePath, &loadedData) + return loadedData, err + case []byte: + var loadedData []byte + err := UnmarshalFromFilePath(filePath, &loadedData) + return loadedData, err + case string: + var loadedData string + err := UnmarshalFromFilePath(filePath, &loadedData) + return loadedData, err + case BasicStruct: + var loadedData BasicStruct + err := UnmarshalFromFilePath(filePath, &loadedData) + return loadedData, err + default: + return nil, fmt.Errorf("unsupported data type: %T", expected) + } +} + +func prepareTest(t *testing.T) (cs *CommandSummary, cleanUp func()) { + // Prepare test env + tempDir, err := fileutils.CreateTempDir() + assert.NoError(t, err) + // Set env + assert.NoError(t, os.Setenv(coreutils.OutputDirPathEnv, tempDir)) + // Create the job summaries home directory + cs, err = New(&mockCommandSummary{}, "testsCommands") + assert.NoError(t, err) + + cleanUp = func() { + assert.NoError(t, os.Unsetenv(coreutils.OutputDirPathEnv)) + assert.NoError(t, fileutils.RemoveTempDir(tempDir)) + } + return +} diff --git a/utils/coreutils/coreconsts.go b/utils/coreutils/coreconsts.go index e9d64a753..6f9ec2dac 100644 --- a/utils/coreutils/coreconsts.go +++ b/utils/coreutils/coreconsts.go @@ -46,6 +46,7 @@ const ( DependenciesDir = "JFROG_CLI_DEPENDENCIES_DIR" TransitiveDownload = "JFROG_CLI_TRANSITIVE_DOWNLOAD_EXPERIMENTAL" FailNoOp = "JFROG_CLI_FAIL_NO_OP" + OutputDirPathEnv = "JFROG_CLI_COMMAND_SUMMARY_OUTPUT_DIR" CI = "CI" ServerID = "JFROG_CLI_SERVER_ID" )