github.com/decred/politeia@v1.4.0/politeiawww/legacy/codestats.go (about)

     1  // Copyright (c) 2020 The Decred developers
     2  // Use of this source code is governed by an ISC
     3  // license that can be found in the LICENSE file.
     4  
     5  package legacy
     6  
     7  import (
     8  	"fmt"
     9  	"strconv"
    10  	"time"
    11  
    12  	cms "github.com/decred/politeia/politeiawww/api/cms/v1"
    13  	www "github.com/decred/politeia/politeiawww/api/www/v1"
    14  	"github.com/decred/politeia/politeiawww/legacy/codetracker"
    15  	"github.com/decred/politeia/politeiawww/legacy/user"
    16  )
    17  
    18  var (
    19  	userCodeStatsRangeLimit = time.Minute * 60 * 24 * 7 * 26 // 6 months in minutes == 60mins * 24hrs * 7days * 26weeks
    20  )
    21  
    22  // processUserCodeStats tries to compile code statistics based on user
    23  // and month/year provided.
    24  func (p *Politeiawww) processUserCodeStats(ucs cms.UserCodeStats, u *user.User) (*cms.UserCodeStatsReply, error) {
    25  	log.Tracef("processUserCodeStats")
    26  
    27  	// Require start time to be entered
    28  	if ucs.StartTime == 0 {
    29  		return nil, www.UserError{
    30  			ErrorCode: cms.ErrorStatusInvalidDatesRequested,
    31  		}
    32  	}
    33  	startDate := time.Unix(ucs.StartTime, 0).UTC()
    34  	var endDate time.Time
    35  	if ucs.EndTime == 0 {
    36  		// If endtime is unset just use start time plus a minute, this will
    37  		// cause it to reply with just the month of the start time.
    38  		endDate = startDate
    39  	} else {
    40  		endDate = time.Unix(ucs.EndTime, 0).UTC()
    41  	}
    42  
    43  	// Check to make sure time range requested is not greater than 6 months OR
    44  	// End time is AFTER Start time
    45  	if endDate.Before(startDate) ||
    46  		endDate.Sub(startDate) > userCodeStatsRangeLimit {
    47  		return nil, www.UserError{
    48  			ErrorCode: cms.ErrorStatusInvalidDatesRequested,
    49  		}
    50  	}
    51  
    52  	requestingUser, err := p.getCMSUserByID(u.ID.String())
    53  	if err == user.ErrUserNotFound {
    54  		log.Debugf("processUserCodeStats failure for %v: cmsuser not found",
    55  			u.ID.String())
    56  		return nil, www.UserError{
    57  			ErrorCode: www.ErrorStatusUserNotFound,
    58  		}
    59  	} else if err != nil {
    60  		log.Debugf("processUserCodeStats failure for %v: getCMSUser %v",
    61  			ucs.UserID)
    62  		return nil, www.UserError{
    63  			ErrorCode: www.ErrorStatusUserNotFound,
    64  		}
    65  	}
    66  
    67  	requestedUser, err := p.getCMSUserByID(ucs.UserID)
    68  	if err == user.ErrUserNotFound {
    69  		log.Debugf("processUserCodeStats failure for %v: cmsuser not found",
    70  			ucs.UserID)
    71  		return nil, www.UserError{
    72  			ErrorCode: www.ErrorStatusUserNotFound,
    73  		}
    74  	} else if err != nil {
    75  		log.Debugf("processUserCodeStats failure for %v: getCMSUser %v",
    76  			ucs.UserID, err)
    77  		return nil, www.UserError{
    78  			ErrorCode: www.ErrorStatusUserNotFound,
    79  		}
    80  	}
    81  
    82  	// If domains don't match then just return empty reply rather than erroring.
    83  	if !requestingUser.Admin && requestingUser.Domain != requestedUser.Domain {
    84  		return &cms.UserCodeStatsReply{}, nil
    85  	}
    86  
    87  	if requestedUser.GitHubName == "" {
    88  		return nil, www.UserError{
    89  			ErrorCode: cms.ErrorStatusMissingCodeStatsUsername,
    90  		}
    91  	}
    92  
    93  	allRepoStats := make([]cms.CodeStats, 0, 1048)
    94  	// Run until start date is after end date, it's incremented by a month
    95  	// a the end of the loop.
    96  	for !startDate.After(endDate) {
    97  		month := startDate.Month()
    98  		year := startDate.Year()
    99  		cu := user.CMSCodeStatsByUserMonthYear{
   100  			GithubName: requestedUser.GitHubName,
   101  			Month:      int(month),
   102  			Year:       year,
   103  		}
   104  		payload, err := user.EncodeCMSCodeStatsByUserMonthYear(cu)
   105  		if err != nil {
   106  			return nil, err
   107  		}
   108  		pc := user.PluginCommand{
   109  			ID:      user.CMSPluginID,
   110  			Command: user.CmdCMSCodeStatsByUserMonthYear,
   111  			Payload: string(payload),
   112  		}
   113  
   114  		// Execute plugin command
   115  		pcr, err := p.db.PluginExec(pc)
   116  		if err != nil {
   117  			return nil, err
   118  		}
   119  
   120  		// Decode reply
   121  		reply, err := user.DecodeCMSCodeStatsByUserMonthYearReply(
   122  			[]byte(pcr.Payload))
   123  		if err != nil {
   124  			return nil, err
   125  		}
   126  		allRepoStats = append(allRepoStats,
   127  			convertCodeStatsFromDatabase(reply.UserCodeStats)...)
   128  
   129  		startDate = time.Date(startDate.Year(), startDate.Month()+1,
   130  			startDate.Day(), startDate.Hour(), startDate.Minute(), 0, 0,
   131  			time.UTC)
   132  	}
   133  	return &cms.UserCodeStatsReply{
   134  		RepoStats: allRepoStats,
   135  	}, nil
   136  }
   137  
   138  func (p *Politeiawww) updateCodeStats(skipStartupSync bool, repos []string, start, end int64) error {
   139  
   140  	// make sure tracker was created, if not alert for them to check github api
   141  	// token config
   142  	if p.tracker == nil {
   143  		return fmt.Errorf("code tracker not running")
   144  	}
   145  	if !skipStartupSync {
   146  		p.tracker.Update(repos, start, end)
   147  	}
   148  
   149  	// Go fetch all Development contractors to update their stats
   150  	cu := user.CMSUsersByDomain{
   151  		Domain: int(cms.DomainTypeDeveloper),
   152  	}
   153  	payload, err := user.EncodeCMSUsersByDomain(cu)
   154  	if err != nil {
   155  		return err
   156  	}
   157  	pc := user.PluginCommand{
   158  		ID:      user.CMSPluginID,
   159  		Command: user.CmdCMSUsersByDomain,
   160  		Payload: string(payload),
   161  	}
   162  
   163  	// Execute plugin command
   164  	pcr, err := p.db.PluginExec(pc)
   165  	if err != nil {
   166  		return err
   167  	}
   168  
   169  	// Decode reply
   170  	reply, err := user.DecodeCMSUsersByDomainReply([]byte(pcr.Payload))
   171  	if err != nil {
   172  		return err
   173  	}
   174  
   175  	now := time.Now()
   176  	// Whenever this runs we want to calculate the stats for the previous month.
   177  	// For example if it runs on Nov 1st it will calculate stats for October.
   178  	// If it is started on Oct. 15th it will calculate stats for September.
   179  	lastMonth := time.Date(now.Year(), now.Month()-1, now.Day(), now.Hour(),
   180  		now.Minute(), 0, 0, now.Location())
   181  
   182  	currentMonth := int(lastMonth.Month())
   183  	currentYear := lastMonth.Year()
   184  
   185  	for _, u := range reply.Users {
   186  		if u.GitHubName == "" {
   187  			// Just move along since user has no github name set
   188  			continue
   189  		}
   190  
   191  		cu := user.CMSCodeStatsByUserMonthYear{
   192  			GithubName: u.GitHubName,
   193  			Month:      currentMonth,
   194  			Year:       currentYear,
   195  		}
   196  		payload, err := user.EncodeCMSCodeStatsByUserMonthYear(cu)
   197  		if err != nil {
   198  			log.Errorf("encode code stats request failed: %v %v %v %v",
   199  				u.GitHubName, currentYear, currentMonth, err)
   200  			continue
   201  		}
   202  		pc := user.PluginCommand{
   203  			ID:      user.CMSPluginID,
   204  			Command: user.CmdCMSCodeStatsByUserMonthYear,
   205  			Payload: string(payload),
   206  		}
   207  
   208  		// Execute plugin command
   209  		pcr, err := p.db.PluginExec(pc)
   210  		if err != nil {
   211  			log.Errorf("decode code stats request failed: %v %v %v %v",
   212  				u.GitHubName, currentYear, currentMonth, err)
   213  			continue
   214  		}
   215  
   216  		// Decode reply
   217  		reply, err := user.DecodeCMSCodeStatsByUserMonthYearReply(
   218  			[]byte(pcr.Payload))
   219  		if err != nil {
   220  			log.Errorf("decode code stats failed: %v %v %v %v",
   221  				u.GitHubName, currentYear, currentMonth, err)
   222  			continue
   223  		}
   224  
   225  		githubUserInfo, err := p.tracker.UserInfo(u.GitHubName, currentYear,
   226  			currentMonth)
   227  		if err != nil {
   228  			log.Errorf("github user information failed: %v %v %v %v",
   229  				u.GitHubName, currentYear, currentMonth, err)
   230  			continue
   231  		}
   232  
   233  		codeStats := convertCodeTrackerToUserCodeStats(u.GitHubName, currentYear,
   234  			currentMonth, githubUserInfo)
   235  
   236  		if len(reply.UserCodeStats) > 0 {
   237  			log.Tracef("Checking update UserCodeStats: %v %v %v", u.GitHubName,
   238  				currentYear, currentMonth)
   239  			err = p.checkUpdateCodeStats(reply.UserCodeStats, codeStats)
   240  			if err != nil {
   241  				log.Errorf("update cms code stats failed: %v %v %v %v",
   242  					u.GitHubName, currentYear, currentMonth, err)
   243  				continue
   244  			}
   245  		} else {
   246  			// No existing code stats for this user month/year
   247  			log.Tracef("New UserCodeStats: %v %v %v", u.GitHubName, currentYear,
   248  				currentMonth)
   249  			// It'll be a new entry if no existing entry had been found
   250  			ncs := user.NewCMSCodeStats{
   251  				UserCodeStats: codeStats,
   252  			}
   253  
   254  			payload, err = user.EncodeNewCMSCodeStats(ncs)
   255  			if err != nil {
   256  				log.Errorf("encode new cms code stats failed: %v %v %v %v",
   257  					u.GitHubName, currentYear, currentMonth, err)
   258  				continue
   259  			}
   260  			pc = user.PluginCommand{
   261  				ID:      user.CMSPluginID,
   262  				Command: user.CmdNewCMSUserCodeStats,
   263  				Payload: string(payload),
   264  			}
   265  			_, err = p.db.PluginExec(pc)
   266  			if err != nil {
   267  				log.Errorf("new cms code stats failed: %v %v %v %v",
   268  					u.GitHubName, currentYear, currentMonth, err)
   269  				continue
   270  			}
   271  		}
   272  	}
   273  	return nil
   274  }
   275  
   276  func (p *Politeiawww) checkUpdateCodeStats(existing, new []user.CodeStats) error {
   277  	// Check to see if current codestats match existing stats.
   278  	updated := false
   279  	// If the length of existing and new, differ that means it's been updated.
   280  	if len(existing) == len(new) {
   281  		// Loop through all newly received code stats
   282  		for _, cs := range new {
   283  			found := false
   284  			for _, ucs := range existing {
   285  				if cs.Repository != ucs.Repository {
   286  					continue
   287  				}
   288  				found = true
   289  				// Repositories match so check stats to see if anything has
   290  				// been updated.
   291  				if ucs.MergedAdditions != cs.MergedAdditions ||
   292  					ucs.MergedDeletions != cs.MergedDeletions ||
   293  					ucs.ReviewDeletions != cs.ReviewDeletions ||
   294  					ucs.ReviewAdditions != cs.ReviewAdditions ||
   295  					ucs.UpdatedAdditions != cs.UpdatedAdditions ||
   296  					ucs.UpdatedDeletions != cs.UpdatedDeletions ||
   297  					ucs.CommitAdditions != cs.CommitAdditions ||
   298  					ucs.CommitDeletions != cs.CommitDeletions ||
   299  					len(ucs.PRs) != len(cs.PRs) ||
   300  					len(ucs.Reviews) != len(cs.Reviews) ||
   301  					len(ucs.Commits) != len(cs.Commits) {
   302  					updated = true
   303  					break
   304  				}
   305  			}
   306  			// The new repository wasn't found so update to the new codestats.
   307  			if !found {
   308  				updated = true
   309  				break
   310  			}
   311  		}
   312  	} else {
   313  		// Lengths of new and exiting code stats differ, so update to new.
   314  		updated = true
   315  	}
   316  	if !updated {
   317  		return nil
   318  	}
   319  
   320  	// Prepare payload and send to user database plugin.
   321  	ncs := user.UpdateCMSCodeStats{
   322  		UserCodeStats: new,
   323  	}
   324  	payload, err := user.EncodeUpdateCMSCodeStats(ncs)
   325  	if err != nil {
   326  		return err
   327  	}
   328  	pc := user.PluginCommand{
   329  		ID:      user.CMSPluginID,
   330  		Command: user.CmdUpdateCMSUserCodeStats,
   331  		Payload: string(payload),
   332  	}
   333  	_, err = p.db.PluginExec(pc)
   334  	if err != nil {
   335  		return err
   336  
   337  	}
   338  	return nil
   339  }
   340  
   341  // Seconds Minutes Hours Days Months DayOfWeek
   342  const codeStatsSchedule = "0 0 1 * *" // Check at 12:00 AM on 1st day every month
   343  
   344  func (p *Politeiawww) startCodeStatsCron() {
   345  	log.Infof("Starting cron for code stats update")
   346  	// Launch invoice notification cron job
   347  	err := p.cron.AddFunc(codeStatsSchedule, func() {
   348  		log.Infof("Running code stats cron")
   349  		// End time for codestats is when the cron starts.
   350  		end := time.Now()
   351  		// Start time is 1 month and 1 day prior to the current time.
   352  		start := time.Date(end.Year(), end.Month()-1, end.Day()-1, end.Hour(),
   353  			end.Minute(), end.Second(), 0, end.Location())
   354  		err := p.updateCodeStats(false, p.cfg.CodeStatRepos, start.Unix(),
   355  			end.Unix())
   356  		if err != nil {
   357  			log.Errorf("erroring updating code stats %v", err)
   358  		}
   359  
   360  	})
   361  	if err != nil {
   362  		log.Errorf("Error running codestats cron: %v", err)
   363  	}
   364  }
   365  
   366  func convertCodeStatsFromDatabase(userCodeStats []user.CodeStats) []cms.CodeStats {
   367  	cmsCodeStats := make([]cms.CodeStats, 0, len(userCodeStats))
   368  	for _, codeStat := range userCodeStats {
   369  		prs := make([]string, 0, len(codeStat.PRs))
   370  		reviews := make([]string, 0, len(codeStat.Reviews))
   371  		commits := make([]string, 0, len(codeStat.Commits))
   372  		for _, pr := range codeStat.PRs {
   373  			if pr == "" {
   374  				continue
   375  			}
   376  			prs = append(prs, pr)
   377  		}
   378  		for _, review := range codeStat.Reviews {
   379  			if review == "" {
   380  				continue
   381  			}
   382  			reviews = append(reviews, review)
   383  		}
   384  		for _, commit := range codeStat.Commits {
   385  			if commit == "" {
   386  				continue
   387  			}
   388  			commits = append(commits, commit)
   389  		}
   390  		cmsCodeStat := cms.CodeStats{
   391  			Month:            codeStat.Month,
   392  			Year:             codeStat.Year,
   393  			Repository:       codeStat.Repository,
   394  			PRs:              prs,
   395  			Reviews:          reviews,
   396  			Commits:          commits,
   397  			MergedAdditions:  codeStat.MergedAdditions,
   398  			MergedDeletions:  codeStat.MergedDeletions,
   399  			UpdatedAdditions: codeStat.UpdatedAdditions,
   400  			UpdatedDeletions: codeStat.UpdatedDeletions,
   401  			ReviewAdditions:  codeStat.ReviewAdditions,
   402  			ReviewDeletions:  codeStat.ReviewDeletions,
   403  			CommitAdditions:  codeStat.CommitAdditions,
   404  			CommitDeletions:  codeStat.CommitDeletions,
   405  		}
   406  		cmsCodeStats = append(cmsCodeStats, cmsCodeStat)
   407  	}
   408  	return cmsCodeStats
   409  }
   410  
   411  func convertCodeTrackerToUserCodeStats(githubName string, year, month int, userInfo *codetracker.UserInformationResult) []user.CodeStats {
   412  	mergedPRs := userInfo.MergedPRs
   413  	updatedPRs := userInfo.UpdatedPRs
   414  	commits := userInfo.Commits
   415  	reviews := userInfo.Reviews
   416  	repoStats := make([]user.CodeStats, 0, 1048) // PNOOMA
   417  	for _, pr := range mergedPRs {
   418  		repoFound := false
   419  		for i, repoStat := range repoStats {
   420  			if repoStat.Repository == pr.Repository {
   421  				repoFound = true
   422  				repoStat.PRs = append(repoStat.PRs, pr.URL)
   423  				repoStat.MergedAdditions += pr.Additions
   424  				repoStat.MergedDeletions += pr.Deletions
   425  				repoStats[i] = repoStat
   426  				break
   427  			}
   428  		}
   429  		if !repoFound {
   430  			id := fmt.Sprintf("%v-%v-%v-%v", githubName, pr.Repository,
   431  				strconv.Itoa(year), strconv.Itoa(month))
   432  			repoStat := user.CodeStats{
   433  				ID:              id,
   434  				GitHubName:      githubName,
   435  				Month:           month,
   436  				Year:            year,
   437  				PRs:             []string{pr.URL},
   438  				Repository:      pr.Repository,
   439  				MergedAdditions: pr.Additions,
   440  				MergedDeletions: pr.Deletions,
   441  			}
   442  			repoStats = append(repoStats, repoStat)
   443  		}
   444  	}
   445  	for _, pr := range updatedPRs {
   446  		repoFound := false
   447  		for i, repoStat := range repoStats {
   448  			if repoStat.Repository == pr.Repository {
   449  				repoFound = true
   450  				repoStat.PRs = append(repoStat.PRs, pr.URL)
   451  				repoStat.UpdatedAdditions += pr.Additions
   452  				repoStat.UpdatedDeletions += pr.Deletions
   453  				repoStats[i] = repoStat
   454  				break
   455  			}
   456  		}
   457  		if !repoFound {
   458  			id := fmt.Sprintf("%v-%v-%v-%v", githubName, pr.Repository,
   459  				strconv.Itoa(year), strconv.Itoa(month))
   460  			repoStat := user.CodeStats{
   461  				ID:               id,
   462  				GitHubName:       githubName,
   463  				Month:            month,
   464  				Year:             year,
   465  				PRs:              []string{pr.URL},
   466  				Repository:       pr.Repository,
   467  				UpdatedAdditions: pr.Additions,
   468  				UpdatedDeletions: pr.Deletions,
   469  			}
   470  			repoStats = append(repoStats, repoStat)
   471  		}
   472  	}
   473  	for _, review := range reviews {
   474  		repoFound := false
   475  		for i, repoStat := range repoStats {
   476  			if repoStat.Repository == review.Repository {
   477  				repoFound = true
   478  				repoStat.ReviewAdditions += int64(review.Additions)
   479  				repoStat.ReviewDeletions += int64(review.Deletions)
   480  				repoStat.Reviews = append(repoStat.Reviews, review.URL)
   481  				repoStats[i] = repoStat
   482  				break
   483  			}
   484  		}
   485  		if !repoFound {
   486  			id := fmt.Sprintf("%v-%v-%v-%v", githubName, review.Repository,
   487  				strconv.Itoa(year), strconv.Itoa(month))
   488  			repoStat := user.CodeStats{
   489  				ID:              id,
   490  				GitHubName:      githubName,
   491  				Month:           month,
   492  				Year:            year,
   493  				Repository:      review.Repository,
   494  				ReviewAdditions: int64(review.Additions),
   495  				ReviewDeletions: int64(review.Deletions),
   496  				Reviews:         []string{review.URL},
   497  			}
   498  			repoStats = append(repoStats, repoStat)
   499  		}
   500  	}
   501  
   502  	for _, commit := range commits {
   503  		repoFound := false
   504  		for i, repoStat := range repoStats {
   505  			if repoStat.Repository == commit.Repository {
   506  				repoFound = true
   507  				repoStat.CommitAdditions += int64(commit.Additions)
   508  				repoStat.CommitDeletions += int64(commit.Deletions)
   509  				repoStat.Commits = append(repoStat.Commits, commit.URL)
   510  				repoStats[i] = repoStat
   511  				break
   512  			}
   513  		}
   514  		if !repoFound {
   515  			id := fmt.Sprintf("%v-%v-%v-%v", githubName, commit.Repository,
   516  				strconv.Itoa(year), strconv.Itoa(month))
   517  			repoStat := user.CodeStats{
   518  				ID:              id,
   519  				GitHubName:      githubName,
   520  				Month:           month,
   521  				Year:            year,
   522  				Repository:      commit.Repository,
   523  				CommitAdditions: int64(commit.Additions),
   524  				CommitDeletions: int64(commit.Deletions),
   525  				Commits:         []string{commit.URL},
   526  			}
   527  			repoStats = append(repoStats, repoStat)
   528  		}
   529  	}
   530  	return repoStats
   531  }