blob: 026771827b9d4a59b6695843f3271e397a7a2278 [file] [log] [blame] [edit]
// 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}.',
);
// Temporary workaround for https://github.com/SpinlockLabs/github.dart/issues/401
if (metadata.isClosed || metadata.state.toUpperCase() == 'CLOSED') {
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());
}
}