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:
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration 0001 Dosing Rows.
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Creates the high-cardinality dosing data table (1:many with peptide)
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
/**
|
||||
* Migration 0001: Create the pr_dosing_rows table.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration 0002 Legal Cells.
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Creates the per-country legal status table (1:many with peptide)
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
/**
|
||||
* Migration 0002: Create the pr_legal_cells table.
|
||||
*
|
||||
@@ -14,15 +26,19 @@ declare(strict_types=1);
|
||||
*/
|
||||
class PR_Core_Migration_0002_Legal_Cells {
|
||||
|
||||
/** @var string[] Valid legal status enum values. */
|
||||
public const STATUSES = [
|
||||
/**
|
||||
* Valid legal status enum values.
|
||||
*
|
||||
* @var string[] Valid legal status enum values.
|
||||
*/
|
||||
public const STATUSES = array(
|
||||
'prescription',
|
||||
'ruo',
|
||||
'otc',
|
||||
'restricted',
|
||||
'banned',
|
||||
'unclear',
|
||||
];
|
||||
);
|
||||
|
||||
/**
|
||||
* Create the pr_legal_cells table.
|
||||
@@ -36,7 +52,7 @@ class PR_Core_Migration_0002_Legal_Cells {
|
||||
$table = $wpdb->prefix . 'pr_legal_cells';
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
$sql = "CREATE TABLE {$table} ( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
peptide_id BIGINT UNSIGNED NOT NULL,
|
||||
country_code CHAR(2) NOT NULL,
|
||||
@@ -61,7 +77,7 @@ class PR_Core_Migration_0002_Legal_Cells {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$index_exists = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s",
|
||||
'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s',
|
||||
DB_NAME,
|
||||
$table,
|
||||
'uq_peptide_country_active'
|
||||
@@ -71,7 +87,7 @@ class PR_Core_Migration_0002_Legal_Cells {
|
||||
if ( '0' === $index_exists ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
"ALTER TABLE {$table} ADD UNIQUE KEY uq_peptide_country_active (peptide_id, country_code, superseded_by_id)"
|
||||
"ALTER TABLE {$table} ADD UNIQUE KEY uq_peptide_country_active (peptide_id, country_code, superseded_by_id)" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration 0003 Candidate Queue.
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Creates the AI-extracted dosing candidate queue table
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
/**
|
||||
* Migration 0003: Create the pr_ai_candidate_queue table.
|
||||
*
|
||||
@@ -15,13 +27,17 @@ declare(strict_types=1);
|
||||
*/
|
||||
class PR_Core_Migration_0003_Candidate_Queue {
|
||||
|
||||
/** @var string[] Valid queue status enum values. */
|
||||
public const STATUSES = [
|
||||
/**
|
||||
* Valid queue status enum values.
|
||||
*
|
||||
* @var string[] Valid queue status enum values.
|
||||
*/
|
||||
public const STATUSES = array(
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'merged',
|
||||
];
|
||||
);
|
||||
|
||||
/**
|
||||
* Create the pr_ai_candidate_queue table.
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration 0004: Backfill peptide schema meta.
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
@@ -30,16 +36,20 @@ declare(strict_types=1);
|
||||
*/
|
||||
class PR_Core_Migration_0004_Backfill_Peptide_Meta {
|
||||
|
||||
/** @var string Meta key: molecular formula (PR Core namespace). */
|
||||
/** @var string Meta key: molecular formula (PR Core namespace). */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
|
||||
public const META_FORMULA = '_pr_molecular_formula';
|
||||
|
||||
/** @var string Meta key: molecular weight as float string (PR Core namespace). */
|
||||
public const META_WEIGHT = '_pr_molecular_weight';
|
||||
/** @var string Meta key: molecular weight as float string (PR Core namespace). */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
|
||||
public const META_WEIGHT = '_pr_molecular_weight';
|
||||
|
||||
/** @var string Meta key: aliases JSON array (PR Core namespace). */
|
||||
/** @var string Meta key: aliases JSON array (PR Core namespace). */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
|
||||
public const META_ALIASES = '_pr_aliases';
|
||||
|
||||
/** @var PR_Core_Pubchem_Client HTTP client for PubChem REST API. */
|
||||
/**
|
||||
* HTTP client for PubChem REST API.
|
||||
*
|
||||
* @var PR_Core_Pubchem_Client
|
||||
*/
|
||||
private PR_Core_Pubchem_Client $pubchem;
|
||||
|
||||
/**
|
||||
@@ -66,20 +76,27 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
|
||||
return;
|
||||
}
|
||||
|
||||
$counts = [ 'skipped' => 0, 'copied_psa' => 0, 'pubchem_ok' => 0, 'pubchem_skip' => 0 ];
|
||||
$counts = array(
|
||||
'skipped' => 0,
|
||||
'copied_psa' => 0,
|
||||
'pubchem_ok' => 0,
|
||||
'pubchem_skip' => 0,
|
||||
);
|
||||
|
||||
foreach ( $peptide_ids as $post_id ) {
|
||||
$result = $this->backfill_post( $post_id );
|
||||
$counts[ $result ] += 1;
|
||||
}
|
||||
|
||||
error_log( sprintf(
|
||||
'[PR Core Migration 0004] Complete. Skipped: %d, PSA-copied: %d, PubChem-ok: %d, PubChem-skip: %d',
|
||||
$counts['skipped'],
|
||||
$counts['copied_psa'],
|
||||
$counts['pubchem_ok'],
|
||||
$counts['pubchem_skip']
|
||||
) );
|
||||
error_log(
|
||||
sprintf(
|
||||
'[PR Core Migration 0004] Complete. Skipped: %d, PSA-copied: %d, PubChem-ok: %d, PubChem-skip: %d',
|
||||
$counts['skipped'],
|
||||
$counts['copied_psa'],
|
||||
$counts['pubchem_ok'],
|
||||
$counts['pubchem_skip']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,10 +110,10 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
|
||||
$existing_weight = (string) get_post_meta( $post_id, self::META_WEIGHT, true );
|
||||
$existing_aliases = (string) get_post_meta( $post_id, self::META_ALIASES, true );
|
||||
|
||||
// Idempotency limitation: this guard is all-or-nothing. A peptide that legitimately
|
||||
// has no aliases (e.g., a novel compound) will never satisfy all three conditions and
|
||||
// the migration will re-run on every manual re-invoke. This is safe because
|
||||
// write_meta_from_psa() / write_meta_from_pubchem() only overwrite with non-empty
|
||||
// Idempotency limitation: this guard is all-or-nothing. A peptide that legitimately.
|
||||
// has no aliases (e.g., a novel compound) will never satisfy all three conditions and.
|
||||
// the migration will re-run on every manual re-invoke. This is safe because.
|
||||
// write_meta_from_psa() / write_meta_from_pubchem() only overwrite with non-empty.
|
||||
// values, but callers should be aware the 'skipped' result requires all three keys set.
|
||||
if ( '' !== $existing_formula && '' !== $existing_weight && '' !== $existing_aliases ) {
|
||||
return 'skipped';
|
||||
@@ -125,26 +142,30 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
|
||||
}
|
||||
|
||||
if ( null === $data ) {
|
||||
error_log( sprintf(
|
||||
'[PR Core Migration 0004] Post ID %d (%s): PubChem returned no data — leaving meta empty.',
|
||||
$post_id,
|
||||
esc_html( $name )
|
||||
) );
|
||||
error_log(
|
||||
sprintf(
|
||||
'[PR Core Migration 0004] Post ID %d (%s): PubChem returned no data — leaving meta empty.',
|
||||
$post_id,
|
||||
esc_html( $name )
|
||||
)
|
||||
);
|
||||
return 'pubchem_skip';
|
||||
}
|
||||
|
||||
$formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $data['formula'] );
|
||||
$weight = (float) $data['weight'];
|
||||
$aliases_json = $this->synonyms_to_json( $data['synonyms'] ?? [] );
|
||||
$aliases_json = $this->synonyms_to_json( $data['synonyms'] ?? array() );
|
||||
$this->write_pr_meta( $post_id, $formula, $weight, $aliases_json );
|
||||
|
||||
error_log( sprintf(
|
||||
'[PR Core Migration 0004] Post ID %d (%s): PubChem-sourced formula=%s weight=%s',
|
||||
$post_id,
|
||||
esc_html( $name ),
|
||||
$formula,
|
||||
(string) $weight
|
||||
) );
|
||||
error_log(
|
||||
sprintf(
|
||||
'[PR Core Migration 0004] Post ID %d (%s): PubChem-sourced formula=%s weight=%s',
|
||||
$post_id,
|
||||
esc_html( $name ),
|
||||
$formula,
|
||||
(string) $weight
|
||||
)
|
||||
);
|
||||
|
||||
return 'pubchem_ok';
|
||||
}
|
||||
@@ -223,7 +244,7 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
$clean = array();
|
||||
foreach ( explode( ',', $value ) as $part ) {
|
||||
$alias = sanitize_text_field( trim( $part ) );
|
||||
if ( '' !== $alias ) {
|
||||
@@ -231,7 +252,7 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
|
||||
}
|
||||
}
|
||||
|
||||
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
|
||||
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,14 +264,14 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
|
||||
* @return string JSON array of sanitized strings.
|
||||
*/
|
||||
private function synonyms_to_json( array $synonyms ): string {
|
||||
$clean = [];
|
||||
$clean = array();
|
||||
foreach ( $synonyms as $syn ) {
|
||||
$alias = sanitize_text_field( (string) $syn );
|
||||
if ( '' !== $alias ) {
|
||||
$clean[] = $alias;
|
||||
}
|
||||
}
|
||||
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
|
||||
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,12 +280,14 @@ class PR_Core_Migration_0004_Backfill_Peptide_Meta {
|
||||
* @return int[]
|
||||
*/
|
||||
private function get_all_peptide_ids(): array {
|
||||
$posts = get_posts( [
|
||||
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => -1,
|
||||
'fields' => 'ids',
|
||||
] );
|
||||
return array_map( 'intval', is_array( $posts ) ? $posts : [] );
|
||||
$posts = get_posts(
|
||||
array(
|
||||
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => -1,
|
||||
'fields' => 'ids',
|
||||
)
|
||||
);
|
||||
return array_map( 'intval', is_array( $posts ) ? $posts : array() );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration Runner.
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Compares current schema version against target, runs pending migrations
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
/**
|
||||
* Schema migration runner.
|
||||
*
|
||||
@@ -16,7 +28,11 @@ declare(strict_types=1);
|
||||
*/
|
||||
class PR_Core_Migration_Runner {
|
||||
|
||||
/** @var string Option key storing current schema version. */
|
||||
/**
|
||||
* Option key storing current schema version.
|
||||
*
|
||||
* @var string Option key storing current schema version.
|
||||
*/
|
||||
private const VERSION_OPTION = 'pr_core_schema_version';
|
||||
|
||||
/**
|
||||
@@ -25,12 +41,12 @@ class PR_Core_Migration_Runner {
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const MIGRATIONS = [
|
||||
private const MIGRATIONS = array(
|
||||
'PR_Core_Migration_0001_Dosing_Rows',
|
||||
'PR_Core_Migration_0002_Legal_Cells',
|
||||
'PR_Core_Migration_0003_Candidate_Queue',
|
||||
'PR_Core_Migration_0004_Backfill_Peptide_Meta',
|
||||
];
|
||||
);
|
||||
|
||||
/**
|
||||
* Run all pending migrations up to PR_CORE_TARGET_SCHEMA_VERSION.
|
||||
@@ -86,11 +102,11 @@ class PR_Core_Migration_Runner {
|
||||
*
|
||||
* PR_Core_Migration_0001_Dosing_Rows => migrations/class-pr-core-migration-0001-dosing-rows.php
|
||||
*
|
||||
* @param string $class Fully-qualified class name.
|
||||
* @param string $class_name Fully-qualified class name.
|
||||
* @return string Absolute file path.
|
||||
*/
|
||||
private function class_to_file( string $class ): string {
|
||||
$suffix = substr( $class, 8 ); // Strip 'PR_Core_'.
|
||||
private function class_to_file( string $class_name ): string {
|
||||
$suffix = substr( $class_name, 8 ); // Strip 'PR_Core_'.
|
||||
$filename = 'class-pr-core-' . str_replace( '_', '-', strtolower( $suffix ) ) . '.php';
|
||||
|
||||
return PR_CORE_PLUGIN_DIR . 'includes/migrations/' . $filename;
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
* Pubchem Client.
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fetches molecular formula, molecular weight, and synonyms from the
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
/**
|
||||
* Lightweight PubChem REST client for the backfill migration.
|
||||
*
|
||||
@@ -20,16 +32,32 @@ declare(strict_types=1);
|
||||
*/
|
||||
class PR_Core_Pubchem_Client {
|
||||
|
||||
/** @var string PubChem REST base URL. */
|
||||
/**
|
||||
* PubChem REST base URL.
|
||||
*
|
||||
* @var string PubChem REST base URL.
|
||||
*/
|
||||
private const BASE = 'https://pubchem.ncbi.nlm.nih.gov/rest/pug';
|
||||
|
||||
/** @var int HTTP timeout per request (seconds). */
|
||||
/**
|
||||
* HTTP timeout per request (seconds).
|
||||
*
|
||||
* @var int HTTP timeout per request (seconds).
|
||||
*/
|
||||
private const TIMEOUT = 8;
|
||||
|
||||
/** @var int Maximum retry attempts with exponential backoff. */
|
||||
/**
|
||||
* Maximum retry attempts with exponential backoff.
|
||||
*
|
||||
* @var int Maximum retry attempts with exponential backoff.
|
||||
*/
|
||||
private const MAX_RETRIES = 3;
|
||||
|
||||
/** @var int Maximum synonyms to return (keeps post-meta size reasonable). */
|
||||
/**
|
||||
* Maximum synonyms to return (keeps post-meta size reasonable).
|
||||
*
|
||||
* @var int Maximum synonyms to return (keeps post-meta size reasonable).
|
||||
*/
|
||||
private const MAX_SYNONYMS = 10;
|
||||
|
||||
/**
|
||||
@@ -109,11 +137,11 @@ class PR_Core_Pubchem_Client {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
return array(
|
||||
'formula' => $formula,
|
||||
'weight' => $weight,
|
||||
'cid' => $cid,
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,14 +158,14 @@ class PR_Core_Pubchem_Client {
|
||||
$body = $this->get_with_retry( $url );
|
||||
|
||||
if ( null === $body ) {
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
$data = json_decode( $body, true );
|
||||
$synonyms = $data['InformationList']['Information'][0]['Synonym'] ?? [];
|
||||
$synonyms = $data['InformationList']['Information'][0]['Synonym'] ?? array();
|
||||
|
||||
if ( ! is_array( $synonyms ) ) {
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
return array_slice( $synonyms, 0, self::MAX_SYNONYMS );
|
||||
@@ -176,10 +204,13 @@ class PR_Core_Pubchem_Client {
|
||||
*/
|
||||
private function get_once( string $url ): ?string {
|
||||
if ( function_exists( 'wp_remote_get' ) ) {
|
||||
$response = wp_remote_get( $url, [
|
||||
'timeout' => self::TIMEOUT,
|
||||
'headers' => [ 'Accept' => 'application/json' ],
|
||||
] );
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'timeout' => self::TIMEOUT,
|
||||
'headers' => array( 'Accept' => 'application/json' ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return null;
|
||||
@@ -194,13 +225,15 @@ class PR_Core_Pubchem_Client {
|
||||
}
|
||||
|
||||
// WP-CLI / plain PHP fallback.
|
||||
$context = stream_context_create( [
|
||||
'http' => [
|
||||
'timeout' => self::TIMEOUT,
|
||||
'header' => "Accept: application/json\r\n",
|
||||
'method' => 'GET',
|
||||
],
|
||||
] );
|
||||
$context = stream_context_create(
|
||||
array(
|
||||
'http' => array(
|
||||
'timeout' => self::TIMEOUT,
|
||||
'header' => "Accept: application/json\r\n",
|
||||
'method' => 'GET',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$body = @file_get_contents( $url, false, $context );
|
||||
return ( false === $body ) ? null : $body;
|
||||
|
||||
Reference in New Issue
Block a user