- 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
311 lines
8.1 KiB
Dart
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;
|
|
}
|