blob: 2046c8d36116314edd4cb6162a7808c3a611bec9 [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:io';
import 'process.dart';
/// The Dart Web team chat space.
const defaultChatSpace = 'AAAAC58FylU';
const _gchat = '/google/bin/releases/gemini-agents-gchat/gchat';
/// Prefixed to the top-level message of each run's thread, which is sent as
/// the user running the tool, so readers know it was automated.
const _chatPrefix = '🤖 roll_browsers: ';
/// Matches the thread name in `gchat mutate send-message` JSON output.
final _threadNamePattern =
RegExp(r'"name":\s*"(spaces/[^/"]+/threads/[^/"]+)"');
/// Posts the messages of a single run to one Google Chat thread.
///
/// The first message posted (e.g. the batched MPA approval requests, or the
/// roll CL if no builds were needed) starts the thread, and everything else,
/// including failures, is posted as a reply to it. Failures that happen
/// before anything else has been posted are held back until [post] or
/// [flush], so that the thread starts with the run's main message.
///
/// Messages are sent with the user's own credentials through the internal
/// `gchat` CLI, so they need a valid `gcert`.
final class Notifier {
/// The chat space ID for the run's thread.
final String chatSpace;
/// Print messages instead of sending them.
final bool dryRun;
/// The thread name (`spaces/<space>/threads/<thread>`) once started.
String? _thread;
final _pendingFailures = <String>[];
Notifier({required this.chatSpace, required this.dryRun});
/// Posts [message] to the run's thread, starting it if needed, followed by
/// any failures that were held back.
Future<void> post(String message) async {
await _send(message);
await flush();
}
/// Reports a failure in the run's thread.
///
/// If the thread hasn't started yet, the failure is held back until the
/// next [post] or [flush].
Future<void> reportFailure(String summary, {String? details}) async {
stderr.writeln('[failure] $summary${details == null ? '' : '\n$details'}');
final text = [
'❌ $summary',
if (details != null) '```\n${details.trim()}\n```',
].join('\n');
if (_thread == null && !dryRun) {
_pendingFailures.add(text);
} else {
await _send(text);
}
}
/// Posts any held-back failures, starting the thread if needed.
Future<void> flush() async {
final failures = [..._pendingFailures];
_pendingFailures.clear();
for (final failure in failures) {
await _send(failure);
}
}
/// Sends [message] as a reply in the run's thread, or as a new top-level
/// message if the thread hasn't started yet. Delivery failures are printed
/// but never fail the roll.
Future<void> _send(String message) async {
final thread = _thread;
final text = thread == null ? '$_chatPrefix$message' : message;
print('[chat $chatSpace${thread == null ? '' : ' $thread'}] $text');
if (dryRun) return;
try {
final output = await runChecked(_gchat, [
'--enable_local_proxy=false',
'mutate',
'send-message',
'--space',
chatSpace,
if (thread != null) ...['--thread', thread],
'--text',
text,
]);
_thread ??= _threadNamePattern.firstMatch(output)?[1];
} on Exception catch (e) {
stderr.writeln('Failed to send chat message: $e');
}
}
}