blob: 8ca093aaaff6074d704d396a03988133a19c1a4f [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:convert';
import 'dart:math' as math;
import 'package:cocoon_server/logging.dart';
import 'package:github/github.dart';
import 'package:http/http.dart' as http;
class GithubService {
GithubService(this.github);
final GitHub github;
static final Map<String, String> headers = <String, String>{
'Accept': 'application/vnd.github.groot-preview+json',
};
static const String kRefsPrefix = 'refs/heads/';
/// Return commits unique to [branch] for the repository [slug].
///
/// When [lastCommitTimestampMills] equals 0, it means a new release branch is
/// found and only the branched commit will be returned for now, though the
/// rare case that multiple commits exist. For other cases, it returns all
/// newer commits since [lastCommitTimestampMills].
Future<List<RepositoryCommit>> listBranchedCommits(
RepositorySlug slug,
String branch,
int? lastCommitTimestampMills,
) async {
ArgumentError.checkNotNull(slug);
final paginationHelper = PaginationHelper(github);
/// The [pages] defines the number of pages of returned http request
/// results. Return only one page when this is a new branch. Otherwise
/// it will return all commits prior to this release branch commit,
/// leading to heavy workload.
int? pages;
if (lastCommitTimestampMills == null || lastCommitTimestampMills == 0) {
pages = 1;
}
var commits = <Map<String, dynamic>>[];
// [lastCommitTimestamp+1] excludes last commit itself.
// Github api url: https://developer.github.com/v3/repos/commits/#list-commits
final path = '/repos/${slug.fullName}/commits';
final params = {
'sha': branch,
'since': DateTime.fromMillisecondsSinceEpoch(
(lastCommitTimestampMills ?? 0) + 1,
).toUtc().toIso8601String(),
};
log.debug('Calling ${Uri(path: path, queryParameters: params)}');
await for (final response in paginationHelper.fetchStreamed(
'GET',
path,
params: params,
pages: pages,
headers: headers,
)) {
commits.addAll(
(json.decode(response.body) as List<dynamic>)
.cast<Map<String, dynamic>>(),
);
}
/// When a release branch is first detected only the most recent commit would be needed.
///
/// If for the worst case, a new release branch consists of a useful cherry pick commit
/// which should be considered as well, here is the todo.
// TODO(keyonghan): https://github.com/flutter/flutter/issues/59275
if (lastCommitTimestampMills == 0) {
commits = commits.take(1).toList();
}
return commits.map<RepositoryCommit>((Map<String, dynamic> commit) {
return RepositoryCommit()
..sha = commit['sha'] as String?
..author = (User()
..login = commit['author']['login'] as String?
..avatarUrl = commit['author']['avatar_url'] as String?)
..commit = (GitCommit()
..message = commit['commit']['message'] as String?
..committer = (GitCommitUser(
commit['commit']['author']['name'] as String?,
commit['commit']['author']['email'] as String?,
DateTime.parse(commit['commit']['author']['date'] as String),
)));
}).toList();
}
Future<bool> deleteBranch(RepositorySlug slug, String branchName) async {
final ref = 'heads/$branchName';
return github.git.deleteReference(slug, ref);
}
/// List pull requests in the repository.
Future<List<PullRequest>> listPullRequests(
RepositorySlug slug,
String? branch,
) {
ArgumentError.checkNotNull(slug);
return github.pullRequests
.list(
slug,
base: branch,
direction: 'desc',
sort: 'created',
state: 'open',
)
.toList();
}
/// Creates a pull request against the `baseRef` in the `slug` repository.
///
/// The `entries` contains the file changes in the created pull request. This
/// method creates a branch in the current user's forked repository to create
/// the pull request. The current user must have a forked repository from the
/// targeted slug, and the targeted slug must not be belong to current user.
Future<PullRequest> createPullRequest(
RepositorySlug slug, {
required String title,
String? body,
String? commitMessage,
required GitReference baseRef,
List<CreateGitTreeEntry>? entries,
}) async {
ArgumentError.checkNotNull(slug);
ArgumentError.checkNotNull(title);
final clientSlug = await _getCurrentUserSlug(slug.name);
final tree = await github.git.createTree(
clientSlug,
CreateGitTree(entries, baseTree: baseRef.object!.sha),
);
final currentUser = (await _getCurrentUser())!;
final commitUser = GitCommitUser(
currentUser.name,
currentUser.email,
DateTime.now(),
);
final commit = await github.git.createCommit(
clientSlug,
CreateGitCommit(
commitMessage,
tree.sha,
parents: <String?>[baseRef.object!.sha],
author: commitUser,
committer: commitUser,
),
);
final headRef = await github.git.createReference(
clientSlug,
'$kRefsPrefix${_generateNewRef()}',
commit.sha,
);
return github.pullRequests.create(
slug,
CreatePullRequest(
title,
'${clientSlug.owner}:${headRef.ref}',
baseRef.ref,
body: body,
),
);
}
/// Assigns a reviewer to the pull request in the repository.
///
/// The `reviewer` contains the github login of the reviewer.
Future<void> assignReviewer(
RepositorySlug slug, {
int? pullRequestNumber,
String? reviewer,
}) async {
const encoder = JsonEncoder();
await github.postJSON<Map<String, dynamic>, PullRequest>(
'/repos/${slug.fullName}/pulls/$pullRequestNumber/requested_reviewers',
convert: PullRequest.fromJson,
body: encoder.convert(<String, dynamic>{
'reviewers': <String?>[reviewer],
}),
);
}
/// Retrieves check runs with the ref.
Future<List<CheckRun>> getCheckRuns(RepositorySlug slug, String ref) {
return github.checks.checkRuns.listCheckRunsForRef(slug, ref: ref).toList();
}
Future<List<CheckRun>> getCheckRunsFiltered({
required RepositorySlug slug,
required String ref,
String? checkName,
CheckRunStatus? status,
CheckRunFilter? filter,
}) async {
return github.checks.checkRuns
.listCheckRunsForRef(
slug,
ref: ref,
checkName: checkName,
status: status,
filter: filter,
)
.toList();
}
Future<CheckRun> updateCheckRun({
required RepositorySlug slug,
required CheckRun checkRun,
String? name,
String? detailsUrl,
String? externalId,
DateTime? startedAt,
CheckRunStatus status = CheckRunStatus.queued,
CheckRunConclusion? conclusion,
DateTime? completedAt,
CheckRunOutput? output,
List<CheckRunAction>? actions,
}) async {
return github.checks.checkRuns.updateCheckRun(
slug,
checkRun,
name: name,
detailsUrl: detailsUrl,
externalId: externalId,
startedAt: startedAt,
status: status,
conclusion: conclusion,
completedAt: completedAt,
output: output,
actions: actions,
);
}
/// Gets label list for an issue.
Future<List<IssueLabel>> getIssueLabels(
RepositorySlug slug,
int issueNumber,
) async {
return github.issues.listLabelsByIssue(slug, issueNumber).toList();
}
/// Adds labels to an issue.
///
/// A pull request is an issue. This works for pull requests as well.
Future<List<IssueLabel>> addIssueLabels(
RepositorySlug slug,
int issueNumber,
List<String> labels,
) async {
ArgumentError.checkNotNull(slug);
ArgumentError.checkNotNull(issueNumber);
ArgumentError.checkNotNull(labels);
return github.issues.addLabelsToIssue(slug, issueNumber, labels);
}
/// Retrieves issues from the repository.
///
/// Uses the `labels` to return the issues that have the labels.
///
/// The `state` can be set `open`, `closed, or `all`. If it is set to `open`,
/// this method only returns issues that are currently open. If it is set to
/// `closed`, this method returns issues that are currently closed. The `all`
/// returns both closed and open issues. Defaults to `open`.
Future<List<Issue>> listIssues(
RepositorySlug slug, {
List<String>? labels,
String state = 'open',
}) {
ArgumentError.checkNotNull(slug);
return github.issues
.listByRepo(slug, labels: labels, state: state)
.toList();
}
/// Removes a label from a pull request.
Future<bool> removeLabel(RepositorySlug slug, int issueNumber, String label) {
return github.issues.removeLabelForIssue(slug, issueNumber, label);
}
/// Get an issue with the issue number
Future<Issue>? getIssue(RepositorySlug slug, {required int issueNumber}) {
ArgumentError.checkNotNull(slug);
ArgumentError.checkNotNull(issueNumber);
return github.issues.get(slug, issueNumber);
}
/// Assign the issue to the assignee.
Future<void> assignIssue(
RepositorySlug slug, {
required int issueNumber,
required String assignee,
}) async {
ArgumentError.checkNotNull(slug);
ArgumentError.checkNotNull(issueNumber);
ArgumentError.checkNotNull(assignee);
await github.issues.edit(
slug,
issueNumber,
IssueRequest(assignee: assignee),
);
}
Future<Issue> createIssue(
RepositorySlug slug, {
String? title,
String? body,
List<String>? labels,
String? assignee,
}) async {
ArgumentError.checkNotNull(slug);
return github.issues.create(
slug,
IssueRequest(
title: title,
body: body,
labels: labels,
assignee: assignee,
),
);
}
Future<IssueComment?> createComment(
RepositorySlug slug, {
required int issueNumber,
required String body,
}) async {
ArgumentError.checkNotNull(slug);
ArgumentError.checkNotNull(issueNumber);
return github.issues.createComment(slug, issueNumber, body);
}
Future<List<IssueLabel>> replaceLabelsForIssue(
RepositorySlug slug, {
required int issueNumber,
required List<String> labels,
}) async {
ArgumentError.checkNotNull(slug);
ArgumentError.checkNotNull(issueNumber);
final response = await github.request(
'PUT',
'/repos/${slug.fullName}/issues/$issueNumber/labels',
body: GitHubJson.encode(labels),
);
final body = jsonDecode(response.body) as List<dynamic>;
return body
.map((dynamic it) => IssueLabel.fromJson(it as Map<String, dynamic>))
.toList();
}
/// Returns changed files for a [PullRequest].
///
/// See more:
/// * https://developer.github.com/v3/pulls/#list-pull-requests-files
Future<List<String>> listFiles(
RepositorySlug slug,
int pullRequestNumber,
) async => github.pullRequests
.listFiles(slug, pullRequestNumber)
.map((file) => file.filename!)
.toList();
/// Gets the file content as UTF8 string of the file specified by the `path`
/// in the repository.
Future<String> getFileContent(
RepositorySlug slug,
String path, {
String? ref,
}) async {
ArgumentError.checkNotNull(slug);
ArgumentError.checkNotNull(path);
final contents = await github.repositories.getContents(
slug,
path,
ref: ref,
);
if (!contents.isFile) {
throw 'The path $path should point to a file, but it is not!';
}
final content = utf8.decode(
base64.decode(contents.file!.content!.replaceAll('\n', '')),
);
return content;
}
/// Gets the reference of a specific branch in the repository.
Future<GitReference> getReference(RepositorySlug slug, String ref) {
ArgumentError.checkNotNull(slug);
ArgumentError.checkNotNull(ref);
return github.git.getReference(slug, ref);
}
/// Returns JSON of the current GitHub API quota usage.
///
/// This does not consume any API usage.
///
/// Reference:
/// * https://docs.github.com/en/rest/reference/rate-limit
Future<RateLimit> getRateLimit() => github.misc.getRateLimit();
CurrentUser? _currentUser;
Future<CurrentUser?> _getCurrentUser() async {
_currentUser ??= await github.users.getCurrentUser();
return _currentUser;
}
Future<RepositorySlug> _getCurrentUserSlug(String repository) async {
return RepositorySlug((await _getCurrentUser())!.login!, repository);
}
String _generateNewRef() {
const chars =
'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final rnd = math.Random();
return String.fromCharCodes(
Iterable<int>.generate(
10,
(_) => chars.codeUnitAt(rnd.nextInt(chars.length)),
),
);
}
/// Returns a [List] of [Issue]s that match the given [query].
///
/// The GitHub package uses the [Issue] object for both issue results and PRs.
///
/// Reference:
/// * https://docs.github.com/en/rest/search?apiVersion=2022-11-28#search-issues-and-pull-requests
/// * https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests
Future<List<Issue>> searchIssuesAndPRs(
RepositorySlug slug,
String query, {
String? sort,
int pages = 2,
}) {
return github.search
.issues(
Uri.encodeComponent('$query repo:${slug.fullName}'),
sort: sort,
pages: pages,
)
.toList();
}
/// Retrieves a pull request with the given [number].
///
/// Reference:
/// * https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request
Future<PullRequest> getPullRequest(RepositorySlug slug, int number) async {
return github.pullRequests.get(slug, number);
}
/// Searches a pull request or issue for a comment with [body].
Future<bool> commentExists(
RepositorySlug slug,
int issue,
String body,
) async {
final comments = github.issues.listCommentsByIssue(slug, issue);
await for (IssueComment comment in comments) {
if (comment.body != null && comment.body!.contains(body)) {
return true;
}
}
return false;
}
/// Retrieves a GitHub [User] by their account ID.
Future<User> getUserByAccountId(String accountId) async {
return github.users.getUser(accountId);
}
/// Checks whether a user has write permissions to a repository.
Future<bool> hasUserWritePermissions(RepositorySlug slug, String user) async {
ArgumentError.checkNotNull(slug);
ArgumentError.checkNotNull(user);
var catchError = false;
http.Response response;
try {
response = await github.request(
'GET',
'/repos/${slug.fullName}/collaborators/$user/permission',
fail: (response) {
if (response.statusCode == StatusCodes.NOT_FOUND) {
catchError = true;
}
},
);
if (response.statusCode == StatusCodes.OK && response.body.isNotEmpty) {
final body = json.decode(response.body) as Map<String, dynamic>;
if (body['permission'] == 'admin' || body['permission'] == 'write') {
return true;
}
}
} catch (e) {
if (!catchError) {
rethrow;
}
}
return false;
}
}