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 }