import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:github/github.dart'; import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; import '../config/app_environment.dart'; import '../config/app_logger.dart'; import '../config/config_schema.dart'; import '../core/process_launcher.dart'; import 'models.dart'; class IssueTrackerClient { IssueTrackerClient({ this.ghCommand = 'gh', this.glabCommand = 'glab', this.teaCommand = 'tea', this.opCommand = 'op', Duration? commandTimeout, AppLogger? logger, ExternalCommandExecutor? commandExecutor, }) : commandTimeout = commandTimeout ?? const Duration(seconds: 15), _logger = logger ?? AppLogger('code_work_spawner.issue_tracker'), _commandExecutor = commandExecutor ?? const DefaultExternalCommandExecutor(); final String ghCommand; final String glabCommand; final String teaCommand; final String opCommand; final Duration commandTimeout; final AppLogger _logger; final ExternalCommandExecutor _commandExecutor; final Map> _authenticatedLoginFutures = >{}; final Map> _openProjectProjectFutures = >{}; static const int _pageSize = 100; Future> fetchUpdatedIssuesForRepos({ required IssueTrackerProvider provider, required Iterable trackerProjects, required DateTime? since, }) { return switch (provider) { IssueTrackerProvider.github => _fetchUpdatedGitHubIssues( trackerProjects: trackerProjects, since: since, ), IssueTrackerProvider.gitlab => _fetchUpdatedGitLabIssues( trackerProjects: trackerProjects, since: since, ), IssueTrackerProvider.gitea => _fetchUpdatedGiteaIssues( trackerProjects: trackerProjects, since: since, ), IssueTrackerProvider.openproject => _fetchUpdatedOpenProjectIssues( trackerProjects: trackerProjects, since: since, ), }; } Future fetchThread({ required IssueTrackerProvider provider, required String trackerProject, required GitHubIssueSummary issue, }) async { final openProjectConfig = provider == IssueTrackerProvider.openproject ? await _loadOpenProjectConfig() : null; final commentJson = switch (provider) { IssueTrackerProvider.github => await _runGhJson([ 'api', '-X', 'GET', _buildApiPath( issue.requiredRepoSlug, ['issues', '${issue.number}', 'comments'], {'per_page': '100'}, ), ]), IssueTrackerProvider.gitlab => await _runGlabJson([ 'api', '-X', 'GET', _buildGitLabApiPath( trackerProject, ['issues', '${issue.number}', 'notes'], {'per_page': '100'}, ), ]), IssueTrackerProvider.gitea => await _runGiteaJson( method: 'GET', path: _buildApiPath( issue.requiredRepoSlug, ['issues', '${issue.number}', 'comments'], {'limit': '100'}, ), ), IssueTrackerProvider.openproject => await _runOpenProjectJson( method: 'GET', path: '/api/v3/work_packages/${issue.number}/activities', ), }; final comments = provider == IssueTrackerProvider.openproject ? ((((commentJson as Map)['_embedded'] as Map?)?['elements'] as List?) ?? const []) .cast>() : (commentJson as List).cast>(); return IssueThread( issue: issue, comments: comments .map(switch (provider) { IssueTrackerProvider.openproject => (json) => GitHubIssueComment.fromOpenProjectJson( json, openProjectConfig!.host, ), IssueTrackerProvider.gitlab => (json) => GitHubIssueComment.fromGitLabJson(json, issue.url), _ => GitHubIssueComment.fromJson, }) .toList(growable: false), ); } Future createIssueComment({ required IssueTrackerProvider provider, required String trackerProject, required int issueNumber, required String body, }) async { final openProjectConfig = provider == IssueTrackerProvider.openproject ? await _loadOpenProjectConfig() : null; final json = switch (provider) { IssueTrackerProvider.github => await _runGhJson([ 'api', '-X', 'POST', _buildApiPath(parseRepositorySlug(trackerProject), [ 'issues', '$issueNumber', 'comments', ]), '-f', 'body=$body', ]), IssueTrackerProvider.gitlab => await _runGlabJson([ 'api', '-X', 'POST', _buildGitLabApiPath(trackerProject, [ 'issues', '$issueNumber', 'notes', ]), '-f', 'body=$body', ]), IssueTrackerProvider.gitea => await _runGiteaJson( method: 'POST', path: _buildApiPath(parseRepositorySlug(trackerProject), [ 'issues', '$issueNumber', 'comments', ]), body: {'body': body}, ), IssueTrackerProvider.openproject => await _runOpenProjectJson( method: 'POST', path: '/api/v3/work_packages/$issueNumber/activities', body: { 'comment': {'raw': body}, }, ), }; final map = json as Map; return PostedComment( id: readIntField(map, 'id'), url: switch (provider) { IssueTrackerProvider.openproject => resolveTrackerUrl( readUrlField(map), openProjectConfig!.host, ), IssueTrackerProvider.gitlab => GitHubIssueComment.buildGitLabNoteUrl( issueUrl: '', json: map, ), _ => readUrlField(map), }, body: map['body'] as String? ?? readOpenProjectCommentBody(map) ?? body, ); } Future createNoReplyReaction({ required IssueTrackerProvider provider, required String trackerProject, required int commentId, }) async { switch (provider) { case IssueTrackerProvider.github: await _runGhJson([ 'api', '-X', 'POST', _buildApiPath(parseRepositorySlug(trackerProject), [ 'issues', 'comments', '$commentId', 'reactions', ]), '-H', 'Accept: application/vnd.github+json', '-f', 'content=eyes', ]); return true; case IssueTrackerProvider.gitlab: case IssueTrackerProvider.gitea: case IssueTrackerProvider.openproject: return false; } } Future getAuthenticatedLogin({ required IssueTrackerProvider provider, }) { return _authenticatedLoginFutures.putIfAbsent( provider, () => _loadAuthenticatedLogin(provider: provider), ); } String trackerCliName(IssueTrackerProvider provider) { return switch (provider) { IssueTrackerProvider.github => 'gh', IssueTrackerProvider.gitlab => 'glab', IssueTrackerProvider.gitea => 'gitea', IssueTrackerProvider.openproject => opCommand, }; } Future createGiteaIssueWebhook({ required RepositorySlug repo, required String url, }) async { final json = await _runGiteaJson( method: 'POST', path: _buildApiPath(repo, ['hooks']), body: { 'type': 'gitea', 'config': {'url': url, 'content_type': 'json'}, 'events': ['issues', 'issue_comment'], 'active': true, }, ); if (json is Map) { return readIntField(json, 'id'); } throw FormatException( 'Gitea API webhook create returned an unexpected response for repo ' '${repo.fullName}.', ); } Future createGitHubIssueWebhook({ required RepositorySlug repo, required String url, }) async { final json = await _runGhJson([ 'api', '-X', 'POST', _buildApiPath(repo, ['hooks']), '-f', 'name=web', '-F', 'active=true', '-f', 'events[]=issues', '-f', 'events[]=issue_comment', '-f', 'config[url]=$url', '-f', 'config[content_type]=json', ]); if (json is! Map) { throw const FormatException( 'gh api webhook create returned an unexpected response.', ); } return readIntField(json, 'id'); } Future createGitLabIssueWebhook({ required String trackerProject, required String url, }) async { final json = await _runGlabJson([ 'api', '-X', 'POST', _buildGitLabApiPath(trackerProject, ['hooks']), '-f', 'url=$url', '-f', 'issues_events=true', '-f', 'note_events=true', '-f', 'push_events=false', ]); if (json is! Map) { throw const FormatException( 'glab api webhook create returned an unexpected response.', ); } return readIntField(json, 'id'); } Future deleteGitHubWebhook({ required RepositorySlug repo, required int webhookId, }) async { await _runGhJson([ 'api', '-X', 'DELETE', _buildApiPath(repo, ['hooks', '$webhookId']), ]); } Future deleteGiteaWebhook({ required RepositorySlug repo, required int webhookId, }) async { await _runGiteaJson( method: 'DELETE', path: _buildApiPath(repo, ['hooks', '$webhookId']), ); } Future deleteGitLabWebhook({ required String trackerProject, required int webhookId, }) async { await _runGlabJson([ 'api', '-X', 'DELETE', _buildGitLabApiPath(trackerProject, ['hooks', '$webhookId']), ]); } Future> _fetchUpdatedGitHubIssues({ required Iterable trackerProjects, required DateTime? since, }) async { final repoList = trackerProjects .map(parseRepositorySlug) .toList(growable: false); if (repoList.isEmpty) { return const []; } final queryParts = [ for (final repo in repoList) 'repo:${repo.fullName}', 'is:issue', 'state:open', if (since != null) 'updated:>=${since.toUtc().toIso8601String()}', ]; final issues = []; for (var page = 1; true; page += 1) { _traceRequest( provider: IssueTrackerProvider.github, requestKind: 'search-issues', trackerProject: repoList.map((repo) => repo.fullName).join(','), page: page, since: since, requestPath: 'search/issues', ); final json = await _runGhJson([ 'api', '-X', 'GET', 'search/issues', '-f', 'q=${queryParts.join(' ')}', '-f', 'sort=updated', '-f', 'order=asc', '-f', 'per_page=$_pageSize', '-f', 'page=$page', ]); final items = ((json as Map)['items'] as List? ?? const []) .cast>(); issues.addAll( items .where((item) => !item.containsKey('pull_request')) .map(GitHubIssueSummary.fromSearchJson), ); if (items.length < _pageSize) { break; } } return issues; } Future> _fetchUpdatedGitLabIssues({ required Iterable trackerProjects, required DateTime? since, }) async { final issues = []; for (final trackerProject in trackerProjects) { for (var page = 1; true; page += 1) { final query = { 'state': 'opened', 'order_by': 'updated_at', 'sort': 'asc', 'per_page': '$_pageSize', 'page': '$page', if (since != null) 'updated_after': since.toUtc().toIso8601String(), }; _traceRequest( provider: IssueTrackerProvider.gitlab, requestKind: 'list-issues', trackerProject: trackerProject, page: page, since: since, requestPath: _buildGitLabApiPath(trackerProject, ['issues'], query), ); final json = await _runGlabJson([ 'api', '-X', 'GET', _buildGitLabApiPath(trackerProject, ['issues'], query), ]); final items = (json as List? ?? const []) .cast>(); issues.addAll( items.map( (item) => GitHubIssueSummary.fromGitLabJson(trackerProject, item), ), ); if (items.length < _pageSize) { break; } } } issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); return issues; } Future> _fetchUpdatedGiteaIssues({ required Iterable trackerProjects, required DateTime? since, }) async { final issues = []; for (final repo in trackerProjects.map(parseRepositorySlug)) { for (var page = 1; true; page += 1) { final query = { 'state': 'open', 'limit': '$_pageSize', 'page': '$page', if (since != null) 'since': since.toUtc().toIso8601String(), }; final requestPath = _buildApiPath(repo, ['issues'], query); _traceRequest( provider: IssueTrackerProvider.gitea, requestKind: 'list-issues', trackerProject: repo.fullName, page: page, since: since, requestPath: requestPath, ); final json = await _runGiteaJson(method: 'GET', path: requestPath); if (json is Map) { throw FormatException( 'Gitea API returned an error while listing issues for ' '${repo.fullName}: ${_describeJsonObject(json)}', ); } if (json != null && json is! List) { throw FormatException( 'Gitea API returned ${json.runtimeType} while listing issues ' 'for ${repo.fullName}; expected a JSON array.', ); } final items = (json as List? ?? const []) .cast>(); issues.addAll( items .where((item) => item['pull_request'] == null) .map((item) => GitHubIssueSummary.fromRepositoryJson(repo, item)), ); if (items.length < _pageSize) { break; } } } issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); return issues; } Future> _fetchUpdatedOpenProjectIssues({ required Iterable trackerProjects, required DateTime? since, }) async { final issues = []; for (final trackerProject in trackerProjects) { final project = await _resolveOpenProjectProjectRef(trackerProject); final config = await _loadOpenProjectConfig(); final json = await _runOpenProjectJson( method: 'GET', path: '/api/v3/projects/${project.id}/work_packages', queryParameters: { 'pageSize': '-1', 'filters': jsonEncode(>[ { 'status': { 'operator': 'o', 'values': const [], }, }, ]), }, ); final embedded = (json as Map)['_embedded'] as Map?; final items = (embedded?['elements'] as List? ?? const []) .cast>(); issues.addAll( items .map( (item) => GitHubIssueSummary.fromOpenProjectJson( trackerProject, item, config.host, ), ) .where((issue) => since == null || issue.updatedAt.isAfter(since)), ); } issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); return issues; } Future _loadAuthenticatedLogin({ required IssueTrackerProvider provider, }) async { final json = switch (provider) { IssueTrackerProvider.github => await _runGhJson(['api', 'user']), IssueTrackerProvider.gitlab => await _runGlabJson(['api', 'user']), IssueTrackerProvider.gitea => await _runGiteaJson( method: 'GET', path: '/user', ), IssueTrackerProvider.openproject => await _runOpenProjectJson( method: 'GET', path: '/api/v3/users/me', ), }; if (json is! Map) { return null; } final login = json['login']?.toString().trim() ?? json['username']?.toString().trim() ?? json['name']?.toString().trim() ?? json['firstName']?.toString().trim(); return login == null || login.isEmpty ? null : login; } Future _runGhJson(List arguments) { return _runJson(ghCommand, arguments); } Future _runGlabJson(List arguments) { return _runJson(glabCommand, arguments); } Future _runGiteaJson({ required String method, required String path, Map? queryParameters, Object? body, }) async { final config = await _loadGiteaConfig(); final client = HttpClient(); if (config.insecure) { client.badCertificateCallback = (_, _, _) => true; } final uri = _buildGiteaUri( config.host, path, queryParameters: queryParameters, ); final stopwatch = Stopwatch()..start(); _logger.debug( 'gitea request start timeout_ms=${commandTimeout.inMilliseconds} ' 'method=$method uri=$uri', ); try { final result = await (() async { final request = await client.openUrl(method, uri); request.headers.set(HttpHeaders.acceptHeader, 'application/json'); request.headers.set( HttpHeaders.authorizationHeader, 'token ${config.token}', ); if (body != null) { request.headers.set( HttpHeaders.contentTypeHeader, 'application/json', ); request.write(jsonEncode(body)); } final response = await request.close(); final responseBody = await utf8.decodeStream(response); return (response: response, body: responseBody); })().timeout( commandTimeout, onTimeout: () { throw TimeoutException( 'Gitea API request timed out after ${commandTimeout.inMilliseconds}ms.', commandTimeout, ); }, ); stopwatch.stop(); _logger.debug( 'gitea request finished duration_ms=${stopwatch.elapsedMilliseconds} ' 'status_code=${result.response.statusCode} method=$method uri=$uri', ); final trimmed = result.body.trim(); final parsedJson = trimmed.isEmpty ? null : jsonDecode(trimmed); if (result.response.statusCode < 200 || result.response.statusCode >= 300) { if (parsedJson is Map) { throw FormatException( 'Gitea API request failed ' '(${result.response.statusCode} ${result.response.reasonPhrase}): ' '${_describeJsonObject(parsedJson)}', ); } throw HttpException( 'Gitea API request failed ' '(${result.response.statusCode} ${result.response.reasonPhrase}): ' '${_summarizeOutput(result.body)}', uri: uri, ); } return parsedJson; } on TimeoutException catch (error, stackTrace) { stopwatch.stop(); _logger.error( 'gitea request timeout duration_ms=${stopwatch.elapsedMilliseconds} ' 'timeout_ms=${commandTimeout.inMilliseconds} method=$method uri=$uri', error: error, stackTrace: stackTrace, ); rethrow; } catch (error, stackTrace) { stopwatch.stop(); _logger.error( 'gitea request failed duration_ms=${stopwatch.elapsedMilliseconds} ' 'method=$method uri=$uri', error: error, stackTrace: stackTrace, ); rethrow; } finally { client.close(force: true); } } Future _runOpenProjectJson({ required String method, required String path, Map? queryParameters, Object? body, }) async { final config = await _loadOpenProjectConfig(); final client = HttpClient(); try { final request = await client.openUrl( method, _buildOpenProjectUri( config.host, path, queryParameters: queryParameters, ), ); request.headers.set(HttpHeaders.acceptHeader, 'application/json'); request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); request.headers.set( HttpHeaders.authorizationHeader, 'Basic ${base64Encode(utf8.encode('apikey:${config.token}'))}', ); if (body != null) { request.write(jsonEncode(body)); } final response = await request.close(); final responseBody = await utf8.decodeStream(response); if (response.statusCode < 200 || response.statusCode >= 300) { throw HttpException( 'OpenProject API request failed ' '(${response.statusCode} ${response.reasonPhrase}): $responseBody', uri: request.uri, ); } final trimmed = responseBody.trim(); if (trimmed.isEmpty) { return null; } return jsonDecode(trimmed); } finally { client.close(force: true); } } Future _runJson(String command, List arguments) async { final result = await _runCommand(command, arguments); final stdoutText = result.stdout.toString().trim(); if (stdoutText.isEmpty) { _traceCommandResult( command: command, arguments: arguments, state: 'empty-stdout', ); return null; } try { final json = jsonDecode(stdoutText); _traceCommandResult( command: command, arguments: arguments, state: 'json-parsed', detail: _describeJsonPayload(json), ); return json; } on FormatException catch (error) { _traceCommandResult( command: command, arguments: arguments, state: 'json-parse-failed', detail: '${error.message} stdout=${_summarizeOutput(stdoutText, maxLength: 120)}', ); throw FormatException( 'Failed to parse JSON from `$command ${arguments.join(' ')}`: ' '${error.message}. stdout=${_summarizeOutput(stdoutText)}', ); } } Future _runCommand( String command, List arguments, ) async { final renderedCommand = _renderCommand(command, arguments); final stopwatch = Stopwatch()..start(); _logger.debug( 'command start timeout_ms=${commandTimeout.inMilliseconds} command=$renderedCommand', ); late final ProcessResult result; try { result = await _commandExecutor.run( command, arguments, timeout: commandTimeout, ); } on TimeoutException catch (error, stackTrace) { stopwatch.stop(); _logger.error( 'command timeout duration_ms=${stopwatch.elapsedMilliseconds} ' 'timeout_ms=${commandTimeout.inMilliseconds} command=$renderedCommand', error: error, stackTrace: stackTrace, ); rethrow; } catch (error, stackTrace) { stopwatch.stop(); _logger.error( 'command failed duration_ms=${stopwatch.elapsedMilliseconds} command=$renderedCommand', error: error, stackTrace: stackTrace, ); rethrow; } stopwatch.stop(); _logger.debug( 'command finished duration_ms=${stopwatch.elapsedMilliseconds} ' 'exit_code=${result.exitCode} command=$renderedCommand', ); if (result.exitCode != 0) { throw ProcessException( command, arguments, '${result.stdout}\n${result.stderr}', result.exitCode, ); } return result; } String _renderCommand(String command, List arguments) { final parts = [command, ...arguments]; return parts.map(_shellQuote).join(' '); } String _shellQuote(String value) { if (value.isEmpty) { return '""'; } if (!value.contains(RegExp(r'[\s"]'))) { return value; } return '"${value.replaceAll('"', r'\"')}"'; } String _describeJsonObject(Map json) { final message = json['message']?.toString().trim(); final errors = (json['errors'] as List?) ?.map((entry) => entry.toString().trim()) .where((entry) => entry.isNotEmpty) .toList(growable: false); final url = json['url']?.toString().trim(); final parts = [ if (message != null && message.isNotEmpty) 'message=$message', if (errors != null && errors.isNotEmpty) 'errors=${errors.join('; ')}', if (url != null && url.isNotEmpty) 'url=$url', ]; if (parts.isEmpty) { return _summarizeOutput(jsonEncode(json)); } return parts.join(' '); } String _summarizeOutput(String text, {int maxLength = 240}) { final singleLine = text.replaceAll(RegExp(r'\s+'), ' ').trim(); if (singleLine.length <= maxLength) { return singleLine; } return '${singleLine.substring(0, maxLength)}...'; } String _describeJsonPayload(Object? json) { return switch (json) { List list => 'type=list length=${list.length}', Map map => 'type=object keys=${map.keys.take(6).join(",")}', null => 'type=null', _ => 'type=${json.runtimeType}', }; } void _traceRequest({ required IssueTrackerProvider provider, required String requestKind, required String trackerProject, required int page, required DateTime? since, required String requestPath, }) { _logger.debug( 'trace request provider=${provider.name} kind=$requestKind ' 'tracker_project=$trackerProject page=$page ' 'since=${since?.toUtc().toIso8601String() ?? "(none)"} ' 'path=$requestPath', ); } void _traceCommandResult({ required String command, required List arguments, required String state, String? detail, }) { final parts = [ 'trace command_result state=$state', 'command=$command', 'args=${arguments.map(_shellQuote).join(" ")}', if (detail != null && detail.isNotEmpty) 'detail=$detail', ]; _logger.debug(parts.join(' ')); } String _buildApiPath( RepositorySlug repo, List tailSegments, [ Map query = const {}, ]) { return Uri( pathSegments: ['repos', repo.owner, repo.name, ...tailSegments], queryParameters: query.isEmpty ? null : query, ).toString(); } String _buildGitLabApiPath( String projectPath, List tailSegments, [ Map query = const {}, ]) { final path = 'projects/${Uri.encodeComponent(projectPath)}/${tailSegments.join('/')}'; if (query.isEmpty) { return path; } return Uri(path: path, queryParameters: query).toString(); } Uri _buildOpenProjectUri( Uri host, String path, { Map? queryParameters, }) { return host.replace( path: p.posix.normalize( '${host.path.endsWith('/') ? host.path.substring(0, host.path.length - 1) : host.path}$path', ), queryParameters: queryParameters == null || queryParameters.isEmpty ? null : queryParameters, ); } Uri _buildGiteaUri( Uri host, String path, { Map? queryParameters, }) { final relativeUri = Uri.parse(path); final normalizedPath = relativeUri.path.startsWith('/') ? relativeUri.path : '/${relativeUri.path}'; final effectiveQueryParameters = queryParameters ?? (relativeUri.hasQuery ? relativeUri.queryParameters : null); return host.replace( path: p.posix.normalize( '${host.path.endsWith('/') ? host.path.substring(0, host.path.length - 1) : host.path}/api/v1$normalizedPath', ), queryParameters: effectiveQueryParameters == null || effectiveQueryParameters.isEmpty ? null : effectiveQueryParameters, ); } Future<_GiteaConfig> _loadGiteaConfig() async { final environment = AppEnvironment.variables; final host = environment['CWS_GITEA_HOST']?.trim() ?? environment['GITEA_HOST']?.trim(); final token = environment['CWS_GITEA_TOKEN']?.trim() ?? environment['GITEA_TOKEN']?.trim(); final insecureValue = environment['CWS_GITEA_INSECURE']?.trim() ?? environment['GITEA_INSECURE']?.trim(); if (host != null && host.isNotEmpty && token != null && token.isNotEmpty) { return _GiteaConfig( host: Uri.parse(host), token: token, insecure: _parseBooleanFlag(insecureValue), ); } final loginName = environment['CWS_GITEA_LOGIN']?.trim() ?? environment['GITEA_LOGIN']?.trim() ?? environment['TEA_LOGIN']?.trim(); final configPath = _teaConfigPath(environment); final file = File(configPath); if (!await file.exists()) { throw const FileSystemException( 'Gitea config not found. Set CWS_GITEA_HOST/CWS_GITEA_TOKEN or configure tea first.', ); } final document = loadYaml(await file.readAsString()); final logins = (document is YamlMap ? document['logins'] : null) as YamlList?; if (logins == null || logins.isEmpty) { throw FileSystemException( 'No tea logins found in Gitea config at $configPath.', ); } final loginMaps = logins .whereType() .map((entry) => Map.from(entry)) .toList(growable: false); final selectedLogin = _selectTeaLogin(loginMaps, loginName); final loginHost = selectedLogin['url']?.toString().trim(); final loginToken = selectedLogin['token']?.toString().trim(); if (loginHost == null || loginHost.isEmpty || loginToken == null || loginToken.isEmpty) { throw FileSystemException( 'Selected tea login in $configPath is missing url or token.', ); } return _GiteaConfig( host: Uri.parse(loginHost), token: loginToken, insecure: _parseBooleanFlag(selectedLogin['insecure']?.toString()), ); } Map _selectTeaLogin( List> logins, String? requestedLoginName, ) { if (requestedLoginName != null && requestedLoginName.isNotEmpty) { final matchedLogin = logins .where((login) { final name = login['name']?.toString().trim(); return name != null && name.toLowerCase() == requestedLoginName.toLowerCase(); }) .toList(growable: false); if (matchedLogin.length == 1) { return matchedLogin.single; } throw FileSystemException( 'tea login "$requestedLoginName" was not found or was ambiguous.', ); } final defaultLogins = logins .where((login) { final value = login['default']; return value == true || value?.toString().toLowerCase() == 'true'; }) .toList(growable: false); if (defaultLogins.length == 1) { return defaultLogins.single; } if (logins.length == 1) { return logins.single; } throw const FileSystemException( 'Multiple tea logins found. Set CWS_GITEA_LOGIN, GITEA_LOGIN, or TEA_LOGIN to choose one.', ); } String _teaConfigPath(Map environment) { final explicitPath = environment['CWS_GITEA_CONFIG_PATH']?.trim() ?? environment['TEA_CONFIG_PATH']?.trim(); if (explicitPath != null && explicitPath.isNotEmpty) { return explicitPath; } final candidates = []; final localAppData = environment['LOCALAPPDATA']?.trim(); if (localAppData != null && localAppData.isNotEmpty) { candidates.add(p.join(localAppData, 'tea', 'config.yml')); } final xdgConfigHome = environment['XDG_CONFIG_HOME']?.trim(); if (xdgConfigHome != null && xdgConfigHome.isNotEmpty) { candidates.add(p.join(xdgConfigHome, 'tea', 'config.yml')); } final userProfile = environment['USERPROFILE']?.trim(); if (userProfile != null && userProfile.isNotEmpty) { candidates.add( p.join(userProfile, 'AppData', 'Local', 'tea', 'config.yml'), ); candidates.add(p.join(userProfile, '.tea', 'tea.yml')); } final home = environment['HOME']?.trim(); if (home != null && home.isNotEmpty) { candidates.add(p.join(home, '.config', 'tea', 'config.yml')); candidates.add(p.join(home, '.tea', 'tea.yml')); } if (candidates.isEmpty) { throw const FileSystemException( 'HOME is not set and LOCALAPPDATA/XDG_CONFIG_HOME/USERPROFILE are unset. Cannot locate tea config.', ); } for (final candidate in candidates) { if (File(candidate).existsSync()) { return candidate; } } return candidates.first; } bool _parseBooleanFlag(String? value) { if (value == null) { return false; } switch (value.trim().toLowerCase()) { case '1': case 'true': case 'yes': case 'on': return true; } return false; } Future<_OpenProjectConfig> _loadOpenProjectConfig() async { final environment = AppEnvironment.variables; final host = environment['OP_CLI_HOST']?.trim(); final token = environment['OP_CLI_TOKEN']?.trim(); if (host != null && host.isNotEmpty && token != null && token.isNotEmpty) { return _OpenProjectConfig(host: Uri.parse(host), token: token); } final configPath = _openProjectConfigPath(environment); final file = File(configPath); if (!await file.exists()) { throw const FileSystemException( 'OpenProject config not found. Set OP_CLI_HOST and OP_CLI_TOKEN or run `op login` first.', ); } final parts = (await file.readAsString()).trim().split(RegExp(r'\s+')); if (parts.length != 2) { throw FileSystemException( 'Invalid OpenProject config file at $configPath. Run `op login` again.', ); } return _OpenProjectConfig(host: Uri.parse(parts[0]), token: parts[1]); } String _openProjectConfigPath(Map environment) { final xdgConfigHome = environment['XDG_CONFIG_HOME']?.trim(); if (xdgConfigHome != null && xdgConfigHome.isNotEmpty) { return p.join(xdgConfigHome, 'openproject', 'config'); } final home = environment['HOME']?.trim(); if (home == null || home.isEmpty) { throw const FileSystemException( 'HOME is not set and XDG_CONFIG_HOME is unset. Cannot locate OpenProject config.', ); } return p.join(home, '.config', 'openproject', 'config'); } Future<_OpenProjectProjectRef> _resolveOpenProjectProjectRef( String projectRef, ) { return _openProjectProjectFutures.putIfAbsent( projectRef, () => _loadOpenProjectProjectRef(projectRef), ); } Future<_OpenProjectProjectRef> _loadOpenProjectProjectRef( String projectRef, ) async { final projectId = int.tryParse(projectRef); if (projectId != null && projectId > 0) { return _OpenProjectProjectRef(id: projectId, name: projectRef); } final json = await _runOpenProjectJson( method: 'GET', path: '/api/v3/projects', queryParameters: { 'pageSize': '-1', 'filters': jsonEncode(>[ { 'typeahead': { 'operator': '**', 'values': [projectRef], }, }, ]), }, ); final embedded = (json as Map)['_embedded'] as Map?; final projects = (embedded?['elements'] as List? ?? const []) .cast>(); final exactMatches = projects .where((project) { final name = project['name']?.toString().trim(); return name != null && name.toLowerCase() == projectRef.toLowerCase(); }) .toList(growable: false); if (exactMatches.length != 1) { throw FormatException( 'OpenProject project "$projectRef" did not resolve to exactly one project.', ); } return _OpenProjectProjectRef( id: readIntField(exactMatches.single, 'id'), name: exactMatches.single['name']?.toString() ?? projectRef, ); } } class _OpenProjectConfig { const _OpenProjectConfig({required this.host, required this.token}); final Uri host; final String token; } class _GiteaConfig { const _GiteaConfig({ required this.host, required this.token, required this.insecure, }); final Uri host; final String token; final bool insecure; } class _OpenProjectProjectRef { const _OpenProjectProjectRef({required this.id, required this.name}); final int id; final String name; }