| // Copyright 2019 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:convert'; |
| import 'dart:io'; |
| import 'dart:typed_data'; |
| |
| import 'package:appengine/appengine.dart' show authClientService, runAppEngine, withAppEngineServices; |
| import 'package:crypto/crypto.dart'; |
| import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; |
| import 'package:github/github.dart'; |
| import 'package:googleapis/secretmanager/v1.dart'; |
| import 'package:http/http.dart' as http show Client; |
| import 'package:nyxx/nyxx.dart'; |
| |
| import 'bytes.dart'; |
| import 'discord.dart'; |
| import 'json.dart'; |
| import 'utils.dart'; |
| |
| const int port = 8213; // used only when not using appengine |
| const int maxLogLength = 1024; |
| |
| sealed class GitHubSettings { |
| static const String organization = 'flutter'; |
| static const String teamName = 'flutter-hackers'; |
| static final RepositorySlug primaryRepository = RepositorySlug(organization, 'flutter'); |
| static const String teamPrefix = 'team-'; |
| static const String triagedPrefix = 'triaged-'; |
| static const String fyiPrefix = 'fyi-'; |
| static const String designDoc = 'design doc'; |
| static const String permanentlyLocked = 'permanently locked'; |
| static const String thumbsUpLabel = ':+1:'; |
| static const String staleIssueLabel = ':hourglass_flowing_sand:'; |
| static const Set<String> priorities = <String>{ 'P0', 'P1', 'P2', 'P3' }; |
| static const String staleP1Message = 'This issue is marked P1 but has had no recent status updates.\n' |
| '\n' |
| 'The P1 label indicates high-priority issues that are at the top of the work list. ' |
| 'This is the highest priority level a bug can have ' |
| 'if it isn\'t affecting a top-tier customer or breaking the build. ' |
| 'Bugs marked P1 are generally actively being worked on ' |
| 'unless the assignee is dealing with a P0 bug (or another P1 bug). ' |
| 'Issues at this level should be resolved in a matter of months and should have monthly updates on GitHub.\n' |
| '\n' |
| 'Please consider where this bug really falls in our current priorities, and label it or assign it accordingly. ' |
| 'This allows people to have a clearer picture of what work is actually planned. Thanks!'; |
| static const String willNeedAdditionalTriage = 'will need additional triage'; |
| static const Set<String> teams = <String>{ |
| // these are the teams that the self-test issue is assigned to |
| 'android', |
| 'codelabs', |
| 'design', |
| 'desktop', |
| 'ecosystem', |
| 'engine', |
| 'framework', |
| 'games', |
| 'go_router', |
| 'google-testing', |
| 'infra', |
| 'ios', |
| 'news', |
| 'release', |
| 'text-input', |
| 'tool', |
| 'web', |
| }; |
| static const int thumbsMinimum = 100; // an issue needs at least this many thumbs up to trigger retriage |
| static const double thumbsThreshold = 2.0; // and the count must have increased by this factor since last triage |
| static const Set<String> knownBots = <String>{ // we don't report events from bots to Discord |
| 'auto-submit[bot]', |
| 'DartDevtoolWorkflowBot', |
| 'dependabot[bot]', |
| 'engine-flutter-autoroll', |
| 'flutter-dashboard[bot]', |
| 'flutter-triage-bot[bot]', // that's us! |
| 'fluttergithubbot', |
| 'github-actions[bot]', |
| 'google-cla[bot]', |
| 'google-ospo-administrator[bot]', |
| 'skia-flutter-autoroll', |
| }; |
| |
| static bool isRelevantLabel(String label, { bool ignorePriorities = false }) { |
| return label.startsWith(GitHubSettings.teamPrefix) |
| || label.startsWith(GitHubSettings.triagedPrefix) |
| || label.startsWith(GitHubSettings.fyiPrefix) |
| || label == GitHubSettings.designDoc |
| || label == GitHubSettings.permanentlyLocked |
| || label == GitHubSettings.thumbsUpLabel |
| || label == GitHubSettings.staleIssueLabel |
| || (!ignorePriorities && GitHubSettings.priorities.contains(label)); |
| } |
| } |
| |
| sealed class Timings { |
| static const Duration backgroundUpdatePeriod = Duration(seconds: 1); // how long to wait between issues when scanning in the background |
| static const Duration cleanupUpdateDelay = Duration(minutes: 45); // how long to wait for an issue to be idle before cleaning it up |
| static const Duration cleanupUpdatePeriod = Duration(seconds: 60); // time between attempting to clean up the pending cleanup issues |
| static const Duration longTermTidyingPeriod = Duration(hours: 5); // time between attempting to run long-term tidying of all issues |
| static const Duration credentialsUpdatePeriod = Duration(minutes: 45); // how often to update GitHub credentials |
| static const Duration timeUntilStale = Duration(days: 18 * 7); // how long since the last team interaction before considering an issue stale |
| static const Duration timeUntilReallyStale = Duration(days: 29 * 7); // how long since the last team interaction before unassigning an issue |
| static const Duration timeUntilUnlock = Duration(days: 28); // how long to leave open issues locked |
| static const Duration selfTestPeriod = Duration(days: 6 * 7); // how often to file an issue to test the triage process |
| static const Duration selfTestWindow = Duration(days: 18); // how long to leave the self-test issue open before assigning it to critical triage |
| static const Duration refeedDelay = Duration(hours: 24); // how long between times we mark an issue as needing retriage (max 7 a week per team) |
| } |
| |
| final class Secrets { |
| Future<List<int>> get serverCertificate => _getSecret('server.cert.pem'); |
| Future<DateTime> get serverCertificateModificationDate => _getSecretModificationDate('server.cert.pem'); |
| Future<List<int>> get serverIntermediateCertificates => _getSecret('server.intermediates.pem'); |
| Future<DateTime> get serverIntermediateCertificatesModificationDate => _getSecretModificationDate('server.intermediates.pem'); |
| Future<List<int>> get serverKey => _getSecret('server.key.pem'); |
| Future<DateTime> get serverKeyModificationDate => _getSecretModificationDate('server.key.pem'); |
| Future<String> get discordToken async => utf8.decode(await _getSecret('discord.token')); |
| Future<int> get discordAppId async => int.parse(utf8.decode(await _getSecret('discord.appid'))); |
| Future<List<int>> get githubWebhookSecret => _getSecret('github.webhook.secret'); |
| Future<String> get githubAppKey async => utf8.decode(await _getSecret('github.app.key.pem')); |
| Future<String> get githubAppId async => utf8.decode(await _getSecret('github.app.id')); |
| Future<String> get githubInstallationId async => utf8.decode(await _getSecret('github.installation.id')); |
| final File store = File('store.db'); |
| |
| static const String _projectId = 'xxxxx?????xxxxx'; // TODO(ianh): we should update this appropriately |
| |
| static File asFile(String name) => File('secrets/$name'); |
| |
| static String asKey(String name) => 'projects/$_projectId/secrets/$name/versions/latest'; |
| |
| static Future<List<int>> _getSecret(String name) async { |
| final File file = asFile(name); |
| if (await file.exists()) { |
| return file.readAsBytes(); |
| } |
| // authClientService is https://pub.dev/documentation/gcloud/latest/http/authClientService.html |
| final SecretManagerApi secretManager = SecretManagerApi(authClientService); |
| final String key = asKey(name); |
| final AccessSecretVersionResponse response = await secretManager.projects.secrets.versions.access(key); |
| return response.payload!.dataAsBytes; |
| } |
| |
| static Future<DateTime> _getSecretModificationDate(String name) async { |
| final File file = asFile(name); |
| if (await file.exists()) { |
| return file.lastModified(); |
| } |
| // authClientService is https://pub.dev/documentation/gcloud/latest/http/authClientService.html |
| final SecretManagerApi secretManager = SecretManagerApi(authClientService); |
| final String key = asKey(name); |
| final SecretVersion response = await secretManager.projects.secrets.versions.get(key); |
| return DateTime.parse(response.createTime!); |
| } |
| } |
| |
| class IssueStats { |
| IssueStats({ |
| this.lastContributorTouch, |
| this.lastAssigneeTouch, |
| Set<String>? labels, |
| this.openedAt, |
| this.lockedAt, |
| this.assignedAt, |
| this.assignedToTeamMemberReporter = false, |
| this.triagedAt, |
| this.thumbsAtTriageTime, |
| this.thumbs = 0, |
| }) : labels = labels ?? <String>{}; |
| |
| factory IssueStats.read(FileReader reader) { |
| return IssueStats( |
| lastContributorTouch: reader.readNullOr<DateTime>(reader.readDateTime), |
| lastAssigneeTouch: reader.readNullOr<DateTime>(reader.readDateTime), |
| labels: reader.readSet<String>(reader.readString), |
| openedAt: reader.readNullOr<DateTime>(reader.readDateTime), |
| lockedAt: reader.readNullOr<DateTime>(reader.readDateTime), |
| assignedAt: reader.readNullOr<DateTime>(reader.readDateTime), |
| assignedToTeamMemberReporter: reader.readBool(), |
| triagedAt: reader.readNullOr<DateTime>(reader.readDateTime), |
| thumbsAtTriageTime: reader.readNullOr<int>(reader.readInt), |
| thumbs: reader.readInt(), |
| ); |
| } |
| |
| static void write(FileWriter writer, IssueStats value) { |
| writer.writeNullOr<DateTime>(value.lastContributorTouch, writer.writeDateTime); |
| writer.writeNullOr<DateTime>(value.lastAssigneeTouch, writer.writeDateTime); |
| writer.writeSet<String>(writer.writeString, value.labels); |
| writer.writeNullOr<DateTime>(value.openedAt, writer.writeDateTime); |
| writer.writeNullOr<DateTime>(value.lockedAt, writer.writeDateTime); |
| writer.writeNullOr<DateTime>(value.assignedAt, writer.writeDateTime); |
| writer.writeBool(value.assignedToTeamMemberReporter); |
| writer.writeNullOr<DateTime>(value.triagedAt, writer.writeDateTime); |
| writer.writeNullOr<int>(value.thumbsAtTriageTime, writer.writeInt); |
| writer.writeInt(value.thumbs); |
| } |
| |
| DateTime? lastContributorTouch; |
| DateTime? lastAssigneeTouch; |
| Set<String> labels; |
| DateTime? openedAt; |
| DateTime? lockedAt; |
| DateTime? assignedAt; |
| bool assignedToTeamMemberReporter = false; |
| DateTime? triagedAt; |
| int? thumbsAtTriageTime; |
| int thumbs; |
| |
| @override |
| String toString() { |
| final StringBuffer buffer = StringBuffer(); |
| buffer.write('{${(labels.toList()..sort()).join(', ')}} and $thumbs 👍'); |
| if (openedAt != null) { |
| buffer.write('; openedAt: $openedAt'); |
| } |
| if (lastContributorTouch != null) { |
| buffer.write('; lastContributorTouch: $lastContributorTouch'); |
| } else { |
| buffer.write('; lastContributorTouch: never'); |
| } |
| if (assignedAt != null) { |
| buffer.write('; assignedAt: $assignedAt'); |
| if (assignedToTeamMemberReporter) { |
| buffer.write(' (to team-member reporter)'); |
| } |
| if (lastAssigneeTouch != null) { |
| buffer.write('; lastAssigneeTouch: $lastAssigneeTouch'); |
| } else { |
| buffer.write('; lastAssigneeTouch: never'); |
| } |
| } else { |
| if (lastAssigneeTouch != null) { |
| buffer.write('; lastAssigneeTouch: $lastAssigneeTouch (?!)'); |
| } |
| if (assignedToTeamMemberReporter) { |
| buffer.write('; assigned to team-member reporter (?!)'); |
| } |
| } |
| if (lockedAt != null) { |
| buffer.write('; lockedAt: $lockedAt'); |
| } |
| if (triagedAt != null) { |
| buffer.write('; triagedAt: $triagedAt'); |
| if (thumbsAtTriageTime != null) { |
| buffer.write(' with $thumbsAtTriageTime 👍'); |
| } |
| } else { |
| if (thumbsAtTriageTime != null) { |
| buffer.write('; not triaged with $thumbsAtTriageTime 👍 when triaged (?!)'); |
| } |
| } |
| return buffer.toString(); |
| } |
| } |
| |
| typedef StoreFields = ({ |
| Map<int, IssueStats> issues, |
| Map<int, DateTime> pendingCleanupIssues, |
| int? selfTestIssue, |
| DateTime? selfTestClosedDate, |
| int currentBackgroundIssue, |
| int highestKnownIssue, |
| Map<String, DateTime> lastRefeedByTime, |
| DateTime? lastCleanupStart, |
| DateTime? lastCleanupEnd, |
| DateTime? lastTidyStart, |
| DateTime? lastTidyEnd, |
| }); |
| |
| class Engine { |
| Engine._({ |
| required this.webhookSecret, |
| required this.discord, |
| required this.github, |
| required this.secrets, |
| required Set<String> contributors, |
| required StoreFields? store, |
| }) : _contributors = contributors, |
| _issues = store?.issues ?? <int, IssueStats>{}, |
| _pendingCleanupIssues = store?.pendingCleanupIssues ?? <int, DateTime>{}, |
| _selfTestIssue = store?.selfTestIssue, |
| _selfTestClosedDate = store?.selfTestClosedDate, |
| _currentBackgroundIssue = store?.currentBackgroundIssue ?? 1, |
| _highestKnownIssue = store?.highestKnownIssue ?? 1, |
| _lastRefeedByTime = store?.lastRefeedByTime ?? <String, DateTime>{}, |
| _lastCleanupStart = store?.lastCleanupStart, |
| _lastCleanupEnd = store?.lastCleanupEnd, |
| _lastTidyStart = store?.lastTidyStart, |
| _lastTidyEnd = store?.lastTidyEnd { |
| _startup = DateTime.timestamp(); |
| scheduleMicrotask(_updateStoreInBackground); |
| _nextCleanup = (_lastCleanupEnd ?? _startup).add(Timings.cleanupUpdatePeriod); |
| _cleanupTimer = Timer(_nextCleanup.difference(_startup), _performCleanups); |
| _nextTidy = (_lastTidyEnd ?? _startup).add(Timings.longTermTidyingPeriod); |
| _tidyTimer = Timer(_nextTidy.difference(_startup), _performLongTermTidying); |
| log('Startup'); |
| } |
| |
| static Future<Engine> initialize({ |
| required List<int> webhookSecret, |
| required INyxx discord, |
| required GitHub github, |
| void Function()? onChange, |
| required Secrets secrets, |
| }) async { |
| return Engine._( |
| webhookSecret: webhookSecret, |
| discord: discord, |
| github: github, |
| secrets: secrets, |
| contributors: await _loadContributors(github), |
| store: await _read(secrets), |
| ); |
| } |
| |
| final List<int> webhookSecret; |
| final INyxx discord; |
| final GitHub github; |
| final Secrets secrets; |
| |
| // data this is stored on local disk |
| final Set<String> _contributors; |
| final Map<int, IssueStats> _issues; |
| final Map<int, DateTime> _pendingCleanupIssues; |
| int? _selfTestIssue; |
| DateTime? _selfTestClosedDate; |
| int _currentBackgroundIssue; |
| int _highestKnownIssue; |
| final Map<String, DateTime> _lastRefeedByTime; // last time we forced an otherwise normal issue to get retriaged by each team |
| |
| final Set<String> _recentIds = <String>{}; // used to detect duplicate messages and discard them |
| final List<String> _log = <String>[]; |
| late final DateTime _startup; |
| DateTime? _lastCleanupStart; |
| DateTime? _lastCleanupEnd; |
| late DateTime _nextCleanup; |
| Timer? _cleanupTimer; |
| DateTime? _lastTidyStart; |
| DateTime? _lastTidyEnd; |
| late DateTime _nextTidy; |
| Timer? _tidyTimer; |
| |
| void log(String message) { |
| stderr.writeln(message); |
| _log.add('${DateTime.timestamp().toIso8601String()} $message'); |
| while (_log.length > maxLogLength) { |
| _log.removeAt(0); |
| } |
| } |
| |
| int _actives = 0; |
| bool _shuttingDown = false; |
| Completer<void> _pendingIdle = Completer<void>(); |
| Future<void> shutdown(Future<void> Function() shutdownCallback) async { |
| assert(!_shuttingDown, 'shutdown called reentrantly'); |
| _shuttingDown = true; |
| while (_actives > 0) { |
| await _pendingIdle.future; |
| } |
| return shutdownCallback(); |
| } |
| |
| static Future<Set<String>> _loadContributors(GitHub github) async { |
| final int teamId = (await github.organizations.getTeamByName(GitHubSettings.organization, GitHubSettings.teamName)).id!; |
| return github.organizations.listTeamMembers(teamId).map((TeamMember member) => member.login!).toSet(); |
| } |
| |
| static Future<StoreFields?> _read(Secrets secrets) async { |
| if (await secrets.store.exists()) { |
| try { |
| final FileReader reader = FileReader((await secrets.store.readAsBytes()).buffer.asByteData()); |
| return ( |
| issues: reader.readMap<int, IssueStats>(reader.readInt, reader.readerForCustom<IssueStats>(IssueStats.read)), |
| pendingCleanupIssues: reader.readMap<int, DateTime>(reader.readInt, reader.readDateTime), |
| selfTestIssue: reader.readNullOr<int>(reader.readInt), |
| selfTestClosedDate: reader.readNullOr<DateTime>(reader.readDateTime), |
| currentBackgroundIssue: reader.readInt(), |
| highestKnownIssue: reader.readInt(), |
| lastRefeedByTime: reader.readMap<String, DateTime>(reader.readString, reader.readDateTime), |
| lastCleanupStart: reader.readNullOr<DateTime>(reader.readDateTime), |
| lastCleanupEnd: reader.readNullOr<DateTime>(reader.readDateTime), |
| lastTidyStart: reader.readNullOr<DateTime>(reader.readDateTime), |
| lastTidyEnd: reader.readNullOr<DateTime>(reader.readDateTime), |
| ); |
| } catch (e) { |
| print('Error loading issue store, consider deleting ${secrets.store.path} file.'); |
| rethrow; |
| } |
| } |
| return null; |
| } |
| |
| bool _writing = false; |
| bool _dirty = false; |
| Future<void> _write() async { |
| if (_writing) { |
| _dirty = true; |
| return; |
| } |
| try { |
| _writing = true; |
| final FileWriter writer = FileWriter(); |
| writer.writeMap<int, IssueStats>(writer.writeInt, writer.writerForCustom<IssueStats>(IssueStats.write), _issues); |
| writer.writeMap<int, DateTime>(writer.writeInt, writer.writeDateTime, _pendingCleanupIssues); |
| writer.writeNullOr<int>(_selfTestIssue, writer.writeInt); |
| writer.writeNullOr<DateTime>(_selfTestClosedDate, writer.writeDateTime); |
| writer.writeInt(_currentBackgroundIssue); |
| writer.writeInt(_highestKnownIssue); |
| writer.writeMap<String, DateTime>(writer.writeString, writer.writeDateTime, _lastRefeedByTime); |
| writer.writeNullOr<DateTime>(_lastCleanupStart, writer.writeDateTime); |
| writer.writeNullOr<DateTime>(_lastCleanupEnd, writer.writeDateTime); |
| writer.writeNullOr<DateTime>(_lastTidyStart, writer.writeDateTime); |
| writer.writeNullOr<DateTime>(_lastTidyEnd, writer.writeDateTime); |
| await writer.write(secrets.store); |
| } finally { |
| _writing = false; |
| } |
| if (_dirty) { |
| _dirty = false; |
| return _write(); |
| } |
| } |
| |
| // the maxFraction argument represents the fraction of the total rate limit that is allowed to be |
| // used before waiting. |
| // |
| // the background update code sets it to 0.5 so that there is still a buffer for the other calls, |
| // otherwise the background update code could just use it all up and then stall everything else. |
| Future<void> _githubReady([double maxFraction = 0.95]) async { |
| if (github.rateLimitRemaining != null && github.rateLimitRemaining! < (github.rateLimitLimit! * (1.0 - maxFraction)).round()) { |
| assert(github.rateLimitReset != null); |
| await _until(github.rateLimitReset!); |
| } |
| } |
| |
| static Future<void> _until(DateTime target) { |
| final DateTime now = DateTime.timestamp(); |
| if (!now.isBefore(target)) { |
| return Future<void>.value(); |
| } |
| final Duration delta = target.difference(now); |
| return Future<void>.delayed(delta); |
| } |
| |
| Future<void> handleRequest(HttpRequest request) async { |
| _actives += 1; |
| try { |
| try { |
| if (await _handleDebugRequests(request)) { |
| return; |
| } |
| final List<int> bytes = await request.expand((Uint8List sublist) => sublist).toList(); |
| final String expectedSignature = 'sha256=${Hmac(sha256, webhookSecret).convert(bytes).bytes.map(hex).join()}'; |
| final List<String> actualSignatures = request.headers['X-Hub-Signature-256'] ?? const <String>[]; |
| final List<String> eventKind = request.headers['X-GitHub-Event'] ?? const <String>[]; |
| final List<String> eventId = request.headers['X-GitHub-Delivery'] ?? const <String>[]; |
| if (actualSignatures.length != 1 || expectedSignature != actualSignatures.single || |
| eventKind.length != 1 || eventId.length != 1) { |
| request.response.writeln('Invalid metadata.'); |
| return; |
| } |
| if (_recentIds.contains(eventId.single)) { |
| request.response.writeln('I got that one already.'); |
| return; |
| } |
| _recentIds.add(eventId.single); |
| while (_recentIds.length > 50) { |
| _recentIds.remove(_recentIds.first); |
| } |
| final dynamic payload = Json.parse(utf8.decode(bytes)); |
| await _updateModelFromWebhook(eventKind.single, payload); |
| await _updateDiscordFromWebhook(eventKind.single, payload); |
| request.response.writeln('Acknowledged.'); |
| } catch (e, s) { |
| log('Failed to handle ${request.uri}: $e (${e.runtimeType})\n$s'); |
| } finally { |
| await request.response.close(); |
| } |
| } finally { |
| _actives -= 1; |
| if (_shuttingDown && _actives == 0) { |
| _pendingIdle.complete(); |
| _pendingIdle = Completer<void>(); |
| } |
| } |
| } |
| |
| Future<bool> _handleDebugRequests(HttpRequest request) async { |
| if (request.uri.path == '/debug') { |
| final DateTime now = DateTime.timestamp(); |
| request.response.writeln('FLUTTER TRIAGE BOT'); |
| request.response.writeln('=================='); |
| request.response.writeln(); |
| request.response.writeln('Current time: $now'); |
| request.response.writeln('Uptime: ${now.difference(_startup)} (startup at $_startup).'); |
| request.response.writeln('Cleaning: ${_cleaning ? "active" : "pending"} (${_pendingCleanupIssues.length} issue${s(_pendingCleanupIssues.length)}); last started $_lastCleanupStart, last ended $_lastCleanupEnd, next in ${_nextCleanup.difference(now)}.'); |
| request.response.writeln('Tidying: ${_tidying ? "active" : "pending"}; last started $_lastTidyStart, last ended $_lastTidyEnd, next in ${_nextTidy.difference(now)}.'); |
| request.response.writeln('Background scan: currently fetching issue #$_currentBackgroundIssue, highest known issue #$_highestKnownIssue.'); |
| request.response.writeln('${_contributors.length} known contributor${s(_contributors.length)}.'); |
| request.response.writeln('GitHub Rate limit status: ${github.rateLimitRemaining}/${github.rateLimitLimit} (reset at ${github.rateLimitReset})'); |
| if (_selfTestIssue != null) { |
| request.response.writeln('Current self test issue: #$_selfTestIssue'); |
| } |
| if (_selfTestClosedDate != null) { |
| request.response.writeln('Self test last closed on: $_selfTestClosedDate (${now.difference(_selfTestClosedDate!)} ago, next in ${_selfTestClosedDate!.add(Timings.selfTestPeriod).difference(now)})'); |
| } |
| request.response.writeln(); |
| request.response.writeln('Last refeeds (refeed delay: ${Timings.refeedDelay}):'); |
| for (final String team in _lastRefeedByTime.keys.toList()..sort((String a, String b) => _lastRefeedByTime[a]!.compareTo(_lastRefeedByTime[b]!))) { |
| final Duration delta = now.difference(_lastRefeedByTime[team]!); |
| final String annotation = delta > Timings.refeedDelay |
| ? '' |
| : '; blocking immediate refeeds'; |
| request.response.writeln('${team.padRight(30, '.')}.${_lastRefeedByTime[team]} ($delta ago$annotation)'); |
| } |
| request.response.writeln(); |
| request.response.writeln('Tracking ${_issues.length} issue${s(_issues.length)}:'); |
| for (final int number in _issues.keys.toList()..sort()) { |
| String cleanup = ''; |
| if (_pendingCleanupIssues.containsKey(number)) { |
| final Duration delta = Timings.cleanupUpdateDelay - now.difference(_pendingCleanupIssues[number]!); |
| if (delta < Duration.zero) { |
| cleanup = ' [cleanup pending]'; |
| } else if (delta.inMinutes <= 1) { |
| cleanup = ' [cleanup soon]'; |
| } else { |
| cleanup = ' [cleanup in ${delta.inMinutes} minute${s(delta.inMinutes)}]'; |
| } |
| } |
| request.response.writeln(' #${number.toString().padLeft(6, "0")}: ${_issues[number]}$cleanup'); |
| } |
| request.response.writeln(); |
| request.response.writeln('LOG'); |
| _log.forEach(request.response.writeln); |
| return true; |
| } |
| if (request.uri.path == '/force-update') { |
| final int number = int.parse(request.uri.query); // if input is not an integer, this'll throw |
| await _updateStoreInBackgroundForIssue(number); |
| request.response.writeln('${_issues[number]}'); |
| return true; |
| } |
| if (request.uri.path == '/force-cleanup') { |
| log('User-triggered forced cleanup'); |
| await _performCleanups(); |
| _log.forEach(request.response.writeln); |
| return true; |
| } |
| if (request.uri.path == '/force-tidy') { |
| log('User-triggered forced tidy'); |
| await _performLongTermTidying(); |
| _log.forEach(request.response.writeln); |
| return true; |
| } |
| return false; |
| } |
| |
| // Called when we get a webhook message. |
| Future<void> _updateModelFromWebhook(String event, dynamic payload) async { |
| final DateTime now = DateTime.timestamp(); |
| switch (event) { |
| case 'issue_comment': |
| if (!payload.issue.hasKey('pull_request') && payload.repository.full_name.toString() == GitHubSettings.primaryRepository.fullName) { |
| _updateIssueFromWebhook(payload.sender.login.toString(), payload.issue, now); |
| } |
| case 'issues': |
| if (payload.repository.full_name.toString() != GitHubSettings.primaryRepository.fullName) { |
| return; |
| } |
| if (payload.action.toString() == 'closed') { |
| final int number = payload.issue.number.toInt(); |
| _issues.remove(number); |
| _pendingCleanupIssues.remove(number); |
| if (number == _selfTestIssue) { |
| _selfTestIssue = null; |
| _selfTestClosedDate = now; |
| } |
| } else { |
| final IssueStats? issue = _updateIssueFromWebhook(payload.sender.login.toString(), payload.issue, now); |
| if (issue != null) { |
| if (payload.action.toString() == 'assigned') { |
| // if we are adding a second assignee, _updateIssueFromWebhook won't update the assignedAt timestamp |
| _issues[payload.issue.number.toInt()]!.assignedAt = now; |
| } else if (payload.action.toString() == 'opened' || payload.action.toString() == 'reopened') { |
| _issues[payload.issue.number.toInt()]!.openedAt = now; |
| } else if (payload.action.toString() == 'labeled') { |
| final String label = payload.label.name.toString(); |
| final String? team = getTeamFor(GitHubSettings.triagedPrefix, label); |
| if (team != null) { |
| final Set<String> teams = getTeamsFor(GitHubSettings.teamPrefix, issue.labels); |
| if (teams.length == 1) { |
| if (teams.single == team) { |
| issue.triagedAt = now; |
| } |
| } |
| } |
| } |
| } |
| } |
| case 'membership': |
| if (payload.team.slug.toString() == '${GitHubSettings.organization}/${GitHubSettings.teamName}') { |
| switch (payload.action.toString()) { |
| case 'added': |
| _contributors.add(payload.member.login.toString()); |
| case 'removed': |
| _contributors.remove(payload.member.login.toString()); |
| } |
| } |
| } |
| await _write(); |
| } |
| |
| // Called when we get a webhook message that we've established is an |
| // interesting update to an issue. |
| // Attempts to build up and/or update the data for an issue based on |
| // the data in a change event. This will be approximate until we can actually |
| // scan the issue properly in _updateStoreInBackground. |
| IssueStats? _updateIssueFromWebhook(String user, dynamic data, DateTime now) { |
| final int number = data.number.toInt(); |
| if (number > _highestKnownIssue) { |
| _highestKnownIssue = number; |
| } |
| if (data.state.toString() == 'closed') { |
| _issues.remove(number); |
| _pendingCleanupIssues.remove(number); |
| if (number == _selfTestIssue) { |
| _selfTestIssue = null; |
| _selfTestClosedDate = now; |
| } |
| return null; |
| } |
| final IssueStats issue = _issues.putIfAbsent(number, IssueStats.new); |
| final Set<String> newLabels = <String>{}; |
| for (final dynamic label in data.labels.asIterable()) { |
| final String name = label.name.toString(); |
| if (GitHubSettings.isRelevantLabel(name)) { |
| newLabels.add(name); |
| } |
| } |
| issue.labels = newLabels; |
| final Set<String> assignees = <String>{}; |
| for (final dynamic assignee in data.assignees.asIterable()) { |
| assignees.add(assignee.login.toString()); |
| } |
| final String reporter = data.user.login.toString(); |
| if (assignees.isEmpty) { |
| issue.lastAssigneeTouch = null; |
| issue.assignedAt = null; |
| issue.assignedToTeamMemberReporter = false; |
| } else { |
| issue.assignedAt ??= now; |
| if (assignees.contains(user)) { |
| issue.lastAssigneeTouch = now; |
| } |
| issue.assignedToTeamMemberReporter = assignees.contains(reporter) && _contributors.contains(reporter); |
| } |
| if (_contributors.contains(user)) { |
| issue.lastContributorTouch = now; |
| } |
| if (!data.locked.toBoolean()) { |
| issue.lockedAt = null; |
| } else { |
| issue.lockedAt ??= now; |
| } |
| final Set<String> teams = getTeamsFor(GitHubSettings.triagedPrefix, newLabels); |
| if (teams.isEmpty) { |
| issue.thumbsAtTriageTime = null; |
| issue.triagedAt = null; |
| } |
| _pendingCleanupIssues[number] = now; |
| return issue; |
| } |
| |
| Future<void> _updateDiscordFromWebhook(String event, dynamic payload) async { |
| if (GitHubSettings.knownBots.contains(payload.sender.login.toString())) { |
| return; |
| } |
| switch (event) { |
| case 'star': |
| switch (payload.action.toString()) { |
| case 'created': |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** starred ${payload.repository.full_name}', |
| channel: DiscordChannels.github2, |
| emoji: UnicodeEmoji('🌟'), |
| log: log, |
| ); |
| } |
| case 'label': |
| switch (payload.action.toString()) { |
| case 'created': |
| String message; |
| if (payload.label.description.toString().isEmpty) { |
| message = '**@${payload.sender.login}** created a new label in ${payload.repository.full_name}, `${payload.label.name}`, but did not give it a description!'; |
| } else { |
| message = '**@${payload.sender.login}** created a new label in ${payload.repository.full_name}, `${payload.label.name}`, with the description "${payload.label.description}".'; |
| } |
| await sendDiscordMessage( |
| discord: discord, |
| body: message, |
| channel: DiscordChannels.hiddenChat, |
| embedTitle: '${payload.label.name}', |
| embedDescription: '${payload.label.description}', |
| embedColor: '${payload.label.color}', |
| log: log, |
| ); |
| } |
| case 'pull_request': |
| switch (payload.action.toString()) { |
| case 'closed': |
| final bool merged = payload.pull_request.merged_at.toScalar() != null; |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** ${ merged ? "merged" : "closed" } *${payload.pull_request.title}* (${payload.pull_request.html_url})', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| case 'opened': |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** submitted a new pull request: **${payload.pull_request.title}** (${payload.repository.full_name} #${payload.pull_request.number.toInt()})\n${stripBoilerplate(payload.pull_request.body.toString())}', |
| suffix: '*${payload.pull_request.html_url}*', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| } |
| case 'pull_request_review': |
| switch (payload.action.toString()) { |
| case 'submitted': |
| switch (payload.review.state.toString()) { |
| case 'approved': |
| await sendDiscordMessage( |
| discord: discord, |
| body: payload.review.body.toString().isEmpty ? |
| '**@${payload.sender.login}** gave **LGTM** for *${payload.pull_request.title}* (${payload.pull_request.html_url})' : |
| '**@${payload.sender.login}** gave **LGTM** for *${payload.pull_request.title}* (${payload.pull_request.html_url}): ${stripBoilerplate(payload.review.body.toString(), inline: true)}', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| } |
| } |
| case 'pull_request_review_comment': |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** wrote: ${stripBoilerplate(payload.comment.body.toString(), inline: true)}', |
| suffix: '*${payload.comment.html_url} ${payload.pull_request.title}*', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| case 'issue_comment': |
| switch (payload.action.toString()) { |
| case 'created': |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** wrote: ${stripBoilerplate(payload.comment.body.toString(), inline: true)}', |
| suffix: '*${payload.comment.html_url} ${payload.issue.title}*', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| } |
| case 'issues': |
| switch (payload.action.toString()) { |
| case 'closed': |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** closed *${payload.issue.title}* (${payload.issue.html_url})', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| case 'reopened': |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** reopened *${payload.issue.title}* (${payload.issue.html_url})', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| case 'opened': |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** filed a new issue: **${payload.issue.title}** (${payload.repository.full_name} #${payload.issue.number.toInt()})\n${stripBoilerplate(payload.issue.body.toString())}', |
| suffix: '*${payload.issue.html_url}*', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| bool isDesignDoc = false; |
| for (final dynamic label in payload.issue.labels.asIterable()) { |
| final String name = label.name.toString(); |
| if (name == GitHubSettings.designDoc) { |
| isDesignDoc = true; |
| break; |
| } |
| } |
| if (isDesignDoc) { |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** wrote a new design doc: **${payload.issue.title}**\n${stripBoilerplate(payload.issue.body.toString())}', |
| suffix: '*${payload.issue.html_url}*', |
| channel: DiscordChannels.hiddenChat, |
| log: log, |
| ); |
| } |
| case 'locked': |
| case 'unlocked': |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** ${payload.action} ${payload.issue.html_url} - ${payload.issue.title}', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| } |
| case 'membership': |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** ${payload.action} user **@${payload.member.login}** (${payload.team.name})', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| case 'gollum': |
| for (final dynamic page in payload.pages.asIterable()) { |
| // sadly the commit message doesn't get put into the event payload |
| await sendDiscordMessage( |
| discord: discord, |
| body: '**@${payload.sender.login}** ${page.action} the **${page.title}** wiki page', |
| suffix: '*${page.html_url}*', |
| channel: DiscordChannels.github2, |
| log: log, |
| ); |
| } |
| } |
| } |
| |
| // This is called every few seconds to update one issue in our store. |
| // We do this because (a) initially, we don't have any data so we need |
| // to fill our database somehow, and (b) thereafter, we might go out of |
| // sync if we miss an event, e.g. due to network issues. |
| Future<void> _updateStoreInBackground() async { |
| await _updateStoreInBackgroundForIssue(_currentBackgroundIssue); |
| _currentBackgroundIssue -= 1; |
| if (_currentBackgroundIssue <= 0) { |
| _currentBackgroundIssue = _highestKnownIssue; |
| } |
| await _write(); |
| await Future<void>.delayed(Timings.backgroundUpdatePeriod); |
| scheduleMicrotask(_updateStoreInBackground); |
| } |
| |
| Future<void> _updateStoreInBackgroundForIssue(int number) async { |
| try { |
| await _githubReady(0.5); |
| final Issue githubIssue = await github.issues.get(GitHubSettings.primaryRepository, number); |
| if (githubIssue.pullRequest == null && githubIssue.isOpen) { |
| final String? reporter = githubIssue.user?.login; |
| bool open = true; |
| final Set<String> assignees = <String>{}; |
| final Set<String> labels = <String>{}; |
| DateTime? lastContributorTouch; |
| DateTime? lastAssigneeTouch; |
| DateTime? openedAt = githubIssue.createdAt; |
| DateTime? lockedAt; |
| DateTime? assignedAt; |
| DateTime? triagedAt; |
| DateTime? lastChange; |
| await _githubReady(); |
| await for (final TimelineEvent event in github.issues.listTimeline(GitHubSettings.primaryRepository, number)) { |
| String? user; |
| DateTime? time; |
| // event.actor could be null if the original user was deleted (shows as "ghost" in GitHub's web UI) |
| // see e.g. https://github.com/flutter/flutter/issues/93070 |
| switch (event.event) { |
| case 'renamed': // The issue or pull request title was changed. |
| case 'commented': |
| user = event.actor?.login; |
| time = event.createdAt; |
| case 'locked': // The issue or pull request was locked. |
| user = event.actor?.login; |
| time = event.createdAt; |
| lockedAt = time; |
| case 'unlocked': // The issue was unlocked. |
| user = event.actor?.login; |
| time = event.createdAt; |
| lockedAt = null; |
| case 'assigned': |
| event as AssigneeEvent; |
| if (event.assignee != null && event.assignee!.login != null) { |
| user = event.actor?.login; |
| time = event.createdAt; |
| assignees.add(event.assignee!.login!); |
| assignedAt = time; |
| } |
| case 'unassigned': |
| event as AssigneeEvent; |
| user = event.actor?.login; |
| time = event.createdAt; |
| if (event.assignee != null) { |
| assignees.remove(event.assignee!.login); |
| if (assignees.isEmpty) { |
| assignedAt = null; |
| lastAssigneeTouch = null; |
| } |
| } |
| case 'labeled': |
| event as LabelEvent; |
| user = event.actor?.login; |
| time = event.createdAt; |
| final String label = event.label!.name; |
| if (GitHubSettings.isRelevantLabel(label, ignorePriorities: true)) { |
| // we add the priority labels later to avoid confusion from the renames |
| labels.add(label); |
| } |
| final String? triagedTeam = getTeamFor(GitHubSettings.triagedPrefix, label); |
| if (triagedTeam != null) { |
| final Set<String> teams = getTeamsFor(GitHubSettings.teamPrefix, labels); |
| if (teams.length == 1 && teams.single == triagedTeam) { |
| triagedAt = event.createdAt; |
| } |
| } |
| case 'unlabeled': |
| event as LabelEvent; |
| user = event.actor?.login; |
| time = event.createdAt; |
| final String label = event.label!.name; |
| labels.remove(label); |
| final Set<String> teams = getTeamsFor(GitHubSettings.teamPrefix, labels); |
| final Set<String> triagedTeams = getTeamsFor(GitHubSettings.triagedPrefix, labels); |
| if (teams.intersection(triagedTeams).isEmpty) { |
| triagedAt = null; |
| } |
| case 'closed': |
| user = event.actor?.login; |
| time = event.createdAt; |
| open = false; |
| case 'reopened': |
| user = event.actor?.login; |
| time = event.createdAt; |
| openedAt = event.createdAt; |
| open = true; |
| } |
| if (user != null) { |
| assert(time != null); |
| if (_contributors.contains(user)) { |
| lastContributorTouch = time; |
| } |
| if (assignees.contains(user)) { |
| lastAssigneeTouch = time; |
| } |
| } |
| if (lastChange == null || (time != null && time.isAfter(lastChange))) { |
| lastChange = time; |
| } |
| await _githubReady(); |
| } |
| if (open) { |
| // Because we renamed some of the labels, we can't trust the |
| // historical names we get from the timeline. We have to use the |
| // actual current labels from the githubIssue. |
| // Also, there might be missing labels because the timeline doesn't |
| // include the issue's original labels from when the issue was filed. |
| final Set<String> actualLabels = githubIssue.labels |
| .map<String>((IssueLabel label) => label.name) |
| .where(GitHubSettings.isRelevantLabel) |
| .toSet(); |
| for (final String label in actualLabels.difference(labels)) { |
| // could have been renamed, but let's assume it was added when the issue was created (and never removed). |
| final String? triagedTeam = getTeamFor(GitHubSettings.triagedPrefix, label); |
| if (triagedTeam != null) { |
| final Set<String> teams = getTeamsFor(GitHubSettings.teamPrefix, actualLabels); |
| if (teams.length == 1 && teams.single == triagedTeam) { |
| triagedAt = openedAt; |
| } |
| } |
| } |
| final IssueStats issue = _issues.putIfAbsent(number, IssueStats.new); |
| issue.lastContributorTouch = lastContributorTouch; |
| issue.lastAssigneeTouch = lastAssigneeTouch; |
| issue.labels = actualLabels; |
| issue.openedAt = openedAt; |
| issue.lockedAt = lockedAt; |
| assert((assignedAt != null) == (assignees.isNotEmpty)); |
| issue.assignedAt = assignedAt; |
| issue.assignedToTeamMemberReporter = reporter != null && assignees.contains(reporter) && _contributors.contains(reporter); |
| issue.thumbs = githubIssue.reactions?.plusOne ?? 0; |
| issue.triagedAt = triagedAt; |
| if (triagedAt != null) { |
| if (issue.thumbsAtTriageTime == null) { |
| int thumbsAtTriageTime = 0; |
| await _githubReady(); |
| await for (final Reaction reaction in github.issues.listReactions(GitHubSettings.primaryRepository, number)) { |
| if (reaction.createdAt != null && reaction.createdAt!.isAfter(triagedAt)) { |
| break; |
| } |
| if (reaction.content == '+1') { |
| thumbsAtTriageTime += 1; |
| } |
| await _githubReady(); |
| } |
| issue.thumbsAtTriageTime = thumbsAtTriageTime; |
| } |
| } else { |
| issue.thumbsAtTriageTime = null; |
| } |
| } else { |
| _issues.remove(number); |
| _pendingCleanupIssues.remove(number); |
| } |
| if (!_pendingCleanupIssues.containsKey(number)) { |
| _pendingCleanupIssues[number] = lastChange ?? DateTime.timestamp(); |
| } |
| } else { |
| if (_selfTestIssue == number) { |
| _selfTestIssue = null; |
| _selfTestClosedDate = githubIssue.closedAt; |
| } |
| } |
| } on NotFound { |
| _issues.remove(number); |
| _pendingCleanupIssues.remove(number); |
| } catch (e, s) { |
| log('Failed to perform background update of issue #$number: $e (${e.runtimeType})\n$s'); |
| } |
| } |
| |
| bool _cleaning = false; |
| // This is called periodically to look at recently-updated issues. |
| // This lets us enforce invariants but only after humans have had a chance |
| // to do whatever it is they are doing on the issue. |
| Future<void> _performCleanups([Timer? timer]) async { |
| final DateTime now = DateTime.timestamp(); |
| _cleanupTimer?.cancel(); |
| _nextCleanup = now.add(Timings.cleanupUpdatePeriod); |
| _cleanupTimer = Timer(Timings.cleanupUpdatePeriod, _performCleanups); |
| if (_cleaning) { |
| return; |
| } |
| try { |
| _cleaning = true; |
| _lastCleanupStart = now; |
| final DateTime refeedThreshold = now.subtract(Timings.refeedDelay); |
| final DateTime cleanupThreshold = now.subtract(Timings.cleanupUpdateDelay); |
| final DateTime staleThreshold = now.subtract(Timings.timeUntilStale); |
| final List<int> issues = _pendingCleanupIssues.keys.toList(); |
| for (final int number in issues) { |
| try { |
| if (_pendingCleanupIssues.containsKey(number) && _pendingCleanupIssues[number]!.isBefore(cleanupThreshold)) { |
| assert(_issues.containsKey(number)); |
| final IssueStats issue = _issues[number]!; |
| final Set<String> labelsToRemove = <String>{}; |
| final List<String> messages = <String>[]; |
| // PRIORITY LABELS |
| final Set<String> priorities = issue.labels.intersection(GitHubSettings.priorities); |
| if (priorities.length > 1) { |
| // When an issue has multiple priorities, remove all but the highest. |
| for (final String priority in GitHubSettings.priorities.toList().reversed) { |
| if (priorities.contains(priority)) { |
| labelsToRemove.add(priority); |
| priorities.remove(priority); |
| } |
| if (priorities.length == 1) { |
| break; |
| } |
| } |
| } |
| // TEAM LABELS |
| final Set<String> teams = getTeamsFor(GitHubSettings.teamPrefix, issue.labels); |
| final Set<String> triaged = getTeamsFor(GitHubSettings.triagedPrefix, issue.labels); |
| final Set<String> fyi = getTeamsFor(GitHubSettings.fyiPrefix, issue.labels); |
| if (teams.length > 1 && number != _selfTestIssue) { |
| // Issues should only have a single "team-foo" label. |
| // When this is violated, we remove all of them to send the issue back to front-line triage. |
| messages.add( |
| 'Issue is assigned to multiple teams (${teams.join(", ")}). ' |
| 'Please ensure the issue has only one `${GitHubSettings.teamPrefix}*` label at a time. ' |
| 'Use `${GitHubSettings.fyiPrefix}*` labels to have another team look at the issue without reassigning it.' |
| ); |
| for (final String team in teams) { |
| labelsToRemove.add('${GitHubSettings.teamPrefix}$team'); |
| // Also remove the labels we'd end up removing below, to avoid having confusing messages. |
| if (triaged.contains(team)) { |
| labelsToRemove.add('${GitHubSettings.triagedPrefix}$team'); |
| triaged.remove(team); |
| } |
| if (fyi.contains(team)) { |
| labelsToRemove.add('${GitHubSettings.fyiPrefix}$team'); |
| fyi.remove(team); |
| } |
| } |
| teams.clear(); |
| } |
| for (final String team in fyi.toList()) { |
| if (teams.contains(team)) { |
| // Remove redundant fyi-* labels. |
| messages.add('The `${GitHubSettings.fyiPrefix}$team` label is redundant with the `${GitHubSettings.teamPrefix}$team` label.'); |
| labelsToRemove.add('${GitHubSettings.fyiPrefix}$team'); |
| fyi.remove(team); |
| } else if (triaged.contains(team)) { |
| // If an fyi-* label has been acknowledged by a triaged-* label, we can remove them both. |
| labelsToRemove.add('${GitHubSettings.fyiPrefix}$team'); |
| labelsToRemove.add('${GitHubSettings.triagedPrefix}$team'); |
| fyi.remove(team); |
| triaged.remove(team); |
| } |
| } |
| for (final String team in triaged.toList()) { |
| // Remove redundant triaged-* labels. |
| if (!teams.contains(team)) { |
| messages.add( |
| 'The `${GitHubSettings.triagedPrefix}$team` label is irrelevant if ' |
| 'there is no `${GitHubSettings.teamPrefix}$team` label or `${GitHubSettings.fyiPrefix}$team` label.' |
| ); |
| labelsToRemove.add('${GitHubSettings.triagedPrefix}$team'); |
| triaged.remove(team); |
| } |
| } |
| assert(teams.length <= 1 || number == _selfTestIssue); |
| assert(triaged.length <= teams.length); |
| if (triaged.isNotEmpty && priorities.isEmpty && number != _selfTestIssue) { |
| assert(triaged.length == 1); |
| final String team = triaged.single; |
| if (!_lastRefeedByTime.containsKey(team) || _lastRefeedByTime[team]!.isBefore(refeedThreshold)) { |
| messages.add( |
| 'This issue is missing a priority label. ' |
| 'Please set a priority label when adding the `${GitHubSettings.triagedPrefix}$team` label.' |
| ); |
| _lastRefeedByTime[team] = now; |
| labelsToRemove.add('${GitHubSettings.triagedPrefix}$team'); |
| triaged.remove(team); |
| assert(triaged.isEmpty); |
| } |
| } |
| // STALE THUMBS UP LABEL |
| if (triaged.isNotEmpty && issue.labels.contains(GitHubSettings.thumbsUpLabel)) { |
| labelsToRemove.add(GitHubSettings.thumbsUpLabel); |
| } |
| // STALE STALE ISSUE LABEL |
| if (issue.labels.contains(GitHubSettings.staleIssueLabel) && |
| ((issue.lastContributorTouch != null && issue.lastContributorTouch!.isAfter(staleThreshold)) || |
| (issue.assignedAt == null))) { |
| labelsToRemove.add(GitHubSettings.staleIssueLabel); |
| } |
| // LOCKED STATUS |
| final bool shouldUnlock = issue.openedAt != null && issue.lockedAt != null && issue.lockedAt!.isBefore(issue.openedAt!); |
| // APPLY PENDING CHANGES |
| if ((labelsToRemove.isNotEmpty || messages.isNotEmpty || shouldUnlock) && await isActuallyOpen(number)) { |
| for (final String label in labelsToRemove) { |
| log('Removing label "$label" on issue #$number'); |
| await _githubReady(); |
| await github.issues.removeLabelForIssue(GitHubSettings.primaryRepository, number, label); |
| issue.labels.remove(label); |
| } |
| if (messages.isNotEmpty) { |
| log('Posting message on issue #$number:\n ${messages.join("\n ")}'); |
| await _githubReady(); |
| await github.issues.createComment(GitHubSettings.primaryRepository, number, messages.join('\n')); |
| } |
| if (shouldUnlock) { |
| log('Unlocking issue #$number (reopened after being locked)'); |
| await _githubReady(); |
| await github.issues.unlock(GitHubSettings.primaryRepository, number); |
| } |
| } |
| _pendingCleanupIssues.remove(number); |
| } |
| } catch (e, s) { |
| log('Failure in cleanup for #$number: $e (${e.runtimeType})\n$s'); |
| } |
| } |
| } finally { |
| _cleaning = false; |
| _lastCleanupEnd = DateTime.timestamp(); |
| } |
| } |
| |
| bool _tidying = false; |
| // This is called periodically to enforce long-term policies (things that |
| // only apply after an issue has been in a particular state for weeks). |
| Future<void> _performLongTermTidying([Timer? timer]) async { |
| _tidyTimer?.cancel(); |
| final DateTime now = DateTime.timestamp(); |
| _nextTidy = now.add(Timings.longTermTidyingPeriod); |
| _tidyTimer = Timer(Timings.longTermTidyingPeriod, _performLongTermTidying); |
| if (_tidying) { |
| return; |
| } |
| try { |
| _tidying = true; |
| _lastTidyStart = now; |
| final DateTime staleThreshold = now.subtract(Timings.timeUntilStale); |
| final DateTime reallyStaleThreshold = now.subtract(Timings.timeUntilReallyStale); |
| final DateTime unlockThreshold = now.subtract(Timings.timeUntilUnlock); |
| final DateTime refeedThreshold = now.subtract(Timings.refeedDelay); |
| int number = 1; |
| while (number < _highestKnownIssue) { |
| try { |
| if (_issues.containsKey(number) && !_pendingCleanupIssues.containsKey(number) && number != _selfTestIssue) { |
| // Tidy the issue. |
| final IssueStats issue = _issues[number]!; |
| final Set<String> triagedTeams = getTeamsFor(GitHubSettings.triagedPrefix, issue.labels); |
| final Set<String> assignedTeams = getTeamsFor(GitHubSettings.teamPrefix, issue.labels); |
| // Check for assigned issues that aren't making progress. |
| if (issue.assignedAt != null && |
| issue.lastContributorTouch != null && |
| issue.lastContributorTouch!.isBefore(staleThreshold) && |
| (!issue.assignedToTeamMemberReporter || issue.labels.contains(GitHubSettings.designDoc))) { |
| await _githubReady(); |
| final Issue actualIssue = await github.issues.get(GitHubSettings.primaryRepository, number); |
| if (actualIssue.assignees != null && actualIssue.assignees!.isNotEmpty && isActuallyOpenFromRawIssue(actualIssue)) { |
| final String assignee = actualIssue.assignees!.map((User user) => '@${user.login}').join(' and '); |
| if (!issue.labels.contains(GitHubSettings.staleIssueLabel)) { |
| log('Issue #$number is assigned to $assignee but not making progress; adding comment.'); |
| await _githubReady(); |
| await github.issues.addLabelsToIssue(GitHubSettings.primaryRepository, number, <String>[GitHubSettings.staleIssueLabel]); |
| issue.labels.add(GitHubSettings.staleIssueLabel); |
| await _githubReady(); |
| await github.issues.createComment(GitHubSettings.primaryRepository, number, |
| 'This issue is assigned to $assignee but has had no recent status updates. ' |
| 'Please consider unassigning this issue if it is not going to be addressed in the near future. ' |
| 'This allows people to have a clearer picture of what work is actually planned. Thanks!', |
| ); |
| } else if (issue.lastContributorTouch!.isBefore(reallyStaleThreshold)) { |
| bool skip = false; |
| String team = 'primary triage'; |
| if (assignedTeams.length == 1) { // if it's more, then cleanup will take care of it |
| team = assignedTeams.single; |
| if (!_lastRefeedByTime.containsKey(team) || _lastRefeedByTime[team]!.isBefore(refeedThreshold)) { |
| _lastRefeedByTime[team] = now; |
| } else { |
| skip = true; |
| } |
| } |
| if (!skip) { |
| log('Issue #$number is assigned to $assignee but still not making progress (for ${now.difference(issue.lastContributorTouch!)}); sending back to triage (for $team team).'); |
| for (final String triagedTeam in triagedTeams) { |
| await _githubReady(); |
| await github.issues.removeLabelForIssue(GitHubSettings.primaryRepository, number, '${GitHubSettings.triagedPrefix}$triagedTeam'); |
| issue.labels.remove('${GitHubSettings.triagedPrefix}$triagedTeam'); |
| } |
| await _githubReady(); |
| await github.issues.edit(GitHubSettings.primaryRepository, number, IssueRequest(assignees: const <String>[])); |
| await _githubReady(); |
| await github.issues.createComment(GitHubSettings.primaryRepository, number, |
| 'This issue was assigned to $assignee but has had no status updates in a long time. ' |
| 'To remove any ambiguity about whether the issue is being worked on, the assignee was removed.', |
| ); |
| await _githubReady(); |
| await github.issues.removeLabelForIssue(GitHubSettings.primaryRepository, number, GitHubSettings.staleIssueLabel); |
| issue.labels.remove(GitHubSettings.staleIssueLabel); |
| } |
| } |
| } |
| } |
| // Check for P1 issues that aren't making progress. |
| // We currently rate-limit this to only a few per week so that teams don't get overwhelmed. |
| if (issue.assignedAt == null && |
| issue.labels.contains('P1') && |
| issue.lastContributorTouch != null && |
| issue.lastContributorTouch!.isBefore(staleThreshold) && |
| triagedTeams.length == 1) { |
| final String team = triagedTeams.single; |
| if ((!_lastRefeedByTime.containsKey(team) || _lastRefeedByTime[team]!.isBefore(refeedThreshold)) && await isActuallyOpen(number)) { |
| log('Issue #$number is P1 but not assigned and not making progress; removing triage label and adding comment.'); |
| await _githubReady(); |
| await github.issues.removeLabelForIssue(GitHubSettings.primaryRepository, number, '${GitHubSettings.triagedPrefix}${triagedTeams.single}'); |
| issue.labels.remove('${GitHubSettings.triagedPrefix}${triagedTeams.single}'); |
| await _githubReady(); |
| await github.issues.createComment(GitHubSettings.primaryRepository, number, GitHubSettings.staleP1Message); |
| _lastRefeedByTime[team] = now; |
| } |
| } |
| // Unlock issues after a timeout. |
| if (issue.lockedAt != null && issue.lockedAt!.isBefore(unlockThreshold) && |
| !issue.labels.contains(GitHubSettings.permanentlyLocked) && |
| await isActuallyOpen(number)) { |
| log('Issue #$number has been locked for too long, unlocking.'); |
| await _githubReady(); |
| await github.issues.unlock(GitHubSettings.primaryRepository, number); |
| } |
| // Flag issues that have gained a lot of thumbs-up. |
| // We don't consider refeedThreshold for this because it should be relatively rare and |
| // is always noteworthy when it happens. |
| if (issue.thumbsAtTriageTime != null && triagedTeams.length == 1 && |
| issue.thumbs >= issue.thumbsAtTriageTime! * GitHubSettings.thumbsThreshold && |
| issue.thumbs >= GitHubSettings.thumbsMinimum && |
| await isActuallyOpen(number)) { |
| log('Issue #$number has gained a lot of thumbs-up, flagging for retriage.'); |
| await _githubReady(); |
| await github.issues.removeLabelForIssue(GitHubSettings.primaryRepository, number, '${GitHubSettings.triagedPrefix}${triagedTeams.single}'); |
| issue.labels.remove('${GitHubSettings.triagedPrefix}${triagedTeams.single}'); |
| await _githubReady(); |
| await github.issues.addLabelsToIssue(GitHubSettings.primaryRepository, number, <String>[GitHubSettings.thumbsUpLabel]); |
| issue.labels.add(GitHubSettings.thumbsUpLabel); |
| } |
| } |
| } catch (e, s) { |
| log('Failure in tidying for #$number: $e (${e.runtimeType})\n$s'); |
| } |
| number += 1; |
| } |
| try { |
| if (_selfTestIssue == null) { |
| if (_selfTestClosedDate == null || _selfTestClosedDate!.isBefore(now.subtract(Timings.selfTestPeriod))) { |
| await _githubReady(); |
| final Issue issue = await github.issues.create(GitHubSettings.primaryRepository, IssueRequest( |
| title: 'Triage process self-test', |
| body: 'This is a test of our triage processes.\n' |
| '\n' |
| 'Please handle this issue the same way you would a normal valid but low-priority issue.\n' |
| '\n' |
| 'For more details see https://github.com/flutter/flutter/wiki/Triage', |
| labels: <String>[ |
| ...GitHubSettings.teams.map((String team) => '${GitHubSettings.teamPrefix}$team'), |
| 'P2', |
| ], |
| )); |
| _selfTestIssue = issue.number; |
| _selfTestClosedDate = null; |
| log('Filed self-test issue #$_selfTestIssue.'); |
| } |
| } else if (_issues.containsKey(_selfTestIssue)) { |
| final IssueStats issue = _issues[_selfTestIssue]!; |
| if (!issue.labels.contains(GitHubSettings.willNeedAdditionalTriage) && |
| issue.lastContributorTouch!.isBefore(now.subtract(Timings.selfTestWindow)) && |
| await isActuallyOpen(_selfTestIssue!)) { |
| log('Flagging self-test issue #$_selfTestIssue for critical triage.'); |
| for (final String team in getTeamsFor(GitHubSettings.triagedPrefix, issue.labels)) { |
| await _githubReady(); |
| await github.issues.removeLabelForIssue(GitHubSettings.primaryRepository, _selfTestIssue!, '${GitHubSettings.teamPrefix}$team'); |
| issue.labels.remove('${GitHubSettings.teamPrefix}$team'); |
| await _githubReady(); |
| await github.issues.removeLabelForIssue(GitHubSettings.primaryRepository, _selfTestIssue!, '${GitHubSettings.triagedPrefix}$team'); |
| issue.labels.remove('${GitHubSettings.triagedPrefix}$team'); |
| } |
| await _githubReady(); |
| await github.issues.addLabelsToIssue(GitHubSettings.primaryRepository, _selfTestIssue!, <String>[GitHubSettings.willNeedAdditionalTriage]); |
| issue.labels.add(GitHubSettings.willNeedAdditionalTriage); |
| } |
| } |
| } catch (e, s) { |
| log('Failure in self-test logic: $e (${e.runtimeType})\n$s'); |
| } |
| } finally { |
| _tidying = false; |
| _lastTidyEnd = DateTime.timestamp(); |
| } |
| } |
| |
| Future<bool> isActuallyOpen(int number) async { |
| if (!_issues.containsKey(number)) { |
| return false; |
| } |
| await _githubReady(); |
| final Issue rawIssue = await github.issues.get(GitHubSettings.primaryRepository, number); |
| return isActuallyOpenFromRawIssue(rawIssue); |
| } |
| |
| bool isActuallyOpenFromRawIssue(Issue rawIssue) { |
| if (rawIssue.isClosed) { |
| log('Issue #${rawIssue.number} was unexpectedly found to be closed when doing cleanup.'); |
| _issues.remove(rawIssue.number); |
| _pendingCleanupIssues.remove(rawIssue.number); |
| if (rawIssue.number == _selfTestIssue) { |
| _selfTestIssue = null; |
| } |
| return false; |
| } |
| return true; |
| } |
| |
| static String? getTeamFor(String prefix, String label) { |
| if (label.startsWith(prefix)) { |
| return label.substring(prefix.length); |
| } |
| return null; |
| } |
| |
| static Set<String> getTeamsFor(String prefix, Set<String> labels) { |
| if (labels.isEmpty) { |
| return const <String>{}; |
| } |
| Set<String>? result; |
| for (final String label in labels) { |
| final String? team = getTeamFor(prefix, label); |
| if (team != null) { |
| result ??= <String>{}; |
| result.add(team); |
| } |
| } |
| return result ?? const <String>{}; |
| } |
| } |
| |
| int secondsSinceEpoch(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; |
| |
| Future<String> obtainGitHubCredentials(Secrets secrets, http.Client client) async { |
| // https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app |
| final DateTime now = DateTime.timestamp(); |
| final String jwt = JWT(<String, dynamic>{ |
| 'iat': secondsSinceEpoch(now.subtract(const Duration(seconds: 60))), |
| 'exp': secondsSinceEpoch(now.add(const Duration(minutes: 10))), |
| 'iss': await secrets.githubAppId, |
| }).sign( |
| RSAPrivateKey(await secrets.githubAppKey), |
| algorithm: JWTAlgorithm.RS256, |
| noIssueAt: true, |
| ); |
| final String installation = await secrets.githubInstallationId; |
| final dynamic response = Json.parse((await client.post( |
| Uri.parse('https://api.github.com/app/installations/$installation/access_tokens'), |
| body: '{}', |
| headers: <String, String>{ |
| 'Accept': 'application/vnd.github+json', |
| 'Authorization': 'Bearer $jwt', // should not need escaping, base64 is safe in a header value |
| 'X-GitHub-Api-Version': '2022-11-28', |
| }, |
| )).body); |
| return response.token.toString(); |
| } |
| |
| Future<void> maintainGitHubCredentials(GitHub github, Secrets secrets, Engine engine, http.Client client) async { |
| try { |
| await Future<void>.delayed(Timings.credentialsUpdatePeriod); |
| github.auth = Authentication.withToken(await obtainGitHubCredentials(secrets, client)); |
| } catch (e, s) { |
| engine.log('Failed to maintain GitHub credentials: $e (${e.runtimeType})\n$s'); |
| } |
| } |
| |
| DateTime _laterOf(DateTime a, DateTime b) { |
| if (a.isAfter(b)) { |
| return a; |
| } |
| return b; |
| } |
| |
| Future<DateTime> getCertificateTimestamp(Secrets secrets) async { |
| return _laterOf( |
| _laterOf( |
| await secrets.serverCertificateModificationDate, |
| await secrets.serverIntermediateCertificatesModificationDate, |
| ), |
| await secrets.serverKeyModificationDate, |
| ); |
| } |
| |
| Future<SecurityContext> loadCertificates(Secrets secrets) async { |
| return SecurityContext() |
| ..useCertificateChainBytes( |
| await secrets.serverCertificate + |
| await secrets.serverIntermediateCertificates, |
| ) |
| ..usePrivateKeyBytes(await secrets.serverKey); |
| } |
| |
| final bool usingAppEngine = Platform.environment.containsKey('APPENGINE_RUNTIME'); |
| |
| Future<Engine> startEngine(void Function()? onChange) async { |
| final Secrets secrets = Secrets(); |
| |
| final INyxx discord = NyxxFactory.createNyxxRest( |
| await secrets.discordToken, |
| GatewayIntents.none, |
| Snowflake.value(await secrets.discordAppId), |
| ); |
| await discord.connect(); |
| |
| final http.Client httpClient = http.Client(); |
| |
| final GitHub github = GitHub( |
| client: httpClient, |
| auth: Authentication.withToken(await obtainGitHubCredentials(secrets, httpClient)), |
| ); |
| |
| final Engine engine = await Engine.initialize( |
| webhookSecret: await secrets.githubWebhookSecret, |
| discord: discord, |
| github: github, |
| onChange: onChange, |
| secrets: secrets, |
| ); |
| |
| if (usingAppEngine) { |
| await withAppEngineServices(() async { |
| runAppEngine(engine.handleRequest); // ignore: unawaited_futures |
| while (true) { |
| await maintainGitHubCredentials(github, secrets, engine, httpClient); |
| } |
| }); |
| } else { |
| scheduleMicrotask(() async { |
| DateTime activeCertificateTimestamp = await getCertificateTimestamp(secrets); |
| SecurityContext securityContext = await loadCertificates(secrets); |
| while (true) { |
| final HttpServer server = await HttpServer.bindSecure(InternetAddress.anyIPv4, port, securityContext); |
| server.listen(engine.handleRequest); |
| DateTime pendingCertificateTimestamp = activeCertificateTimestamp; |
| do { |
| await maintainGitHubCredentials(github, secrets, engine, httpClient); |
| pendingCertificateTimestamp = await getCertificateTimestamp(secrets); |
| } while (pendingCertificateTimestamp == activeCertificateTimestamp); |
| activeCertificateTimestamp = pendingCertificateTimestamp; |
| engine.log('Updating TLS credentials...'); |
| securityContext = await loadCertificates(secrets); |
| await engine.shutdown(server.close); |
| // There's a race condition here where we might miss messages because the server is down. |
| } |
| }); |
| } |
| |
| return engine; |
| } |