Adopt reusable CI standard + PHPUnit conversion (#22)

Squash-merge of PR #22: adopt shared reusable CI workflow, convert PHPUnit to stubs, update branch-protection contexts.

QA-approved at 05d8094a1d. All required CI checks green.

Agent-Session: 2026-06-17-prcore-ci-deploy
This commit is contained in:
peptiderepo
2026-06-17 01:12:00 +07:00
committed by GitHub
parent d0b5875fd8
commit 665d7fa9e1
72 changed files with 4604 additions and 1212 deletions

View File

@@ -1,6 +1,18 @@
<?php
/**
* Candidate Queue Repository.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* CRUD for AI-extracted dosing candidates awaiting human review
*
* @package Peptide_Repo_Core
*/
/**
* Repository for AI candidate queue (pr_ai_candidate_queue table).
*
@@ -28,7 +40,7 @@ class PR_Core_Candidate_Queue_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ),
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
ARRAY_A
);
@@ -47,25 +59,25 @@ class PR_Core_Candidate_Queue_Repository {
global $wpdb;
$table = $wpdb->prefix . 'pr_ai_candidate_queue';
$where = [ 'queue_status = %s' ];
$params = [ sanitize_text_field( $status ) ];
$where = array( 'queue_status = %s' );
$params = array( sanitize_text_field( $status ) );
if ( $peptide_id > 0 ) {
$where[] = 'peptide_id = %d';
$params[] = $peptide_id;
}
$params[] = $limit;
$params[] = $limit;
$where_sql = implode( ' AND ', $where );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY extraction_confidence DESC LIMIT %d",
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY extraction_confidence DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
...$params
),
ARRAY_A
) ?: [];
) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
return array_map(
static fn( array $row ) => new PR_Core_Candidate_DTO( $row ),
@@ -85,7 +97,7 @@ class PR_Core_Candidate_Queue_Repository {
global $wpdb;
$table = $wpdb->prefix . 'pr_ai_candidate_queue';
$row = [
$row = array(
'peptide_id' => absint( $data['peptide_id'] ?? 0 ),
'dose_min' => isset( $data['dose_min'] ) ? (float) $data['dose_min'] : null,
'dose_max' => isset( $data['dose_max'] ) ? (float) $data['dose_max'] : null,
@@ -106,7 +118,7 @@ class PR_Core_Candidate_Queue_Repository {
'extraction_confidence' => (float) ( $data['extraction_confidence'] ?? 0 ),
'queue_status' => 'pending',
'extracted_at' => current_time( 'mysql' ),
];
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->insert( $table, $row );
@@ -134,30 +146,32 @@ class PR_Core_Candidate_Queue_Repository {
// Copy candidate data into a dosing row.
$dosing_repo = new PR_Core_Dosing_Repository();
$dosing_id = $dosing_repo->insert( [
'peptide_id' => $candidate->peptide_id,
'dose_min' => $candidate->dose_min,
'dose_max' => $candidate->dose_max,
'dose_unit' => $candidate->dose_unit,
'route' => $candidate->route,
'frequency' => $candidate->frequency,
'duration_value' => $candidate->duration_value,
'duration_unit' => $candidate->duration_unit,
'population' => $candidate->population,
'indication' => $candidate->indication,
'evidence_strength' => $candidate->evidence_strength,
'study_title' => $candidate->study_title,
'study_year' => $candidate->study_year,
'citation_pubmed_id' => $candidate->citation_pubmed_id,
'citation_doi' => $candidate->citation_doi,
'citation_url' => $candidate->citation_url,
'notes' => $candidate->notes,
'source' => 'ai-candidate-approved',
'ai_candidate_id' => $candidate_id,
'added_by' => $reviewer_id,
'reviewed_by' => $reviewer_id,
'reviewed_at' => current_time( 'mysql' ),
] );
$dosing_id = $dosing_repo->insert(
array(
'peptide_id' => $candidate->peptide_id,
'dose_min' => $candidate->dose_min,
'dose_max' => $candidate->dose_max,
'dose_unit' => $candidate->dose_unit,
'route' => $candidate->route,
'frequency' => $candidate->frequency,
'duration_value' => $candidate->duration_value,
'duration_unit' => $candidate->duration_unit,
'population' => $candidate->population,
'indication' => $candidate->indication,
'evidence_strength' => $candidate->evidence_strength,
'study_title' => $candidate->study_title,
'study_year' => $candidate->study_year,
'citation_pubmed_id' => $candidate->citation_pubmed_id,
'citation_doi' => $candidate->citation_doi,
'citation_url' => $candidate->citation_url,
'notes' => $candidate->notes,
'source' => 'ai-candidate-approved',
'ai_candidate_id' => $candidate_id,
'added_by' => $reviewer_id,
'reviewed_by' => $reviewer_id,
'reviewed_at' => current_time( 'mysql' ),
)
);
if ( 0 === $dosing_id ) {
return 0;
@@ -204,7 +218,7 @@ class PR_Core_Candidate_Queue_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
return (int) $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE queue_status = %s", $status )
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE queue_status = %s", $status ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
}
@@ -224,15 +238,15 @@ class PR_Core_Candidate_Queue_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->update(
$table,
[
array(
'queue_status' => sanitize_text_field( $status ),
'reviewed_by' => $reviewer_id,
'reviewed_at' => current_time( 'mysql' ),
'reviewer_notes' => sanitize_textarea_field( $notes ),
],
[ 'id' => $id ],
[ '%s', '%d', '%s', '%s' ],
[ '%d' ]
),
array( 'id' => $id ),
array( '%s', '%d', '%s', '%s' ),
array( '%d' )
);
return false !== $result;

View File

@@ -1,6 +1,18 @@
<?php
/**
* Dosing Repository.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* CRUD operations for dosing data, returning typed DTOs
*
* @package Peptide_Repo_Core
*/
/**
* Repository for dosing rows (pr_dosing_rows table).
*
@@ -25,7 +37,7 @@ class PR_Core_Dosing_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ),
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
ARRAY_A
);
@@ -39,12 +51,12 @@ class PR_Core_Dosing_Repository {
* @param array<string, mixed> $filters Optional: route, population, evidence_strength.
* @return PR_Core_Dosing_Row_DTO[]
*/
public function find_by_peptide( int $peptide_id, array $filters = [] ): array {
public function find_by_peptide( int $peptide_id, array $filters = array() ): array {
global $wpdb;
$table = $wpdb->prefix . 'pr_dosing_rows';
$where = [ 'peptide_id = %d', 'superseded_by_id IS NULL' ];
$params = [ $peptide_id ];
$where = array( 'peptide_id = %d', 'superseded_by_id IS NULL' );
$params = array( $peptide_id );
if ( ! empty( $filters['route'] ) ) {
$where[] = 'route = %s';
@@ -65,12 +77,12 @@ class PR_Core_Dosing_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY evidence_strength DESC, study_year DESC",
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- dynamic {$where_sql} is builder-constructed, not a placeholder
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY evidence_strength DESC, study_year DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
...$params
),
ARRAY_A
) ?: [];
) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
return array_map(
static fn( array $row ) => new PR_Core_Dosing_Row_DTO( $row ),
@@ -92,11 +104,15 @@ class PR_Core_Dosing_Repository {
global $wpdb;
$table = $wpdb->prefix . 'pr_dosing_rows';
$row = $this->sanitize_row( $data );
$row = $this->sanitize_row( $data );
$row['added_at'] = current_time( 'mysql' );
$row['schema_version'] = 1;
/** @see PR_Core::register_public_filters() — Documented lifecycle hook. */
/**
* Property.
*
* @see PR_Core::register_public_filters() — Documented lifecycle hook.
*/
do_action( 'pr_core_before_dosing_row_publish', $row );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
@@ -137,10 +153,10 @@ class PR_Core_Dosing_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
[ 'superseded_by_id' => $new_id ],
[ 'id' => $old_id ],
[ '%d' ],
[ '%d' ]
array( 'superseded_by_id' => $new_id ),
array( 'id' => $old_id ),
array( '%d' ),
array( '%d' )
);
return $new_id;
@@ -159,7 +175,7 @@ class PR_Core_Dosing_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE peptide_id = %d AND superseded_by_id IS NULL",
"SELECT COUNT(*) FROM {$table} WHERE peptide_id = %d AND superseded_by_id IS NULL", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$peptide_id
)
);
@@ -172,7 +188,7 @@ class PR_Core_Dosing_Repository {
* @return array<string, mixed> Sanitized row.
*/
private function sanitize_row( array $data ): array {
return [
return array(
'peptide_id' => absint( $data['peptide_id'] ?? 0 ),
'dose_min' => isset( $data['dose_min'] ) ? (float) $data['dose_min'] : null,
'dose_max' => isset( $data['dose_max'] ) ? (float) $data['dose_max'] : null,
@@ -195,6 +211,6 @@ class PR_Core_Dosing_Repository {
'added_by' => absint( $data['added_by'] ?? get_current_user_id() ),
'reviewed_by' => isset( $data['reviewed_by'] ) ? absint( $data['reviewed_by'] ) : null,
'reviewed_at' => $data['reviewed_at'] ?? null,
];
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Legal Repository.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* CRUD operations for per-country legal status data, returning typed DTOs
*
* @package Peptide_Repo_Core
*/
/**
* Repository for legal status cells (pr_legal_cells table).
*
@@ -28,7 +40,7 @@ class PR_Core_Legal_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ),
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
ARRAY_A
);
@@ -48,11 +60,11 @@ class PR_Core_Legal_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE peptide_id = %d AND superseded_by_id IS NULL ORDER BY country_code ASC",
"SELECT * FROM {$table} WHERE peptide_id = %d AND superseded_by_id IS NULL ORDER BY country_code ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$peptide_id
),
ARRAY_A
) ?: [];
) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
return array_map(
static fn( array $row ) => new PR_Core_Legal_Cell_DTO( $row ),
@@ -74,7 +86,7 @@ class PR_Core_Legal_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE peptide_id = %d AND country_code = %s AND superseded_by_id IS NULL",
"SELECT * FROM {$table} WHERE peptide_id = %d AND country_code = %s AND superseded_by_id IS NULL", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$peptide_id,
strtoupper( sanitize_text_field( $country_code ) )
),
@@ -97,11 +109,11 @@ class PR_Core_Legal_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE country_code = %s AND superseded_by_id IS NULL ORDER BY peptide_id ASC",
"SELECT * FROM {$table} WHERE country_code = %s AND superseded_by_id IS NULL ORDER BY peptide_id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
strtoupper( sanitize_text_field( $country_code ) )
),
ARRAY_A
) ?: [];
) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
return array_map(
static fn( array $row ) => new PR_Core_Legal_Cell_DTO( $row ),
@@ -161,10 +173,10 @@ class PR_Core_Legal_Repository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
[ 'superseded_by_id' => $new_id ],
[ 'id' => $old_id ],
[ '%d' ],
[ '%d' ]
array( 'superseded_by_id' => $new_id ),
array( 'id' => $old_id ),
array( '%d' ),
array( '%d' )
);
return $new_id;
@@ -184,7 +196,7 @@ class PR_Core_Legal_Repository {
$status = 'unclear';
}
return [
return array(
'peptide_id' => absint( $data['peptide_id'] ?? 0 ),
'country_code' => strtoupper( sanitize_text_field( $data['country_code'] ?? '' ) ),
'status' => $status,
@@ -195,6 +207,6 @@ class PR_Core_Legal_Repository {
'last_verified_at' => sanitize_text_field( $data['last_verified_at'] ?? current_time( 'mysql' ) ),
'schema_version' => 1,
'reviewer_id' => absint( $data['reviewer_id'] ?? get_current_user_id() ),
];
);
}
}

View File

@@ -1,6 +1,18 @@
<?php
/**
* Peptide Repository.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* CRUD operations over peptide posts, returning typed DTOs
*
* @package Peptide_Repo_Core
*/
/**
* Repository for peptide CPT records.
*
@@ -36,12 +48,14 @@ class PR_Core_Peptide_Repository {
* @return PR_Core_Peptide_DTO|null
*/
public function find_by_slug( string $slug ): ?PR_Core_Peptide_DTO {
$posts = get_posts( [
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'name' => sanitize_title( $slug ),
'posts_per_page' => 1,
'post_status' => 'any',
] );
$posts = get_posts(
array(
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'name' => sanitize_title( $slug ),
'posts_per_page' => 1,
'post_status' => 'any',
)
);
return ! empty( $posts ) ? $this->post_to_dto( $posts[0] ) : null;
}
@@ -56,14 +70,16 @@ class PR_Core_Peptide_Repository {
public function search( string $query, int $limit = 20 ): array {
$query = sanitize_text_field( $query );
$posts = get_posts( [
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
's' => $query,
'posts_per_page' => $limit,
'post_status' => 'publish',
] );
$posts = get_posts(
array(
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
's' => $query,
'posts_per_page' => $limit,
'post_status' => 'publish',
)
);
return array_map( [ $this, 'post_to_dto' ], $posts );
return array_map( array( $this, 'post_to_dto' ), $posts );
}
/**
@@ -73,40 +89,44 @@ class PR_Core_Peptide_Repository {
* evidence_strength, per_page, page.
* @return PR_Core_Peptide_DTO[]
*/
public function find_all( array $filters = [] ): array {
$args = [
public function find_all( array $filters = array() ): array {
$args = array(
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'posts_per_page' => (int) ( $filters['per_page'] ?? 100 ),
'paged' => (int) ( $filters['page'] ?? 1 ),
'orderby' => 'title',
'order' => 'ASC',
];
);
$args['post_status'] = $filters['status'] ?? 'publish';
if ( ! empty( $filters['category'] ) ) {
$args['tax_query'] = [ [
'taxonomy' => PR_Core_Peptide_CPT::TAX_CATEGORY,
'field' => 'slug',
'terms' => sanitize_text_field( $filters['category'] ),
] ];
$args['tax_query'] = array(
array(
'taxonomy' => PR_Core_Peptide_CPT::TAX_CATEGORY,
'field' => 'slug',
'terms' => sanitize_text_field( $filters['category'] ),
),
);
}
// v0.2.0: `family` filter silently ignored — `pr_peptide_family` taxonomy
// was removed with the CPT consolidation. Callers passing `family` get
// all-category results rather than a fatal. Remove this key from the
// v0.2.0: `family` filter silently ignored — `pr_peptide_family` taxonomy.
// was removed with the CPT consolidation. Callers passing `family` get.
// all-category results rather than a fatal. Remove this key from the.
// REST schema in a future minor bump.
if ( ! empty( $filters['evidence_strength'] ) ) {
$args['meta_query'] = [ [
'key' => 'evidence_strength',
'value' => sanitize_text_field( $filters['evidence_strength'] ),
] ];
$args['meta_query'] = array(
array(
'key' => 'evidence_strength',
'value' => sanitize_text_field( $filters['evidence_strength'] ),
),
);
}
$posts = get_posts( $args );
return array_map( [ $this, 'post_to_dto' ], $posts );
return array_map( array( $this, 'post_to_dto' ), $posts );
}
/**
@@ -128,34 +148,36 @@ class PR_Core_Peptide_Repository {
*/
private function post_to_dto( \WP_Post $post ): PR_Core_Peptide_DTO {
$aliases_raw = get_post_meta( $post->ID, 'aliases', true );
$aliases = json_decode( $aliases_raw ?: '[]', true ) ?: [];
$aliases = json_decode( $aliases_raw ?: '[]', true ) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$categories = wp_get_post_terms( $post->ID, PR_Core_Peptide_CPT::TAX_CATEGORY, [ 'fields' => 'names' ] );
// v0.2.0: `pr_peptide_family` taxonomy removed. DTO `families` field
// preserved as empty array to keep the REST response shape stable for
$categories = wp_get_post_terms( $post->ID, PR_Core_Peptide_CPT::TAX_CATEGORY, array( 'fields' => 'names' ) );
// v0.2.0: `pr_peptide_family` taxonomy removed. DTO `families` field.
// preserved as empty array to keep the REST response shape stable for.
// clients; will be dropped in a future minor bump with a release note.
$families = [];
$families = array();
return new PR_Core_Peptide_DTO( [
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'content' => $post->post_content,
'excerpt' => $post->post_excerpt,
'status' => $post->post_status,
'display_name' => get_post_meta( $post->ID, 'display_name', true ) ?: '',
'aliases' => $aliases,
'molecular_formula' => get_post_meta( $post->ID, 'molecular_formula', true ) ?: '',
'molecular_weight' => (float) get_post_meta( $post->ID, 'molecular_weight', true ),
'cas_number' => get_post_meta( $post->ID, 'cas_number', true ) ?: '',
'drugbank_id' => get_post_meta( $post->ID, 'drugbank_id', true ) ?: '',
'chembl_id' => get_post_meta( $post->ID, 'chembl_id', true ) ?: '',
'evidence_strength' => get_post_meta( $post->ID, 'evidence_strength', true ) ?: 'preclinical',
'editorial_review_status' => get_post_meta( $post->ID, 'editorial_review_status', true ) ?: 'draft',
'last_editorial_review_at' => get_post_meta( $post->ID, 'last_editorial_review_at', true ) ?: '',
'medical_editor_id' => (int) get_post_meta( $post->ID, 'medical_editor_id', true ),
'categories' => is_array( $categories ) ? $categories : [],
'families' => $families,
] );
return new PR_Core_Peptide_DTO(
array(
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'content' => $post->post_content,
'excerpt' => $post->post_excerpt,
'status' => $post->post_status,
'display_name' => get_post_meta( $post->ID, 'display_name', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'aliases' => $aliases,
'molecular_formula' => get_post_meta( $post->ID, 'molecular_formula', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'molecular_weight' => (float) get_post_meta( $post->ID, 'molecular_weight', true ),
'cas_number' => get_post_meta( $post->ID, 'cas_number', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'drugbank_id' => get_post_meta( $post->ID, 'drugbank_id', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'chembl_id' => get_post_meta( $post->ID, 'chembl_id', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'evidence_strength' => get_post_meta( $post->ID, 'evidence_strength', true ) ?: 'preclinical', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'editorial_review_status' => get_post_meta( $post->ID, 'editorial_review_status', true ) ?: 'draft', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'last_editorial_review_at' => get_post_meta( $post->ID, 'last_editorial_review_at', true ) ?: '', // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'medical_editor_id' => (int) get_post_meta( $post->ID, 'medical_editor_id', true ),
'categories' => is_array( $categories ) ? $categories : array(),
'families' => $families,
)
);
}
}