feat: switch logging to logger for #22
Replace the plain logging backend with a color-capable logger adapter so CLI output is easier to scan, and add richer gosmee/webhook-forward diagnostics so tracker and forwarding failures can be debugged from logs.
This commit is contained in:
parent
64901a348a
commit
9ae02a6f4b
|
|
@ -3,7 +3,6 @@ import 'dart:io';
|
||||||
import 'package:args/args.dart';
|
import 'package:args/args.dart';
|
||||||
import 'package:args/command_runner.dart';
|
import 'package:args/command_runner.dart';
|
||||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
Future<void> main(List<String> arguments) async {
|
Future<void> main(List<String> arguments) async {
|
||||||
_configureLogging();
|
_configureLogging();
|
||||||
|
|
@ -73,7 +72,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
final config = await AppConfig.load(topLevelResults['config'] as String);
|
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(
|
logger.info(
|
||||||
'loaded config=${config.configPath} '
|
'loaded config=${config.configPath} '
|
||||||
'projects=${config.projects.length} '
|
'projects=${config.projects.length} '
|
||||||
|
|
@ -162,11 +161,8 @@ class _InitConfigCommand extends Command<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _configureLogging() {
|
void _configureLogging() {
|
||||||
hierarchicalLoggingEnabled = true;
|
AppLogger.configure(
|
||||||
Logger.root.level = Level.INFO;
|
minimumLevel: AppLogLevel.info,
|
||||||
Logger.root.onRecord.listen((record) {
|
useColors: stdout.hasTerminal,
|
||||||
stdout.writeln(
|
);
|
||||||
'[${record.loggerName}] ${record.level.name}: ${record.message}',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export 'src/comment_templates.dart';
|
export 'src/comment_templates.dart';
|
||||||
export 'src/config.dart';
|
export 'src/config.dart';
|
||||||
export 'src/database.dart';
|
export 'src/database.dart';
|
||||||
|
export 'src/app_logger.dart';
|
||||||
export 'src/issue_event_source/base.dart';
|
export 'src/issue_event_source/base.dart';
|
||||||
export 'src/issue_tracker_client.dart';
|
export 'src/issue_tracker_client.dart';
|
||||||
export 'src/issue_assistant_app.dart';
|
export 'src/issue_assistant_app.dart';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
|
@ -2,8 +2,8 @@ import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
|
import 'app_logger.dart';
|
||||||
import 'cli_responder.dart';
|
import 'cli_responder.dart';
|
||||||
import 'config.dart';
|
import 'config.dart';
|
||||||
import 'database.dart';
|
import 'database.dart';
|
||||||
|
|
@ -16,7 +16,7 @@ import 'notifier.dart';
|
||||||
import 'workspace_manager.dart';
|
import 'workspace_manager.dart';
|
||||||
|
|
||||||
class IssueAssistantApp {
|
class IssueAssistantApp {
|
||||||
static final Logger _logger = Logger('code_work_spawner.app');
|
static final AppLogger _logger = AppLogger('code_work_spawner.app');
|
||||||
|
|
||||||
IssueAssistantApp._({
|
IssueAssistantApp._({
|
||||||
required this.config,
|
required this.config,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:github/github.dart';
|
import 'package:github/github.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
|
import '../app_logger.dart';
|
||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import '../database.dart';
|
import '../database.dart';
|
||||||
import '../issue_tracker_client.dart';
|
import '../issue_tracker_client.dart';
|
||||||
|
|
@ -129,14 +129,14 @@ class PollingIssueEventSource implements IssueEventSource {
|
||||||
PollingIssueEventSource({
|
PollingIssueEventSource({
|
||||||
required this.database,
|
required this.database,
|
||||||
required this.issueTrackerClient,
|
required this.issueTrackerClient,
|
||||||
required Logger logger,
|
required AppLogger logger,
|
||||||
}) : _logger = logger;
|
}) : _logger = logger;
|
||||||
|
|
||||||
static const Duration _pollCoalescingWindow = Duration(seconds: 2);
|
static const Duration _pollCoalescingWindow = Duration(seconds: 2);
|
||||||
|
|
||||||
final AppDatabase database;
|
final AppDatabase database;
|
||||||
final IssueTrackerClient issueTrackerClient;
|
final IssueTrackerClient issueTrackerClient;
|
||||||
final Logger _logger;
|
final AppLogger _logger;
|
||||||
final Map<String, _ProjectPollState> _projectPollStates = {};
|
final Map<String, _ProjectPollState> _projectPollStates = {};
|
||||||
final Set<Future<void>> _activePolls = <Future<void>>{};
|
final Set<Future<void>> _activePolls = <Future<void>>{};
|
||||||
Timer? _schedulerTimer;
|
Timer? _schedulerTimer;
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:github/github.dart';
|
import 'package:github/github.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
|
import '../app_logger.dart';
|
||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import '../issue_tracker_client.dart';
|
import '../issue_tracker_client.dart';
|
||||||
import 'base.dart';
|
import 'base.dart';
|
||||||
|
|
@ -13,14 +13,14 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
GiteaGosmeeIssueEventSource({
|
GiteaGosmeeIssueEventSource({
|
||||||
required this.issueTrackerClient,
|
required this.issueTrackerClient,
|
||||||
required this.gosmeeCommand,
|
required this.gosmeeCommand,
|
||||||
required Logger logger,
|
required AppLogger logger,
|
||||||
}) : _logger = logger;
|
}) : _logger = logger;
|
||||||
|
|
||||||
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
|
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
|
||||||
|
|
||||||
final IssueTrackerClient issueTrackerClient;
|
final IssueTrackerClient issueTrackerClient;
|
||||||
final String gosmeeCommand;
|
final String gosmeeCommand;
|
||||||
final Logger _logger;
|
final AppLogger _logger;
|
||||||
final Map<String, _QueuedGiteaIssue> _queuedIssues = {};
|
final Map<String, _QueuedGiteaIssue> _queuedIssues = {};
|
||||||
final Set<Future<void>> _activeDispatches = <Future<void>>{};
|
final Set<Future<void>> _activeDispatches = <Future<void>>{};
|
||||||
final Map<String, Process> _gosmeeProcesses = {};
|
final Map<String, Process> _gosmeeProcesses = {};
|
||||||
|
|
@ -80,13 +80,33 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
|
|
||||||
final localUrl =
|
final localUrl =
|
||||||
'http://${server.address.address}:${server.port}/gitea/webhooks';
|
'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) {
|
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);
|
final publicUrl = await _createGosmeeChannelUrl(localUrl);
|
||||||
|
_log(
|
||||||
|
'gitea/gosmee project=${project.key} channel_ready '
|
||||||
|
'public_url=$publicUrl local_url=$localUrl',
|
||||||
|
);
|
||||||
final webhookId = await issueTrackerClient.createGiteaIssueWebhook(
|
final webhookId = await issueTrackerClient.createGiteaIssueWebhook(
|
||||||
repo: project.repoSlug,
|
repo: project.repoSlug,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
);
|
);
|
||||||
_webhookIdsByProject[project.key] = webhookId;
|
_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, [
|
final process = await Process.start(gosmeeCommand, [
|
||||||
'client',
|
'client',
|
||||||
publicUrl,
|
publicUrl,
|
||||||
|
|
@ -170,6 +190,10 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
_webhookIdsByProject.clear();
|
_webhookIdsByProject.clear();
|
||||||
for (final entry in webhookIds.entries) {
|
for (final entry in webhookIds.entries) {
|
||||||
try {
|
try {
|
||||||
|
_log(
|
||||||
|
'gitea/gosmee project=${entry.key} webhook_delete '
|
||||||
|
'webhook_id=${entry.value}',
|
||||||
|
);
|
||||||
await issueTrackerClient.deleteGiteaWebhook(
|
await issueTrackerClient.deleteGiteaWebhook(
|
||||||
repo: _repoSlugsByProject[entry.key]!,
|
repo: _repoSlugsByProject[entry.key]!,
|
||||||
webhookId: entry.value,
|
webhookId: entry.value,
|
||||||
|
|
@ -196,6 +220,10 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
required IssueEventBatchHandler onBatch,
|
required IssueEventBatchHandler onBatch,
|
||||||
}) async {
|
}) async {
|
||||||
if (request.method != 'POST') {
|
if (request.method != 'POST') {
|
||||||
|
_log(
|
||||||
|
'gitea/gosmee webhook ignored method=${request.method} '
|
||||||
|
'path=${request.uri.path}',
|
||||||
|
);
|
||||||
request.response
|
request.response
|
||||||
..statusCode = HttpStatus.methodNotAllowed
|
..statusCode = HttpStatus.methodNotAllowed
|
||||||
..write('Only POST is supported.');
|
..write('Only POST is supported.');
|
||||||
|
|
@ -215,6 +243,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
: null;
|
: null;
|
||||||
final project = fullName == null ? null : repoToProject[fullName];
|
final project = fullName == null ? null : repoToProject[fullName];
|
||||||
final issueNode = payload['issue'];
|
final issueNode = payload['issue'];
|
||||||
|
final action = payload['action']?.toString() ?? 'unknown';
|
||||||
if (project != null &&
|
if (project != null &&
|
||||||
issueNode is Map<String, dynamic> &&
|
issueNode is Map<String, dynamic> &&
|
||||||
(issueNode['pull_request'] == null)) {
|
(issueNode['pull_request'] == null)) {
|
||||||
|
|
@ -222,6 +251,10 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
project.repoSlug,
|
project.repoSlug,
|
||||||
issueNode,
|
issueNode,
|
||||||
);
|
);
|
||||||
|
_log(
|
||||||
|
'gitea/gosmee webhook accepted project=${project.key} '
|
||||||
|
'repo=$fullName action=$action issue=${issue.number} state=${issue.state}',
|
||||||
|
);
|
||||||
if (issue.state == 'open') {
|
if (issue.state == 'open') {
|
||||||
_queuedIssues['${project.key}#${issue.number}'] = _QueuedGiteaIssue(
|
_queuedIssues['${project.key}#${issue.number}'] = _QueuedGiteaIssue(
|
||||||
project: project,
|
project: project,
|
||||||
|
|
@ -230,6 +263,13 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
);
|
);
|
||||||
_scheduleFlush();
|
_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;
|
request.response.statusCode = HttpStatus.accepted;
|
||||||
|
|
@ -263,6 +303,9 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
if (stdoutText.isEmpty) {
|
if (stdoutText.isEmpty) {
|
||||||
throw const FormatException('gosmee did not print a public URL.');
|
throw const FormatException('gosmee did not print a public URL.');
|
||||||
}
|
}
|
||||||
|
_log(
|
||||||
|
'gitea/gosmee channel_raw_output local_url=$localUrl stdout=$stdoutText',
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final decodedJson = jsonDecode(stdoutText);
|
final decodedJson = jsonDecode(stdoutText);
|
||||||
if (decodedJson is Map<String, dynamic>) {
|
if (decodedJson is Map<String, dynamic>) {
|
||||||
|
|
@ -350,12 +393,14 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
required Stream<List<int>> stream,
|
required Stream<List<int>> stream,
|
||||||
required String level,
|
required String level,
|
||||||
}) {
|
}) {
|
||||||
utf8.decoder
|
utf8.decoder.bind(stream).transform(const LineSplitter()).listen((line) {
|
||||||
.bind(stream)
|
final message = 'gitea/gosmee project=${project.key} $level=$line';
|
||||||
.transform(const LineSplitter())
|
if (level == 'stderr') {
|
||||||
.listen(
|
_logError(message);
|
||||||
(line) => _log('gitea/gosmee project=${project.key} $level=$line'),
|
return;
|
||||||
);
|
}
|
||||||
|
_log(message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _log(String message) {
|
void _log(String message) {
|
||||||
|
|
@ -363,7 +408,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _logError(String message) {
|
void _logError(String message) {
|
||||||
_logger.warning(message);
|
_logger.error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:github/github.dart';
|
import 'package:github/github.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
|
import '../app_logger.dart';
|
||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import '../issue_tracker_client.dart';
|
import '../issue_tracker_client.dart';
|
||||||
import 'base.dart';
|
import 'base.dart';
|
||||||
|
|
@ -13,14 +13,14 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
GitHubGosmeeIssueEventSource({
|
GitHubGosmeeIssueEventSource({
|
||||||
required this.issueTrackerClient,
|
required this.issueTrackerClient,
|
||||||
required this.gosmeeCommand,
|
required this.gosmeeCommand,
|
||||||
required Logger logger,
|
required AppLogger logger,
|
||||||
}) : _logger = logger;
|
}) : _logger = logger;
|
||||||
|
|
||||||
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
|
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
|
||||||
|
|
||||||
final IssueTrackerClient issueTrackerClient;
|
final IssueTrackerClient issueTrackerClient;
|
||||||
final String gosmeeCommand;
|
final String gosmeeCommand;
|
||||||
final Logger _logger;
|
final AppLogger _logger;
|
||||||
final Map<String, _QueuedGitHubIssue> _queuedIssues = {};
|
final Map<String, _QueuedGitHubIssue> _queuedIssues = {};
|
||||||
final Set<Future<void>> _activeDispatches = <Future<void>>{};
|
final Set<Future<void>> _activeDispatches = <Future<void>>{};
|
||||||
final Map<String, Process> _gosmeeProcesses = {};
|
final Map<String, Process> _gosmeeProcesses = {};
|
||||||
|
|
@ -80,13 +80,33 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
|
|
||||||
final localUrl =
|
final localUrl =
|
||||||
'http://${server.address.address}:${server.port}/github/webhooks';
|
'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) {
|
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);
|
final publicUrl = await _createGosmeeChannelUrl(localUrl);
|
||||||
|
_log(
|
||||||
|
'github/gosmee project=${project.key} channel_ready '
|
||||||
|
'public_url=$publicUrl local_url=$localUrl',
|
||||||
|
);
|
||||||
final webhookId = await issueTrackerClient.createGitHubIssueWebhook(
|
final webhookId = await issueTrackerClient.createGitHubIssueWebhook(
|
||||||
repo: project.repoSlug,
|
repo: project.repoSlug,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
);
|
);
|
||||||
_webhookIdsByProject[project.key] = webhookId;
|
_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, [
|
final process = await Process.start(gosmeeCommand, [
|
||||||
'client',
|
'client',
|
||||||
publicUrl,
|
publicUrl,
|
||||||
|
|
@ -170,6 +190,10 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
_webhookIdsByProject.clear();
|
_webhookIdsByProject.clear();
|
||||||
for (final entry in webhookIds.entries) {
|
for (final entry in webhookIds.entries) {
|
||||||
try {
|
try {
|
||||||
|
_log(
|
||||||
|
'github/gosmee project=${entry.key} webhook_delete '
|
||||||
|
'webhook_id=${entry.value}',
|
||||||
|
);
|
||||||
await issueTrackerClient.deleteGitHubWebhook(
|
await issueTrackerClient.deleteGitHubWebhook(
|
||||||
repo: _repoSlugsByProject[entry.key]!,
|
repo: _repoSlugsByProject[entry.key]!,
|
||||||
webhookId: entry.value,
|
webhookId: entry.value,
|
||||||
|
|
@ -196,6 +220,10 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
required IssueEventBatchHandler onBatch,
|
required IssueEventBatchHandler onBatch,
|
||||||
}) async {
|
}) async {
|
||||||
if (request.method != 'POST') {
|
if (request.method != 'POST') {
|
||||||
|
_log(
|
||||||
|
'github/gosmee webhook ignored method=${request.method} '
|
||||||
|
'path=${request.uri.path}',
|
||||||
|
);
|
||||||
request.response
|
request.response
|
||||||
..statusCode = HttpStatus.methodNotAllowed
|
..statusCode = HttpStatus.methodNotAllowed
|
||||||
..write('Only POST is supported.');
|
..write('Only POST is supported.');
|
||||||
|
|
@ -215,6 +243,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
: null;
|
: null;
|
||||||
final project = fullName == null ? null : repoToProject[fullName];
|
final project = fullName == null ? null : repoToProject[fullName];
|
||||||
final issueNode = payload['issue'];
|
final issueNode = payload['issue'];
|
||||||
|
final action = payload['action']?.toString() ?? 'unknown';
|
||||||
if (project != null &&
|
if (project != null &&
|
||||||
issueNode is Map<String, dynamic> &&
|
issueNode is Map<String, dynamic> &&
|
||||||
(issueNode['pull_request'] == null)) {
|
(issueNode['pull_request'] == null)) {
|
||||||
|
|
@ -222,6 +251,10 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
project.repoSlug,
|
project.repoSlug,
|
||||||
issueNode,
|
issueNode,
|
||||||
);
|
);
|
||||||
|
_log(
|
||||||
|
'github/gosmee webhook accepted project=${project.key} '
|
||||||
|
'repo=$fullName action=$action issue=${issue.number} state=${issue.state}',
|
||||||
|
);
|
||||||
if (issue.state == 'open') {
|
if (issue.state == 'open') {
|
||||||
_queuedIssues['${project.key}#${issue.number}'] = _QueuedGitHubIssue(
|
_queuedIssues['${project.key}#${issue.number}'] = _QueuedGitHubIssue(
|
||||||
project: project,
|
project: project,
|
||||||
|
|
@ -230,6 +263,13 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
);
|
);
|
||||||
_scheduleFlush();
|
_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;
|
request.response.statusCode = HttpStatus.accepted;
|
||||||
|
|
@ -264,6 +304,9 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
if (stdoutText.isEmpty) {
|
if (stdoutText.isEmpty) {
|
||||||
throw const FormatException('gosmee did not print a public URL.');
|
throw const FormatException('gosmee did not print a public URL.');
|
||||||
}
|
}
|
||||||
|
_log(
|
||||||
|
'github/gosmee channel_raw_output local_url=$localUrl stdout=$stdoutText',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(stdoutText);
|
final decoded = jsonDecode(stdoutText);
|
||||||
|
|
@ -355,7 +398,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
utf8.decoder.bind(stream).transform(const LineSplitter()).listen((line) {
|
utf8.decoder.bind(stream).transform(const LineSplitter()).listen((line) {
|
||||||
final message = 'github/gosmee project=${project.key} $level=$line';
|
final message = 'github/gosmee project=${project.key} $level=$line';
|
||||||
if (level == 'stderr') {
|
if (level == 'stderr') {
|
||||||
_logger.severe(message);
|
_logger.error(message);
|
||||||
} else {
|
} else {
|
||||||
_logger.info(message);
|
_logger.info(message);
|
||||||
}
|
}
|
||||||
|
|
@ -364,7 +407,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
|
|
||||||
void _log(String message) => _logger.info(message);
|
void _log(String message) => _logger.info(message);
|
||||||
|
|
||||||
void _logError(String message) => _logger.severe(message);
|
void _logError(String message) => _logger.error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QueuedGitHubIssue {
|
class _QueuedGitHubIssue {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:logging/logging.dart';
|
import '../app_logger.dart';
|
||||||
|
|
||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import '../issue_tracker_client.dart';
|
import '../issue_tracker_client.dart';
|
||||||
import 'base.dart';
|
import 'base.dart';
|
||||||
|
|
@ -11,13 +10,13 @@ import 'base.dart';
|
||||||
class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||||
GitHubWebhookForwardIssueEventSource({
|
GitHubWebhookForwardIssueEventSource({
|
||||||
required this.issueTrackerClient,
|
required this.issueTrackerClient,
|
||||||
required Logger logger,
|
required AppLogger logger,
|
||||||
}) : _logger = logger;
|
}) : _logger = logger;
|
||||||
|
|
||||||
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
|
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
|
||||||
|
|
||||||
final IssueTrackerClient issueTrackerClient;
|
final IssueTrackerClient issueTrackerClient;
|
||||||
final Logger _logger;
|
final AppLogger _logger;
|
||||||
final Map<String, _QueuedWebhookIssue> _queuedIssues = {};
|
final Map<String, _QueuedWebhookIssue> _queuedIssues = {};
|
||||||
final Set<Future<void>> _activeDispatches = <Future<void>>{};
|
final Set<Future<void>> _activeDispatches = <Future<void>>{};
|
||||||
final Map<String, Process> _forwardProcesses = {};
|
final Map<String, Process> _forwardProcesses = {};
|
||||||
|
|
@ -72,7 +71,15 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||||
|
|
||||||
final url =
|
final url =
|
||||||
'http://${server.address.address}:${server.port}/github/webhooks';
|
'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) {
|
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, [
|
final process = await Process.start(issueTrackerClient.ghCommand, [
|
||||||
'webhook',
|
'webhook',
|
||||||
'forward',
|
'forward',
|
||||||
|
|
@ -168,6 +175,10 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||||
required IssueEventBatchHandler onBatch,
|
required IssueEventBatchHandler onBatch,
|
||||||
}) async {
|
}) async {
|
||||||
if (request.method != 'POST') {
|
if (request.method != 'POST') {
|
||||||
|
_log(
|
||||||
|
'github/webhook-forward webhook ignored method=${request.method} '
|
||||||
|
'path=${request.uri.path}',
|
||||||
|
);
|
||||||
request.response
|
request.response
|
||||||
..statusCode = HttpStatus.methodNotAllowed
|
..statusCode = HttpStatus.methodNotAllowed
|
||||||
..write('Only POST is supported.');
|
..write('Only POST is supported.');
|
||||||
|
|
@ -187,6 +198,7 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||||
: null;
|
: null;
|
||||||
final project = fullName == null ? null : repoToProject[fullName];
|
final project = fullName == null ? null : repoToProject[fullName];
|
||||||
final issueNode = payload['issue'];
|
final issueNode = payload['issue'];
|
||||||
|
final action = payload['action']?.toString() ?? 'unknown';
|
||||||
if (project != null &&
|
if (project != null &&
|
||||||
issueNode is Map<String, dynamic> &&
|
issueNode is Map<String, dynamic> &&
|
||||||
(issueNode['pull_request'] == null)) {
|
(issueNode['pull_request'] == null)) {
|
||||||
|
|
@ -194,6 +206,10 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||||
project.repoSlug,
|
project.repoSlug,
|
||||||
issueNode,
|
issueNode,
|
||||||
);
|
);
|
||||||
|
_log(
|
||||||
|
'github/webhook-forward webhook accepted project=${project.key} '
|
||||||
|
'repo=$fullName action=$action issue=${issue.number} state=${issue.state}',
|
||||||
|
);
|
||||||
if (issue.state == 'open') {
|
if (issue.state == 'open') {
|
||||||
_queuedIssues['${project.key}#${issue.number}'] = _QueuedWebhookIssue(
|
_queuedIssues['${project.key}#${issue.number}'] = _QueuedWebhookIssue(
|
||||||
project: project,
|
project: project,
|
||||||
|
|
@ -202,6 +218,13 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||||
);
|
);
|
||||||
_scheduleFlush();
|
_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;
|
request.response.statusCode = HttpStatus.accepted;
|
||||||
|
|
@ -294,7 +317,7 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _logError(String message) {
|
void _logError(String message) {
|
||||||
_logger.severe(message);
|
_logger.error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ dependencies:
|
||||||
yaml: ^3.1.3
|
yaml: ^3.1.3
|
||||||
result_dart: ^2.1.1
|
result_dart: ^2.1.1
|
||||||
dotenv: ^4.2.0
|
dotenv: ^4.2.0
|
||||||
logging: ^1.3.0
|
logger: ^2.6.2
|
||||||
meta: ^1.16.0
|
meta: ^1.16.0
|
||||||
github: ^9.25.0
|
github: ^9.25.0
|
||||||
git: ^2.3.2
|
git: ^2.3.2
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:test/test.dart';
|
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 a gh script that writes a webhook creation failure to stderr while forwarding
|
||||||
/// And an orchestrator-style config that uses gh/webhook-forward
|
/// And an orchestrator-style config that uses gh/webhook-forward
|
||||||
/// When the long-running app starts and webhook-forward emits that stderr line
|
/// 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 {
|
test('Log webhook forward stderr at error level', () async {
|
||||||
// Given a temporary project checkout that can be used as the local repo path.
|
// 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'),
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
ghCommand: ghScript.path,
|
ghCommand: ghScript.path,
|
||||||
);
|
);
|
||||||
final records = <LogRecord>[];
|
final records = <AppLogRecord>[];
|
||||||
final previousLevel = Logger.root.level;
|
void captureLog(AppLogRecord record) {
|
||||||
Logger.root.level = Level.ALL;
|
records.add(record);
|
||||||
final subscription = Logger.root.onRecord.listen(records.add);
|
}
|
||||||
|
|
||||||
|
AppLogger.addListener(captureLog);
|
||||||
|
|
||||||
// When the long-running app starts and webhook-forward emits that stderr line.
|
// When the long-running app starts and webhook-forward emits that stderr line.
|
||||||
final runFuture = app.run();
|
final runFuture = app.run();
|
||||||
|
|
@ -210,14 +211,13 @@ projects:
|
||||||
}
|
}
|
||||||
await app.close();
|
await app.close();
|
||||||
await runFuture;
|
await runFuture;
|
||||||
await subscription.cancel();
|
AppLogger.removeListener(captureLog);
|
||||||
Logger.root.level = previousLevel;
|
|
||||||
|
|
||||||
// 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(
|
expect(
|
||||||
records.any(
|
records.any(
|
||||||
(record) =>
|
(record) =>
|
||||||
record.level == Level.SEVERE &&
|
record.level == AppLogLevel.error &&
|
||||||
record.message.contains(
|
record.message.contains(
|
||||||
'stderr=Error: error creating webhook: HTTP 422: Validation Failed',
|
'stderr=Error: error creating webhook: HTTP 422: Validation Failed',
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
|
@ -300,16 +299,15 @@ projects:
|
||||||
ghCommand: ghScript.path,
|
ghCommand: ghScript.path,
|
||||||
);
|
);
|
||||||
final logMessages = <String>[];
|
final logMessages = <String>[];
|
||||||
final previousLevel = Logger.root.level;
|
void captureLog(AppLogRecord record) {
|
||||||
Logger.root.level = Level.INFO;
|
|
||||||
final subscription = Logger.root.onRecord.listen((record) {
|
|
||||||
logMessages.add(record.message);
|
logMessages.add(record.message);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
AppLogger.addListener(captureLog);
|
||||||
|
|
||||||
// When the app processes one polling cycle while app logs are captured.
|
// When the app processes one polling cycle while app logs are captured.
|
||||||
await app.runOnce();
|
await app.runOnce();
|
||||||
await subscription.cancel();
|
AppLogger.removeListener(captureLog);
|
||||||
Logger.root.level = previousLevel;
|
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|
||||||
// Then the application logs include the responder stdout payload.
|
// Then the application logs include the responder stdout payload.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
|
@ -85,11 +84,11 @@ projects:
|
||||||
ghCommand: ghScript.path,
|
ghCommand: ghScript.path,
|
||||||
);
|
);
|
||||||
final logMessages = <String>[];
|
final logMessages = <String>[];
|
||||||
final previousLevel = Logger.root.level;
|
void captureLog(AppLogRecord record) {
|
||||||
Logger.root.level = Level.INFO;
|
|
||||||
final subscription = Logger.root.onRecord.listen((record) {
|
|
||||||
logMessages.add(record.message);
|
logMessages.add(record.message);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
AppLogger.addListener(captureLog);
|
||||||
|
|
||||||
// When the long-running app starts and enough time passes for a retry.
|
// When the long-running app starts and enough time passes for a retry.
|
||||||
final runFuture = app.run();
|
final runFuture = app.run();
|
||||||
|
|
@ -114,8 +113,7 @@ projects:
|
||||||
}
|
}
|
||||||
await app.close();
|
await app.close();
|
||||||
await runFuture;
|
await runFuture;
|
||||||
await subscription.cancel();
|
AppLogger.removeListener(captureLog);
|
||||||
Logger.root.level = previousLevel;
|
|
||||||
|
|
||||||
// Then the app logs the failed poll without stopping entirely.
|
// Then the app logs the failed poll without stopping entirely.
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue