| // 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; |
| } |
| } |