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
/**
* 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.
*

View File

@@ -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
);
}
}

View File

@@ -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.

View File

@@ -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() );
}
}

View File

@@ -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;

View File

@@ -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;