blob: 8ca763debef6083aa594c51ed0f9f9962e8f7c1f [file] [log] [blame]
// Copyright 2021 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 'package:cocoon_service/src/service/config.dart';
import 'package:cocoon_service/src/service/datastore.dart';
import 'package:cocoon_service/src/service/github_service.dart';
import 'package:gcloud/db.dart';
import 'package:github/github.dart' as gh;
import 'package:github/hooks.dart';
import 'package:retry/retry.dart';
import '../model/appengine/branch.dart';
import '../model/gerrit/commit.dart';
import '../request_handling/exceptions.dart';
import 'gerrit_service.dart';
import 'logging.dart';
class RetryException implements Exception {}
/// A class to manage GitHub branches.
///
/// Track branch activities such as branch creation, and helps manage release branches.
class BranchService {
BranchService({
required this.config,
required this.gerritService,
this.retryOptions = const RetryOptions(),
});
final Config config;
final GerritService gerritService;
final RetryOptions retryOptions;
/// Add a [CreateEvent] branch to Datastore.
Future<void> handleCreateRequest(CreateEvent createEvent) async {
log.info('the branch parsed from string request is ${createEvent.ref}');
final String? refType = createEvent.refType;
if (refType == 'tag') {
log.info('create branch event was rejected because it is a tag');
return;
}
final String? branch = createEvent.ref;
if (branch == null) {
log.warning('Branch is null, exiting early');
return;
}
final String repository = createEvent.repository!.slug().fullName;
final int lastActivity = createEvent.repository!.pushedAt!.millisecondsSinceEpoch;
final bool forked = createEvent.repository!.isFork;
if (forked) {
log.info('create branch event was rejected because the branch is a fork');
return;
}
final String id = '$repository/$branch';
log.info('the id used to create branch key was $id');
final DatastoreService datastore = DatastoreService.defaultProvider(config.db);
final Key<String> key = datastore.db.emptyKey.append<String>(Branch, id: id);
final Branch currentBranch = Branch(key: key, lastActivity: lastActivity);
try {
await datastore.lookupByValue<Branch>(currentBranch.key);
} on KeyNotFoundException {
log.info('create branch event was successful since the key is unique');
await datastore.insert(<Branch>[currentBranch]);
} catch (e) {
log.severe('Unexpected exception was encountered while inserting branch into database: $e');
}
}
/// Creates a flutter/recipes branch that aligns to a flutter/flutter branch.
///
/// Take the example repo history:
/// flutter/flutter: A -> B -> C -> D -> E
/// flutter/recipes: V -> W -> X -> Y -> Z
///
/// If flutter/flutter branches at C, this finds the flutter/recipes commit that should be used for C.
/// The best guess for a flutter/recipes commit that aligns with C is whatever was the most recently committed
/// before C was committed.
///
/// Once the flutter/recipes commit is found, it is branched to match flutter/flutter.
///
/// Generally, this should work. However, some edge cases may require CPs. Such as when commits land in a
/// short timespan, and require the release manager to CP onto the recipes branch (in the case of reverts).
Future<void> branchFlutterRecipes(String branch) async {
final gh.RepositorySlug recipesSlug = gh.RepositorySlug('flutter', 'recipes');
if ((await gerritService.branches('${recipesSlug.owner}-review.googlesource.com', recipesSlug.name,
filterRegex: branch))
.contains(branch)) {
// subString is a regex, and can return multiple matches
log.warning('$branch already exists for $recipesSlug');
throw BadRequestException('$branch already exists');
}
final Iterable<GerritCommit> recipeCommits =
await gerritService.commits(recipesSlug, Config.defaultBranch(recipesSlug));
log.info('$recipesSlug commits: $recipeCommits');
final GithubService githubService = await config.createDefaultGitHubService();
final List<gh.RepositoryCommit> githubCommits = await retryOptions.retry(
() async => await githubService.listCommits(Config.flutterSlug, branch, null),
retryIf: (Exception e) => e is gh.GitHubError,
);
log.info('${Config.flutterSlug} branch commits: $githubCommits');
for (GerritCommit recipeCommit in recipeCommits) {
if (recipeCommit.author!.time!.isBefore(githubCommits.first.commit!.committer!.date!)) {
final String revision = recipeCommit.commit!;
return await gerritService.createBranch(recipesSlug, branch, revision);
}
}
throw InternalServerError('Failed to find a revision to branch Flutter recipes for $branch');
}
/// Returns a Map that contains the latest google3 roll, beta, and stable branches.
///
/// Latest beta and stable branches are retrieved based on 'beta' and 'stable' tags. Dev branch is retrived
/// as the latest flutter candidate branch.
Future<List<Map<String, String>>> getReleaseBranches({
required GithubService githubService,
required gh.RepositorySlug slug,
}) async {
List<gh.Branch> branches = await githubService.github.repositories.listBranches(slug).toList();
final String latestCandidateBranch = await _getLatestCandidateBranch(
github: githubService.github,
slug: slug,
branches: branches,
);
final String betaName = await _getBranchNameFromFile(
githubService: githubService,
slug: slug,
branchName: "beta",
);
final String stableName = await _getBranchNameFromFile(
githubService: githubService,
slug: slug,
branchName: "stable",
);
return <Map<String, String>>[
{
"branch": stableName,
"name": "stable",
},
{
"branch": betaName,
"name": "beta",
},
{
"branch": latestCandidateBranch,
"name": "latestCandidateBranch",
}
];
}
Future<String> _getBranchNameFromFile({
required GithubService githubService,
required gh.RepositorySlug slug,
required String branchName,
}) async {
return (await githubService.getFileContent(
slug,
'bin/internal/release-candidate-branch.version',
ref: branchName,
))
.trim();
}
/// Retrieve the latest canidate branch from all candidate branches.
Future<String> _getLatestCandidateBranch({
required gh.GitHub github,
required gh.RepositorySlug slug,
required List<gh.Branch> branches,
}) async {
final RegExp candidateBranchName = RegExp(r'flutter-\d+\.\d+-candidate\.\d+');
List<gh.Branch> devBranches = branches.where((gh.Branch b) => candidateBranchName.hasMatch(b.name!)).toList();
devBranches.sort((b, a) => (_versionSum(a.name!)).compareTo(_versionSum(b.name!)));
String devBranchName = devBranches.take(1).single.name!;
return devBranchName;
}
/// Helper function to convert candidate branch versions to numbers for comparison.
int _versionSum(String tagOrBranchName) {
List<String> digits = tagOrBranchName.replaceAll(r'flutter|candidate', '0').split(RegExp(r'\.|\-'));
int versionSum = 0;
for (String digit in digits) {
int? d = int.tryParse(digit);
if (d == null) {
continue;
}
versionSum = versionSum * 100 + d;
}
return versionSum;
}
}