blob: b1feb8859c3c8c6393a738dbb0d98fc8d37d22b0 [file] [log] [blame]
// 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;
}