import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/jenkins_build.dart'; class DownloadProgressEvent { const DownloadProgressEvent({ required this.receivedBytes, this.totalBytes, this.isComplete = false, this.chunkBytes = const [], }); final int receivedBytes; final int? totalBytes; final bool isComplete; /// Raw bytes delivered in this event. Empty on the final [isComplete] event. final List chunkBytes; } class DownloadTask { DownloadTask._(this.events, this._cancel); /// Creates a [DownloadTask] from an existing event stream and a cancel callback. /// Used in tests and production wrappers where the stream is already constructed. DownloadTask.fromStream( Stream events, { void Function()? onCancel, }) : this._(events, onCancel ?? () {}); final Stream events; final void Function() _cancel; void cancel() => _cancel(); } class JenkinsClient { JenkinsClient({http.Client? client}) : _client = client ?? http.Client(); final http.Client _client; Future> fetchJobs({ required Uri baseUrl, required JenkinsCredentials credentials, }) async { final uri = baseUrl.replace( path: _appendPath(baseUrl.path, 'api/json'), queryParameters: const {'tree': 'jobs[name,fullName,url,color,_class]'}, ); final response = await _client.get(uri, headers: _headers(credentials)); _throwIfFailed(response); final json = jsonDecode(response.body) as Map; final jobs = json['jobs']; if (jobs is! List) { return const []; } return jobs .whereType>() .map(JenkinsJob.fromJson) .toList(); } Future> fetchRecentBuilds({ required Uri jobUrl, required String jobName, required JenkinsCredentials credentials, }) async { final uri = jobUrl.replace( path: _appendPath(jobUrl.path, 'api/json'), queryParameters: { 'tree': 'builds[number,url,timestamp,result,actions[parameters[name,value],causes[userName,userId,shortDescription,upstreamProject,upstreamBuild]],changeSet[items[author[fullName,id],authorEmail]],artifacts[fileName,relativePath]]', }, ); final response = await _client.get(uri, headers: _headers(credentials)); _throwIfFailed(response); final json = jsonDecode(response.body) as Map; final builds = json['builds']; if (builds is! List) { return const []; } return builds .whereType>() .map((build) => JenkinsBuild.fromJson(build, jobName: jobName)) .where((build) => build.apkArtifacts.isNotEmpty) .toList(); } Future> downloadArtifact({ required Uri artifactUrl, required JenkinsCredentials credentials, }) async { final response = await _client.get( artifactUrl, headers: _headers(credentials), ); _throwIfFailed(response); return response.bodyBytes; } DownloadTask downloadArtifactStream({ required Uri artifactUrl, required JenkinsCredentials credentials, }) { final controller = StreamController(); var cancelled = false; StreamSubscription>? subscription; controller.onListen = () async { try { final request = http.Request('GET', artifactUrl); request.headers.addAll(_headers(credentials)); final streamed = await _client.send(request); if (streamed.statusCode < 200 || streamed.statusCode >= 300) { final body = await streamed.stream.bytesToString(); controller.addError( JenkinsClientException( statusCode: streamed.statusCode, message: body, ), ); await controller.close(); return; } final totalBytes = streamed.contentLength; var received = 0; subscription = streamed.stream.listen( (chunk) { if (cancelled) return; received += chunk.length; controller.add( DownloadProgressEvent( receivedBytes: received, totalBytes: totalBytes, chunkBytes: chunk, ), ); }, onDone: () { if (!cancelled && !controller.isClosed) { controller.add( DownloadProgressEvent( receivedBytes: received, totalBytes: totalBytes, isComplete: true, ), ); controller.close(); } }, onError: (Object e) { if (!cancelled && !controller.isClosed) { controller.addError(e); controller.close(); } }, cancelOnError: true, ); } catch (e) { if (!controller.isClosed) { controller.addError(e); controller.close(); } } }; void cancel() { cancelled = true; subscription?.cancel(); if (!controller.isClosed) { controller.close(); } } return DownloadTask._(controller.stream, cancel); } Future fetchCurrentUser({ required Uri baseUrl, required JenkinsCredentials credentials, }) async { final uri = baseUrl.replace( path: _appendPath(baseUrl.path, 'whoAmI/api/json'), ); final response = await _client.get(uri, headers: _headers(credentials)); _throwIfFailed(response); final json = jsonDecode(response.body) as Map; final id = (json['id'] as String?)?.trim() ?? ''; final displayName = _firstNonBlank([ json['fullName'] as String?, json['name'] as String?, id, ]); return JenkinsUser( id: id, displayName: displayName.isEmpty ? id : displayName, ); } String _firstNonBlank(List values) { for (final value in values) { final trimmed = value?.trim(); if (trimmed != null && trimmed.isNotEmpty) { return trimmed; } } return ''; } Map _headers(JenkinsCredentials credentials) { final raw = '${credentials.username}:${credentials.apiToken}'; return {'Authorization': 'Basic ${base64Encode(utf8.encode(raw))}'}; } void _throwIfFailed(http.Response response) { if (response.statusCode >= 200 && response.statusCode < 300) { return; } throw JenkinsClientException( statusCode: response.statusCode, message: response.body, ); } String _appendPath(String path, String tail) { if (path.endsWith('/')) { return '$path$tail'; } return '$path/$tail'; } } class JenkinsCredentials { const JenkinsCredentials({required this.username, required this.apiToken}); final String username; final String apiToken; } class JenkinsUser { const JenkinsUser({required this.id, required this.displayName}); final String id; final String displayName; } class JenkinsClientException implements Exception { const JenkinsClientException({ required this.statusCode, required this.message, }); final int statusCode; final String message; @override String toString() => 'JenkinsClientException($statusCode): ${_redactSensitiveText(message)}'; } String _redactSensitiveText(String value) { const marker = '[redacted]'; var redacted = value; redacted = redacted.replaceAllMapped( RegExp( r'(authorization\s*[:=]\s*basic\s+)[A-Za-z0-9+/_=-]+', caseSensitive: false, ), (match) => '${match[1]}$marker', ); redacted = redacted.replaceAllMapped( RegExp( r'''(\b(?:api[_-]?token|token|password|jenkins-crumb|crumb)\b\s*[:=]\s*)(["']?)[^"'\s,;&]+(["']?)''', caseSensitive: false, ), (match) => '${match[1]}${match[2]}$marker${match[3]}', ); redacted = redacted.replaceAllMapped( RegExp( r'([?&](?:api[_-]?token|token|password|jenkins-crumb|crumb)=)[^&\s]+', caseSensitive: false, ), (match) => '${match[1]}$marker', ); return redacted; }