blob: 2f96e50f4e68edf560c455fbe53d5d290ac4aa3b [file] [edit]
// 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, ''));
}
}