'read',
'default_pbx_version' => '',
'gitlab_enabled' => 1,
'gitlab_base_url' => 'https://git.steinert.cc',
'gitlab_project' => 'qa/templates',
'gitlab_ref' => 'main',
'gitlab_template_path' => 'templates',
'gitlab_token' => '',
'gitlab_per_page' => 100,
'gitlab_allow_writes' => 1,
'storage_enabled' => 1,
'media_pdf_enabled' => 1,
'docbee_enabled' => 1,
'docbee_base_url' => 'https://obyte.docbee.com',
'docbee_username' => '',
'docbee_password' => '',
'docbee_lifetime' => 1440,
'docbee_refresh_lifetime' => 1,
'docbee_message_internal' => 1,
'docbee_message_hidden' => 0,
'docbee_enable_fallback_note' => 0,
'docbee_preserve_ticket_status' => 1,
];
}
private function settings(): array
{
$stored = get_option(self::OPTION_NAME, []);
return wp_parse_args(is_array($stored) ? $stored : [], self::defaults());
}
private static function reports_table(): string
{
global $wpdb;
return $wpdb->prefix . 'obyte_qa_reports';
}
private static function steps_table(): string
{
global $wpdb;
return $wpdb->prefix . 'obyte_qa_steps';
}
private static function create_tables(): void
{
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset_collate = $wpdb->get_charset_collate();
$reports_table = self::reports_table();
$steps_table = self::steps_table();
dbDelta("
CREATE TABLE {$reports_table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
created_at DATETIME NOT NULL,
user_id BIGINT UNSIGNED NULL,
module VARCHAR(255) NULL,
module_version VARCHAR(100) NULL,
pbx_version VARCHAR(100) NULL,
olm_nummer VARCHAR(100) NULL,
tester VARCHAR(255) NULL,
docbee_url TEXT NULL,
docbee_result_url TEXT NULL,
summary VARCHAR(255) NULL,
pdf_attachment_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
pdf_url TEXT NULL,
run_json LONGTEXT NULL,
PRIMARY KEY (id),
KEY module (module(120)),
KEY created_at (created_at),
KEY user_id (user_id)
) {$charset_collate};
");
dbDelta("
CREATE TABLE {$steps_table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
step_index INT UNSIGNED NOT NULL DEFAULT 0,
step_id VARCHAR(80) NULL,
title TEXT NULL,
expected TEXT NULL,
status VARCHAR(20) NOT NULL DEFAULT '',
required TINYINT(1) NOT NULL DEFAULT 0,
comment TEXT NULL,
evidence TEXT NULL,
group_title VARCHAR(255) NULL,
group_index INT NULL,
PRIMARY KEY (id),
KEY report_id (report_id),
KEY status (status)
) {$charset_collate};
");
}
public function register_assets(): void
{
wp_register_style(
'obyte-qa-tool',
plugins_url('assets/css/qa-tool.css', __FILE__),
[],
self::VERSION
);
wp_register_script(
'obyte-qa-tool-js-yaml',
'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js',
[],
'4.1.0',
true
);
wp_register_script(
'obyte-qa-tool',
plugins_url('assets/js/qa-tool.js', __FILE__),
['obyte-qa-tool-js-yaml'],
self::VERSION,
true
);
}
private function enqueue_frontend_assets(string $root_id): void
{
$settings = $this->settings();
$user = wp_get_current_user();
wp_enqueue_style('obyte-qa-tool');
wp_enqueue_style('dashicons');
wp_enqueue_script('obyte-qa-tool-js-yaml');
wp_enqueue_script('obyte-qa-tool');
wp_localize_script(
'obyte-qa-tool',
'ObyteQaTool',
[
'rootId' => $root_id,
'restUrl' => esc_url_raw(rest_url(self::REST_NAMESPACE . '/')),
'nonce' => wp_create_nonce('wp_rest'),
'tester' => $this->tester_name($user),
'defaultPbxVersion' => (string) $settings['default_pbx_version'],
'gitlabEnabled' => !empty($settings['gitlab_enabled']),
'gitlabWritesEnabled' => !empty($settings['gitlab_enabled']) && !empty($settings['gitlab_allow_writes']),
'docbeeEnabled' => !empty($settings['docbee_enabled']),
'docbeeConfigured' => $this->docbee_configured($settings),
'storageEnabled' => !empty($settings['storage_enabled']),
'mediaPdfEnabled' => !empty($settings['media_pdf_enabled']),
'logoUrl' => esc_url_raw(plugins_url('assets/img/o-byte_Logo_2024_Dark.svg', __FILE__)),
'printLogoUrl' => esc_url_raw(plugins_url('assets/img/o-byte_Logo_2024_Dark.svg', __FILE__)),
'jsPdfUrls' => [
'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js',
'https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js',
],
'autoTableUrls' => [
'https://cdn.jsdelivr.net/npm/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js',
'https://unpkg.com/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js',
],
]
);
}
public function render_shortcode(array $atts = []): string
{
$settings = $this->settings();
$capability = $this->required_capability($settings);
if (!is_user_logged_in()) {
$login_url = wp_login_url(get_permalink());
return '
';
}
if (!current_user_can($capability)) {
return '' .
esc_html__('Dein WordPress Benutzer hat keine Berechtigung für das QA Tool.', 'obyte-qa-tool') .
'
';
}
$root_id = wp_unique_id('obyte-qa-tool-');
$this->enqueue_frontend_assets($root_id);
ob_start();
?>
'array',
'sanitize_callback' => [$this, 'sanitize_settings'],
'default' => self::defaults(),
]
);
}
public function sanitize_settings($input): array
{
$input = is_array($input) ? $input : [];
$old = $this->settings();
$out = self::defaults();
$out['required_capability'] = sanitize_text_field((string) ($input['required_capability'] ?? $old['required_capability']));
$out['default_pbx_version'] = sanitize_text_field((string) ($input['default_pbx_version'] ?? ''));
$out['gitlab_enabled'] = empty($input['gitlab_enabled']) ? 0 : 1;
$out['gitlab_base_url'] = $this->base_url((string) ($input['gitlab_base_url'] ?? ''));
$out['gitlab_project'] = sanitize_text_field((string) ($input['gitlab_project'] ?? ''));
$out['gitlab_ref'] = sanitize_text_field((string) ($input['gitlab_ref'] ?? ''));
$out['gitlab_template_path'] = trim(sanitize_text_field((string) ($input['gitlab_template_path'] ?? '')), '/');
$out['gitlab_per_page'] = min(100, max(1, absint($input['gitlab_per_page'] ?? 100)));
$out['gitlab_token'] = $this->sanitize_secret_field('gitlab_token', $input, $old, 'gitlab_token_clear');
$out['gitlab_allow_writes'] = empty($input['gitlab_allow_writes']) ? 0 : 1;
$out['storage_enabled'] = empty($input['storage_enabled']) ? 0 : 1;
$out['media_pdf_enabled'] = empty($input['media_pdf_enabled']) ? 0 : 1;
$out['docbee_enabled'] = empty($input['docbee_enabled']) ? 0 : 1;
$out['docbee_base_url'] = $this->base_url((string) ($input['docbee_base_url'] ?? ''));
$out['docbee_username'] = sanitize_text_field((string) ($input['docbee_username'] ?? ''));
$out['docbee_password'] = $this->sanitize_secret_field('docbee_password', $input, $old, 'docbee_password_clear');
$out['docbee_lifetime'] = min(10080, max(1, absint($input['docbee_lifetime'] ?? 1440)));
$out['docbee_refresh_lifetime'] = empty($input['docbee_refresh_lifetime']) ? 0 : 1;
$out['docbee_message_internal'] = empty($input['docbee_message_internal']) ? 0 : 1;
$out['docbee_message_hidden'] = empty($input['docbee_message_hidden']) ? 0 : 1;
$out['docbee_enable_fallback_note'] = empty($input['docbee_enable_fallback_note']) ? 0 : 1;
$out['docbee_preserve_ticket_status'] = empty($input['docbee_preserve_ticket_status']) ? 0 : 1;
return $out;
}
public function render_admin_page(): void
{
if (!current_user_can('manage_options')) {
return;
}
$settings = $this->settings();
$option = self::OPTION_NAME;
?>
o-Byte QA Tool
Shortcode: [obyte_qa_tool]
get_results("SELECT * FROM {$reports_table} ORDER BY created_at DESC LIMIT 100", ARRAY_A);
?>
o-Byte QA Reports
Die letzten 100 gespeicherten QA-Exports aus der WordPress Datenbank.
| ID |
Datum |
Modul |
Version |
OLM |
Tester |
Summary |
PDF |
DocBee |
| Noch keine QA Reports gespeichert. |
|
|
|
|
|
|
|
PDF
-
|
DocBee
Ticket
-
|
WP_REST_Server::READABLE,
'callback' => [$this, 'rest_list_templates'],
'permission_callback' => [$this, 'rest_can_use'],
]);
register_rest_route(self::REST_NAMESPACE, '/template', [
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'rest_get_template'],
'permission_callback' => [$this, 'rest_can_use'],
'args' => [
'path' => [
'type' => 'string',
'required' => true,
],
],
]);
register_rest_route(self::REST_NAMESPACE, '/docbee/message', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_post_docbee_message'],
'permission_callback' => [$this, 'rest_can_use'],
]);
register_rest_route(self::REST_NAMESPACE, '/export', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_export_run'],
'permission_callback' => [$this, 'rest_can_use'],
]);
register_rest_route(self::REST_NAMESPACE, '/gitlab/template', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_push_gitlab_template'],
'permission_callback' => [$this, 'rest_can_use'],
]);
register_rest_route(self::REST_NAMESPACE, '/reports', [
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'rest_list_reports'],
'permission_callback' => [$this, 'rest_can_use'],
]);
}
public function rest_can_use(): bool
{
$settings = $this->settings();
return is_user_logged_in() && current_user_can($this->required_capability($settings));
}
public function rest_list_templates(WP_REST_Request $request)
{
$settings = $this->settings();
if (empty($settings['gitlab_enabled'])) {
return new WP_Error('gitlab_disabled', 'GitLab templates are disabled.', ['status' => 403]);
}
$base_url = $settings['gitlab_base_url'];
$project = $settings['gitlab_project'];
if (!$base_url || !$project) {
return new WP_Error('gitlab_missing_config', 'GitLab base URL or project is missing.', ['status' => 400]);
}
$url = $base_url . '/api/v4/projects/' . rawurlencode($project) . '/repository/tree';
$url = add_query_arg(
[
'path' => $settings['gitlab_template_path'],
'ref' => $settings['gitlab_ref'],
'per_page' => (int) $settings['gitlab_per_page'],
],
$url
);
$response = wp_remote_get($url, [
'timeout' => 20,
'headers' => $this->gitlab_headers($settings),
]);
$data = $this->remote_json($response, 'gitlab_tree_failed');
if (is_wp_error($data)) {
return $data;
}
$files = [];
foreach ($data as $item) {
if (!is_array($item)) {
continue;
}
$name = (string) ($item['name'] ?? '');
$path = (string) ($item['path'] ?? '');
$type = (string) ($item['type'] ?? '');
if ($type === 'blob' && preg_match('/\.ya?ml$/i', $name)) {
$files[] = [
'name' => $name,
'path' => $path,
];
}
}
return rest_ensure_response(['templates' => $files]);
}
public function rest_get_template(WP_REST_Request $request)
{
$settings = $this->settings();
if (empty($settings['gitlab_enabled'])) {
return new WP_Error('gitlab_disabled', 'GitLab templates are disabled.', ['status' => 403]);
}
$path = $this->clean_gitlab_path((string) $request->get_param('path'));
if (!$path) {
return new WP_Error('gitlab_bad_path', 'Template path is invalid.', ['status' => 400]);
}
$url = $settings['gitlab_base_url'] . '/api/v4/projects/' . rawurlencode($settings['gitlab_project']) .
'/repository/files/' . rawurlencode($path) . '/raw';
$url = add_query_arg(['ref' => $settings['gitlab_ref']], $url);
$response = wp_remote_get($url, [
'timeout' => 20,
'headers' => $this->gitlab_headers($settings),
]);
if (is_wp_error($response)) {
return new WP_Error('gitlab_raw_failed', $response->get_error_message(), ['status' => 502]);
}
$code = (int) wp_remote_retrieve_response_code($response);
if ($code < 200 || $code >= 300) {
return new WP_Error('gitlab_raw_failed', 'GitLab returned HTTP ' . $code . '.', ['status' => $code ?: 502]);
}
return rest_ensure_response([
'path' => $path,
'content' => (string) wp_remote_retrieve_body($response),
]);
}
public function rest_post_docbee_message(WP_REST_Request $request)
{
$settings = $this->settings();
$params = $request->get_json_params();
$run = is_array($params) && isset($params['run']) && is_array($params['run']) ? $params['run'] : [];
if (empty($run['steps']) || !is_array($run['steps'])) {
return new WP_Error('docbee_bad_run', 'QA run payload is invalid.', ['status' => 400]);
}
$posted = $this->docbee_post_run($run, $settings);
if (is_wp_error($posted)) {
return $posted;
}
return rest_ensure_response($posted);
}
public function rest_export_run(WP_REST_Request $request)
{
$settings = $this->settings();
$run_raw = (string) ($request->get_param('run') ?? '');
$run = json_decode($run_raw, true);
if (!is_array($run) || empty($run['steps']) || !is_array($run['steps'])) {
return new WP_Error('qa_bad_run', 'QA run payload is invalid.', ['status' => 400]);
}
$docbee = null;
if (!empty($settings['docbee_enabled']) && $this->docbee_configured($settings) && $this->extract_ticket_id((string) ($run['docbee_url'] ?? ''))) {
$posted = $this->docbee_post_run($run, $settings);
if (is_wp_error($posted)) {
$docbee = [
'ok' => false,
'message' => $posted->get_error_message(),
];
} else {
$docbee = $posted;
}
}
$attachment_id = 0;
$pdf_url = '';
$files = $request->get_file_params();
if (!empty($settings['media_pdf_enabled']) && isset($files['pdf']) && is_array($files['pdf']) && (int) ($files['pdf']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) {
$uploaded = $this->store_pdf_attachment($files['pdf'], $run);
if (is_wp_error($uploaded)) {
return $uploaded;
}
$attachment_id = (int) $uploaded['attachment_id'];
$pdf_url = (string) $uploaded['url'];
}
$report_id = 0;
$summary = $this->summary_text($run);
if (!empty($settings['storage_enabled'])) {
$stored = $this->store_report($run, $summary, $attachment_id, $pdf_url, $docbee);
if (is_wp_error($stored)) {
return $stored;
}
$report_id = (int) $stored;
}
return rest_ensure_response([
'ok' => true,
'report_id' => $report_id,
'summary' => $summary,
'pdf_attachment_id' => $attachment_id,
'pdf_url' => $pdf_url,
'docbee' => $docbee,
]);
}
public function rest_push_gitlab_template(WP_REST_Request $request)
{
$settings = $this->settings();
if (empty($settings['gitlab_enabled']) || empty($settings['gitlab_allow_writes'])) {
return new WP_Error('gitlab_writes_disabled', 'GitLab template writes are disabled.', ['status' => 403]);
}
if (empty($settings['gitlab_token'])) {
return new WP_Error('gitlab_missing_token', 'GitLab token is missing.', ['status' => 400]);
}
$params = $request->get_json_params();
$template = is_array($params) && isset($params['template']) && is_array($params['template']) ? $params['template'] : [];
$yaml = is_array($params) ? (string) ($params['yaml'] ?? '') : '';
if (empty($template['steps']) || !is_array($template['steps']) || $yaml === '') {
return new WP_Error('gitlab_bad_template', 'Template payload is invalid.', ['status' => 400]);
}
if (empty($template['module']) || empty($template['module_version']) || empty($template['pbx_version'])) {
return new WP_Error('gitlab_missing_template_meta', 'Module, module version, and PBX version are required for GitLab template export.', ['status' => 400]);
}
$filename = $this->safe_filename((string) ($template['module'] ?? $template['name'] ?? 'qa-template')) . '.yaml';
$template_path = trim((string) $settings['gitlab_template_path'], '/');
$path = ($template_path !== '' ? $template_path . '/' : '') . $filename;
$file_url = $settings['gitlab_base_url'] . '/api/v4/projects/' . rawurlencode($settings['gitlab_project']) .
'/repository/files/' . rawurlencode($path);
$check_url = add_query_arg(['ref' => $settings['gitlab_ref']], $file_url);
$check = wp_remote_get($check_url, [
'timeout' => 20,
'headers' => $this->gitlab_headers($settings),
]);
$exists = !is_wp_error($check) && (int) wp_remote_retrieve_response_code($check) >= 200 && (int) wp_remote_retrieve_response_code($check) < 300;
$message = $exists
? 'Update QA template for ' . sanitize_text_field((string) $template['module']) . ' ' . sanitize_text_field((string) $template['module_version'])
: 'Add QA template for ' . sanitize_text_field((string) $template['module']) . ' ' . sanitize_text_field((string) $template['module_version']);
$response = wp_remote_request($file_url, [
'method' => $exists ? 'PUT' : 'POST',
'timeout' => 20,
'headers' => array_merge($this->gitlab_headers($settings), ['Content-Type' => 'application/json']),
'body' => wp_json_encode([
'branch' => $settings['gitlab_ref'],
'commit_message' => $message,
'content' => base64_encode($yaml),
'encoding' => 'base64',
]),
]);
$data = $this->remote_json($response, 'gitlab_template_push_failed');
if (is_wp_error($data)) {
return $data;
}
return rest_ensure_response([
'ok' => true,
'path' => $path,
'created' => !$exists,
'result' => $data,
]);
}
public function rest_list_reports(WP_REST_Request $request)
{
global $wpdb;
$reports_table = self::reports_table();
$limit = min(100, max(1, (int) ($request->get_param('limit') ?: 20)));
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$reports_table} ORDER BY created_at DESC LIMIT %d", $limit), ARRAY_A);
return rest_ensure_response([
'reports' => is_array($rows) ? $rows : [],
]);
}
private function tester_name(WP_User $user): string
{
if ($user->exists()) {
if ($user->display_name) {
return $user->display_name;
}
return $user->user_login;
}
return '';
}
private function required_capability(array $settings): string
{
$capability = trim((string) ($settings['required_capability'] ?? 'read'));
return $capability !== '' ? $capability : 'read';
}
private function docbee_configured(array $settings): bool
{
return !empty($settings['docbee_base_url'])
&& !empty($settings['docbee_username'])
&& !empty($settings['docbee_password']);
}
private function docbee_post_run(array $run, array $settings)
{
if (empty($settings['docbee_enabled'])) {
return new WP_Error('docbee_disabled', 'DocBee posting is disabled.', ['status' => 403]);
}
if (!$this->docbee_configured($settings)) {
return new WP_Error('docbee_missing_config', 'DocBee credentials are missing.', ['status' => 400]);
}
$ticket_id = $this->extract_ticket_id((string) ($run['docbee_url'] ?? ''));
if (!$ticket_id) {
return new WP_Error('docbee_missing_ticket', 'No DocBee ticket ID found in the URL.', ['status' => 400]);
}
$token = $this->docbee_token($settings);
if (is_wp_error($token)) {
return $token;
}
$previous_status_id = null;
if (!empty($settings['docbee_preserve_ticket_status'])) {
$ticket = $this->docbee_get_json($settings['docbee_base_url'] . '/restApi/v1/ticket/' . rawurlencode($ticket_id) . '?fields=ticketStatus.id,ticketStatus.name,status.id,status.name', $token);
if (!is_wp_error($ticket)) {
$previous_status_id = $this->extract_docbee_status_id($ticket);
}
}
$content = $this->format_docbee_message($run);
$subject = 'QA Report ' . $ticket_id;
if (!empty($run['module'])) {
$subject .= ' - ' . sanitize_text_field((string) $run['module']);
}
$message_url = $settings['docbee_base_url'] . '/restApi/v1/ticket/' . rawurlencode($ticket_id) . '/message';
$payload = [
'content' => $content,
'subject' => $subject,
'internal' => !empty($settings['docbee_message_internal']),
'hidden' => !empty($settings['docbee_message_hidden']),
];
$message_response = $this->docbee_post_json($message_url, $payload, $token);
if (!is_wp_error($message_response)) {
$restored = false;
if ($previous_status_id !== null) {
$restored = $this->docbee_restore_ticket_status($ticket_id, $previous_status_id, $settings, $token);
}
return [
'ok' => true,
'type' => 'message',
'ticket_id' => $ticket_id,
'status_restored' => $restored,
'url' => $this->docbee_result_url($settings['docbee_base_url'], $message_response, 'message'),
];
}
if (empty($settings['docbee_enable_fallback_note'])) {
return $message_response;
}
$note_url = $settings['docbee_base_url'] . '/restApi/v1/note';
$note_payload = [
'note' => [
'ticket' => ['id' => (int) $ticket_id],
'subject' => $subject,
'text' => $content,
'internal' => false,
],
];
$note_response = $this->docbee_post_json($note_url, $note_payload, $token);
if (is_wp_error($note_response)) {
return $note_response;
}
return [
'ok' => true,
'type' => 'note',
'ticket_id' => $ticket_id,
'status_restored' => false,
'url' => $this->docbee_result_url($settings['docbee_base_url'], $note_response, 'note'),
];
}
private function store_report(array $run, string $summary, int $attachment_id, string $pdf_url, ?array $docbee)
{
global $wpdb;
self::create_tables();
$reports_table = self::reports_table();
$steps_table = self::steps_table();
$docbee_result_url = is_array($docbee) && !empty($docbee['url']) ? (string) $docbee['url'] : '';
$inserted = $wpdb->insert(
$reports_table,
[
'created_at' => current_time('mysql'),
'user_id' => get_current_user_id(),
'module' => $this->plain($run['module'] ?? ''),
'module_version' => $this->plain($run['module_version'] ?? ''),
'pbx_version' => $this->plain($run['pbx_version'] ?? ''),
'olm_nummer' => $this->plain($run['olm_nummer'] ?? ''),
'tester' => $this->plain($run['tester'] ?? ''),
'docbee_url' => esc_url_raw((string) ($run['docbee_url'] ?? '')),
'docbee_result_url' => esc_url_raw($docbee_result_url),
'summary' => $summary,
'pdf_attachment_id' => $attachment_id,
'pdf_url' => esc_url_raw($pdf_url),
'run_json' => wp_json_encode($run),
],
['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s']
);
if (!$inserted) {
return new WP_Error('qa_report_store_failed', $wpdb->last_error ?: 'Report could not be stored.', ['status' => 500]);
}
$report_id = (int) $wpdb->insert_id;
$step_index = 0;
$group_index = -1;
$current_group = null;
foreach ((array) ($run['steps'] ?? []) as $step) {
if (!is_array($step)) {
continue;
}
$kind = (string) ($step['kind'] ?? $step['type'] ?? 'step');
if ($kind === 'group') {
$current_group = $this->plain($step['title'] ?? '');
$group_index++;
continue;
}
$step_index++;
$wpdb->insert(
$steps_table,
[
'report_id' => $report_id,
'step_index' => $step_index,
'step_id' => $this->plain($step['id'] ?? ''),
'title' => $this->plain($step['title'] ?? ''),
'expected' => wp_strip_all_tags((string) ($step['expected'] ?? '')),
'status' => $this->normalize_status((string) ($step['status'] ?? '')),
'required' => empty($step['required']) ? 0 : 1,
'comment' => wp_strip_all_tags((string) ($step['comment'] ?? '')),
'evidence' => esc_url_raw((string) ($step['evidence'] ?? '')),
'group_title' => $current_group,
'group_index' => $group_index >= 0 ? $group_index : null,
],
['%d', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%d']
);
}
return $report_id;
}
private function store_pdf_attachment(array $file, array $run)
{
if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
return new WP_Error('qa_pdf_upload_failed', 'PDF upload is invalid.', ['status' => 400]);
}
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$filename = sprintf(
'QA-%s-%s-%s-%s.pdf',
$this->safe_filename((string) ($run['module'] ?? 'mod')),
$this->safe_filename((string) ($run['module_version'] ?? 'v')),
$this->safe_filename((string) ($run['pbx_version'] ?? 'pbx')),
wp_date('Ymd_His')
);
$file_array = [
'name' => $filename,
'type' => 'application/pdf',
'tmp_name' => $file['tmp_name'],
'error' => (int) ($file['error'] ?? 0),
'size' => (int) ($file['size'] ?? 0),
];
$upload = wp_handle_sideload($file_array, [
'test_form' => false,
'mimes' => ['pdf' => 'application/pdf'],
]);
if (!empty($upload['error'])) {
return new WP_Error('qa_pdf_upload_failed', (string) $upload['error'], ['status' => 500]);
}
$title = 'QA Report';
if (!empty($run['module'])) {
$title .= ' - ' . $this->plain($run['module']);
}
$attachment_id = wp_insert_attachment(
[
'post_mime_type' => 'application/pdf',
'post_title' => $title,
'post_content' => '',
'post_status' => 'inherit',
],
$upload['file']
);
if (is_wp_error($attachment_id) || !$attachment_id) {
return new WP_Error('qa_pdf_attachment_failed', 'PDF could not be added to the Media Library.', ['status' => 500]);
}
$metadata = wp_generate_attachment_metadata($attachment_id, $upload['file']);
if (is_array($metadata)) {
wp_update_attachment_metadata($attachment_id, $metadata);
}
return [
'attachment_id' => (int) $attachment_id,
'url' => wp_get_attachment_url($attachment_id) ?: '',
];
}
private function summary_text(array $run): string
{
$counts = $this->status_counts((array) ($run['steps'] ?? []));
return sprintf(
'%d/%d pass, %d fail, %d skip, %d blocked',
$counts['pass'],
$counts['total'],
$counts['fail'],
$counts['skip'],
$counts['blocked']
);
}
private function status_counts(array $steps): array
{
$counts = ['total' => 0, 'pass' => 0, 'fail' => 0, 'skip' => 0, 'blocked' => 0, 'na' => 0];
foreach ($steps as $step) {
if (!is_array($step)) {
continue;
}
$kind = (string) ($step['kind'] ?? $step['type'] ?? 'step');
if ($kind !== 'step') {
continue;
}
$counts['total']++;
$status = $this->normalize_status((string) ($step['status'] ?? ''));
if (isset($counts[$status])) {
$counts[$status]++;
}
}
return $counts;
}
private function normalize_status(string $status): string
{
$status = strtolower(trim($status));
$map = [
'ok' => 'pass',
'passed' => 'pass',
'success' => 'pass',
'true' => 'pass',
'yes' => 'pass',
'ko' => 'fail',
'failed' => 'fail',
'error' => 'fail',
'x' => 'fail',
'skipped' => 'skip',
'block' => 'blocked',
'n/a' => 'na',
'not applicable' => 'na',
];
$status = $map[$status] ?? $status;
return in_array($status, ['pass', 'fail', 'skip', 'blocked', 'na', ''], true) ? $status : '';
}
private function safe_filename(string $value): string
{
$value = sanitize_title($value);
return $value !== '' ? $value : 'qa';
}
private function base_url(string $url): string
{
$url = esc_url_raw(trim($url));
return untrailingslashit($url);
}
private function sanitize_secret_field(string $field, array $input, array $old, string $clear_field): string
{
if (!empty($input[$clear_field])) {
return '';
}
if (!array_key_exists($field, $input) || (string) $input[$field] === '') {
return (string) ($old[$field] ?? '');
}
$value = (string) wp_unslash($input[$field]);
$value = preg_replace('/[\x00-\x1F\x7F]/', '', $value);
return trim((string) $value);
}
private function gitlab_headers(array $settings): array
{
$headers = ['Accept' => 'application/json'];
if (!empty($settings['gitlab_token'])) {
$headers['PRIVATE-TOKEN'] = (string) $settings['gitlab_token'];
}
return $headers;
}
private function remote_json($response, string $error_code)
{
if (is_wp_error($response)) {
return new WP_Error($error_code, $response->get_error_message(), ['status' => 502]);
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
if ($code < 200 || $code >= 300) {
return new WP_Error($error_code, 'Remote service returned HTTP ' . $code . '.', ['status' => $code ?: 502]);
}
$data = json_decode($body, true);
if (!is_array($data)) {
return new WP_Error($error_code, 'Remote service returned invalid JSON.', ['status' => 502]);
}
return $data;
}
private function clean_gitlab_path(string $path): string
{
$path = trim(wp_unslash($path));
$path = str_replace('\\', '/', $path);
$path = ltrim($path, '/');
if ($path === '' || strpos($path, '..') !== false) {
return '';
}
return $path;
}
private function docbee_token(array $settings)
{
$payload = [
'username' => (string) $settings['docbee_username'],
'password' => (string) $settings['docbee_password'],
'lifeTime' => (int) $settings['docbee_lifetime'],
'lifeTimeRefresh' => !empty($settings['docbee_refresh_lifetime']),
];
$response = wp_remote_post($settings['docbee_base_url'] . '/restApi/login', [
'timeout' => 20,
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => wp_json_encode($payload),
]);
$data = $this->remote_json($response, 'docbee_login_failed');
if (is_wp_error($data)) {
return $data;
}
$token = (string) ($data['access_token'] ?? '');
if ($token === '') {
return new WP_Error('docbee_login_failed', 'DocBee did not return an access token.', ['status' => 502]);
}
return $token;
}
private function docbee_post_json(string $url, array $payload, string $token)
{
$response = wp_remote_post($url, [
'timeout' => 20,
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
return new WP_Error('docbee_post_failed', $response->get_error_message(), ['status' => 502]);
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
if ($code < 200 || $code >= 300) {
return new WP_Error('docbee_post_failed', 'DocBee returned HTTP ' . $code . '.', ['status' => $code ?: 502]);
}
return $body;
}
private function docbee_get_json(string $url, string $token)
{
$response = wp_remote_get($url, [
'timeout' => 20,
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Accept' => 'application/json',
],
]);
return $this->remote_json($response, 'docbee_get_failed');
}
private function docbee_put_json(string $url, array $payload, string $token)
{
$response = wp_remote_request($url, [
'method' => 'PUT',
'timeout' => 20,
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code($response);
if ($code < 200 || $code >= 300) {
return new WP_Error('docbee_put_failed', 'DocBee returned HTTP ' . $code . '.', ['status' => $code ?: 502]);
}
return (string) wp_remote_retrieve_body($response);
}
private function extract_docbee_status_id(array $ticket): ?int
{
$candidates = [
$ticket['ticketStatus']['id'] ?? null,
$ticket['ticketStatus'] ?? null,
$ticket['status']['id'] ?? null,
$ticket['status'] ?? null,
];
foreach ($candidates as $candidate) {
if (is_numeric($candidate)) {
return (int) $candidate;
}
}
return null;
}
private function docbee_restore_ticket_status(string $ticket_id, int $status_id, array $settings, string $token): bool
{
$url = $settings['docbee_base_url'] . '/restApi/v1/ticket/' . rawurlencode($ticket_id);
$tries = [
['ticketStatus' => $status_id],
['ticketStatus' => ['id' => $status_id]],
['status' => $status_id],
['status' => ['id' => $status_id]],
];
foreach ($tries as $payload) {
$result = $this->docbee_put_json($url, $payload, $token);
if (!is_wp_error($result)) {
return true;
}
}
return false;
}
private function extract_ticket_id(string $url): string
{
if (preg_match('/(?:\/ticket\/show\/|\/tickets\/|\/ticket\/)(\d+)/i', $url, $matches)) {
return $matches[1];
}
if (preg_match('/^\d+$/', trim($url))) {
return trim($url);
}
return '';
}
private function format_docbee_message(array $run): string
{
$counts = $this->status_counts((array) ($run['steps'] ?? []));
$timestamp = !empty($run['ts']) ? strtotime((string) $run['ts']) : time();
if (!$timestamp) {
$timestamp = time();
}
$date = wp_date('d.m.Y H:i', $timestamp);
$lines = [];
$lines[] = 'QA REPORT';
$lines[] = '=========';
$lines[] = 'Modul: ' . $this->plain($run['module'] ?? '');
$lines[] = 'Modul-Version: ' . $this->plain($run['module_version'] ?? '');
$lines[] = 'PBX-Version: ' . $this->plain($run['pbx_version'] ?? '');
if (!empty($run['olm_nummer'])) {
$lines[] = 'OLM-Nummer: ' . $this->plain($run['olm_nummer']);
}
$lines[] = 'Tester: ' . $this->plain($run['tester'] ?? '');
if (!empty($run['docbee_url'])) {
$lines[] = 'Ticket: ' . $this->plain($run['docbee_url']);
}
$lines[] = 'Datum: ' . $date;
$lines[] = '';
$lines[] = sprintf('Uebersicht: PASS %d | FAIL %d | SKIP %d | BLOCK %d', $counts['pass'], $counts['fail'], $counts['skip'], $counts['blocked']);
$lines[] = '';
$lines[] = '------------------------------------------------------------------------';
$lines[] = 'Schritt Status Titel';
$lines[] = '------------------------------------------------------------------------';
foreach ((array) ($run['steps'] ?? []) as $step) {
if (!is_array($step)) {
continue;
}
$kind = (string) ($step['kind'] ?? $step['type'] ?? 'step');
if ($kind === 'group') {
$lines[] = '';
$lines[] = '## ' . $this->plain($step['title'] ?? '');
$lines[] = '';
continue;
}
$status = strtoupper((string) ($step['status'] ?? ''));
$status = $status === 'BLOCKED' ? 'BLOCK' : $status;
$required = !empty($step['required']) ? ' [required]' : '';
$lines[] = sprintf(
'%-12s %-7s %s%s',
$this->plain($step['id'] ?? ''),
$status,
$this->plain($step['title'] ?? ''),
$required
);
if (!empty($step['comment'])) {
$lines[] = ' Kommentar: ' . $this->plain($step['comment']);
}
if (!empty($step['evidence'])) {
$lines[] = ' Evidenz: ' . $this->plain($step['evidence']);
}
}
$lines[] = '------------------------------------------------------------------------';
$lines[] = 'Legende: PASS, FAIL, SKIP, BLOCK';
return implode("\n", $lines);
}
private function docbee_result_url(string $base_url, string $response_body, string $prefix): string
{
$data = json_decode($response_body, true);
if (!is_array($data)) {
return '';
}
if (!empty($data['link'])) {
$link = (string) $data['link'];
if (preg_match('/^https?:\/\//i', $link)) {
return esc_url_raw($link);
}
return esc_url_raw(trailingslashit($base_url) . ltrim($link, '/'));
}
if (!empty($data['id'])) {
return esc_url_raw(trailingslashit($base_url) . $prefix . '/' . rawurlencode((string) $data['id']));
}
return '';
}
private function plain($value): string
{
$value = wp_strip_all_tags((string) $value);
$value = preg_replace('/\s+/', ' ', $value);
return trim((string) $value);
}
}
register_activation_hook(__FILE__, ['Obyte_QA_Tool', 'activate']);
Obyte_QA_Tool::instance();