| // 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:convert'; |
| import 'dart:io'; |
| import 'dart:math' as math; |
| |
| import 'package:github/github.dart'; |
| import 'package:http/http.dart'; |
| |
| import 'cache.dart'; |
| import 'utils.dart'; |
| |
| const List<String> priorities = <String>['P0', 'P1', 'P2', 'P3']; |
| |
| class FullIssue { |
| FullIssue( |
| this.repo, |
| this.issueNumber, |
| this._metadata, |
| this.comments, |
| this.reactions, { |
| this.redirect, |
| this.isDeleted = false, |
| }) { |
| _labels = _metadata?.labels.map<String>((final IssueLabel label) => label.name).toSet(); |
| if (_labels != null) { |
| final Set<String> matches = priorities.toSet().intersection(_labels!); |
| if (matches.length > 1) { |
| print('\nWARNING: Too many priority labels on issue #$issueNumber: ${matches.join(', ')}'); |
| for (final String priority in priorities) { |
| if (matches.contains(priority)) { |
| _priority = priority; |
| break; |
| } |
| } |
| } else if (matches.length == 1) { |
| _priority = matches.single; |
| } else { |
| _priority = null; |
| } |
| } |
| } |
| |
| final RepositorySlug repo; |
| final int issueNumber; |
| final Issue? _metadata; |
| Issue get metadata => _metadata!; // only available when isValid |
| final List<IssueComment> comments; |
| final List<Reaction> reactions; |
| final String? redirect; // not null when metadata == null && !isDeleted |
| final bool isDeleted; |
| |
| late final Set<String>? _labels; |
| Set<String> get labels => _labels!; // only available when isValid |
| |
| late final String? _priority; |
| String? get priority => _priority; // only available when isValid |
| |
| bool get isValid => _metadata != null; |
| bool get isPullRequest => _metadata?.pullRequest != null; |
| |
| static Future<FullIssue> load({ |
| required final Directory cache, |
| required final GitHub github, |
| required final RepositorySlug repo, |
| required final int issueNumber, |
| final DateTime? cacheEpoch, |
| }) async { |
| final String cacheData = await loadFromCache( |
| cache, github, <String>['issue', repo.owner, repo.name, issueNumber.toString()], cacheEpoch, () async { |
| try { |
| final Issue issue = await github.issues.get(repo, issueNumber); |
| if (issue.url.startsWith(github.endpoint)) { |
| if (issue.url != '${github.endpoint}/repos/$repo/issues/$issueNumber') { |
| return json.encode(<String, Object?>{'redirect': issue.url}); |
| } |
| } else if (issue.url == '') { |
| return json.encode(<String, Object?>{'deleted': true}); |
| } |
| final List<IssueComment> comments = await github.issues.listCommentsByIssue(repo, issueNumber).toList(); |
| final List<Reaction> reactions = await github.issues.listReactions(repo, issueNumber).toList(); |
| return json.encode(<String, Object?>{'issue': issue, 'comments': comments, 'reactions': reactions}); |
| } on ClientException catch (e) { |
| // print('\nIssue $issueNumber is a problem child (treating as deleted): $e'); |
| return json.encode(<String, Object?>{'deleted': true, 'error': e.toString()}); |
| } |
| }); |
| final Map<String, Object?> data = json.decode(cacheData)! as Map<String, Object?>; |
| if (data['redirect'] != null) { |
| return FullIssue( |
| repo, |
| issueNumber, |
| null, |
| const <IssueComment>[], |
| const <Reaction>[], |
| redirect: data['redirect']! as String, |
| ); |
| } |
| if (data['deleted'] == true) { |
| return FullIssue(repo, issueNumber, null, const <IssueComment>[], const <Reaction>[], isDeleted: true); |
| } |
| final Issue issue = Issue.fromJson(data['issue']! as Map<String, Object?>); |
| return FullIssue( |
| repo, |
| issueNumber, |
| issue, |
| (data['comments']! as List<Object?>) |
| .cast<Map<String, Object?>>() |
| .map<IssueComment>(IssueComment.fromJson) |
| .toList(), |
| (data['reactions']! as List<Object?>).cast<Map<String, Object?>>().map<Reaction>(Reaction.fromJson).toList(), |
| ); |
| } |
| |
| @override |
| String toString() { |
| final StringBuffer result = StringBuffer(); |
| if (isValid) { |
| result |
| ..writeln('Issue $issueNumber (${metadata.state}): ${metadata.title}') |
| ..writeln(metadata.htmlUrl) |
| ..writeln( |
| 'Created by ${metadata.user!.login} on ${metadata.createdAt}, last updated on ${metadata.updatedAt}.', |
| ); |
| if (metadata.isClosed) { |
| result.writeln('Closed by ${metadata.closedBy?.login} on ${metadata.closedAt}.'); |
| if (metadata.closedBy != null) { |
| final User user = metadata.closedBy!; |
| result |
| ..writeln(' avatarUrl: ${user.avatarUrl}') |
| ..writeln(' bio: ${user.bio}') |
| ..writeln(' blog: ${user.blog}') |
| ..writeln(' company: ${user.company}') |
| ..writeln(' createdAt: ${user.createdAt}') |
| ..writeln(' email: ${user.email}') |
| ..writeln(' followersCount: ${user.followersCount}') |
| ..writeln(' followingCount: ${user.followingCount}') |
| ..writeln(' hirable: ${user.hirable}') |
| ..writeln(' htmlUrl: ${user.htmlUrl}') |
| ..writeln(' id: ${user.id}') |
| ..writeln(' location: ${user.location}') |
| ..writeln(' login: ${user.login}') |
| ..writeln(' name: ${user.name}') |
| ..writeln(' publicGistsCount: ${user.publicGistsCount}') |
| ..writeln(' publicReposCount: ${user.publicReposCount}') |
| ..writeln(' siteAdmin: ${user.siteAdmin}') |
| ..writeln(' twitterUsername: ${user.twitterUsername}') |
| ..writeln(' updatedAt: ${user.updatedAt}'); |
| } |
| } |
| if (isPullRequest) { |
| result.writeln(' Pull request: ${metadata.pullRequest?.diffUrl}'); |
| } |
| result |
| ..writeln('Assignees: ${metadata.assignees!.map((final User user) => user.login!).join(', ')}') |
| ..writeln('Labels: ${metadata.labels.map((final IssueLabel label) => label.name).join(', ')}') |
| ..writeln('Milestone: ${metadata.milestone ?? ""}'); |
| } else { |
| result.writeln('Issue $issueNumber (redirected): see $redirect'); |
| } |
| for (final IssueComment comment in comments) { |
| final String line = comment.body!.split('\n').first; |
| final String body = line.substring(0, math.min(40, line.length)); |
| result.writeln('Comment by ${comment.user!.login} at ${comment.createdAt}: $body'); |
| } |
| for (final Reaction reaction in reactions) { |
| result.writeln('Reaction: ${reaction.user!.login} ${reaction.content} at ${reaction.createdAt}'); |
| } |
| return result.toString(); |
| } |
| } |
| |
| Future<void> fetchAllIssues( |
| final GitHub github, |
| final Directory cache, |
| final RepositorySlug repo, |
| final Duration issueMaxAge, |
| final Map<int, FullIssue> results, |
| ) async { |
| int index = 1; |
| int issues = 0; |
| int prs = 0; |
| int invalid = 0; |
| final File lastIssueNumberFile = cacheFileFor(cache, <String>['issue', repo.owner, repo.name, 'last']); |
| int lastIssueNumber = await readFromFile<int>(lastIssueNumberFile, int.tryParse) ?? 0; |
| bool maxKnown = false; |
| while (true) { |
| try { |
| final FullIssue issue = await FullIssue.load( |
| cache: cache, |
| github: github, |
| repo: repo, |
| issueNumber: index, |
| cacheEpoch: maxAge(issueMaxAge), |
| ); |
| if (issue.isValid) { |
| if (issue.isPullRequest) { |
| prs += 1; |
| } else { |
| issues += 1; |
| } |
| } else if (issue.isDeleted) { |
| throw NotFound(github, 'Issue $index deleted or not yet filed.'); |
| } else { |
| // probably redirect |
| invalid += 1; |
| } |
| results[index] = issue; |
| } on FormatException catch (e) { |
| print('\nIssue $index could not be processed: $e'); |
| invalid += 1; |
| } on NotFound { |
| if (index > lastIssueNumber) { |
| final Issue lastIssue = |
| await github.issues.listByRepo(repo, state: 'all', sort: 'created', direction: 'desc').first; |
| lastIssueNumber = lastIssue.number; |
| maxKnown = true; |
| if (index > lastIssueNumber) { |
| break; |
| } |
| invalid += 1; |
| } |
| } |
| await rateLimit( |
| github, |
| '${repo.fullName}: #$index${lastIssueNumber > 0 ? " of ${maxKnown ? "" : "~"}$lastIssueNumber" : ""} ($issues issues; $prs PRs; $invalid errors)', |
| 'issue #${index + 1}', |
| ); |
| index += 1; |
| } |
| await lastIssueNumberFile.writeAsString(lastIssueNumber.toString()); |
| stdout.write('\x1B[K\r'); |
| } |
| |
| Future<void> updateAllIssues( |
| final GitHub github, |
| final Directory cache, |
| final RepositorySlug repo, |
| final Map<int, FullIssue> issues, |
| ) async { |
| final Set<int> pendingIssues = issues.isEmpty ? <int>{} : issues.keys.toSet(); |
| int highestKnownIssue = pendingIssues.isEmpty ? 0 : (pendingIssues.toList()..sort()).last; |
| final File updateStampFile = cacheFileFor(cache, <String>['issue', repo.owner, repo.name, 'last-update']); |
| final DateTime? lastFullScanStartTime = await readFromFile<DateTime>(updateStampFile, DateTime.tryParse); |
| DateTime? thisFullScanStartTime; |
| int count = 0; |
| await rateLimit(github, '${repo.fullName}: fetching issues with recent changes', 'scan'); |
| await for (final Issue summary |
| in github.issues.listByRepo(repo, state: 'all', sort: 'updated', direction: 'desc', perPage: 100)) { |
| if (summary.updatedAt != null) { |
| thisFullScanStartTime ??= summary.updatedAt!; |
| } |
| if (mode == Mode.abbreviated && |
| summary.updatedAt != null && |
| lastFullScanStartTime != null && |
| summary.updatedAt!.isBefore(lastFullScanStartTime)) { |
| stdout.write('\x1B[K\r'); |
| return; |
| } |
| const int maxRetry = 5; |
| for (int retry = 1; retry <= maxRetry; retry += 1) { |
| try { |
| final String parenthetical; |
| Duration? delta = lastFullScanStartTime?.difference(summary.updatedAt!); |
| if (delta != null && delta.inMilliseconds < 0) { |
| delta = -delta; |
| if (delta.inDays > 2) { |
| parenthetical = '${delta.inDays} days remaining'; |
| } else if (delta.inHours > 2) { |
| parenthetical = '${delta.inHours} hours remaining'; |
| } else if (delta.inMinutes > 2) { |
| parenthetical = '${delta.inMinutes} minutes remaining'; |
| } else { |
| parenthetical = '${delta.inMilliseconds}ms remaining'; |
| } |
| } else { |
| parenthetical = '${100 * count ~/ (pendingIssues.length + count)}% newer than ${summary.updatedAt}'; |
| } |
| await rateLimit(github, '${repo.fullName}: $count issues updated ($parenthetical)', 'scan'); |
| final FullIssue issue = await FullIssue.load( |
| cache: cache, |
| github: github, |
| repo: repo, |
| issueNumber: summary.number, |
| cacheEpoch: summary.updatedAt, |
| ); |
| assert( |
| !issue.isValid || !issue.metadata.updatedAt!.isBefore(summary.updatedAt!), |
| 'invariant violation\nOLD DATA:\n${json.encode(issue.metadata.toJson())}\nNEW DATA:\n${json.encode(summary.toJson())}', |
| ); |
| if (issue.issueNumber > highestKnownIssue) { |
| for (int index = highestKnownIssue; index < issue.issueNumber; index += 1) { |
| pendingIssues.add(index); |
| } |
| highestKnownIssue = issue.issueNumber; |
| } else { |
| pendingIssues.remove(issue.issueNumber); |
| } |
| issues[issue.issueNumber] = issue; |
| break; |
| } on FormatException catch (e) { |
| print('\nError while updating issue #${summary.number} (attempt $retry/$maxRetry): $e'); |
| } |
| } |
| count += 1; |
| } |
| stdout.write('\x1B[K\r'); |
| if (pendingIssues.isNotEmpty) { |
| // looks like these went away, so force fetch them |
| int count = 0; |
| for (final int issueNumber in pendingIssues) { |
| await rateLimit( |
| github, |
| '${repo.fullName}: $count / ${pendingIssues.length} missing issues checked', |
| 'issue #$issueNumber', |
| ); |
| try { |
| issues[issueNumber] = await FullIssue.load( |
| cache: cache, |
| github: github, |
| repo: repo, |
| issueNumber: issueNumber, |
| cacheEpoch: lastFullScanStartTime, |
| ); |
| count += 1; |
| } on Exception catch (e) { |
| print('\nError while updating issue #$issueNumber: $e'); |
| } |
| } |
| stdout.write('\x1B[K\r'); |
| } |
| if (thisFullScanStartTime != null) { |
| await updateStampFile.writeAsString(thisFullScanStartTime.toIso8601String()); |
| } |
| } |