appsok/lib/src/services/jenkins_client.dart
toki ce859e1cc0 feat: jenkins credential integration and UI updates
- Add jenkins credential web login entry implementation
- Update roadmap for jenkins credential phase
- Archive usb-install milestone
- Update app shell and devices page for jenkins integration
- Add jenkins client service and update models
- Update tests for changed services
2026-06-13 09:44:43 +09:00

311 lines
8.1 KiB
Dart

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<int> 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<DownloadProgressEvent> events, {
void Function()? onCancel,
}) : this._(events, onCancel ?? () {});
final Stream<DownloadProgressEvent> events;
final void Function() _cancel;
void cancel() => _cancel();
}
class JenkinsClient {
JenkinsClient({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
Future<List<JenkinsJob>> 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<String, Object?>;
final jobs = json['jobs'];
if (jobs is! List) {
return const [];
}
return jobs
.whereType<Map<String, Object?>>()
.map(JenkinsJob.fromJson)
.toList();
}
Future<List<JenkinsBuild>> 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<String, Object?>;
final builds = json['builds'];
if (builds is! List) {
return const [];
}
return builds
.whereType<Map<String, Object?>>()
.map((build) => JenkinsBuild.fromJson(build, jobName: jobName))
.where((build) => build.apkArtifacts.isNotEmpty)
.toList();
}
Future<List<int>> 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<DownloadProgressEvent>();
var cancelled = false;
StreamSubscription<List<int>>? 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<JenkinsUser> 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<String, Object?>;
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<String?> values) {
for (final value in values) {
final trimmed = value?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
return trimmed;
}
}
return '';
}
Map<String, String> _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;
}