Merge pull request #23 from existedinnettw/feat/22

feat: switch logging to logger
This commit is contained in:
existedinnettw 2026-04-08 22:03:51 +08:00 committed by GitHub
commit 479f44e4b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 686 additions and 59 deletions

View File

@ -3,7 +3,6 @@ import 'dart:io';
import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:logging/logging.dart';
Future<void> main(List<String> arguments) async {
_configureLogging();
@ -73,7 +72,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
}
final config = await AppConfig.load(topLevelResults['config'] as String);
final logger = Logger('code_work_spawner.cli');
final logger = AppLogger('code_work_spawner.cli');
logger.info(
'loaded config=${config.configPath} '
'projects=${config.projects.length} '
@ -162,11 +161,8 @@ class _InitConfigCommand extends Command<void> {
}
void _configureLogging() {
hierarchicalLoggingEnabled = true;
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen((record) {
stdout.writeln(
'[${record.loggerName}] ${record.level.name}: ${record.message}',
);
});
AppLogger.configure(
minimumLevel: AppLogLevel.info,
useColors: stdout.hasTerminal,
);
}

View File

@ -1,6 +1,7 @@
export 'src/comment_templates.dart';
export 'src/config.dart';
export 'src/database.dart';
export 'src/app_logger.dart';
export 'src/issue_event_source/base.dart';
export 'src/issue_tracker_client.dart';
export 'src/issue_assistant_app.dart';

206
lib/src/app_logger.dart Normal file
View File

@ -0,0 +1,206 @@
import 'dart:convert';
import 'package:logger/web.dart' as logger;
typedef AppLogListener = void Function(AppLogRecord record);
enum AppLogLevel { trace, debug, info, warning, error, fatal }
class AppLogRecord {
const AppLogRecord({
required this.loggerName,
required this.level,
required this.message,
required this.time,
this.error,
this.stackTrace,
});
final String loggerName;
final AppLogLevel level;
final String message;
final DateTime time;
final Object? error;
final StackTrace? stackTrace;
}
class AppLogger {
AppLogger(this.name);
static final Set<AppLogListener> _listeners = <AppLogListener>{};
static logger.Logger? _backend;
static AppLogLevel _minimumLevel = AppLogLevel.info;
static bool _useColors = false;
final String name;
static AppLogLevel get minimumLevel => _minimumLevel;
static void configure({
AppLogLevel minimumLevel = AppLogLevel.info,
bool useColors = true,
}) {
_minimumLevel = minimumLevel;
_useColors = useColors;
_backend = _createBackend();
}
static void addListener(AppLogListener listener) {
_listeners.add(listener);
}
static bool removeListener(AppLogListener listener) {
return _listeners.remove(listener);
}
void trace(String message, {Object? error, StackTrace? stackTrace}) {
_log(AppLogLevel.trace, message, error: error, stackTrace: stackTrace);
}
void debug(String message, {Object? error, StackTrace? stackTrace}) {
_log(AppLogLevel.debug, message, error: error, stackTrace: stackTrace);
}
void info(String message, {Object? error, StackTrace? stackTrace}) {
_log(AppLogLevel.info, message, error: error, stackTrace: stackTrace);
}
void warning(String message, {Object? error, StackTrace? stackTrace}) {
_log(AppLogLevel.warning, message, error: error, stackTrace: stackTrace);
}
void error(String message, {Object? error, StackTrace? stackTrace}) {
_log(AppLogLevel.error, message, error: error, stackTrace: stackTrace);
}
void fatal(String message, {Object? error, StackTrace? stackTrace}) {
_log(AppLogLevel.fatal, message, error: error, stackTrace: stackTrace);
}
void severe(String message, {Object? error, StackTrace? stackTrace}) {
error == null && stackTrace == null
? this.error(message)
: this.error(message, error: error, stackTrace: stackTrace);
}
void _log(
AppLogLevel level,
String message, {
Object? error,
StackTrace? stackTrace,
}) {
final record = AppLogRecord(
loggerName: name,
level: level,
message: message,
time: DateTime.now(),
error: error,
stackTrace: stackTrace,
);
for (final listener in _listeners) {
listener(record);
}
(_backend ??= _createBackend()).log(
_mapLevel(level),
record,
error: error,
stackTrace: stackTrace,
);
}
static logger.Logger _createBackend() {
return logger.Logger(
level: _mapLevel(_minimumLevel),
printer: _AppLogPrinter(useColors: _useColors),
);
}
static logger.Level _mapLevel(AppLogLevel level) {
switch (level) {
case AppLogLevel.trace:
return logger.Level.trace;
case AppLogLevel.debug:
return logger.Level.debug;
case AppLogLevel.info:
return logger.Level.info;
case AppLogLevel.warning:
return logger.Level.warning;
case AppLogLevel.error:
return logger.Level.error;
case AppLogLevel.fatal:
return logger.Level.fatal;
}
}
}
class _AppLogPrinter extends logger.LogPrinter {
_AppLogPrinter({required bool useColors}) : _useColors = useColors;
final bool _useColors;
static final Map<AppLogLevel, logger.AnsiColor> _levelColors = {
AppLogLevel.trace: logger.AnsiColor.fg(logger.AnsiColor.grey(0.5)),
AppLogLevel.debug: const logger.AnsiColor.none(),
AppLogLevel.info: const logger.AnsiColor.fg(39),
AppLogLevel.warning: const logger.AnsiColor.fg(214),
AppLogLevel.error: const logger.AnsiColor.fg(196),
AppLogLevel.fatal: const logger.AnsiColor.fg(199),
};
@override
List<String> log(logger.LogEvent event) {
final record = event.message is AppLogRecord
? event.message as AppLogRecord
: AppLogRecord(
loggerName: 'app',
level: AppLogLevel.info,
message: event.message.toString(),
time: event.time,
error: event.error,
stackTrace: event.stackTrace,
);
final prefix = '[${record.loggerName}] ${_labelFor(record.level)}: ';
final lines = <String>[
for (final line in const LineSplitter().convert(record.message))
_applyColor(record.level, '$prefix$line'),
];
if (record.message.isEmpty) {
lines.add(_applyColor(record.level, prefix.trimRight()));
}
if (record.error != null) {
lines.add(_applyColor(record.level, '$prefix${record.error}'));
}
if (record.stackTrace != null) {
lines.addAll(
const LineSplitter()
.convert(record.stackTrace.toString())
.map((line) => _applyColor(record.level, '$prefix$line')),
);
}
return lines;
}
String _applyColor(AppLogLevel level, String text) {
if (!_useColors) {
return text;
}
return (_levelColors[level] ?? const logger.AnsiColor.none())(text);
}
String _labelFor(AppLogLevel level) {
switch (level) {
case AppLogLevel.trace:
return 'TRACE';
case AppLogLevel.debug:
return 'DEBUG';
case AppLogLevel.info:
return 'INFO';
case AppLogLevel.warning:
return 'WARN';
case AppLogLevel.error:
return 'ERROR';
case AppLogLevel.fatal:
return 'FATAL';
}
}
}

View File

@ -0,0 +1,317 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'config_schema.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AppConfigDocument _$AppConfigDocumentFromJson(Map<String, dynamic> json) =>
$checkedCreate('AppConfigDocument', json, ($checkedConvert) {
$checkKeys(
json,
allowedKeys: const ['dataDir', 'worktreeDir', 'defaults', 'projects'],
);
final val = AppConfigDocument(
dataDir: $checkedConvert('dataDir', (v) => v as String?),
worktreeDir: $checkedConvert('worktreeDir', (v) => v as String?),
defaults: $checkedConvert(
'defaults',
(v) => v == null
? null
: AppConfigDefaultsDocument.fromJson(v as Map<String, dynamic>),
),
projects: $checkedConvert(
'projects',
(v) => (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(
k,
ProjectConfigDocument.fromJson(e as Map<String, dynamic>),
),
),
),
);
return val;
});
Map<String, dynamic> _$AppConfigDocumentToJson(AppConfigDocument instance) =>
<String, dynamic>{
'dataDir': ?instance.dataDir,
'worktreeDir': ?instance.worktreeDir,
'defaults': ?instance.defaults?.toJson(),
'projects': ?instance.projects?.map((k, e) => MapEntry(k, e.toJson())),
};
AppConfigDefaultsDocument _$AppConfigDefaultsDocumentFromJson(
Map<String, dynamic> json,
) => $checkedCreate('AppConfigDefaultsDocument', json, ($checkedConvert) {
$checkKeys(json, allowedKeys: const ['issueAssistant']);
final val = AppConfigDefaultsDocument(
issueAssistant: $checkedConvert(
'issueAssistant',
(v) => v == null
? null
: IssueAssistantConfigDocument.fromJson(v as Map<String, dynamic>),
),
);
return val;
});
Map<String, dynamic> _$AppConfigDefaultsDocumentToJson(
AppConfigDefaultsDocument instance,
) => <String, dynamic>{'issueAssistant': ?instance.issueAssistant?.toJson()};
ProjectConfigDocument _$ProjectConfigDocumentFromJson(
Map<String, dynamic> json,
) => $checkedCreate('ProjectConfigDocument', json, ($checkedConvert) {
$checkKeys(
json,
allowedKeys: const [
'provider',
'repo',
'path',
'defaultBranch',
'sessionPrefix',
'issueAssistant',
],
);
final val = ProjectConfigDocument(
provider: $checkedConvert(
'provider',
(v) => $enumDecodeNullable(_$IssueTrackerProviderEnumMap, v),
),
repo: $checkedConvert('repo', (v) => v as String?),
path: $checkedConvert('path', (v) => v as String?),
defaultBranch: $checkedConvert('defaultBranch', (v) => v as String?),
sessionPrefix: $checkedConvert('sessionPrefix', (v) => v as String?),
issueAssistant: $checkedConvert(
'issueAssistant',
(v) => v == null
? null
: IssueAssistantConfigDocument.fromJson(v as Map<String, dynamic>),
),
);
return val;
});
Map<String, dynamic> _$ProjectConfigDocumentToJson(
ProjectConfigDocument instance,
) => <String, dynamic>{
'provider': ?_$IssueTrackerProviderEnumMap[instance.provider],
'repo': ?instance.repo,
'path': ?instance.path,
'defaultBranch': ?instance.defaultBranch,
'sessionPrefix': ?instance.sessionPrefix,
'issueAssistant': ?instance.issueAssistant?.toJson(),
};
const _$IssueTrackerProviderEnumMap = {
IssueTrackerProvider.github: 'github',
IssueTrackerProvider.gitea: 'gitea',
};
IssueAssistantConfigDocument _$IssueAssistantConfigDocumentFromJson(
Map<String, dynamic> json,
) => $checkedCreate('IssueAssistantConfigDocument', json, ($checkedConvert) {
$checkKeys(
json,
allowedKeys: const [
'enabled',
'eventSource',
'pollInterval',
'maxConcurrentIssues',
'mentionTriggers',
'prompt',
'responders',
'capabilities',
'notifications',
'commentTag',
'commentPrefixTemplate',
'commentFooterTemplate',
],
);
final val = IssueAssistantConfigDocument(
enabled: $checkedConvert('enabled', (v) => v as bool?),
eventSource: $checkedConvert(
'eventSource',
(v) => $enumDecodeNullable(_$IssueAssistantEventSourceKindEnumMap, v),
),
pollInterval: $checkedConvert('pollInterval', (v) => v as String?),
maxConcurrentIssues: $checkedConvert(
'maxConcurrentIssues',
(v) => (v as num?)?.toInt(),
),
mentionTriggers: $checkedConvert(
'mentionTriggers',
(v) => (v as List<dynamic>?)?.map((e) => e as String).toList(),
),
prompt: $checkedConvert('prompt', (v) => v as String?),
responders: $checkedConvert(
'responders',
(v) => (v as List<dynamic>?)
?.map(
(e) =>
CliResponderConfigDocument.fromJson(e as Map<String, dynamic>),
)
.toList(),
),
capabilities: $checkedConvert(
'capabilities',
(v) => (v as List<dynamic>?)
?.map((e) => $enumDecode(_$AssistantCapabilityEnumMap, e))
.toList(),
),
notifications: $checkedConvert(
'notifications',
(v) => (v as List<dynamic>?)
?.map(
(e) =>
NotificationConfigDocument.fromJson(e as Map<String, dynamic>),
)
.toList(),
),
commentTag: $checkedConvert('commentTag', (v) => v as String?),
commentPrefixTemplate: $checkedConvert(
'commentPrefixTemplate',
(v) => v as String?,
),
commentFooterTemplate: $checkedConvert(
'commentFooterTemplate',
(v) => v as String?,
),
);
return val;
});
Map<String, dynamic> _$IssueAssistantConfigDocumentToJson(
IssueAssistantConfigDocument instance,
) => <String, dynamic>{
'enabled': ?instance.enabled,
'eventSource': ?_$IssueAssistantEventSourceKindEnumMap[instance.eventSource],
'pollInterval': ?instance.pollInterval,
'maxConcurrentIssues': ?instance.maxConcurrentIssues,
'mentionTriggers': ?instance.mentionTriggers,
'prompt': ?instance.prompt,
'responders': ?instance.responders?.map((e) => e.toJson()).toList(),
'capabilities': ?instance.capabilities
?.map((e) => _$AssistantCapabilityEnumMap[e]!)
.toList(),
'notifications': ?instance.notifications?.map((e) => e.toJson()).toList(),
'commentTag': ?instance.commentTag,
'commentPrefixTemplate': ?instance.commentPrefixTemplate,
'commentFooterTemplate': ?instance.commentFooterTemplate,
};
const _$IssueAssistantEventSourceKindEnumMap = {
IssueAssistantEventSourceKind.ghPolling: 'gh/polling',
IssueAssistantEventSourceKind.teaPolling: 'tea/polling',
IssueAssistantEventSourceKind.ghGosmee: 'gh/gosmee',
IssueAssistantEventSourceKind.ghWebhookForward: 'gh/webhook-forward',
IssueAssistantEventSourceKind.teaGosmee: 'tea/gosmee',
};
const _$AssistantCapabilityEnumMap = {
AssistantCapability.planning: 'planning',
AssistantCapability.bugVerification: 'bug_verification',
};
CliResponderConfigDocument _$CliResponderConfigDocumentFromJson(
Map<String, dynamic> json,
) => $checkedCreate('CliResponderConfigDocument', json, ($checkedConvert) {
$checkKeys(
json,
allowedKeys: const [
'id',
'command',
'args',
'env',
'stdinTemplate',
'timeout',
],
);
final val = CliResponderConfigDocument(
id: $checkedConvert('id', (v) => v as String?),
command: $checkedConvert('command', (v) => v as String?),
args: $checkedConvert(
'args',
(v) => (v as List<dynamic>?)?.map((e) => e as String).toList(),
),
env: $checkedConvert(
'env',
(v) =>
(v as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as String)),
),
stdinTemplate: $checkedConvert('stdinTemplate', (v) => v as String?),
timeout: $checkedConvert('timeout', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$CliResponderConfigDocumentToJson(
CliResponderConfigDocument instance,
) => <String, dynamic>{
'id': ?instance.id,
'command': ?instance.command,
'args': ?instance.args,
'env': ?instance.env,
'stdinTemplate': ?instance.stdinTemplate,
'timeout': ?instance.timeout,
};
NotificationConfigDocument _$NotificationConfigDocumentFromJson(
Map<String, dynamic> json,
) => $checkedCreate('NotificationConfigDocument', json, ($checkedConvert) {
$checkKeys(
json,
allowedKeys: const [
'type',
'enabled',
'events',
'webhookUrl',
'username',
'avatarUrl',
],
);
final val = NotificationConfigDocument(
type: $checkedConvert(
'type',
(v) => $enumDecodeNullable(_$NotificationTypeEnumMap, v),
),
enabled: $checkedConvert('enabled', (v) => v as bool?),
events: $checkedConvert(
'events',
(v) => (v as List<dynamic>?)
?.map((e) => $enumDecode(_$NotificationEventEnumMap, e))
.toList(),
),
webhookUrl: $checkedConvert('webhookUrl', (v) => v as String?),
username: $checkedConvert('username', (v) => v as String?),
avatarUrl: $checkedConvert('avatarUrl', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$NotificationConfigDocumentToJson(
NotificationConfigDocument instance,
) => <String, dynamic>{
'type': ?_$NotificationTypeEnumMap[instance.type],
'enabled': ?instance.enabled,
'events': ?instance.events
?.map((e) => _$NotificationEventEnumMap[e]!)
.toList(),
'webhookUrl': ?instance.webhookUrl,
'username': ?instance.username,
'avatarUrl': ?instance.avatarUrl,
};
const _$NotificationTypeEnumMap = {
NotificationType.discordWebhook: 'discordWebhook',
NotificationType.desktop: 'desktop',
};
const _$NotificationEventEnumMap = {
NotificationEvent.mentionDetected: 'mention_detected',
NotificationEvent.replyPosted: 'reply_posted',
NotificationEvent.noReply: 'no_reply',
NotificationEvent.responderFailed: 'responder_failed',
};

View File

@ -2,8 +2,8 @@ import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:logging/logging.dart';
import 'app_logger.dart';
import 'cli_responder.dart';
import 'config.dart';
import 'database.dart';
@ -16,7 +16,7 @@ import 'notifier.dart';
import 'workspace_manager.dart';
class IssueAssistantApp {
static final Logger _logger = Logger('code_work_spawner.app');
static final AppLogger _logger = AppLogger('code_work_spawner.app');
IssueAssistantApp._({
required this.config,

View File

@ -2,8 +2,8 @@ import 'dart:async';
import 'dart:io';
import 'package:github/github.dart';
import 'package:logging/logging.dart';
import '../app_logger.dart';
import '../config.dart';
import '../database.dart';
import '../issue_tracker_client.dart';
@ -129,14 +129,14 @@ class PollingIssueEventSource implements IssueEventSource {
PollingIssueEventSource({
required this.database,
required this.issueTrackerClient,
required Logger logger,
required AppLogger logger,
}) : _logger = logger;
static const Duration _pollCoalescingWindow = Duration(seconds: 2);
final AppDatabase database;
final IssueTrackerClient issueTrackerClient;
final Logger _logger;
final AppLogger _logger;
final Map<String, _ProjectPollState> _projectPollStates = {};
final Set<Future<void>> _activePolls = <Future<void>>{};
Timer? _schedulerTimer;

View File

@ -3,8 +3,8 @@ import 'dart:convert';
import 'dart:io';
import 'package:github/github.dart';
import 'package:logging/logging.dart';
import '../app_logger.dart';
import '../config.dart';
import '../issue_tracker_client.dart';
import 'base.dart';
@ -13,14 +13,14 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
GiteaGosmeeIssueEventSource({
required this.issueTrackerClient,
required this.gosmeeCommand,
required Logger logger,
required AppLogger logger,
}) : _logger = logger;
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
final IssueTrackerClient issueTrackerClient;
final String gosmeeCommand;
final Logger _logger;
final AppLogger _logger;
final Map<String, _QueuedGiteaIssue> _queuedIssues = {};
final Set<Future<void>> _activeDispatches = <Future<void>>{};
final Map<String, Process> _gosmeeProcesses = {};
@ -80,13 +80,33 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
final localUrl =
'http://${server.address.address}:${server.port}/gitea/webhooks';
_log(
'gitea/gosmee listening local_url=$localUrl '
'projects=${giteaProjects.map((project) => project.key).join(",")}',
);
for (final project in giteaProjects) {
_log(
'gitea/gosmee project=${project.key} create_channel '
'command="$gosmeeCommand --output json client --new-url $localUrl"',
);
final publicUrl = await _createGosmeeChannelUrl(localUrl);
_log(
'gitea/gosmee project=${project.key} channel_ready '
'public_url=$publicUrl local_url=$localUrl',
);
final webhookId = await issueTrackerClient.createGiteaIssueWebhook(
repo: project.repoSlug,
url: publicUrl,
);
_webhookIdsByProject[project.key] = webhookId;
_log(
'gitea/gosmee project=${project.key} webhook_registered '
'webhook_id=$webhookId repo=${project.repo}',
);
_log(
'gitea/gosmee project=${project.key} start_client '
'command="$gosmeeCommand client $publicUrl $localUrl"',
);
final process = await Process.start(gosmeeCommand, [
'client',
publicUrl,
@ -170,6 +190,10 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
_webhookIdsByProject.clear();
for (final entry in webhookIds.entries) {
try {
_log(
'gitea/gosmee project=${entry.key} webhook_delete '
'webhook_id=${entry.value}',
);
await issueTrackerClient.deleteGiteaWebhook(
repo: _repoSlugsByProject[entry.key]!,
webhookId: entry.value,
@ -196,6 +220,10 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
required IssueEventBatchHandler onBatch,
}) async {
if (request.method != 'POST') {
_log(
'gitea/gosmee webhook ignored method=${request.method} '
'path=${request.uri.path}',
);
request.response
..statusCode = HttpStatus.methodNotAllowed
..write('Only POST is supported.');
@ -215,6 +243,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
: null;
final project = fullName == null ? null : repoToProject[fullName];
final issueNode = payload['issue'];
final action = payload['action']?.toString() ?? 'unknown';
if (project != null &&
issueNode is Map<String, dynamic> &&
(issueNode['pull_request'] == null)) {
@ -222,6 +251,10 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
project.repoSlug,
issueNode,
);
_log(
'gitea/gosmee webhook accepted project=${project.key} '
'repo=$fullName action=$action issue=${issue.number} state=${issue.state}',
);
if (issue.state == 'open') {
_queuedIssues['${project.key}#${issue.number}'] = _QueuedGiteaIssue(
project: project,
@ -230,6 +263,13 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
);
_scheduleFlush();
}
} else {
_log(
'gitea/gosmee webhook ignored repo=${fullName ?? "(missing)"} '
'action=$action matched_project=${project != null} '
'has_issue=${issueNode is Map<String, dynamic>} '
'is_pull_request=${issueNode is Map<String, dynamic> && issueNode["pull_request"] != null}',
);
}
request.response.statusCode = HttpStatus.accepted;
@ -263,6 +303,9 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
if (stdoutText.isEmpty) {
throw const FormatException('gosmee did not print a public URL.');
}
_log(
'gitea/gosmee channel_raw_output local_url=$localUrl stdout=$stdoutText',
);
try {
final decodedJson = jsonDecode(stdoutText);
if (decodedJson is Map<String, dynamic>) {
@ -350,12 +393,14 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
required Stream<List<int>> stream,
required String level,
}) {
utf8.decoder
.bind(stream)
.transform(const LineSplitter())
.listen(
(line) => _log('gitea/gosmee project=${project.key} $level=$line'),
);
utf8.decoder.bind(stream).transform(const LineSplitter()).listen((line) {
final message = 'gitea/gosmee project=${project.key} $level=$line';
if (level == 'stderr') {
_logError(message);
return;
}
_log(message);
});
}
void _log(String message) {
@ -363,7 +408,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
}
void _logError(String message) {
_logger.warning(message);
_logger.error(message);
}
}

View File

@ -3,8 +3,8 @@ import 'dart:convert';
import 'dart:io';
import 'package:github/github.dart';
import 'package:logging/logging.dart';
import '../app_logger.dart';
import '../config.dart';
import '../issue_tracker_client.dart';
import 'base.dart';
@ -13,14 +13,14 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
GitHubGosmeeIssueEventSource({
required this.issueTrackerClient,
required this.gosmeeCommand,
required Logger logger,
required AppLogger logger,
}) : _logger = logger;
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
final IssueTrackerClient issueTrackerClient;
final String gosmeeCommand;
final Logger _logger;
final AppLogger _logger;
final Map<String, _QueuedGitHubIssue> _queuedIssues = {};
final Set<Future<void>> _activeDispatches = <Future<void>>{};
final Map<String, Process> _gosmeeProcesses = {};
@ -80,13 +80,33 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
final localUrl =
'http://${server.address.address}:${server.port}/github/webhooks';
_log(
'github/gosmee listening local_url=$localUrl '
'projects=${githubProjects.map((project) => project.key).join(",")}',
);
for (final project in githubProjects) {
_log(
'github/gosmee project=${project.key} create_channel '
'command="$gosmeeCommand --output json client --new-url $localUrl"',
);
final publicUrl = await _createGosmeeChannelUrl(localUrl);
_log(
'github/gosmee project=${project.key} channel_ready '
'public_url=$publicUrl local_url=$localUrl',
);
final webhookId = await issueTrackerClient.createGitHubIssueWebhook(
repo: project.repoSlug,
url: publicUrl,
);
_webhookIdsByProject[project.key] = webhookId;
_log(
'github/gosmee project=${project.key} webhook_registered '
'webhook_id=$webhookId repo=${project.repo}',
);
_log(
'github/gosmee project=${project.key} start_client '
'command="$gosmeeCommand client $publicUrl $localUrl"',
);
final process = await Process.start(gosmeeCommand, [
'client',
publicUrl,
@ -170,6 +190,10 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
_webhookIdsByProject.clear();
for (final entry in webhookIds.entries) {
try {
_log(
'github/gosmee project=${entry.key} webhook_delete '
'webhook_id=${entry.value}',
);
await issueTrackerClient.deleteGitHubWebhook(
repo: _repoSlugsByProject[entry.key]!,
webhookId: entry.value,
@ -196,6 +220,10 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
required IssueEventBatchHandler onBatch,
}) async {
if (request.method != 'POST') {
_log(
'github/gosmee webhook ignored method=${request.method} '
'path=${request.uri.path}',
);
request.response
..statusCode = HttpStatus.methodNotAllowed
..write('Only POST is supported.');
@ -215,6 +243,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
: null;
final project = fullName == null ? null : repoToProject[fullName];
final issueNode = payload['issue'];
final action = payload['action']?.toString() ?? 'unknown';
if (project != null &&
issueNode is Map<String, dynamic> &&
(issueNode['pull_request'] == null)) {
@ -222,6 +251,10 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
project.repoSlug,
issueNode,
);
_log(
'github/gosmee webhook accepted project=${project.key} '
'repo=$fullName action=$action issue=${issue.number} state=${issue.state}',
);
if (issue.state == 'open') {
_queuedIssues['${project.key}#${issue.number}'] = _QueuedGitHubIssue(
project: project,
@ -230,6 +263,13 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
);
_scheduleFlush();
}
} else {
_log(
'github/gosmee webhook ignored repo=${fullName ?? "(missing)"} '
'action=$action matched_project=${project != null} '
'has_issue=${issueNode is Map<String, dynamic>} '
'is_pull_request=${issueNode is Map<String, dynamic> && issueNode["pull_request"] != null}',
);
}
request.response.statusCode = HttpStatus.accepted;
@ -264,6 +304,9 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
if (stdoutText.isEmpty) {
throw const FormatException('gosmee did not print a public URL.');
}
_log(
'github/gosmee channel_raw_output local_url=$localUrl stdout=$stdoutText',
);
try {
final decoded = jsonDecode(stdoutText);
@ -355,7 +398,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
utf8.decoder.bind(stream).transform(const LineSplitter()).listen((line) {
final message = 'github/gosmee project=${project.key} $level=$line';
if (level == 'stderr') {
_logger.severe(message);
_logger.error(message);
} else {
_logger.info(message);
}
@ -364,7 +407,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
void _log(String message) => _logger.info(message);
void _logError(String message) => _logger.severe(message);
void _logError(String message) => _logger.error(message);
}
class _QueuedGitHubIssue {

View File

@ -2,8 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart';
import '../app_logger.dart';
import '../config.dart';
import '../issue_tracker_client.dart';
import 'base.dart';
@ -11,13 +10,13 @@ import 'base.dart';
class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
GitHubWebhookForwardIssueEventSource({
required this.issueTrackerClient,
required Logger logger,
required AppLogger logger,
}) : _logger = logger;
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
final IssueTrackerClient issueTrackerClient;
final Logger _logger;
final AppLogger _logger;
final Map<String, _QueuedWebhookIssue> _queuedIssues = {};
final Set<Future<void>> _activeDispatches = <Future<void>>{};
final Map<String, Process> _forwardProcesses = {};
@ -72,7 +71,15 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
final url =
'http://${server.address.address}:${server.port}/github/webhooks';
_log(
'github/webhook-forward listening local_url=$url '
'projects=${githubProjects.map((project) => project.key).join(",")}',
);
for (final project in githubProjects) {
_log(
'github/webhook-forward project=${project.key} start_forwarder '
'command="${issueTrackerClient.ghCommand} webhook forward --events=issues,issue_comment --repo=${project.repo} --url=$url"',
);
final process = await Process.start(issueTrackerClient.ghCommand, [
'webhook',
'forward',
@ -168,6 +175,10 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
required IssueEventBatchHandler onBatch,
}) async {
if (request.method != 'POST') {
_log(
'github/webhook-forward webhook ignored method=${request.method} '
'path=${request.uri.path}',
);
request.response
..statusCode = HttpStatus.methodNotAllowed
..write('Only POST is supported.');
@ -187,6 +198,7 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
: null;
final project = fullName == null ? null : repoToProject[fullName];
final issueNode = payload['issue'];
final action = payload['action']?.toString() ?? 'unknown';
if (project != null &&
issueNode is Map<String, dynamic> &&
(issueNode['pull_request'] == null)) {
@ -194,6 +206,10 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
project.repoSlug,
issueNode,
);
_log(
'github/webhook-forward webhook accepted project=${project.key} '
'repo=$fullName action=$action issue=${issue.number} state=${issue.state}',
);
if (issue.state == 'open') {
_queuedIssues['${project.key}#${issue.number}'] = _QueuedWebhookIssue(
project: project,
@ -202,6 +218,13 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
);
_scheduleFlush();
}
} else {
_log(
'github/webhook-forward webhook ignored repo=${fullName ?? "(missing)"} '
'action=$action matched_project=${project != null} '
'has_issue=${issueNode is Map<String, dynamic>} '
'is_pull_request=${issueNode is Map<String, dynamic> && issueNode["pull_request"] != null}',
);
}
request.response.statusCode = HttpStatus.accepted;
@ -294,7 +317,7 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
}
void _logError(String message) {
_logger.severe(message);
_logger.error(message);
}
}

View File

@ -17,7 +17,7 @@ dependencies:
yaml: ^3.1.3
result_dart: ^2.1.1
dotenv: ^4.2.0
logging: ^1.3.0
logger: ^2.6.2
meta: ^1.16.0
github: ^9.25.0
git: ^2.3.2

View File

@ -3,7 +3,6 @@ import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
@ -140,7 +139,7 @@ projects:
/// And a gh script that writes a webhook creation failure to stderr while forwarding
/// And an orchestrator-style config that uses gh/webhook-forward
/// When the long-running app starts and webhook-forward emits that stderr line
/// Then the app logs the webhook-forward stderr message at severe level
/// Then the app logs the webhook-forward stderr message at error level
/// ```
test('Log webhook forward stderr at error level', () async {
// Given a temporary project checkout that can be used as the local repo path.
@ -193,10 +192,12 @@ projects:
databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path,
);
final records = <LogRecord>[];
final previousLevel = Logger.root.level;
Logger.root.level = Level.ALL;
final subscription = Logger.root.onRecord.listen(records.add);
final records = <AppLogRecord>[];
void captureLog(AppLogRecord record) {
records.add(record);
}
AppLogger.addListener(captureLog);
// When the long-running app starts and webhook-forward emits that stderr line.
final runFuture = app.run();
@ -210,14 +211,13 @@ projects:
}
await app.close();
await runFuture;
await subscription.cancel();
Logger.root.level = previousLevel;
AppLogger.removeListener(captureLog);
// Then the app logs the webhook-forward stderr message at severe level.
// Then the app logs the webhook-forward stderr message at error level.
expect(
records.any(
(record) =>
record.level == Level.SEVERE &&
record.level == AppLogLevel.error &&
record.message.contains(
'stderr=Error: error creating webhook: HTTP 422: Validation Failed',
),

View File

@ -2,7 +2,6 @@ import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
@ -300,16 +299,15 @@ projects:
ghCommand: ghScript.path,
);
final logMessages = <String>[];
final previousLevel = Logger.root.level;
Logger.root.level = Level.INFO;
final subscription = Logger.root.onRecord.listen((record) {
void captureLog(AppLogRecord record) {
logMessages.add(record.message);
});
}
AppLogger.addListener(captureLog);
// When the app processes one polling cycle while app logs are captured.
await app.runOnce();
await subscription.cancel();
Logger.root.level = previousLevel;
AppLogger.removeListener(captureLog);
await app.close();
// Then the application logs include the responder stdout payload.

View File

@ -3,7 +3,6 @@ import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
@ -85,11 +84,11 @@ projects:
ghCommand: ghScript.path,
);
final logMessages = <String>[];
final previousLevel = Logger.root.level;
Logger.root.level = Level.INFO;
final subscription = Logger.root.onRecord.listen((record) {
void captureLog(AppLogRecord record) {
logMessages.add(record.message);
});
}
AppLogger.addListener(captureLog);
// When the long-running app starts and enough time passes for a retry.
final runFuture = app.run();
@ -114,8 +113,7 @@ projects:
}
await app.close();
await runFuture;
await subscription.cancel();
Logger.root.level = previousLevel;
AppLogger.removeListener(captureLog);
// Then the app logs the failed poll without stopping entirely.
expect(