blob: c162b7e536c7daffce0ba05bfa97112c930b0591 [file] [log] [blame]
// 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:collection' show SplayTreeMap;
import 'dart:convert';
import 'dart:html';
import 'package:flutter_web/foundation.dart';
import 'package:intl/intl.dart';
import '../models/github_authentication.dart' as github;
import '../models/repository_status.dart';
Map<Uri, String> _eTagByURL =
<Uri, String>{}; // GitHub change token (eTag) by URL.
Future<void> fetchRepositoryDetails(RepositoryStatus repositoryStatus) async {
final Map<String, dynamic> fetchedDetails =
await _getBody('repos/flutter/${repositoryStatus.name}');
if (fetchedDetails != null) {
repositoryStatus
..watchersCount = fetchedDetails['watchers']
..subscribersCount = fetchedDetails['subscribers_count']
..issuesEnabled = fetchedDetails['has_issues'];
}
}
Future<int> fetchToDoCount(String repositoryName) async {
final Map<String, dynamic> body = await _getBody('search/code',
queryParameters: <String, String>{
'q': 'repo:flutter/${repositoryName} TODO',
'per_page': '1',
'page': '0'
});
return (body == null) ? null : body['total_count'];
}
Future<DateTime> fetchBranchLastCommitDate(
String repositoryName, String branchName) async {
final Map<String, dynamic> body =
await _getBody('repos/flutter/$repositoryName/branches/$branchName');
return (body == null)
? null
: DateTime.tryParse(body['commit']['commit']['committer']['date']);
}
Future<DateTime> lastCommitFromAuthor(
String repositoryName, String author) async {
final List<dynamic> body = await _getBody(
'repos/flutter/$repositoryName/commits',
queryParameters: <String, String>{'author': author, 'per_page': '1'});
return (body == null)
? null
: DateTime.tryParse(body[0]['commit']['committer']['date']);
}
Future<int> fetchIssueCount(String repositoryName) async {
return _searchIssuesTotalCount(repositoryName);
}
Future<int> fetchStaleIssueCount(String repositoryName) async {
final DateTime staleDate = DateTime.now().subtract(
const Duration(days: RepositoryStatus.staleIssueThresholdInDays));
final String stateDateQuery = DateFormat('yyyy-MM-dd').format(staleDate);
return _searchIssuesTotalCount(repositoryName,
additionalQuery: 'updated:<=$stateDateQuery');
}
Future<int> fetchIssuesWithoutLabels(String repositoryName) async {
return _searchIssuesTotalCount(repositoryName, additionalQuery: 'no:label');
}
Future<int> _searchIssuesTotalCount(String repositoryName,
{String additionalQuery = ''}) async {
final Map<String, dynamic> body =
await _getBody('search/issues', queryParameters: <String, String>{
'q': 'repo:flutter/${repositoryName} is:open is:issue $additionalQuery',
'page': '0',
'per_page': '1'
});
return (body == null) ? null : body['total_count'];
}
Future<Map<String, int>> fetchTriageIssues(
String repositoryName, List<String> triageLabels) async {
final Map<String, int> issueCountByLabelAggregator = {};
for (String triageLabel in triageLabels) {
// There is no way to OR labels, fetch each one individually.
int count = await _searchIssuesTotalCount(repositoryName,
additionalQuery: 'label:"$triageLabel"');
if (count > 0) {
issueCountByLabelAggregator[triageLabel] = count;
}
}
// SplayTreeMap doesn't allow sorting by value (count) on insert. Sort at the end once all search page fetches are complete.
return issueCountByLabelAggregator;
}
Future<void> fetchPullRequests(RepositoryStatus repositoryStatus) async {
final Map<String, int> pullRequestCountByTopicAggregator = {};
final Map<String, int> pullRequestCountByLabelAggregator = {};
// Reset counters to be aggregated in _fetchRepositoryPullRequestsByPage.
repositoryStatus.pullRequestCount = 0;
repositoryStatus.stalePullRequestCount = 0;
repositoryStatus.totalAgeOfAllPullRequests = 0;
// Use spaces instead of pluses in GitHub query parameters. Dart encodes spaces as +, but + is encoded as %2B which GitHub cannot parse and will think is malformed.
final Uri pullRequestUrl = Uri.https(
'api.github.com', 'search/issues', <String, String>{
'q': 'repo:flutter/${repositoryStatus.name} is:open is:pr',
'per_page': '100'
});
await _fetchRepositoryPullRequestsByPage(pullRequestUrl, repositoryStatus,
pullRequestCountByLabelAggregator, pullRequestCountByTopicAggregator);
// SplayTreeMap doesn't allow sorting by value (count) on insert. Sort at the end once all search page fetches are complete.
repositoryStatus.pullRequestCountByLabelName =
_sortTopics(pullRequestCountByLabelAggregator);
repositoryStatus.pullRequestCountByTitleTopic =
_sortTopics(pullRequestCountByTopicAggregator);
}
Future<void> _fetchRepositoryPullRequestsByPage(
Uri url,
RepositoryStatus repositoryStatus,
Map<String, int> pullRequestCountByLabelAggregator,
Map<String, int> pullRequestCountByTopicAggregator) async {
final HttpRequest response = await _getResponse(url);
if (response == null) {
return;
}
final String body = response.response;
if (body == null || body.isEmpty) {
return;
}
final Map<String, dynamic> fetchedDetails = jsonDecode(body);
final List<dynamic> issues = fetchedDetails['items'];
for (Map<String, dynamic> issue in issues) {
_processPullRequest(
issue, repositoryStatus, pullRequestCountByTopicAggregator);
_processLabels(issue, pullRequestCountByLabelAggregator);
}
final Uri nextPageUrl =
_nextSearchPageURLFromHeaders(response.responseHeaders);
if (nextPageUrl != null) {
await _fetchRepositoryPullRequestsByPage(nextPageUrl, repositoryStatus,
pullRequestCountByLabelAggregator, pullRequestCountByTopicAggregator);
}
}
SplayTreeMap<String, int> _sortTopics(Map<String, int> previousTopics) {
return SplayTreeMap<String, int>.of(previousTopics, (String a, String b) {
// Sort by count, descending.
final int aValue = previousTopics[a];
final int bValue = previousTopics[b];
if (bValue > aValue) {
return 1;
}
if (bValue < aValue) {
return -1;
}
// If equal counts, compare topic name.
return a.compareTo(b);
});
}
Uri _nextSearchPageURLFromHeaders(Map<String, String> responseHeaders) {
final String linkHeader = responseHeaders['link'];
if (linkHeader == null) {
return null;
}
final List<String> links = linkHeader.split(',');
final int index =
links.indexWhere((String link) => link.contains('rel="next"'));
if (index == -1) {
return null;
}
final String link = links[index];
final int start = link.indexOf('<') + 1;
final int end = link.indexOf('>');
final String nextPageUrlString = link.substring(start, end);
return Uri.parse(nextPageUrlString);
}
void _processPullRequest(
Map<String, dynamic> pullRequest,
RepositoryStatus repositoryStatus,
Map<String, int> pullRequestCountByTopicAggregator) {
repositoryStatus.pullRequestCount += 1;
final DateTime createdAt = DateTime.tryParse(pullRequest['created_at']);
if (createdAt != null) {
repositoryStatus.totalAgeOfAllPullRequests +=
DateTime.now().difference(createdAt).inDays;
}
final DateTime updatedAt = DateTime.tryParse(pullRequest['updated_at']);
if (updatedAt != null &&
DateTime.now().difference(updatedAt).inDays >=
RepositoryStatus.stalePullRequestThresholdInDays) {
repositoryStatus.stalePullRequestCount += 1;
}
final String title = pullRequest['title'];
if (title.startsWith('[')) {
final int end = title.indexOf(']');
if (end != -1) {
final String titleTopic = title.substring(1, end);
if (titleTopic.isNotEmpty) {
pullRequestCountByTopicAggregator.putIfAbsent(titleTopic, () => 0);
pullRequestCountByTopicAggregator[titleTopic] += 1;
}
}
}
}
void _processLabels(
Map<String, dynamic> issue, Map<String, int> issueCountByLabelAggregator) {
final List<dynamic> labelNames =
issue['labels'].map((dynamic issue) => issue['name']).toList();
for (String labelName in labelNames) {
issueCountByLabelAggregator.putIfAbsent(labelName, () => 0);
issueCountByLabelAggregator[labelName] += 1;
}
}
Future<HttpRequest> _getResponse(Uri url) async {
final Map<String, String> headers = <String, String>{};
if (github.isSignedIn) {
headers['Authorization'] = 'token ${github.token}';
}
final String requestETag = _eTagByURL[url];
if (requestETag != null) {
headers['If-None-Match'] = requestETag;
}
final HttpRequest response =
await HttpRequest.request(url.toString(), requestHeaders: headers)
.catchError((Error error) {
print('Error fetching"$url": $error');
});
if (response?.status == HttpStatus.notModified) {
debugPrint(
'GitHub reports query results have not been updated since last check of "$url", skipping.');
}
final String responseETag = response?.responseHeaders['etag'];
if (responseETag != null) {
_eTagByURL[url] = responseETag;
}
return response;
}
Future<dynamic> _getBody(String path,
{Map<String, String> queryParameters}) async {
final Uri url = Uri.https('api.github.com', path, queryParameters);
final HttpRequest response = await _getResponse(url);
final String body = response?.response;
return (body != null && body.isNotEmpty) ? jsonDecode(body) : null;
}