github.com/jfrog/jfrog-cli-core/v2@v2.52.0/artifactory/commands/buildinfo/addgit.go (about)

     1  package buildinfo
     2  
     3  import (
     4  	"errors"
     5  	"io"
     6  	"os"
     7  	"os/exec"
     8  	"strconv"
     9  
    10  	buildinfo "github.com/jfrog/build-info-go/entities"
    11  	gofrogcmd "github.com/jfrog/gofrog/io"
    12  	"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils"
    13  	"github.com/jfrog/jfrog-cli-core/v2/common/build"
    14  	"github.com/jfrog/jfrog-cli-core/v2/common/project"
    15  	utilsconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config"
    16  	"github.com/jfrog/jfrog-client-go/artifactory/services"
    17  	artclientutils "github.com/jfrog/jfrog-client-go/artifactory/services/utils"
    18  	clientutils "github.com/jfrog/jfrog-client-go/utils"
    19  
    20  	"github.com/forPelevin/gomoji"
    21  	"github.com/jfrog/jfrog-client-go/utils/errorutils"
    22  	"github.com/jfrog/jfrog-client-go/utils/io/fileutils"
    23  	"github.com/jfrog/jfrog-client-go/utils/log"
    24  	"github.com/spf13/viper"
    25  )
    26  
    27  const (
    28  	GitLogLimit               = 100
    29  	ConfigIssuesPrefix        = "issues."
    30  	ConfigParseValueError     = "Failed parsing %s from configuration file: %s"
    31  	MissingConfigurationError = "Configuration file must contain: %s"
    32  )
    33  
    34  type BuildAddGitCommand struct {
    35  	buildConfiguration *build.BuildConfiguration
    36  	dotGitPath         string
    37  	configFilePath     string
    38  	serverId           string
    39  	issuesConfig       *IssuesConfiguration
    40  }
    41  
    42  func NewBuildAddGitCommand() *BuildAddGitCommand {
    43  	return &BuildAddGitCommand{}
    44  }
    45  
    46  func (config *BuildAddGitCommand) SetIssuesConfig(issuesConfig *IssuesConfiguration) *BuildAddGitCommand {
    47  	config.issuesConfig = issuesConfig
    48  	return config
    49  }
    50  
    51  func (config *BuildAddGitCommand) SetConfigFilePath(configFilePath string) *BuildAddGitCommand {
    52  	config.configFilePath = configFilePath
    53  	return config
    54  }
    55  
    56  func (config *BuildAddGitCommand) SetDotGitPath(dotGitPath string) *BuildAddGitCommand {
    57  	config.dotGitPath = dotGitPath
    58  	return config
    59  }
    60  
    61  func (config *BuildAddGitCommand) SetBuildConfiguration(buildConfiguration *build.BuildConfiguration) *BuildAddGitCommand {
    62  	config.buildConfiguration = buildConfiguration
    63  	return config
    64  }
    65  
    66  func (config *BuildAddGitCommand) SetServerId(serverId string) *BuildAddGitCommand {
    67  	config.serverId = serverId
    68  	return config
    69  }
    70  
    71  func (config *BuildAddGitCommand) Run() error {
    72  	log.Info("Reading the git branch, revision and remote URL and adding them to the build-info.")
    73  	buildName, err := config.buildConfiguration.GetBuildName()
    74  	if err != nil {
    75  		return err
    76  	}
    77  	buildNumber, err := config.buildConfiguration.GetBuildNumber()
    78  	if err != nil {
    79  		return err
    80  	}
    81  	err = build.SaveBuildGeneralDetails(buildName, buildNumber, config.buildConfiguration.GetProject())
    82  	if err != nil {
    83  		return err
    84  	}
    85  
    86  	// Find .git if it wasn't provided in the command.
    87  	if config.dotGitPath == "" {
    88  		var exists bool
    89  		config.dotGitPath, exists, err = fileutils.FindUpstream(".git", fileutils.Any)
    90  		if err != nil {
    91  			return err
    92  		}
    93  		if !exists {
    94  			return errorutils.CheckErrorf("Could not find .git")
    95  		}
    96  	}
    97  
    98  	// Collect URL, branch and revision into GitManager.
    99  	gitManager := clientutils.NewGitManager(config.dotGitPath)
   100  	err = gitManager.ReadConfig()
   101  	if err != nil {
   102  		return err
   103  	}
   104  
   105  	// Collect issues if required.
   106  	var issues []buildinfo.AffectedIssue
   107  	if config.configFilePath != "" {
   108  		issues, err = config.collectBuildIssues(gitManager.GetUrl())
   109  		if err != nil {
   110  			return err
   111  		}
   112  	}
   113  
   114  	// Populate partials with VCS info.
   115  	populateFunc := func(partial *buildinfo.Partial) {
   116  		partial.VcsList = append(partial.VcsList, buildinfo.Vcs{
   117  			Url:      gitManager.GetUrl(),
   118  			Revision: gitManager.GetRevision(),
   119  			Branch:   gitManager.GetBranch(),
   120  			Message:  gomoji.RemoveEmojis(gitManager.GetMessage()),
   121  		})
   122  
   123  		if config.configFilePath != "" {
   124  			partial.Issues = &buildinfo.Issues{
   125  				Tracker:                &buildinfo.Tracker{Name: config.issuesConfig.TrackerName, Version: ""},
   126  				AggregateBuildIssues:   config.issuesConfig.Aggregate,
   127  				AggregationBuildStatus: config.issuesConfig.AggregationStatus,
   128  				AffectedIssues:         issues,
   129  			}
   130  		}
   131  	}
   132  	err = build.SavePartialBuildInfo(buildName, buildNumber, config.buildConfiguration.GetProject(), populateFunc)
   133  	if err != nil {
   134  		return err
   135  	}
   136  
   137  	// Done.
   138  	log.Debug("Collected VCS details for", buildName+"/"+buildNumber+".")
   139  	return nil
   140  }
   141  
   142  // Priorities for selecting server:
   143  // 1. 'server-id' flag.
   144  // 2. 'serverID' in config file.
   145  // 3. Default server.
   146  func (config *BuildAddGitCommand) ServerDetails() (*utilsconfig.ServerDetails, error) {
   147  	var serverId string
   148  	if config.serverId != "" {
   149  		serverId = config.serverId
   150  	} else if config.configFilePath != "" {
   151  		// Get the server ID from the conf file.
   152  		var vConfig *viper.Viper
   153  		vConfig, err := project.ReadConfigFile(config.configFilePath, project.YAML)
   154  		if err != nil {
   155  			return nil, err
   156  		}
   157  		serverId = vConfig.GetString(ConfigIssuesPrefix + "serverID")
   158  	}
   159  	return utilsconfig.GetSpecificConfig(serverId, true, false)
   160  }
   161  
   162  func (config *BuildAddGitCommand) CommandName() string {
   163  	return "rt_build_add_git"
   164  }
   165  
   166  func (config *BuildAddGitCommand) collectBuildIssues(vcsUrl string) ([]buildinfo.AffectedIssue, error) {
   167  	log.Info("Collecting build issues from VCS...")
   168  
   169  	// Check that git exists in path.
   170  	_, err := exec.LookPath("git")
   171  	if err != nil {
   172  		return nil, errorutils.CheckError(err)
   173  	}
   174  
   175  	// Initialize issues-configuration.
   176  	config.issuesConfig = new(IssuesConfiguration)
   177  
   178  	// Create config's IssuesConfigurations from the provided spec file.
   179  	err = config.createIssuesConfigs()
   180  	if err != nil {
   181  		return nil, err
   182  	}
   183  
   184  	// Get latest build's VCS revision from Artifactory.
   185  	lastVcsRevision, err := config.getLatestVcsRevision(vcsUrl)
   186  	if err != nil {
   187  		return nil, err
   188  	}
   189  
   190  	// Run issues collection.
   191  	return config.DoCollect(config.issuesConfig, lastVcsRevision)
   192  }
   193  
   194  func (config *BuildAddGitCommand) DoCollect(issuesConfig *IssuesConfiguration, lastVcsRevision string) (foundIssues []buildinfo.AffectedIssue, err error) {
   195  	logRegExp, err := createLogRegExpHandler(issuesConfig, &foundIssues)
   196  	if err != nil {
   197  		return
   198  	}
   199  
   200  	errRegExp, err := createErrRegExpHandler(lastVcsRevision)
   201  	if err != nil {
   202  		return
   203  	}
   204  
   205  	// Get log with limit, starting from the latest commit.
   206  	logCmd := &LogCmd{logLimit: issuesConfig.LogLimit, lastVcsRevision: lastVcsRevision}
   207  
   208  	// Change working dir to where .git is.
   209  	wd, err := os.Getwd()
   210  	if errorutils.CheckError(err) != nil {
   211  		return
   212  	}
   213  	defer func() {
   214  		err = errors.Join(err, errorutils.CheckError(os.Chdir(wd)))
   215  	}()
   216  	err = os.Chdir(config.dotGitPath)
   217  	if errorutils.CheckError(err) != nil {
   218  		return
   219  	}
   220  
   221  	// Run git command.
   222  	_, _, exitOk, err := gofrogcmd.RunCmdWithOutputParser(logCmd, false, logRegExp, errRegExp)
   223  	if errorutils.CheckError(err) != nil {
   224  		var revisionRangeError RevisionRangeError
   225  		if errors.As(err, &revisionRangeError) {
   226  			// Revision not found in range. Ignore and don't collect new issues.
   227  			log.Info(err.Error())
   228  			return []buildinfo.AffectedIssue{}, nil
   229  		}
   230  		return
   231  	}
   232  	if !exitOk {
   233  		// May happen when trying to run git log for non-existing revision.
   234  		err = errorutils.CheckErrorf("failed executing git log command")
   235  	}
   236  	return
   237  }
   238  
   239  // Creates a regexp handler to parse and fetch issues from the output of the git log command.
   240  func createLogRegExpHandler(issuesConfig *IssuesConfiguration, foundIssues *[]buildinfo.AffectedIssue) (*gofrogcmd.CmdOutputPattern, error) {
   241  	// Create regex pattern.
   242  	issueRegexp, err := clientutils.GetRegExp(issuesConfig.Regexp)
   243  	if err != nil {
   244  		return nil, err
   245  	}
   246  
   247  	// Create handler with exec function.
   248  	logRegExp := gofrogcmd.CmdOutputPattern{
   249  		RegExp: issueRegexp,
   250  		ExecFunc: func(pattern *gofrogcmd.CmdOutputPattern) (string, error) {
   251  			// Reached here - means no error occurred.
   252  
   253  			// Check for out of bound results.
   254  			if len(pattern.MatchedResults)-1 < issuesConfig.KeyGroupIndex || len(pattern.MatchedResults)-1 < issuesConfig.SummaryGroupIndex {
   255  				return "", errors.New("unexpected result while parsing issues from git log. Make sure that the regular expression used to find issues, includes two capturing groups, for the issue ID and the summary")
   256  			}
   257  			// Create found Affected Issue.
   258  			foundIssue := buildinfo.AffectedIssue{Key: pattern.MatchedResults[issuesConfig.KeyGroupIndex], Summary: pattern.MatchedResults[issuesConfig.SummaryGroupIndex], Aggregated: false}
   259  			if issuesConfig.TrackerUrl != "" {
   260  				foundIssue.Url = issuesConfig.TrackerUrl + pattern.MatchedResults[issuesConfig.KeyGroupIndex]
   261  			}
   262  			*foundIssues = append(*foundIssues, foundIssue)
   263  			log.Debug("Found issue: " + pattern.MatchedResults[issuesConfig.KeyGroupIndex])
   264  			return "", nil
   265  		},
   266  	}
   267  	return &logRegExp, nil
   268  }
   269  
   270  // Error to be thrown when revision could not be found in the git revision range.
   271  type RevisionRangeError struct {
   272  	ErrorMsg string
   273  }
   274  
   275  func (err RevisionRangeError) Error() string {
   276  	return err.ErrorMsg
   277  }
   278  
   279  // Creates a regexp handler to handle the event of revision missing in the git revision range.
   280  func createErrRegExpHandler(lastVcsRevision string) (*gofrogcmd.CmdOutputPattern, error) {
   281  	// Create regex pattern.
   282  	invalidRangeExp, err := clientutils.GetRegExp(`fatal: Invalid revision range [a-fA-F0-9]+\.\.`)
   283  	if err != nil {
   284  		return nil, err
   285  	}
   286  
   287  	// Create handler with exec function.
   288  	errRegExp := gofrogcmd.CmdOutputPattern{
   289  		RegExp: invalidRangeExp,
   290  		ExecFunc: func(pattern *gofrogcmd.CmdOutputPattern) (string, error) {
   291  			// Revision could not be found in the revision range, probably due to a squash / revert. Ignore and don't collect new issues.
   292  			errMsg := "Revision: '" + lastVcsRevision + "' that was fetched from latest build info does not exist in the git revision range. No new issues are added."
   293  			return "", RevisionRangeError{ErrorMsg: errMsg}
   294  		},
   295  	}
   296  	return &errRegExp, nil
   297  }
   298  
   299  func (config *BuildAddGitCommand) createIssuesConfigs() (err error) {
   300  	// Read file's data.
   301  	err = config.issuesConfig.populateIssuesConfigsFromSpec(config.configFilePath)
   302  	if err != nil {
   303  		return
   304  	}
   305  
   306  	// Use 'server-id' flag if provided.
   307  	if config.serverId != "" {
   308  		config.issuesConfig.ServerID = config.serverId
   309  	}
   310  
   311  	// Build ServerDetails from provided serverID.
   312  	err = config.issuesConfig.setServerDetails()
   313  	if err != nil {
   314  		return
   315  	}
   316  
   317  	// Add '/' suffix to URL if required.
   318  	if config.issuesConfig.TrackerUrl != "" {
   319  		// Url should end with '/'
   320  		config.issuesConfig.TrackerUrl = clientutils.AddTrailingSlashIfNeeded(config.issuesConfig.TrackerUrl)
   321  	}
   322  
   323  	return
   324  }
   325  
   326  func (config *BuildAddGitCommand) getLatestVcsRevision(vcsUrl string) (string, error) {
   327  	// Get latest build's build-info from Artifactory
   328  	buildInfo, err := config.getLatestBuildInfo(config.issuesConfig)
   329  	if err != nil {
   330  		return "", err
   331  	}
   332  
   333  	// Get previous VCS Revision from BuildInfo.
   334  	lastVcsRevision := ""
   335  	for _, vcs := range buildInfo.VcsList {
   336  		if vcs.Url == vcsUrl {
   337  			lastVcsRevision = vcs.Revision
   338  			break
   339  		}
   340  	}
   341  
   342  	return lastVcsRevision, nil
   343  }
   344  
   345  // Returns build info, or empty build info struct if not found.
   346  func (config *BuildAddGitCommand) getLatestBuildInfo(issuesConfig *IssuesConfiguration) (*buildinfo.BuildInfo, error) {
   347  	// Create services manager to get build-info from Artifactory.
   348  	sm, err := utils.CreateServiceManager(issuesConfig.ServerDetails, -1, 0, false)
   349  	if err != nil {
   350  		return nil, err
   351  	}
   352  
   353  	// Get latest build-info from Artifactory.
   354  	buildName, err := config.buildConfiguration.GetBuildName()
   355  	if err != nil {
   356  		return nil, err
   357  	}
   358  	buildInfoParams := services.BuildInfoParams{BuildName: buildName, BuildNumber: artclientutils.LatestBuildNumberKey}
   359  	publishedBuildInfo, found, err := sm.GetBuildInfo(buildInfoParams)
   360  	if err != nil {
   361  		return nil, err
   362  	}
   363  	if !found {
   364  		return &buildinfo.BuildInfo{}, nil
   365  	}
   366  
   367  	return &publishedBuildInfo.BuildInfo, nil
   368  }
   369  
   370  func (ic *IssuesConfiguration) populateIssuesConfigsFromSpec(configFilePath string) (err error) {
   371  	var vConfig *viper.Viper
   372  	vConfig, err = project.ReadConfigFile(configFilePath, project.YAML)
   373  	if err != nil {
   374  		return err
   375  	}
   376  
   377  	// Validate that the config contains issues.
   378  	if !vConfig.IsSet("issues") {
   379  		return errorutils.CheckErrorf(MissingConfigurationError, "issues")
   380  	}
   381  
   382  	// Get server-id.
   383  	if vConfig.IsSet(ConfigIssuesPrefix + "serverID") {
   384  		ic.ServerID = vConfig.GetString(ConfigIssuesPrefix + "serverID")
   385  	}
   386  
   387  	// Set log limit.
   388  	ic.LogLimit = GitLogLimit
   389  
   390  	// Get tracker data
   391  	if !vConfig.IsSet(ConfigIssuesPrefix + "trackerName") {
   392  		return errorutils.CheckErrorf(MissingConfigurationError, ConfigIssuesPrefix+"trackerName")
   393  	}
   394  	ic.TrackerName = vConfig.GetString(ConfigIssuesPrefix + "trackerName")
   395  
   396  	// Get issues pattern
   397  	if !vConfig.IsSet(ConfigIssuesPrefix + "regexp") {
   398  		return errorutils.CheckErrorf(MissingConfigurationError, ConfigIssuesPrefix+"regexp")
   399  	}
   400  	ic.Regexp = vConfig.GetString(ConfigIssuesPrefix + "regexp")
   401  
   402  	// Get issues base url
   403  	if vConfig.IsSet(ConfigIssuesPrefix + "trackerUrl") {
   404  		ic.TrackerUrl = vConfig.GetString(ConfigIssuesPrefix + "trackerUrl")
   405  	}
   406  
   407  	// Get issues key group index
   408  	if !vConfig.IsSet(ConfigIssuesPrefix + "keyGroupIndex") {
   409  		return errorutils.CheckErrorf(MissingConfigurationError, ConfigIssuesPrefix+"keyGroupIndex")
   410  	}
   411  	ic.KeyGroupIndex, err = strconv.Atoi(vConfig.GetString(ConfigIssuesPrefix + "keyGroupIndex"))
   412  	if err != nil {
   413  		return errorutils.CheckErrorf(ConfigParseValueError, ConfigIssuesPrefix+"keyGroupIndex", err.Error())
   414  	}
   415  
   416  	// Get issues summary group index
   417  	if !vConfig.IsSet(ConfigIssuesPrefix + "summaryGroupIndex") {
   418  		return errorutils.CheckErrorf(MissingConfigurationError, ConfigIssuesPrefix+"summaryGroupIndex")
   419  	}
   420  	ic.SummaryGroupIndex, err = strconv.Atoi(vConfig.GetString(ConfigIssuesPrefix + "summaryGroupIndex"))
   421  	if err != nil {
   422  		return errorutils.CheckErrorf(ConfigParseValueError, ConfigIssuesPrefix+"summaryGroupIndex", err.Error())
   423  	}
   424  
   425  	// Get aggregation aggregate
   426  	ic.Aggregate = false
   427  	if vConfig.IsSet(ConfigIssuesPrefix + "aggregate") {
   428  		ic.Aggregate, err = strconv.ParseBool(vConfig.GetString(ConfigIssuesPrefix + "aggregate"))
   429  		if err != nil {
   430  			return errorutils.CheckErrorf(ConfigParseValueError, ConfigIssuesPrefix+"aggregate", err.Error())
   431  		}
   432  	}
   433  
   434  	// Get aggregation status
   435  	if vConfig.IsSet(ConfigIssuesPrefix + "aggregationStatus") {
   436  		ic.AggregationStatus = vConfig.GetString(ConfigIssuesPrefix + "aggregationStatus")
   437  	}
   438  
   439  	return nil
   440  }
   441  
   442  func (ic *IssuesConfiguration) setServerDetails() error {
   443  	// If no server-id provided, use default server.
   444  	serverDetails, err := utilsconfig.GetSpecificConfig(ic.ServerID, true, false)
   445  	if err != nil {
   446  		return err
   447  	}
   448  	ic.ServerDetails = serverDetails
   449  	return nil
   450  }
   451  
   452  type IssuesConfiguration struct {
   453  	ServerDetails     *utilsconfig.ServerDetails
   454  	Regexp            string
   455  	LogLimit          int
   456  	TrackerUrl        string
   457  	TrackerName       string
   458  	KeyGroupIndex     int
   459  	SummaryGroupIndex int
   460  	Aggregate         bool
   461  	AggregationStatus string
   462  	ServerID          string
   463  }
   464  
   465  type LogCmd struct {
   466  	logLimit        int
   467  	lastVcsRevision string
   468  }
   469  
   470  func (logCmd *LogCmd) GetCmd() *exec.Cmd {
   471  	var cmd []string
   472  	cmd = append(cmd, "git")
   473  	cmd = append(cmd, "log", "--pretty=format:%s", "-"+strconv.Itoa(logCmd.logLimit))
   474  	if logCmd.lastVcsRevision != "" {
   475  		cmd = append(cmd, logCmd.lastVcsRevision+"..")
   476  	}
   477  	return exec.Command(cmd[0], cmd[1:]...)
   478  }
   479  
   480  func (logCmd *LogCmd) GetEnv() map[string]string {
   481  	return map[string]string{}
   482  }
   483  
   484  func (logCmd *LogCmd) GetStdWriter() io.WriteCloser {
   485  	return nil
   486  }
   487  
   488  func (logCmd *LogCmd) GetErrWriter() io.WriteCloser {
   489  	return nil
   490  }