import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:github/github.dart'; import 'package:logging/logging.dart'; import 'cli_responder.dart'; import 'config.dart'; import 'database.dart'; import 'issue_tracker_client.dart'; import 'notifier.dart'; import 'workspace_manager.dart'; class IssueAssistantApp { static final Logger _logger = Logger('code_work_spawner.app'); static const Duration _pollCoalescingWindow = Duration(seconds: 2); IssueAssistantApp._({ required this.config, required this.database, required this.issueTrackerClient, required this.responderRunner, required this.workspaceManager, required this.notifierRegistryFactory, }); final AppConfig config; final AppDatabase database; final IssueTrackerClient issueTrackerClient; final CliResponderRunner responderRunner; final WorkspaceManager workspaceManager; final NotifierRegistry Function(ProjectConfig project) notifierRegistryFactory; final Map _projectPollStates = {}; final Set> _activePolls = >{}; Timer? _schedulerTimer; Completer? _runCompleter; bool _isClosing = false; bool _isClosed = false; static Future open({ required AppConfig config, required String databasePath, String ghCommand = 'gh', String teaCommand = 'tea', }) async { final database = AppDatabase.open(databasePath); return IssueAssistantApp._( config: config, database: database, issueTrackerClient: IssueTrackerClient( ghCommand: ghCommand, teaCommand: teaCommand, ), responderRunner: CliResponderRunner(), workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir), notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs( project.issueAssistant.notifications, log: _logger.warning, ), ); } Future run() async { if (_isClosed) { throw StateError('IssueAssistantApp is already closed.'); } if (_runCompleter != null) { return _runCompleter!.future; } final enabledProjects = config.projects.values .where((project) => project.issueAssistant.enabled) .toList(growable: false); if (enabledProjects.isEmpty) { _log('no enabled projects configured'); return; } final completer = Completer(); _runCompleter = completer; final now = DateTime.now(); for (final project in enabledProjects) { _projectPollStates.putIfAbsent( project.key, () => _ProjectPollState(project: project, nextDueAt: now), ); } unawaited(_triggerDueProjectPolls(source: 'startup')); try { await completer.future; } finally { _cancelScheduler(); } } Future runOnce() async { _log('starting poll cycle projects=${config.projects.length}'); final enabledProjects = []; for (final project in config.projects.values) { if (!project.issueAssistant.enabled) { _log('skip project=${project.key} reason=issue_assistant_disabled'); continue; } enabledProjects.add(project); } if (enabledProjects.isEmpty) { _log('no enabled projects configured'); return; } await _pollProjects(enabledProjects); _log('poll cycle completed'); } Future close() async { if (_isClosed || _isClosing) { return; } _isClosing = true; _cancelScheduler(); _completeRunIfPending(); await Future.wait(_activePolls.toList(growable: false)); await database.close(); _isClosed = true; _isClosing = false; } Future _triggerDueProjectPolls({required String source}) async { if (_isClosing || _isClosed) { return; } final now = DateTime.now(); final dueStates = _projectPollStates.values .where((state) => !state.isRunning && !state.nextDueAt.isAfter(now)) .toList(growable: false); if (dueStates.isEmpty) { _scheduleNextPoll(); return; } late final Future pollFuture; pollFuture = () async { for (final state in dueStates) { state.isRunning = true; } final stopwatch = Stopwatch()..start(); final projects = dueStates .map((state) => state.project) .toList(growable: false); _log( 'poll tick source=$source projects=${projects.map((p) => p.key).join(",")}', ); try { await _pollProjects(projects); stopwatch.stop(); final batchCompletedAt = DateTime.now(); for (final state in dueStates) { while (!state.nextDueAt.isAfter(batchCompletedAt)) { state.nextDueAt = state.nextDueAt.add( state.project.issueAssistant.pollInterval, ); } } _log( 'poll tick source=$source projects=${projects.map((p) => p.key).join(",")} ' 'duration_ms=${stopwatch.elapsedMilliseconds}', ); } catch (error, stackTrace) { stopwatch.stop(); _logError( 'poll tick source=$source projects=${projects.map((p) => p.key).join(",")} ' 'duration_ms=${stopwatch.elapsedMilliseconds} error=$error', ); if (_runCompleter != null) { _logError( 'poll tick source=$source will_retry=true ' 'next_run=scheduled stack_trace=$stackTrace', ); } } finally { for (final state in dueStates) { state.isRunning = false; } _activePolls.remove(pollFuture); _scheduleNextPoll(); } }(); _activePolls.add(pollFuture); await pollFuture; } void _cancelScheduler() { _schedulerTimer?.cancel(); _schedulerTimer = null; } void _scheduleNextPoll() { if (_isClosing || _isClosed || _runCompleter == null) { return; } _cancelScheduler(); DateTime? nextDueAt; for (final state in _projectPollStates.values) { if (state.isRunning) { continue; } if (nextDueAt == null || state.nextDueAt.isBefore(nextDueAt)) { nextDueAt = state.nextDueAt; } } if (nextDueAt == null) { return; } final scheduledAt = nextDueAt.add(_pollCoalescingWindow); final delay = scheduledAt.difference(DateTime.now()); _schedulerTimer = Timer( delay.isNegative ? Duration.zero : delay, () => unawaited(_triggerDueProjectPolls(source: 'interval')), ); } void _completeRunIfPending() { final completer = _runCompleter; if (completer != null && !completer.isCompleted) { completer.complete(); } } Future _pollProjects(List projects) async { final pollRunIds = {}; final projectsByRepo = { for (final project in projects) _projectRepoKey(project): project, }; final sinceByProject = {}; final latestSeenByProject = {}; for (final project in projects) { pollRunIds[project.key] = await database.startPollRun(project.key); final repoDirectory = Directory(project.path); final repoExists = await repoDirectory.exists(); final projectState = await database.findProjectState(project.key); final since = projectState?.lastSeenUpdatedAt == null ? null : DateTime.parse(projectState!.lastSeenUpdatedAt!); sinceByProject[project.key] = since; latestSeenByProject[project.key] = since; _log( 'poll project=${project.key} repo=${project.repo} ' 'path=${project.path} path_exists=$repoExists ' 'since=${since?.toUtc().toIso8601String() ?? "initial"}', ); } try { final issuesByProject = >{ for (final project in projects) project.key: [], }; for (final provider in IssueTrackerProvider.values) { final providerProjects = projects .where((project) => project.provider == provider) .toList(growable: false); if (providerProjects.isEmpty) { continue; } final providerSince = providerProjects .map((project) => sinceByProject[project.key]) .whereType() .fold(null, (current, next) { if (current == null || next.isBefore(current)) { return next; } return current; }); final issues = await issueTrackerClient.fetchUpdatedIssuesForRepos( provider: provider, repos: providerProjects.map((project) => project.repoSlug), since: providerSince, ); for (final issue in issues) { final project = projectsByRepo[_providerRepoKey(provider, issue.repoSlug)]; if (project == null) { continue; } final since = sinceByProject[project.key]; if (since != null && !issue.updatedAt.isAfter(since)) { continue; } issuesByProject[project.key]!.add(issue); final latestSeen = latestSeenByProject[project.key]; if (latestSeen == null || issue.updatedAt.isAfter(latestSeen)) { latestSeenByProject[project.key] = issue.updatedAt; } } } for (final project in projects) { final projectIssues = issuesByProject[project.key]!; _log('project=${project.key} fetched_issues=${projectIssues.length}'); _log( 'project=${project.key} issue_queue=${projectIssues.length} ' 'max_concurrent=${project.issueAssistant.maxConcurrentIssues}', ); await _processIssuesConcurrently(project, projectIssues); await database.upsertProjectState( projectKey: project.key, repo: project.repo, lastSeenUpdatedAt: latestSeenByProject[project.key], ); await database.finishPollRun( pollRunId: pollRunIds.remove(project.key)!, success: true, ); _log( 'poll project=${project.key} completed latest_seen=' '${latestSeenByProject[project.key]?.toUtc().toIso8601String() ?? "unchanged"}', ); } } catch (error) { for (final entry in pollRunIds.entries) { await database.finishPollRun( pollRunId: entry.value, success: false, error: error.toString(), ); _log('poll project=${entry.key} failed error=$error'); } rethrow; } } Future _processIssuesConcurrently( ProjectConfig project, List issues, ) async { if (issues.isEmpty) { return; } var nextIndex = 0; Future worker() async { while (true) { final currentIndex = nextIndex; if (currentIndex >= issues.length) { return; } nextIndex += 1; final activeCount = currentIndex + 1 <= project.issueAssistant.maxConcurrentIssues ? currentIndex + 1 : project.issueAssistant.maxConcurrentIssues; _log( 'project=${project.key} process_issue issue=${issues[currentIndex].number} ' 'queue_index=${currentIndex + 1}/${issues.length} active_limit=$activeCount', ); await _processIssue(project, issues[currentIndex]); } } final workerCount = issues.length < project.issueAssistant.maxConcurrentIssues ? issues.length : project.issueAssistant.maxConcurrentIssues; await Future.wait([for (var i = 0; i < workerCount; i += 1) worker()]); } Future _processIssue( ProjectConfig project, GitHubIssueSummary issue, ) async { final notifierRegistry = notifierRegistryFactory(project); final thread = await issueTrackerClient.fetchThread( provider: project.provider, repo: project.repoSlug, issue: issue, ); final fingerprint = _fingerprintThread(thread); final record = await database.findIssueThread(project.key, issue.number); if (record?.lastEvaluatedFingerprint == fingerprint) { _log( 'skip issue=${project.key}#${issue.number} reason=fingerprint_unchanged', ); return; } final trigger = _determineTrigger(project, thread); _log( 'evaluate issue=${project.key}#${issue.number} ' 'comments=${thread.comments.length} trigger=$trigger', ); final baseNotificationContext = NotificationContext( projectKey: project.key, repo: project.repo, issueNumber: issue.number, issueTitle: thread.issue.title, issueUrl: thread.issue.url, trigger: trigger, ); if (trigger == 'mention') { await notifierRegistry.notify( event: NotificationEvent.mentionDetected, context: baseNotificationContext, ); } final jobId = await database.createJob( projectKey: project.key, issueNumber: issue.number, fingerprint: fingerprint, trigger: trigger, status: JobStatus.queued, ); await database.upsertIssueThread( projectKey: project.key, issueNumber: issue.number, fingerprint: fingerprint, updatedAt: issue.updatedAt, lastEvaluatedFingerprint: record?.lastEvaluatedFingerprint, lastDecision: record?.lastDecision, lastCommentId: record?.lastCommentId, lastCommentUrl: record?.lastCommentUrl, ); await database.updateJobStatus(jobId, JobStatus.running); final forceReply = trigger == 'mention'; final prompt = _buildPrompt( project: project, thread: thread, trigger: trigger, ); if (project.issueAssistant.responders.isEmpty) { _logError( 'issue=${project.key}#${issue.number} ' 'skipped no_responders_configured', ); await database.upsertIssueThread( projectKey: project.key, issueNumber: issue.number, fingerprint: fingerprint, updatedAt: issue.updatedAt, lastEvaluatedFingerprint: fingerprint, lastDecision: record?.lastDecision, lastCommentId: record?.lastCommentId, lastCommentUrl: record?.lastCommentUrl, ); await database.updateJobStatus(jobId, JobStatus.skipped); return; } ResponderResult? selectedResult; Object? lastError; final needsWorktree = _looksLikeBugVerification(thread); final workspacePath = await workspaceManager.createEphemeralWorktree( project.path, ); _log( 'issue=${project.key}#${issue.number} workspace_mode=' 'worktree workspace=$workspacePath', ); try { for (final responder in project.issueAssistant.responders) { _log( 'run responder=${responder.id} issue=${project.key}#${issue.number} ' 'mode_hint=${needsWorktree ? "bug_verification" : "planning"} ' 'workspace=$workspacePath', ); try { final result = await responderRunner.run( responder: responder, project: project, thread: thread, prompt: prompt, forceReply: forceReply, workspacePath: workspacePath, ); await database.addExecutionRun( jobId: jobId, responderId: result.responderId, mode: result.mode, decision: result.decision, success: true, workspacePath: workspacePath, stdout: result.rawStdout, stderr: result.rawStderr, ); _logResponderOutput( responderId: responder.id, issueKey: '${project.key}#${issue.number}', stdout: result.rawStdout, stderr: result.rawStderr, ); _log( 'responder=${responder.id} issue=${project.key}#${issue.number}, ' 'title="${thread.issue.title}", ' 'decision=${result.decision}, mode=${result.mode}', ); selectedResult = forceReply && !result.shouldReply ? result.forceReply() : result; if (forceReply && !result.shouldReply) { _log( 'responder=${responder.id} issue=${project.key}#${issue.number} ' 'override=force_reply original_decision=${result.decision}', ); } break; } catch (error) { lastError = error; final rawStdout = switch (error) { CliResponderExecutionException(:final rawStdout) => rawStdout, _ => '', }; final rawStderr = switch (error) { CliResponderExecutionException(:final rawStderr) => rawStderr, _ => error.toString(), }; _logResponderOutput( responderId: responder.id, issueKey: '${project.key}#${issue.number}', stdout: rawStdout, stderr: rawStderr, ); _log( 'responder=${responder.id} issue=${project.key}#${issue.number} ' 'failed error=$error', ); await notifierRegistry.notify( event: NotificationEvent.responderFailed, context: NotificationContext( projectKey: project.key, repo: project.repo, issueNumber: issue.number, issueTitle: thread.issue.title, issueUrl: thread.issue.url, trigger: trigger, responderId: responder.id, errorSummary: error.toString(), ), ); await database.addExecutionRun( jobId: jobId, responderId: responder.id, mode: 'error', decision: 'no_reply', success: false, workspacePath: workspacePath, stdout: rawStdout, stderr: rawStderr, ); } } } finally { await workspaceManager.disposeEphemeralWorktree(workspacePath); } if (selectedResult == null) { await database.updateJobStatus(jobId, JobStatus.failed); _log( 'issue=${project.key}#${issue.number} failed no_responder_succeeded', ); throw StateError( 'All responders failed for ${project.key}#${issue.number}: $lastError', ); } if (!selectedResult.shouldReply) { await database.upsertIssueThread( projectKey: project.key, issueNumber: issue.number, fingerprint: fingerprint, updatedAt: issue.updatedAt, lastEvaluatedFingerprint: fingerprint, lastDecision: selectedResult.decision, lastCommentId: record?.lastCommentId, lastCommentUrl: record?.lastCommentUrl, ); await database.updateJobStatus(jobId, JobStatus.completed); _log('issue=${project.key}#${issue.number} completed decision=no_reply'); await notifierRegistry.notify( event: NotificationEvent.noReply, context: NotificationContext( projectKey: project.key, repo: project.repo, issueNumber: issue.number, issueTitle: thread.issue.title, issueUrl: thread.issue.url, trigger: trigger, mode: selectedResult.mode, responderId: selectedResult.responderId, decision: selectedResult.decision, summary: selectedResult.summary, ), ); return; } final commentBody = await _decorateComment( project: project, issueNumber: issue.number, trigger: trigger, fingerprint: fingerprint, markdown: selectedResult.markdown, ); final posted = await issueTrackerClient.createIssueComment( provider: project.provider, repo: project.repoSlug, issueNumber: issue.number, body: commentBody, ); await database.addCommentRecord( projectKey: project.key, issueNumber: issue.number, fingerprint: fingerprint, githubCommentId: posted.id, githubCommentUrl: posted.url, body: posted.body, ); await database.upsertIssueThread( projectKey: project.key, issueNumber: issue.number, fingerprint: fingerprint, updatedAt: issue.updatedAt, lastEvaluatedFingerprint: fingerprint, lastDecision: selectedResult.decision, lastCommentId: posted.id, lastCommentUrl: posted.url, ); await database.updateJobStatus(jobId, JobStatus.completed); _log('issue=${project.key}#${issue.number} posted_comment_id=${posted.id}'); await notifierRegistry.notify( event: NotificationEvent.replyPosted, context: NotificationContext( projectKey: project.key, repo: project.repo, issueNumber: issue.number, issueTitle: thread.issue.title, issueUrl: thread.issue.url, trigger: trigger, mode: selectedResult.mode, responderId: selectedResult.responderId, decision: selectedResult.decision, summary: selectedResult.summary, ), ); } String _buildPrompt({ required ProjectConfig project, required IssueThread thread, required String trigger, }) { final payload = { 'repo': project.repo, 'tracker_provider': project.provider.name, 'project_key': project.key, 'project_path': project.path, 'issue_number': thread.issue.number.toString(), 'issue_title': thread.issue.title, 'issue_body': thread.issue.body, 'issue_url': thread.issue.url, 'thread_json': const JsonEncoder.withIndent( ' ', ).convert(thread.toJson()), 'mention_triggers': project.issueAssistant.mentionTriggers.join(', '), 'trigger': trigger, 'capabilities': project.issueAssistant.capabilities .map((capability) => capability.name) .join(', '), }; return renderTemplate(project.issueAssistant.prompt, payload); } String _determineTrigger(ProjectConfig project, IssueThread thread) { final latestText = thread.latestComment?.body ?? thread.issue.body; final latestAuthor = thread.latestComment?.userLogin ?? thread.issue.userLogin; final latestTextLower = latestText.toLowerCase(); final mention = project.issueAssistant.mentionTriggers.any( (trigger) => latestTextLower.contains(trigger.toLowerCase()), ); if (mention && !_looksLikeBot(project, latestAuthor)) { return 'mention'; } return 'update'; } bool _looksLikeBot(ProjectConfig project, String login) { final lower = login.toLowerCase(); if (lower.endsWith('[bot]')) { return true; } return lower.contains(project.issueAssistant.commentTag.toLowerCase()); } bool _looksLikeBugVerification(IssueThread thread) { final text = [ thread.issue.title, thread.issue.body, if (thread.latestComment != null) thread.latestComment!.body, ].join('\n').toLowerCase(); return text.contains('bug') && (text.contains('verify') || text.contains('test') || text.contains('reproduce')); } String _fingerprintThread(IssueThread thread) { final bytes = utf8.encode(jsonEncode(thread.toJson())); return sha256.convert(bytes).toString(); } Future _decorateComment({ required ProjectConfig project, required int issueNumber, required String trigger, required String fingerprint, required String markdown, }) async { var trackerLogin = 'unknown'; try { trackerLogin = await issueTrackerClient.getAuthenticatedLogin( provider: project.provider, ) ?? 'unknown'; } catch (error) { _log( 'comment_identity_lookup_failed project=${project.key} ' 'issue=$issueNumber error=$error', ); } final trackerCli = issueTrackerClient.trackerCliName(project.provider); final templateValues = { 'repo': project.repo, 'project_key': project.key, 'issue_number': issueNumber.toString(), 'trigger': trigger, 'comment_tag': project.issueAssistant.commentTag, 'fingerprint': fingerprint, 'tracker_cli': trackerCli, 'tracker_login': trackerLogin, 'gh_login': trackerLogin, }; final prefix = renderTemplate( project.issueAssistant.commentPrefixTemplate, templateValues, ).trim(); final footer = renderTemplate( project.issueAssistant.commentFooterTemplate, templateValues, ).trim(); final sections = [ if (prefix.isNotEmpty) prefix, markdown.trim(), if (footer.isNotEmpty) footer, '', ]; return sections.where((section) => section.isNotEmpty).join('\n\n'); } void _log(String message) { _logger.info(message); } void _logError(String message) { _logger.severe(message); } void _logResponderOutput({ required String responderId, required String issueKey, required String stdout, required String stderr, }) { if (stdout.isNotEmpty) { _log('responder=$responderId issue=$issueKey stream=stdout\n$stdout'); } if (stderr.isNotEmpty) { _log('responder=$responderId issue=$issueKey stream=stderr\n$stderr'); } } String _projectRepoKey(ProjectConfig project) { return _providerRepoKey(project.provider, project.repoSlug); } String _providerRepoKey( IssueTrackerProvider provider, RepositorySlug repoSlug, ) { return '${provider.name}:${repoSlug.fullName}'; } } class _ProjectPollState { _ProjectPollState({required this.project, required this.nextDueAt}); final ProjectConfig project; DateTime nextDueAt; bool isRunning = false; }