| // Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file | |
| // for details. 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 'package:http/http.dart' as http; | |
| import 'git.dart'; | |
| const _gerritHost = 'dart-review.googlesource.com'; | |
| /// Gerrit prefixes JSON responses with this to prevent XSSI. | |
| const _xssiPrefix = ")]}'"; | |
| /// A change on the SDK's Gerrit instance. | |
| final class GerritChange { | |
| final int number; | |
| GerritChange(this.number); | |
| Uri get url => Uri.https(_gerritHost, 'c/sdk/+/$number'); | |
| @override | |
| String toString() => '$url'; | |
| } | |
| /// The lifecycle state of a change. | |
| enum ChangeStatus { open, merged, abandoned } | |
| /// A failed commit queue run that LUCI CV reported on a change. | |
| final class CqFailure { | |
| /// The ID of the Gerrit message reporting the failure. | |
| final String id; | |
| /// Whether this was a dry run (Commit-Queue+1) rather than a full run. | |
| final bool dryRun; | |
| /// Why the run failed, e.g. which tryjobs failed. | |
| final String reason; | |
| CqFailure({required this.id, required this.dryRun, required this.reason}); | |
| } | |
| /// Where a change is in review. | |
| final class ReviewState { | |
| final ChangeStatus status; | |
| /// Whether the change has an approving Code-Review vote. | |
| final bool approved; | |
| /// Whether the change is in the commit queue (Commit-Queue+2). | |
| final bool inCommitQueue; | |
| /// The failed commit queue runs reported on the change, oldest first. | |
| final List<CqFailure> cqFailures; | |
| ReviewState({ | |
| required this.status, | |
| required this.approved, | |
| required this.inCommitQueue, | |
| required this.cqFailures, | |
| }); | |
| } | |
| /// Matches the tag of LUCI CV messages about commit queue runs, e.g. | |
| /// `autogenerated:cq:dry-run:1792028985`, capturing the run kind. | |
| final _cqMessageTag = RegExp(r'^autogenerated:cq:(dry-run|full-run)'); | |
| /// Starts the text of a LUCI CV message reporting a failed run, after the | |
| /// `Patch Set N:` header. | |
| const _cqFailureText = 'This CL has failed the run.'; | |
| /// Reads from the SDK's Gerrit instance and uploads roll CLs to it. | |
| final class Gerrit { | |
| final http.Client _client; | |
| Gerrit(this._client); | |
| /// Returns the open SDK changes tagged with [hashtag]. | |
| Future<List<GerritChange>> openChanges({required String hashtag}) async { | |
| final changes = await _getJson('changes/', { | |
| 'q': 'status:open project:sdk hashtag:$hashtag', | |
| }) as List; | |
| return [ | |
| for (final change in changes) | |
| GerritChange((change as Map<String, dynamic>)['_number'] as int), | |
| ]; | |
| } | |
| /// Returns the current review state of [change]. | |
| Future<ReviewState> reviewState(GerritChange change) async { | |
| final json = await _getJson('changes/${change.number}', { | |
| 'o': ['LABELS', 'MESSAGES'], | |
| }) as Map<String, dynamic>; | |
| final labels = json['labels'] as Map<String, dynamic>? ?? const {}; | |
| bool isApproved(String name) { | |
| final label = labels[name] as Map<String, dynamic>?; | |
| return label?['approved'] != null && label?['rejected'] == null; | |
| } | |
| final messages = json['messages'] as List? ?? const []; | |
| return ReviewState( | |
| status: switch (json['status']) { | |
| 'MERGED' => ChangeStatus.merged, | |
| 'ABANDONED' => ChangeStatus.abandoned, | |
| _ => ChangeStatus.open, | |
| }, | |
| approved: isApproved('Code-Review'), | |
| inCommitQueue: isApproved('Commit-Queue'), | |
| cqFailures: [ | |
| for (final message in messages) | |
| if (_parseCqFailure(message as Map<String, dynamic>) | |
| case final failure?) | |
| failure, | |
| ], | |
| ); | |
| } | |
| /// Returns the failed run reported by a LUCI CV [message], or `null` if it | |
| /// isn't one. | |
| CqFailure? _parseCqFailure(Map<String, dynamic> message) { | |
| final kind = _cqMessageTag.firstMatch(message['tag'] as String? ?? '')?[1]; | |
| final text = message['message'] as String; | |
| final start = text.indexOf(_cqFailureText); | |
| if (kind == null || start == -1) return null; | |
| final reason = text.substring(start + _cqFailureText.length).trim(); | |
| return CqFailure( | |
| id: message['id'] as String, | |
| dryRun: kind == 'dry-run', | |
| reason: reason.replaceFirst(RegExp(r'^Reason:\s*'), ''), | |
| ); | |
| } | |
| /// Sends [change] to the commit queue (Commit-Queue+2). | |
| Future<void> sendToCommitQueue(GitCheckout checkout, GerritChange change) => | |
| checkout.cl(['set-commit', '--issue', '${change.number}']); | |
| /// Commits [depsContents] in a temporary worktree of [checkout], uploads | |
| /// it with `git cl upload`, and returns the new change. | |
| Future<GerritChange> uploadDepsChange({ | |
| required GitCheckout checkout, | |
| required String depsContents, | |
| required String commitMessage, | |
| required Iterable<String> reviewers, | |
| required Iterable<String> hashtags, | |
| }) => | |
| checkout.withTemporaryDepsCommit( | |
| depsContents: depsContents, | |
| commitMessage: commitMessage, | |
| action: (worktree) async { | |
| await worktree.cl(_uploadArguments(reviewers, hashtags)); | |
| return _parseIssue(await worktree.cl(['issue'])); | |
| }, | |
| ); | |
| /// Roll CLs get a CQ dry run right away and Auto-Submit, so they land as | |
| /// soon as a reviewer approves them. | |
| List<String> _uploadArguments( | |
| Iterable<String> reviewers, | |
| Iterable<String> hashtags, | |
| ) => | |
| [ | |
| 'upload', | |
| '--force', | |
| '--bypass-hooks', | |
| '--skip-title', | |
| '--send-mail', | |
| '--cq-dry-run', | |
| '--enable-auto-submit', | |
| for (final hashtag in hashtags) '--hashtag=$hashtag', | |
| if (reviewers.isNotEmpty) '--reviewers=${reviewers.join(',')}', | |
| ]; | |
| /// Parses the change from `git cl issue` output, e.g. | |
| /// `Issue number: 123 (https://dart-review.googlesource.com/c/sdk/+/123)`. | |
| GerritChange _parseIssue(String output) { | |
| final number = RegExp(r'Issue number: (\d+)').firstMatch(output)?[1]; | |
| if (number == null) { | |
| throw FormatException('Could not find the CL number', output); | |
| } | |
| return GerritChange(int.parse(number)); | |
| } | |
| /// Fetches and decodes a JSON response from the Gerrit REST API. | |
| /// | |
| /// Each [query] value is a `String` or, for repeated parameters, an | |
| /// `Iterable<String>`. | |
| Future<Object?> _getJson(String path, Map<String, Object> query) async { | |
| final uri = Uri.https(_gerritHost, path, query); | |
| final response = await _client.get(uri); | |
| if (response.statusCode != 200) { | |
| throw http.ClientException('HTTP ${response.statusCode}', uri); | |
| } | |
| return jsonDecode(response.body.replaceFirst(_xssiPrefix, '')); | |
| } | |
| } |