| // Copyright 2020 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 'package:github/github.dart' as github; |
| import 'package:github/hooks.dart'; |
| |
| import '../foundation/github_checks_util.dart'; |
| import '../model/luci/buildbucket.dart'; |
| import '../model/luci/push_message.dart' as push_message; |
| import 'config.dart'; |
| import 'github_service.dart'; |
| import 'logging.dart'; |
| import 'luci_build_service.dart'; |
| import 'scheduler.dart'; |
| |
| const String kGithubSummary = ''' |
| **[Understanding a LUCI build failure](https://github.com/flutter/flutter/wiki/Understanding-a-LUCI-build-failure)** |
| |
| '''; |
| |
| /// Controls triggering builds and updating their status in the Github UI. |
| class GithubChecksService { |
| GithubChecksService(this.config, {GithubChecksUtil? githubChecksUtil}) |
| : githubChecksUtil = githubChecksUtil ?? const GithubChecksUtil(); |
| |
| Config config; |
| GithubChecksUtil githubChecksUtil; |
| |
| static Set<github.CheckRunConclusion> failedStatesSet = <github.CheckRunConclusion>{ |
| github.CheckRunConclusion.cancelled, |
| github.CheckRunConclusion.failure, |
| }; |
| |
| /// Takes a [CheckSuiteEvent] and trigger all the relevant builds if this is a |
| /// new commit or only failed builds if the event was generated by a click on |
| /// the re-run all button in the Github UI. |
| /// Relevant API docs: |
| /// https://docs.github.com/en/rest/reference/checks#create-a-check-suite |
| /// https://docs.github.com/en/rest/reference/checks#rerequest-a-check-suite |
| Future<void> handleCheckSuite( |
| github.PullRequest pullRequest, |
| CheckSuiteEvent checkSuiteEvent, |
| Scheduler scheduler, |
| ) async { |
| switch (checkSuiteEvent.action) { |
| case 'requested': |
| // Trigger all try builders. |
| log.info('Check suite request for pull request ${pullRequest.number}, ${pullRequest.title}'); |
| await scheduler.triggerPresubmitTargets( |
| pullRequest: pullRequest, |
| ); |
| break; |
| case 'rerequested': |
| log.info('Check suite re-request for pull request ${pullRequest.number}, ${pullRequest.title}'); |
| pullRequest.head = github.PullRequestHead(sha: checkSuiteEvent.checkSuite?.headSha); |
| return scheduler.retryPresubmitTargets( |
| pullRequest: pullRequest, |
| checkSuiteEvent: checkSuiteEvent, |
| ); |
| } |
| } |
| |
| /// Updates the Github build status using a [BuildPushMessage] sent by LUCI in |
| /// a pub/sub notification. |
| /// Relevant APIs: |
| /// https://docs.github.com/en/rest/reference/checks#update-a-check-run |
| Future<bool> updateCheckStatus( |
| push_message.BuildPushMessage buildPushMessage, |
| LuciBuildService luciBuildService, |
| github.RepositorySlug slug, { |
| bool rescheduled = false, |
| }) async { |
| final push_message.Build? build = buildPushMessage.build; |
| if (buildPushMessage.userData.isEmpty) { |
| return false; |
| } |
| |
| if (!buildPushMessage.userData.containsKey('check_run_id') || |
| !buildPushMessage.userData.containsKey('repo_owner') || |
| !buildPushMessage.userData.containsKey('repo_name')) { |
| log.severe( |
| 'UserData did not contain check_run_id,' |
| 'repo_owner, or repo_name: ${buildPushMessage.userData}', |
| ); |
| return false; |
| } |
| github.CheckRunStatus status = statusForResult(build!.status); |
| // Only `id` and `name` in the CheckRun are needed. |
| // Instead of making an API call to get the details of each check run, we |
| // generate the check run with only necessary info. |
| final github.CheckRun checkRun = github.CheckRun.fromJson({ |
| 'id': buildPushMessage.userData['check_run_id'] as int?, |
| 'status': status, |
| 'check_suite': const {'id': null}, |
| 'started_at': build.createdTimestamp.toString(), |
| 'conclusion': null, |
| 'name': build.buildParameters!['builder_name'], |
| }); |
| github.CheckRunConclusion? conclusion = |
| (buildPushMessage.build!.result != null) ? conclusionForResult(buildPushMessage.build!.result) : null; |
| final String? url = buildPushMessage.build!.url; |
| github.CheckRunOutput? output; |
| // If status has completed with failure then provide more details. |
| if (taskFailed(buildPushMessage)) { |
| if (rescheduled) { |
| status = github.CheckRunStatus.queued; |
| conclusion = null; |
| output = github.CheckRunOutput( |
| title: checkRun.name!, |
| summary: 'Note: this is an auto rerun. The timestamp above is based on the first attempt of this check run.', |
| ); |
| } else { |
| final Build buildbucketBuild = |
| await luciBuildService.getBuildById(buildPushMessage.build!.id, fields: 'id,builder,summaryMarkdown'); |
| output = github.CheckRunOutput( |
| title: checkRun.name!, |
| summary: getGithubSummary(buildbucketBuild.summaryMarkdown), |
| ); |
| log.fine('Updating check run with output: [$output]'); |
| } |
| } |
| await githubChecksUtil.updateCheckRun( |
| config, |
| slug, |
| checkRun, |
| status: status, |
| conclusion: conclusion, |
| detailsUrl: url, |
| output: output, |
| ); |
| return true; |
| } |
| |
| /// Check if task has completed with failure. |
| bool taskFailed(push_message.BuildPushMessage buildPushMessage) { |
| final push_message.Build? build = buildPushMessage.build; |
| final github.CheckRunStatus status = statusForResult(build!.status); |
| final github.CheckRunConclusion? conclusion = |
| (buildPushMessage.build!.result != null) ? conclusionForResult(buildPushMessage.build!.result) : null; |
| return status == github.CheckRunStatus.completed && failedStatesSet.contains(conclusion); |
| } |
| |
| /// Returns current reschedule attempt. |
| /// |
| /// It returns 1 if this is the first run, and +1 with each reschedule. |
| int currentAttempt(push_message.BuildPushMessage buildPushMessage) { |
| final push_message.Build build = buildPushMessage.build!; |
| if (build.tagsByName('current_attempt').isEmpty) { |
| return 1; |
| } else { |
| return int.parse(build.tagsByName('current_attempt').single); |
| } |
| } |
| |
| /// Appends triage wiki page to `summaryMarkdown` from LUCI build so that people can easily |
| /// reference from github check run page. |
| String getGithubSummary(String? summary) { |
| if (summary == null) { |
| return '${kGithubSummary}Empty summaryMarkdown'; |
| } |
| // This is an imposed GitHub limit |
| const int checkSummaryLimit = 65535; |
| // This is to give buffer room incase GitHub lowers the amount. |
| const int checkSummaryBufferLimit = checkSummaryLimit - 10000 - kGithubSummary.length; |
| // Return the last [checkSummaryBufferLimit] characters as they are likely the most relevant. |
| if (summary.length > checkSummaryBufferLimit) { |
| final String truncatedSummary = summary.substring(summary.length - checkSummaryBufferLimit); |
| summary = '[TRUNCATED...] $truncatedSummary'; |
| } |
| return '$kGithubSummary$summary'; |
| } |
| |
| /// Transforms a [push_message.Result] to a [github.CheckRunConclusion]. |
| /// Relevant APIs: |
| /// https://developer.github.com/v3/checks/runs/#check-runs |
| github.CheckRunConclusion conclusionForResult(push_message.Result? result) { |
| switch (result) { |
| case push_message.Result.canceled: |
| // Set conclusion cancelled as a failure to ensure developers can retry |
| // tasks when builds timeout. |
| return github.CheckRunConclusion.failure; |
| case push_message.Result.failure: |
| return github.CheckRunConclusion.failure; |
| case push_message.Result.success: |
| return github.CheckRunConclusion.success; |
| case null: |
| throw StateError('unreachable'); |
| } |
| } |
| |
| /// Transforms a [push_message.Status] to a [github.CheckRunStatus]. |
| /// Relevant APIs: |
| /// https://developer.github.com/v3/checks/runs/#check-runs |
| github.CheckRunStatus statusForResult(push_message.Status? status) { |
| switch (status) { |
| case push_message.Status.completed: |
| return github.CheckRunStatus.completed; |
| case push_message.Status.scheduled: |
| return github.CheckRunStatus.queued; |
| case push_message.Status.started: |
| return github.CheckRunStatus.inProgress; |
| case null: |
| throw StateError('unreachable'); |
| } |
| } |
| |
| /// Given a [headSha] and [checkSuiteId], finds the [PullRequest] that matches. |
| Future<github.PullRequest?> findMatchingPullRequest( |
| github.RepositorySlug slug, |
| String headSha, |
| int checkSuiteId, |
| ) async { |
| final GithubService githubService = await config.createDefaultGitHubService(); |
| |
| // There could be multiple PRs that have the same [headSha] commit. |
| final List<github.Issue> prIssues = await githubService.searchIssuesAndPRs(slug, '$headSha type:pr'); |
| |
| for (final prIssue in prIssues) { |
| final int prNumber = prIssue.number; |
| |
| // Each PR can have multiple check suites. |
| final List<github.CheckSuite> checkSuites = await githubChecksUtil.listCheckSuitesForRef( |
| githubService.github, |
| slug, |
| ref: 'refs/pull/$prNumber/head', |
| ); |
| |
| // Use check suite ID equality to verify that we have iterated to the correct PR. |
| final bool doesPrIncludeMatchingCheckSuite = checkSuites.any((checkSuite) => checkSuite.id! == checkSuiteId); |
| if (doesPrIncludeMatchingCheckSuite) { |
| return githubService.getPullRequest(slug, prNumber); |
| } |
| } |
| |
| return null; |
| } |
| } |