| // Copyright 2023 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:io'; |
| |
| import 'package:github/github.dart'; |
| import 'package:http/http.dart'; |
| |
| import 'debug_http.dart'; |
| import 'issue.dart'; |
| import 'team.dart'; |
| import 'utils.dart'; |
| |
| const bool _debugNetwork = false; |
| |
| // File that contains OAuth token obtained via https://github.com/settings/personal-access-tokens/new |
| // + Resource owner "flutter" |
| // + Public Repositories (read-only) |
| // + Organization permissions -> Members -> Read-only |
| final File tokenFile = File('.github-token'); |
| final File membersFile = File('members.txt'); |
| final File exmembersFile = File('exmembers.txt'); |
| |
| final Directory cache = Directory('cache'); |
| final Directory output = Directory('output'); |
| const String orgName = 'flutter'; |
| final RepositorySlug issueDatabaseRepo = RepositorySlug(orgName, 'flutter'); |
| final Set<RepositorySlug> repos = <RepositorySlug>{ |
| issueDatabaseRepo, // flutter/flutter |
| RepositorySlug(orgName, 'assets-for-api-docs'), |
| RepositorySlug(orgName, 'cocoon'), |
| RepositorySlug(orgName, 'codelabs'), |
| RepositorySlug(orgName, 'devtools'), |
| RepositorySlug(orgName, 'flutter-intellij'), |
| RepositorySlug(orgName, 'gallery'), |
| RepositorySlug(orgName, 'games'), |
| RepositorySlug(orgName, 'holobooth'), |
| RepositorySlug(orgName, 'io_flip'), |
| RepositorySlug(orgName, 'news_toolkit'), |
| RepositorySlug(orgName, 'packages'), |
| RepositorySlug(orgName, 'photobooth'), |
| RepositorySlug(orgName, 'pinball'), |
| RepositorySlug(orgName, 'platform_tests'), |
| RepositorySlug(orgName, 'samples'), |
| RepositorySlug(orgName, 'tests'), |
| RepositorySlug(orgName, 'website'), |
| }; |
| const String primaryTeam = 'flutter-hackers'; |
| const Duration rosterMaxAge = Duration(minutes: 10); |
| const Duration issueMaxAge = Duration(days: 365); |
| |
| const Set<String> csvSpecials = <String>{'\'', '"', ',', '\n'}; |
| |
| // Temporary workaround for https://github.com/SpinlockLabs/github.dart/issues/401 |
| bool issueIsOpen(final FullIssue issue) { |
| return issue.metadata.isOpen || issue.metadata.state.toUpperCase() == 'OPEN'; |
| } |
| |
| // Temporary workaround for https://github.com/SpinlockLabs/github.dart/issues/401 |
| bool issueIsClosed(final FullIssue issue) { |
| return issue.metadata.isClosed || |
| issue.metadata.state.toUpperCase() == 'CLOSED'; |
| } |
| |
| // Turns a username into an internal canonicalized form. |
| // We add a "👤" emoji here so that if we accidentally use the canonicalized form |
| // in the output, we will catch it. |
| String canon(final String? s) => '👤${(s ?? "").toLowerCase()}'; |
| |
| Future<int> full(final Directory cache, final GitHub github) async { |
| try { |
| // FETCH USER AND TEAM DATA |
| print('Team roster...'); |
| final roster = await TeamRoster.load( |
| cache: cache, |
| github: github, |
| orgName: orgName, |
| cacheEpoch: maxAge(rosterMaxAge), |
| ); |
| final allMembers = <String>{}; |
| final currentMembers = roster.teams[primaryTeam]!.keys.map(canon).toSet(); |
| final expectedMembers = (await membersFile.readAsString()) |
| .trimRight() |
| .split('\n') |
| .where((final String name) => !name.endsWith(' (DO NOT ADD)')) |
| .map(canon) |
| .toSet(); |
| final expectedExmembers = (await exmembersFile.readAsString()) |
| .trimRight() |
| .split('\n') |
| .map(canon) |
| .toSet(); |
| try { |
| final unexpectedMembers = currentMembers.difference(expectedMembers); |
| final memberExmembers = expectedExmembers.intersection(currentMembers); |
| final missingMembers = expectedMembers.difference(currentMembers); |
| if (unexpectedMembers.isNotEmpty) { |
| print( |
| 'WARNING: The following users are currently members of $primaryTeam but not expected: ${unexpectedMembers.join(', ')}', |
| ); |
| } |
| if (memberExmembers.isNotEmpty) { |
| print( |
| 'WARNING: The following users are currently members of $primaryTeam but should have been removed: ${memberExmembers.join(', ')}', |
| ); |
| } |
| if (missingMembers.isNotEmpty) { |
| print( |
| 'WARNING: The following users are currently NOT members of $primaryTeam but were expected:\n ${missingMembers.join('\n ')}', |
| ); |
| } |
| allMembers |
| ..addAll(currentMembers) |
| ..addAll(expectedMembers) |
| ..addAll(expectedExmembers); |
| } on FileSystemException catch (e) { |
| if (membersFile.existsSync()) { |
| print('Unable to read ${membersFile.path}: ${e.message}'); |
| return 1; |
| } |
| } |
| for (final teamName in roster.teams.keys.where( |
| (final String? team) => team != null, |
| )) { |
| for (final userName in roster.teams[teamName]!.keys) { |
| if (!roster.teams[null]!.containsKey(userName)) { |
| print( |
| 'WARNING: user $userName is in $teamName but not in organization.', |
| ); |
| } |
| } |
| } |
| |
| // FETCH ACTIVITY |
| print(''); |
| print('Fetching issues...'); |
| final issues = <String, Map<int, FullIssue>>{}; |
| try { |
| for (final repo in repos) { |
| await fetchAllIssues( |
| github, |
| cache, |
| repo, |
| issueMaxAge, |
| issues[repo.fullName] = <int, FullIssue>{}, |
| ); |
| } |
| print('Updating issues...'); |
| for (final repo in repos) { |
| await updateAllIssues(github, cache, repo, issues[repo.fullName]!); |
| } |
| } on Abort {} // ignore: empty_catches |
| |
| // ANALYZE ACTIVITY RESULTS |
| print(''); |
| print('Analyzing...'); |
| try { |
| await output.create(recursive: true); |
| } on FileSystemException catch (e) { |
| print('Unable to create output in "${output.path}": $e'); |
| return 1; |
| } |
| final activityMetrics = <String, UserActivity>{}; |
| UserActivity forUser(final User? user) { |
| return activityMetrics.putIfAbsent(user!.login!, () { |
| final result = UserActivity(); |
| if (expectedMembers.contains(canon(user.login))) { |
| result |
| ..isMember = true |
| ..isActiveMember = true; |
| } else if (expectedExmembers.contains(canon(user.login))) { |
| result.isMember = true; |
| } |
| return result; |
| }); |
| } |
| |
| final reactionKinds = <String>{}; |
| final foundPriorities = <String?>{}; |
| void increment<T>(final Map<T, int> map, final Set<T> keys, final T key) { |
| keys.add(key); |
| if (map.containsKey(key)) { |
| map[key] = map[key]! + 1; |
| } else { |
| map[key] = 1; |
| } |
| } |
| |
| roster.teams[primaryTeam]!.values.forEach(forUser); |
| |
| final allIssues = issues.values |
| .expand((final Map<int, FullIssue> issues) => issues.values) |
| .where((final FullIssue issue) => issue.isValid) |
| .toList(); |
| for (final issue in allIssues) { |
| if (issue.isPullRequest) { |
| // Pull requests filed. |
| forUser(issue.metadata.user).pullRequests.add(issue.metadata.createdAt); |
| } else { |
| // Issues filed by users. |
| forUser(issue.metadata.user).issues.add(issue.metadata.createdAt); |
| increment<String?>( |
| forUser(issue.metadata.user).priorityCount, |
| foundPriorities, |
| issue.priority, |
| ); |
| } |
| |
| // Pull request comments. |
| for (final comment in issue.comments) { |
| // Comments left by users on pull requests. |
| // Issue comments have a lot of spam, excluding for now. |
| if (issue.isPullRequest) { |
| forUser(comment.user).comments.add(comment.createdAt); |
| } |
| } |
| } |
| DateTime? earliest; |
| DateTime? latest; |
| void considerTimes( |
| final UserActivity activity, |
| final List<DateTime?> times, |
| ) { |
| for (final time in times) { |
| if (activity.earliest == null || |
| (time != null && time.isBefore(activity.earliest!))) { |
| activity.earliest = time; |
| } |
| if (activity.latest == null || |
| (time != null && time.isAfter(activity.latest!))) { |
| activity.latest = time; |
| } |
| if (earliest == null || (time != null && time.isBefore(earliest!))) { |
| earliest = time; |
| } |
| if (latest == null || (time != null && time.isAfter(latest!))) { |
| latest = time; |
| } |
| } |
| } |
| |
| for (final activity in activityMetrics.values) { |
| considerTimes(activity, activity.issues); |
| considerTimes(activity, activity.comments); |
| considerTimes(activity, activity.pullRequests); |
| } |
| |
| // PRINT ACTIVITY RESULTS |
| final summary = StringBuffer(); |
| for (final reactionKind in reactionKinds) { |
| verifyStringSanity(reactionKind, csvSpecials); |
| } |
| final sortedReactionKinds = reactionKinds.toList()..sort(); |
| summary.writeln( |
| 'user,is member,is active member,earliest,latest,days active,total,density,issues,comments,closures,self closures,pull requests,characters,missing priority,${priorities.join(',')},reactions,${sortedReactionKinds.join(',')}', |
| ); |
| var usersWithMoreThanOneDayActive = 0; |
| for (final user |
| in activityMetrics.keys.toList()..sort( |
| (final String a, final String b) => |
| activityMetrics[b]!.total - activityMetrics[a]!.total, |
| )) { |
| verifyStringSanity(user, csvSpecials); |
| final activity = activityMetrics[user]!; |
| if (activity.daysActive > 0) { |
| usersWithMoreThanOneDayActive += 1; |
| } |
| summary.write( |
| '$user,${activity.isMember},${activity.isActiveMember},${activity.earliest},${activity.latest},${activity.daysActive},${activity.total},${activity.density},${activity.issues.length},${activity.comments.length},${activity.closures.length},${activity.selfClosures},${activity.pullRequests.length},${activity.characters},${activity.priorityCount[null] ?? 0}', |
| ); |
| for (final priority in priorities) { |
| summary.write(',${activity.priorityCount[priority] ?? 0}'); |
| } |
| summary.write(',${activity.reactions.length}'); |
| for (final reactionKind in sortedReactionKinds) { |
| summary.write(',${activity.reactionCount[reactionKind] ?? 0}'); |
| } |
| summary.writeln(); |
| } |
| await File('${output.path}/users.csv').writeAsString(summary.toString()); |
| print('Total participants: ${activityMetrics.length}'); |
| print( |
| 'Participants with more than one day of activity: $usersWithMoreThanOneDayActive', |
| ); |
| print('User activity results stored in: ${output.path}/users.csv'); |
| |
| // ANALYZE PRIORITIES |
| final priorityAnalysis = <String, PriorityResults>{}; |
| for (final priority in priorities) { |
| priorityAnalysis[priority] = PriorityResults(); |
| } |
| |
| final primaryIssues = issues[issueDatabaseRepo.fullName]!.values |
| .where((final issue) => issue.isValid && !issue.isPullRequest) |
| .toList(); |
| final primaryPRs = issues[issueDatabaseRepo.fullName]!.values |
| .where((final issue) => issue.isValid && issue.isPullRequest) |
| .toList(); |
| for (final issue in primaryIssues.where( |
| (final issue) => issue.priority != null, |
| )) { |
| final priorityResults = priorityAnalysis[issue.priority!]!; |
| final teamIssue = allMembers.contains(canon(issue.metadata.user!.login)); |
| priorityResults.total += 1; |
| if (teamIssue) { |
| priorityResults.openedByTeam += 1; |
| } else { |
| priorityResults.openedByNonTeam += 1; |
| } |
| if (issueIsOpen(issue)) { |
| priorityResults.open += 1; |
| } else { |
| priorityResults.closed += 1; |
| if (issue.metadata.closedAt == null || |
| issue.metadata.createdAt == null) { |
| print( |
| 'WARNING: bogus open/close timeline data in ${issue.issueNumber}: opened at ${issue.metadata.createdAt}, closed at ${issue.metadata.closedAt}, state: ${issue.metadata.state}', |
| ); |
| } else { |
| final timeOpen = issue.metadata.closedAt!.difference( |
| issue.metadata.createdAt!, |
| ); |
| priorityResults.timeOpen.add(timeOpen); |
| } |
| if (teamIssue) { |
| priorityResults.openedByTeamAndClosed += 1; |
| } else { |
| priorityResults.openedByNonTeamAndClosed += 1; |
| } |
| } |
| } |
| |
| // PRINT PRIORITY RESULTS |
| summary |
| ..clear() |
| ..writeln( |
| 'priority,total,open,closed,openedByTeam,openedByNonTeam,openedByTeamAndClosed,openedByNonTeamAndClosed,meanTimeOpen,p01TimeOpen,p05TimeOpen,medianTimeOpen,p95TimeOpen,p99TimeOpen', |
| ); |
| for (final priority in priorities) { |
| verifyStringSanity(priority, csvSpecials); |
| final entry = priorityAnalysis[priority]!; |
| summary.write( |
| '$priority,${entry.total},${entry.open},${entry.closed},${entry.openedByTeam},${entry.openedByNonTeam},${entry.openedByTeamAndClosed},${entry.openedByNonTeamAndClosed},', |
| ); |
| if (entry.timeOpen.isEmpty) { |
| summary.write('NaN,NaN,NaN,NaN'); |
| } else { |
| entry.timeOpen.sort(); |
| summary |
| ..write( |
| '${entry.timeOpen.fold<int>(0, (final int sum, final Duration t) => sum + t.inMilliseconds) / (entry.timeOpen.length * Duration.millisecondsPerDay)},', |
| ) |
| ..write( |
| '${entry.timeOpen[(entry.timeOpen.length * 0.01).floor()].inMilliseconds / Duration.millisecondsPerDay},', |
| ) |
| ..write( |
| '${entry.timeOpen[(entry.timeOpen.length * 0.05).floor()].inMilliseconds / Duration.millisecondsPerDay},', |
| ); |
| if (entry.timeOpen.length > 1) { |
| final median1 = entry.timeOpen[(entry.timeOpen.length / 2.0).floor()]; |
| final median2 = entry.timeOpen[(entry.timeOpen.length / 2.0).ceil()]; |
| summary.write( |
| '${(median1.inMilliseconds + median2.inMilliseconds) / (2.0 * Duration.millisecondsPerDay)},', |
| ); |
| } else { |
| summary.write( |
| '${(entry.timeOpen.first.inMilliseconds) / Duration.millisecondsPerDay},', |
| ); |
| } |
| summary |
| ..write( |
| '${entry.timeOpen[(entry.timeOpen.length * 0.95).floor()].inMilliseconds / Duration.millisecondsPerDay},', |
| ) |
| ..write( |
| '${entry.timeOpen[(entry.timeOpen.length * 0.99).floor()].inMilliseconds / Duration.millisecondsPerDay},', |
| ); |
| } |
| summary.writeln(); |
| } |
| await File( |
| '${output.path}/priorities.csv', |
| ).writeAsString(summary.toString()); |
| print('Priority results stored in: ${output.path}/priorities.csv'); |
| |
| // PRINT ISSUE DATA |
| var deadCount = 0; |
| var zombieCount = 0; |
| summary |
| ..clear() |
| ..writeln( |
| 'repository,issue,state,createdAt,createdBy,closedAt,closedBy,timeOpen,updatedAt,priority,labelCount,commentCount,${sortedReactionKinds.join(',')},daysToTwentyVotes,isNewFeature,isProposal,isPendingAutoclosure,isFiledByTeam,isFiledByExMember,', |
| ); |
| for (final issue in allIssues.where( |
| (final FullIssue issue) => !issue.isPullRequest, |
| )) { |
| verifyStringSanity(issue.metadata.state, csvSpecials); |
| summary.write( |
| '${issue.repo.fullName},${issue.issueNumber},${issue.metadata.state},${issue.metadata.createdAt},${issue.metadata.user!.login},${issue.metadata.closedAt},${issue.metadata.closedBy?.login ?? (issue.isValid && issueIsClosed(issue) ? "<unknown>" : "")},${issue.isValid && issueIsClosed(issue) ? issue.metadata.closedAt!.difference(issue.metadata.createdAt!).inMilliseconds / Duration.millisecondsPerDay : ""},${issue.metadata.updatedAt},${issue.priority ?? ""},${issue.labels.length},${issue.comments.length}', |
| ); |
| for (final reactionKind in sortedReactionKinds) { |
| var count = 0; |
| for (final reaction in issue.reactions) { |
| if (reaction.content == reactionKind) { |
| count += 1; |
| } |
| } |
| summary.write(',$count'); |
| } |
| var count = 0; |
| int? daysToTwentyVotes; |
| for (final reaction in issue.reactions) { |
| if (reaction.content == '+1') { |
| count += 1; |
| if (count >= 20) { |
| daysToTwentyVotes = reaction.createdAt! |
| .difference(issue.metadata.createdAt!) |
| .inDays; |
| break; |
| } |
| } |
| } |
| summary |
| ..write(',${daysToTwentyVotes ?? ''}') |
| ..write(',${issue.labels.contains('new feature')}') |
| ..write(',${issue.labels.contains('proposal')}') |
| ..write(',${issue.labels.contains('waiting for customer response')}') |
| ..write(',${allMembers.contains(canon(issue.metadata.user!.login))}') |
| ..write( |
| ',${expectedExmembers.contains(canon(issue.metadata.user!.login))}', |
| ) |
| ..writeln(); |
| if ((daysToTwentyVotes == null || daysToTwentyVotes > 60) && |
| issue.labels.contains('new feature') && |
| (issueIsOpen(issue) || |
| issue.metadata.closedAt! |
| .difference(issue.metadata.createdAt!) |
| .inDays > |
| 60)) { |
| if (issueIsOpen(issue)) { |
| deadCount += 1; |
| } else { |
| zombieCount += 1; |
| } |
| } |
| } |
| await File('${output.path}/issues.csv').writeAsString(summary.toString()); |
| print('Issue summaries stored in: ${output.path}/issues.csv'); |
| print( |
| '$deadCount issues would be closed; $zombieCount issues would not have been fixed.', |
| ); |
| |
| // COLLECT CLOSE TIME PERCENTILES |
| var maxDaysToClose = 0; |
| final closureTimeHistogramClosed = <int, Map<String?, int>>{}; |
| final closureTimeTotalsClosed = <String?, int>{ |
| null: 0, |
| for (final String priority in priorities) priority: 0, |
| }; |
| for (final issue in primaryIssues.where(issueIsClosed)) { |
| final timeOpen = issue.metadata.closedAt! |
| .difference(issue.metadata.createdAt!) |
| .inDays; |
| final priority = issue.priority; |
| closureTimeHistogramClosed |
| .putIfAbsent(timeOpen, () => <String?, int>{}) |
| .update(priority, (final int value) => value + 1, ifAbsent: () => 1); |
| closureTimeTotalsClosed[priority] = |
| closureTimeTotalsClosed[priority]! + 1; |
| if (timeOpen > maxDaysToClose) { |
| maxDaysToClose = timeOpen; |
| } |
| } |
| |
| // PRINT CLOSE TIME PERCENTILES OF CLOSED BUGS |
| summary |
| ..clear() |
| ..writeln( |
| 'time to close (days),unprioritized,${priorities.where((final String priority) => closureTimeTotalsClosed[priority]! > 0).join(",")},unprioritized,${priorities.where((final String priority) => closureTimeTotalsClosed[priority]! > 0).join(",")}', |
| ); |
| if (closureTimeTotalsClosed[null]! > 0) { |
| final closureTimeCumulativeSum = <String?, int>{ |
| null: 0, |
| for (final String priority in priorities) |
| if (closureTimeTotalsClosed[priority]! > 0) priority: 0, |
| }; |
| for (var day = 0; day <= maxDaysToClose; day += 1) { |
| if (closureTimeHistogramClosed.containsKey(day)) { |
| if (closureTimeHistogramClosed[day]!.containsKey(null)) { |
| closureTimeCumulativeSum[null] = |
| closureTimeCumulativeSum[null]! + |
| closureTimeHistogramClosed[day]![null]!; |
| } |
| for (final priority in priorities) { |
| if (closureTimeHistogramClosed[day]!.containsKey(priority)) { |
| closureTimeCumulativeSum[priority] = |
| closureTimeCumulativeSum[priority]! + |
| closureTimeHistogramClosed[day]![priority]!; |
| } |
| } |
| } |
| summary.write('$day,${closureTimeCumulativeSum[null]}'); |
| for (final priority in priorities) { |
| if (closureTimeTotalsClosed[priority]! > 0) { |
| summary.write(',${closureTimeCumulativeSum[priority]}'); |
| } |
| } |
| summary.write( |
| ',${100.0 * closureTimeCumulativeSum[null]! / closureTimeTotalsClosed[null]!}%', |
| ); |
| for (final priority in priorities) { |
| if (closureTimeTotalsClosed[priority]! > 0) { |
| summary.write( |
| ',${100.0 * closureTimeCumulativeSum[priority]! / closureTimeTotalsClosed[priority]!}%', |
| ); |
| } |
| } |
| summary.writeln(); |
| } |
| } |
| await File( |
| '${output.path}/priority-percentiles.csv', |
| ).writeAsString(summary.toString()); |
| print( |
| 'Priority percentiles stored in: ${output.path}/priority-percentiles.csv', |
| ); |
| |
| // COLLECT CLOSE TIME PERCENTILES OF ALL BUGS |
| final closureTimeHistogramAll = <int, Map<String?, int>>{}; |
| final closureTimeTotalsAll = <String?, int>{ |
| null: 0, |
| for (final String priority in priorities) priority: 0, |
| }; |
| for (final issue in primaryIssues) { |
| final priority = issue.priority; |
| closureTimeTotalsAll[priority] = closureTimeTotalsAll[priority]! + 1; |
| final timeOpen = issueIsClosed(issue) |
| ? issue.metadata.closedAt! |
| .difference(issue.metadata.createdAt!) |
| .inDays |
| : maxDaysToClose + 1; |
| closureTimeHistogramAll |
| .putIfAbsent(timeOpen, () => <String?, int>{}) |
| .update(priority, (final int value) => value + 1, ifAbsent: () => 1); |
| } |
| |
| // PRINT CLOSE TIME PERCENTILES OF ALL BUGS |
| summary |
| ..clear() |
| ..writeln( |
| 'time to close (days),unprioritized,${priorities.where((final String priority) => closureTimeTotalsAll[priority]! > 0).join(",")},unprioritized,${priorities.where((final String priority) => closureTimeTotalsAll[priority]! > 0).join(",")}', |
| ); |
| if (closureTimeTotalsAll[null]! > 0) { |
| final closureTimeCumulativeSum = <String?, int>{ |
| null: 0, |
| for (final String priority in priorities) |
| if (closureTimeTotalsAll[priority]! > 0) priority: 0, |
| }; |
| for (var day = 0; day <= maxDaysToClose + 1; day += 1) { |
| if (closureTimeHistogramAll.containsKey(day)) { |
| if (closureTimeHistogramAll[day]!.containsKey(null)) { |
| closureTimeCumulativeSum[null] = |
| closureTimeCumulativeSum[null]! + |
| closureTimeHistogramAll[day]![null]!; |
| } |
| for (final priority in priorities) { |
| if (closureTimeHistogramAll[day]!.containsKey(priority)) { |
| closureTimeCumulativeSum[priority] = |
| closureTimeCumulativeSum[priority]! + |
| closureTimeHistogramAll[day]![priority]!; |
| } |
| } |
| } |
| summary.write('$day,${closureTimeCumulativeSum[null]}'); |
| for (final priority in priorities) { |
| if (closureTimeTotalsAll[priority]! > 0) { |
| summary.write(',${closureTimeCumulativeSum[priority]}'); |
| } |
| } |
| summary.write( |
| ',${100.0 * closureTimeCumulativeSum[null]! / closureTimeTotalsAll[null]!}%', |
| ); |
| for (final priority in priorities) { |
| if (closureTimeTotalsAll[priority]! > 0) { |
| summary.write( |
| ',${100.0 * closureTimeCumulativeSum[priority]! / closureTimeTotalsAll[priority]!}%', |
| ); |
| } |
| } |
| summary.writeln(); |
| } |
| } |
| await File( |
| '${output.path}/priority-percentiles-all.csv', |
| ).writeAsString(summary.toString()); |
| print( |
| 'Priority percentiles stored in: ${output.path}/priority-percentiles-all.csv', |
| ); |
| |
| // PRINT PR DATA |
| summary |
| ..clear() |
| ..writeln( |
| 'repository,pr,user,state,createdAt,closedAt,timeOpen,updatedAt,labelCount,commentCount,${sortedReactionKinds.join(',')}', |
| ); |
| for (final issue in allIssues.where( |
| (final FullIssue issue) => issue.isPullRequest, |
| )) { |
| verifyStringSanity(issue.metadata.state, csvSpecials); |
| summary.write( |
| '${issue.repo.fullName},${issue.issueNumber},${issue.metadata.user!.login},${issue.metadata.state},${issue.metadata.createdAt},${issue.metadata.closedAt},${issueIsClosed(issue) ? issue.metadata.closedAt!.difference(issue.metadata.createdAt!).inMilliseconds / Duration.millisecondsPerDay : ""},${issue.metadata.updatedAt},${issue.labels.length},${issue.comments.length}', |
| ); |
| for (final reactionKind in sortedReactionKinds) { |
| var count = 0; |
| for (final reaction in issue.reactions) { |
| if (reaction.content == reactionKind) { |
| count += 1; |
| } |
| } |
| summary.write(',$count'); |
| } |
| summary.writeln(); |
| } |
| await File('${output.path}/prs.csv').writeAsString(summary.toString()); |
| print('PR summaries stored in: ${output.path}/prs.csv'); |
| |
| // PRINT USERS |
| final teamNames = |
| roster.teams.keys |
| .where((final String? name) => name != null) |
| .cast<String>() |
| .toList() |
| ..sort(); |
| final userNames = roster.teams[null]!.keys.toList()..sort(); |
| summary |
| ..clear() |
| ..writeln('user,${teamNames.join(',')}'); |
| for (final userName in userNames) { |
| verifyStringSanity(userName, csvSpecials); |
| summary.write(userName); |
| for (final String? teamName in teamNames) { |
| summary.write(','); |
| if (roster.teams[teamName]!.containsKey(userName)) { |
| summary.write('1'); |
| } else { |
| summary.write('0'); |
| } |
| } |
| summary.writeln(); |
| } |
| await File('${output.path}/teams.csv').writeAsString(summary.toString()); |
| print('Team membership summaries stored in: ${output.path}/teams.csv'); |
| |
| // WEEKLY ACTIVITY OVER TIME |
| if (earliest != null) { |
| assert(latest != null, 'invariant violation'); |
| const window = Duration.millisecondsPerDay * 7; |
| final firstWeekStart = earliest!.millisecondsSinceEpoch ~/ window; |
| final weeks = List<WeekActivity>.generate( |
| 1 + (latest!.millisecondsSinceEpoch ~/ window) - firstWeekStart, |
| (final int index) => WeekActivity( |
| DateTime.fromMillisecondsSinceEpoch( |
| (index + firstWeekStart) * window, |
| ), |
| reactionKinds, |
| priorities, |
| ), |
| ); |
| WeekActivity? forWeek(final DateTime? time) { |
| if (time == null) { |
| return null; |
| } |
| return weeks[(time.millisecondsSinceEpoch ~/ window) - firstWeekStart]; |
| } |
| |
| for (final issue in allIssues) { |
| if (!issue.isValid) { |
| continue; |
| } |
| if (issue.isPullRequest) { |
| forWeek(issue.metadata.createdAt)!.pullRequests += 1; |
| } else { |
| forWeek(issue.metadata.createdAt)!.issues += 1; |
| forWeek(issue.metadata.createdAt)!.priorityCount[issue.priority] = |
| forWeek( |
| issue.metadata.createdAt, |
| )!.priorityCount[issue.priority]! + |
| 1; |
| if (issueIsOpen(issue)) { |
| forWeek(issue.metadata.createdAt)!.remainingIssues += 1; |
| } |
| } |
| if (!issue.isPullRequest && (issue.metadata.closedBy != null)) { |
| forWeek(issue.metadata.closedAt)?.closures += 1; |
| if (issue.metadata.closedBy!.login == issue.metadata.user!.login) { |
| forWeek(issue.metadata.closedAt)?.selfClosures += 1; |
| } |
| } |
| forWeek(issue.metadata.createdAt)?.characters += |
| issue.metadata.body.length; |
| for (final comment in issue.comments) { |
| forWeek(comment.createdAt)!.comments += 1; |
| forWeek(comment.createdAt)!.characters += comment.body!.length; |
| } |
| } |
| if (weeks.isNotEmpty) { |
| weeks.removeLast(); // last week is incomplete data |
| } |
| |
| // PRINT WEEKLY ACTIVITY |
| summary |
| ..clear() |
| ..writeln( |
| 'week,total,issues,remaining issues,closures,self closures,net issues opened,comments,pull requests,characters,missing priority,${priorities.join(',')},reactions,${sortedReactionKinds.join(',')}', |
| ); |
| for (final week in weeks) { |
| verifyStringSanity(week.start.toIso8601String(), csvSpecials); |
| summary.write( |
| '${week.start},${week.total},${week.issues},${week.remainingIssues},${week.closures},${week.selfClosures},${week.issues - week.closures},${week.comments},${week.pullRequests},${week.characters},${week.priorityCount[null]!}', |
| ); |
| for (final priority in priorities) { |
| summary.write(',${week.priorityCount[priority]!}'); |
| } |
| summary.write(',${week.reactions}'); |
| for (final reactionKind in sortedReactionKinds) { |
| summary.write(',${week.reactionCount[reactionKind]!}'); |
| } |
| summary.writeln(); |
| } |
| await File('${output.path}/weeks.csv').writeAsString(summary.toString()); |
| print('Weekly activity results stored in: ${output.path}/weeks.csv'); |
| } |
| |
| // COLLECT LABELS DATA |
| final labels = <String, LabelData>{}; |
| final now = DateTime.now(); |
| for (final issue in primaryIssues) { |
| for (final label in issue.metadata.labels) { |
| final data = labels.putIfAbsent(label.name, () => LabelData(label.name)) |
| ..all += 1 |
| ..issues += 1; |
| if (issueIsOpen(issue)) { |
| data.open += 1; |
| } |
| if (issueIsClosed(issue)) { |
| data.closed += 1; |
| } |
| if (now.difference(issue.metadata.updatedAt!) < |
| const Duration(days: 52 * 7)) { |
| data.issuesUpdated52 += 1; |
| if (now.difference(issue.metadata.updatedAt!) < |
| const Duration(days: 12 * 7)) { |
| data.issuesUpdated12 += 1; |
| } |
| } |
| } |
| } |
| for (final issue in primaryPRs) { |
| for (final label in issue.metadata.labels) { |
| final data = labels.putIfAbsent(label.name, () => LabelData(label.name)) |
| ..all += 1 |
| ..prs += 1; |
| if (now.difference(issue.metadata.updatedAt!) < |
| const Duration(days: 52 * 7)) { |
| data.prsUpdated52 += 1; |
| if (now.difference(issue.metadata.updatedAt!) < |
| const Duration(days: 12 * 7)) { |
| data.prsUpdated12 += 1; |
| } |
| } |
| } |
| } |
| |
| // PRINT LABELS DATA |
| summary |
| ..clear() |
| ..writeln( |
| 'label,issues and PRs,all issues,open issues,closed issues,issues updated in last 12 weeks,issues updated in last 52 weeks,all PRs,PRs updated in last 12 weeks,PRs updated in last 52 weeks', |
| ); |
| for (final label in labels.values) { |
| verifyStringSanity(label.name, csvSpecials); |
| summary.writeln( |
| '${label.name},${label.all},${label.issues},${label.open},${label.closed},${label.issuesUpdated12},${label.issuesUpdated52},${label.prs},${label.prsUpdated12},${label.prsUpdated52}', |
| ); |
| } |
| await File('${output.path}/labels.csv').writeAsString(summary.toString()); |
| print('Labels stored in: ${output.path}/labels.csv'); |
| |
| return 0; |
| } on Abort { |
| print(''); |
| return 2; |
| // ignore: avoid_catches_without_on_clauses |
| } catch (e, stack) { |
| print('\nFatal error (${e.runtimeType}).'); |
| print('$e\n$stack'); |
| return 1; |
| } |
| } |
| |
| class LabelData { |
| LabelData(this.name); |
| final String name; |
| int all = 0; |
| int issues = 0; |
| int open = 0; |
| int closed = 0; |
| int issuesUpdated12 = 0; |
| int issuesUpdated52 = 0; |
| int prs = 0; |
| int prsUpdated12 = 0; |
| int prsUpdated52 = 0; |
| } |
| |
| class WeekActivity { |
| WeekActivity( |
| this.start, |
| final Set<String> reactionKinds, |
| final List<String> priorities, |
| ) { |
| priorityCount[null] = 0; |
| for (final priority in priorities) { |
| priorityCount[priority] = 0; |
| } |
| for (final reactionKind in reactionKinds) { |
| reactionCount[reactionKind] = 0; |
| } |
| } |
| final DateTime start; |
| int issues = 0; |
| int comments = 0; |
| int closures = 0; |
| int remainingIssues = 0; |
| int pullRequests = 0; |
| int reactions = 0; |
| Map<String, int> reactionCount = <String, int>{}; |
| Map<String?, int> priorityCount = <String?, int>{}; |
| int selfClosures = 0; |
| int characters = 0; |
| |
| int get total => issues + comments + closures + pullRequests + reactions; |
| } |
| |
| class UserActivity { |
| bool isMember = false; |
| bool isActiveMember = false; |
| List<DateTime?> issues = <DateTime?>[]; |
| List<DateTime?> comments = <DateTime?>[]; |
| List<DateTime?> closures = <DateTime?>[]; |
| List<DateTime?> pullRequests = <DateTime?>[]; |
| List<DateTime?> reactions = <DateTime?>[]; |
| Map<String, int> reactionCount = <String, int>{}; |
| Map<String?, int> priorityCount = <String?, int>{}; |
| int selfClosures = 0; |
| int characters = 0; |
| |
| DateTime? earliest; |
| DateTime? latest; |
| |
| int get total => |
| issues.length + |
| comments.length + |
| closures.length + |
| pullRequests.length + |
| reactions.length; |
| double get density => (earliest == null || latest == null) |
| ? double.nan |
| : total / |
| (latest!.millisecondsSinceEpoch - earliest!.millisecondsSinceEpoch); |
| double get daysActive => (earliest == null || latest == null) |
| ? double.nan |
| : (latest!.millisecondsSinceEpoch - earliest!.millisecondsSinceEpoch) / |
| Duration.millisecondsPerDay; |
| } |
| |
| class PriorityResults { |
| int total = 0; |
| int open = 0; |
| int closed = 0; |
| int openedByTeam = 0; |
| int openedByNonTeam = 0; |
| int openedByTeamAndClosed = 0; |
| int openedByNonTeamAndClosed = 0; |
| final List<Duration> timeOpen = <Duration>[]; |
| } |
| |
| Future<int> main(final List<String> arguments) async { |
| print(''); |
| print('GitHub Repository Analysis'); |
| print('=========================='); |
| print(''); |
| ProcessSignal.sigint.watch().listen((final ProcessSignal signal) { |
| stdout.write('\x1B[K\r'); |
| switch (mode) { |
| case Mode.full: |
| print('Skipping full update...'); |
| mode = Mode.abbreviated; |
| case Mode.abbreviated: |
| mode = Mode.aborted; |
| print('Skipping to generation...'); |
| aborter.complete(); |
| case Mode.aborted: |
| print('Terminating immediately!'); |
| exit(2); |
| } |
| }); |
| try { |
| await cache.create(recursive: true); |
| } on FileSystemException catch (e) { |
| print('Unable to create cache in "${cache.path}": $e'); |
| return 1; |
| } |
| final client = _debugNetwork ? DebugHttpClient() : Client(); |
| late final GitHub github; |
| try { |
| final token = await tokenFile.readAsString(); |
| github = GitHub(auth: Authentication.withToken(token), client: client); |
| } on FileSystemException catch (e) { |
| if (tokenFile.existsSync()) { |
| print('Unable to read ${tokenFile.path}: ${e.message}'); |
| return 1; |
| } |
| print('No token file; connecting to GitHub anonymously...'); |
| print(''); |
| github = GitHub(client: client); |
| } |
| if (arguments.isEmpty) { |
| return full(cache, github); |
| } |
| for (final argument in arguments) { |
| final parts = argument.split(':'); |
| if (parts.isNotEmpty && parts[0] == 'issue') { |
| if (parts.length != 4) { |
| print( |
| 'Not sure what to do with "$argument" (format for issue is issue:org:repo:number).', |
| ); |
| exit(1); |
| } |
| final repo = RepositorySlug(parts[1], parts[2]); |
| final issueNumber = int.tryParse(parts[3], radix: 10); |
| if (issueNumber == null) { |
| print( |
| 'Not sure what to do with "$argument" (fourth component is not a number).', |
| ); |
| exit(1); |
| } |
| final issue = await FullIssue.load( |
| cache: cache, |
| github: github, |
| repo: repo, |
| issueNumber: issueNumber, |
| cacheEpoch: DateTime.now().subtract(const Duration(hours: 24)), |
| ); |
| final summary = StringBuffer(); |
| final thumbs = List<int>.filled( |
| issue.reactions.last.createdAt! |
| .difference(issue.metadata.createdAt!) |
| .inDays + |
| 1, |
| 0, |
| ); |
| for (final reaction in issue.reactions) { |
| if (reaction.content == '+1') { |
| final day = reaction.createdAt! |
| .difference(issue.metadata.createdAt!) |
| .inDays; |
| thumbs[day] = thumbs[day] + 1; |
| } |
| } |
| summary.writeln('day,thumbs,sum'); |
| var sum = 0; |
| for (var day = 0; day < thumbs.length; day += 1) { |
| sum += thumbs[day]; |
| summary.writeln('$day,${thumbs[day]},$sum'); |
| } |
| await File( |
| '${output.path}/issue:${repo.owner}:${repo.name}:$issueNumber:thumbs:history.csv', |
| ).writeAsString(summary.toString()); |
| } |
| } |
| return 0; |
| } |