Compare commits

..

3 Commits

Author SHA1 Message Date
peptiderepo
e029eae47e ci(review): drop panel to Haiku-only per QA A/B decision 2026-06-12
A/B analysis across 13 PRs (convo/cto/threads/2026-06-qa-ab-review/02-analyst-findings.md):
- DeepSeek: high false-positive rate (absint()="code exec", PLUGIN_DIR="dir traversal")
- Kimi: ~60% model failure rate, 1 unique-real finding in 13 PRs
- Neurometric: summarizer only, 0 unique-real findings, unreliable heuristic verdict label
- Haiku: 0 failures, lowest noise, equivalent real-signal catch vs 4-voice panel

Removes DeepSeek, Kimi, and Neurometric voices. Haiku is now sole reviewer.
Also removes NEUROMETRIC_API_KEY env var (no longer needed).
No plugin version bump -- CI-only change (precedent: prautoblogger #150).

Agent-Session: engineer-panel-haiku-20260612
2026-06-12 14:25:43 +00:00
peptiderepo
3b6a302f95 fix(maintenance): v0.19.4 — composer GD-rung tests, layout-key cleanup, carried P3s (#160)
QA: APPROVE-WITH-P2-NOTES @ c0929be (qa-reviews/prautoblogger/2026-06-12-c0929be.md)
CEO greenlight: 2026-06-12 in chat.

Agent-Session: cto-maint-v0194-merge-20260612
2026-06-12 21:21:52 +07:00
peptiderepo
5c3328dee2 v0.19.3 - hotfix: dossier 403 (hidden-page pattern -> options.php parent) (#159)
Dossier registers under options.php parent (stable admin_page_* hookname at
registration AND request time); hide-by-unset removed; enqueue gate fixed;
request-time-faithful regression tests (fail on v0.19.2 by construction);
hidden-admin-page convention codified in CONVENTIONS.md.

QA: REJECT-P1 (ae32876, test file 551 lines) -> split -> APPROVE (6689126).
Verdicts: qa-reviews/prautoblogger/2026-06-12-{ae32876,6689126}.md
Thread: pipeline-v2-phase2-admin seq 21-28

Agent-Session: cto-dossier-403-hotfix-merge-20260612
2026-06-12 18:41:46 +07:00
9 changed files with 424 additions and 222 deletions

View File

@@ -1,20 +1,17 @@
name: CTO Review
# Automated PR review powered by Claude + DeepSeek + Kimi (via OpenRouter)
# plus Neurometric PR-Summarizer (a Qwen3-4B task-specialist SLM).
# Automated PR review powered by Claude Haiku (via OpenRouter).
#
# During the A/B window (until further notice), every PR gets reviewed by FOUR models
# in parallel. The PR comment shows each model's verdict side-by-side so we can compare
# quality/cost on real PRs before picking a permanent default.
# A/B panel decision 2026-06-12: panel dropped to Haiku-only.
# Evidence: convo/cto/threads/2026-06-qa-ab-review/02-analyst-findings.md
# DeepSeek: high false-positive rate (absint()="code exec", PLUGIN_DIR="dir traversal").
# Kimi: ~60% model failure rate across 13 PRs sampled.
# Neurometric: summarizer only, zero unique-real findings, unreliable heuristic verdict.
# Haiku: 0 failures, lowest noise, equivalent real-signal catch rate vs 4-voice panel.
#
# Neurometric is a "prose voice": it does not support OpenAI tool-calling, so its
# contribution is raw markdown + a heuristic verdict. It is never the primary voice.
# Does NOT merge. Posts formal APPROVE/REQUEST_CHANGES/COMMENT reviews.
#
# Does NOT merge. Does NOT post formal APPROVE/REQUEST_CHANGES reviews — always COMMENT
# (GITHUB_TOKEN can't formally approve PRs without repo-setting toggle).
#
# Requires repo secrets: OPENROUTER_API_KEY, NEUROMETRIC_API_KEY (optional; the
# Neurometric voice self-skips with an inline error note if the key is missing).
# Requires repo secret: OPENROUTER_API_KEY
#
# Cost cap: tracked in Repository Variables (CTO_REVIEW_SPEND_CENTS, CTO_REVIEW_CAP_CENTS).
# Default cap is 1000 cents ($10/month). Auto-resets on the 1st of each month.
@@ -125,8 +122,8 @@ jobs:
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
# Write the diff to a file, not a step output / env var. Passing a
# ~400KB diff through the process env (execve argv+envp) exceeds the
# runner ARG_MAX and fails the next step with "Argument list too long"
# (issue #110). File transport also sidesteps the ~1MB step-output cap.
# runner ARG_MAX and fails the next step with "Argument list too long".
# File transport also sidesteps the ~1MB step-output cap.
git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
@@ -151,12 +148,11 @@ jobs:
echo "EOF_CONV"
} >> "$GITHUB_OUTPUT"
- name: Call 4 models in parallel (OpenRouter + Neurometric)
- name: Call Haiku via OpenRouter
if: steps.budget.outputs.skip != 'true'
id: review
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
NEUROMETRIC_API_KEY: ${{ secrets.NEUROMETRIC_API_KEY }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
@@ -184,24 +180,17 @@ jobs:
fi
python3 - <<'PY'
import json, os, sys, urllib.request, concurrent.futures
import json, os, sys, urllib.request
api_key = os.environ["OPENROUTER_API_KEY"]
# A/B harness: four models. Each gets the same prompt.
# Primary model's verdict drives the heading. Others are labeled.
# Edit this list to change the roster (or drop to 1 after A/B window closes).
#
# provider_type semantics:
# - "openrouter_tools": OpenAI tool-calling via OpenRouter; structured verdict.
# - "neurometric_prose": Neurometric marketplace SLM; prose output + heuristic verdict.
MODELS = [
# (slug, display name, input_per_M_USD, output_per_M_USD, is_primary, provider_type)
("anthropic/claude-haiku-4.5", "Claude Haiku 4.5", 1.00, 5.00, True, "openrouter_tools"),
("deepseek/deepseek-chat-v3.1", "DeepSeek V3.1", 0.15, 0.75, False, "openrouter_tools"),
("moonshotai/kimi-k2-thinking", "Kimi K2 Thinking", 0.60, 2.50, False, "openrouter_tools"),
("neurometric/pr-summarizer", "Neurometric PR-Summarizer (Qwen3-4B)", 0.00, 0.00, False, "neurometric_prose"),
]
# Haiku-only reviewer. A/B panel dropped 2026-06-12 per QA data:
# DeepSeek high false-positive rate, Kimi ~60% failure rate,
# Neurometric zero unique-real findings. Haiku retained as sole voice.
MODEL_SLUG = "anthropic/claude-haiku-4.5"
MODEL_DISPLAY = "Claude Haiku 4.5"
INPUT_PER_M_USD = 1.00
OUTPUT_PER_M_USD = 5.00
system = """You are the CTO and lead reviewer for the peptiderepo project. You are reviewing a pull request opened by an autonomous agent or human contributor.
@@ -264,218 +253,100 @@ jobs:
}
}]
PER_MODEL_TIMEOUT = 60 # seconds; one hung model should not block siblings
body = {
"model": MODEL_SLUG,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message}
],
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "submit_review"}}
}
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {api_key}",
"content-type": "application/json",
"HTTP-Referer": "https://github.com/peptiderepo",
"X-Title": f"peptiderepo CTO reviewer ({MODEL_DISPLAY})"
},
method="POST"
)
# Heuristic verdict inference for prose-voice models that can't do tool-calling.
# Keyword order matters: block markers beat approve markers.
PROSE_BLOCK_MARKERS = [
"do not merge", "do not approve", "should not be merged", "reject this",
"critical security", "critical issue", "critical vulnerability",
"blocker", "blocking issue", "request_changes", "request changes",
"**critical**", "sql injection", "must be addressed before",
]
PROSE_APPROVE_MARKERS = [
"safe to merge", "ready to merge", "no blocking issues", "lgtm",
"approve this", "looks good to merge", "no concerns",
]
error = None
review = None
cost_cents = 0.0
ptok = 0
ctok = 0
def infer_verdict_from_prose(text):
t = (text or "").lower()
if any(m in t for m in PROSE_BLOCK_MARKERS):
return "REQUEST_CHANGES"
if any(m in t for m in PROSE_APPROVE_MARKERS):
return "APPROVE"
return "COMMENT"
def call_openrouter_tools(slug, display, in_rate, out_rate, is_primary):
body = {
"model": slug,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message}
],
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "submit_review"}}
}
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {api_key}",
"content-type": "application/json",
"HTTP-Referer": "https://github.com/peptiderepo",
"X-Title": f"peptiderepo CTO reviewer ({display})"
},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
data = json.loads(resp.read())
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": str(e)[:300],
"cost_cents": 0,
}
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read())
except Exception as e:
error = str(e)[:300]
data = {}
if not error:
choice = data.get("choices", [{}])[0].get("message", {})
tool_calls = choice.get("tool_calls", [])
if not tool_calls:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": f"No tool_call in response. Raw content: {str(choice)[:300]}",
"cost_cents": 0,
}
error = f"No tool_call in response. Raw content: {str(choice)[:300]}"
else:
try:
review = json.loads(tool_calls[0]["function"]["arguments"])
except Exception as e:
error = f"Malformed tool args: {e}"
try:
review = json.loads(tool_calls[0]["function"]["arguments"])
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": f"Malformed tool args: {e}",
"cost_cents": 0,
}
if not error:
usage = data.get("usage", {})
ptok = usage.get("prompt_tokens", 0)
ctok = usage.get("completion_tokens", 0)
cost_usd = (ptok * INPUT_PER_M_USD / 1_000_000) + (ctok * OUTPUT_PER_M_USD / 1_000_000)
cost_cents = cost_usd * 100
usage = data.get("usage", {})
ptok = usage.get("prompt_tokens", 0)
ctok = usage.get("completion_tokens", 0)
cost_usd = (ptok * in_rate / 1_000_000) + (ctok * out_rate / 1_000_000)
cost_cents = cost_usd * 100
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"review": review, "prompt_tokens": ptok, "completion_tokens": ctok,
"cost_cents": cost_cents,
}
def call_neurometric_prose(slug, display, is_primary):
nm_key = os.environ.get("NEUROMETRIC_API_KEY", "")
if not nm_key:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"error": "NEUROMETRIC_API_KEY secret not set on this repo",
"cost_cents": 0,
}
body = {
"model": slug,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message},
],
}
req = urllib.request.Request(
"https://api.neurometric.ai/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {nm_key}",
"content-type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
data = json.loads(resp.read())
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"error": str(e)[:300],
"cost_cents": 0,
}
prose = data.get("choices", [{}])[0].get("message", {}).get("content") or ""
verdict = infer_verdict_from_prose(prose)
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"review": {"verdict": verdict, "prose": prose},
"prompt_tokens": 0, "completion_tokens": 0,
"cost_cents": 0,
}
def call_model(slug, display, in_rate, out_rate, is_primary, provider_type="openrouter_tools"):
if provider_type == "neurometric_prose":
return call_neurometric_prose(slug, display, is_primary)
return call_openrouter_tools(slug, display, in_rate, out_rate, is_primary)
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=len(MODELS)) as pool:
futures = [pool.submit(call_model, *m) for m in MODELS]
for f in concurrent.futures.as_completed(futures):
results.append(f.result())
# Order: primary first, then alphabetical for stable comparison
results.sort(key=lambda r: (not r.get("is_primary"), r["display"]))
# Build combined comment
primary = next((r for r in results if r.get("is_primary") and "review" in r), None)
heading_verdict = primary["review"]["verdict"] if primary else "MIXED"
lines = [f"## CTO Review — {heading_verdict}", ""]
lines.append(f"_A/B harness active: {len(results)} models reviewing in parallel. Primary verdict drives heading; others are advisory._")
lines.append("")
total_cents = 0.0
for r in results:
lines.append(f"---")
primary_tag = " (primary)" if r.get("is_primary") else ""
lines.append(f"### {r['display']}{primary_tag}")
if "error" in r:
lines.append("")
lines.append(f"⚠️ Model failed: `{r['error']}`")
lines.append("")
continue
rev = r["review"]
# Build comment
if error:
heading_verdict = "ERROR"
lines = [f"## CTO Review — {heading_verdict}", ""]
lines.append(f"Model failed: `{error}`")
lines.append("")
if r.get("provider_type") == "neurometric_prose":
lines.append(f"**Verdict:** {rev['verdict']} _(heuristic, inferred from prose)_")
lines.append("")
# Cap at 6KB to keep PR comments readable; 4B model output is rarely over this
prose = rev.get("prose", "")
if len(prose) > 6000:
prose = prose[:6000] + "\n\n_…output truncated for comment length…_"
lines.append(prose)
lines.append("")
lines.append("_free tier; per-call token usage not tracked_")
lines.append("")
continue
lines.append(f"**Verdict:** {rev.get('verdict', 'COMMENT')}")
lines.append(f"_reviewer: {MODEL_DISPLAY}_")
else:
heading_verdict = review.get("verdict", "COMMENT")
lines = [f"## CTO Review — {heading_verdict}", ""]
lines.append(f"**Verdict:** {heading_verdict}")
lines.append("")
lines.append(rev.get("summary") or "_(model returned no summary)_")
lines.append(review.get("summary") or "_(model returned no summary)_")
lines.append("")
if rev.get("concerns"):
if review.get("concerns"):
lines.append("**Concerns:**")
for c in rev["concerns"]:
for c in review["concerns"]:
sev = c.get("severity", "UNKNOWN").upper()
f = f"`{c['file']}`" if c.get("file") else ""
lines.append(f"- **{sev}** {f} — {c['issue']}")
lines.append("")
if rev.get("positives"):
lines.append("**Positives:** " + "; ".join(rev["positives"]))
if review.get("positives"):
lines.append("**Positives:** " + "; ".join(review["positives"]))
lines.append("")
lines.append(f"_tokens in/out: {r['prompt_tokens']}/{r['completion_tokens']} · cost: ${r['cost_cents']/100:.4f}_")
total_cents += r["cost_cents"]
lines.append(f"_tokens in/out: {ptok}/{ctok} · cost: ${cost_cents/100:.4f}_")
lines.append("")
lines.append("---")
lines.append(f"_Total review cost: **${total_cents/100:.4f}**. A/B roster configured in `.github/workflows/cto-review.yml`._")
lines.append(f"_Total review cost: **${cost_cents/100:.4f}**. Reviewer: {MODEL_DISPLAY}. Roster configured in `.github/workflows/cto-review.yml`._")
with open("/tmp/review-body.md", "w") as f:
f.write("\n".join(lines))
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"added_cents={int(round(total_cents))}\n")
f.write(f"added_cents={int(round(cost_cents))}\n")
f.write(f"skipped=0\n")
f.write(f"primary_verdict={heading_verdict}\n")
PY
- name: Post PR review (formal APPROVE / REQUEST_CHANGES / COMMENT)
# Uses primary model's verdict as the formal event. Falls back to COMMENT
# if the primary model failed or returned something unexpected.
# Uses Haiku's verdict as the formal event. Falls back to COMMENT
# if the model failed or returned something unexpected.
if: steps.budget.outputs.skip != 'true' && steps.review.outputs.skipped != '1'
uses: actions/github-script@v7
env:

View File

@@ -5,6 +5,41 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.19.4] - 2026-06-12
### Added
- **GD-rung unit tests.** New `ImageComposerEditorTest.php` (7 tests) covering
`PRAutoBlogger_Image_Composer_Editor`: `wp_get_image_editor` WP_Error, resize WP_Error,
upscale-guard size mismatch, save WP_Error, zero-width target geometry guard, missing-role
guard, and successful OG resize happy path. Orchestrator ladder test
`test_gd_rung_emits_passthrough_featured_and_single_warning` added to `ImageComposerTest.php`,
asserting the Imagick renderer is never called on the `gd` rung and pass-through featured is
always first.
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P2-1.)
### Fixed
- **Dead layout keys removed.** `caption_margin_right` (og) and `caption_side_padding` (square)
were defined in `Layout::defaults()` and exposed via the `prautoblogger_image_compose_layout`
filter but never consumed by the Imagick renderer. The og renderer positions text from
`logo_x + logo_w + caption_gap` (left-anchor); the square renderer uses centered `x` alignment.
Right-edge overflow is enforced via `caption_chars_per_line` on both variants. Keys removed;
filter users tuning these values had no effect and received no error.
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P3-3.)
- **Amortized research row now carries audit fields.** `Post_Assembler::amortize_research_costs()`
previously inserted per-article `llm_research` copies without `prompt_version` or `agent_role`,
leaving those columns NULL/empty while the original (correctly stamped) row was deleted.
SELECT now includes both columns; INSERT carries them forward from the original row.
(Source: phase-1 thread seq 49, seq 50 carried-list: "amortized llm_research rows pv=null/role
empty" — `convo/prautoblogger/threads/2026-06-pipeline-v2-phase1/`.)
- **`fetch_page()` error body included in exception.** Non-200 responses from the Runware
modelSearch API now include up to 200 chars of the response body in the `RuntimeException`
message, making diagnosis from logs actionable without re-reproducing the request.
(Source: phase-1 thread seq 50 carried-list: "`fetch_page()` error-body logging".)
### Notes
- cto-review.yml isinstance guard (handoff item 5): the fix (`c.get("severity", "UNKNOWN")`)
is already applied at HEAD (line 454 of the workflow). No changes made.
## [0.19.3] - 2026-06-12
### Fixed

View File

@@ -38,6 +38,10 @@ class PRAutoBlogger_Image_Composer_Layout {
* plugin; sizes are px. Values were tuned against rendered samples (see
* PR #thread 2026-06-image-composer assets).
*
* Note: 'caption_margin_right' (og) and 'caption_side_padding' (square) were removed
* in v0.19.4 — the Imagick renderer enforces right-edge geometry via caption_chars_per_line
* and centered x positioning respectively, so those keys had no consumer.
*
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
*/
public static function defaults(): array {
@@ -60,7 +64,6 @@ class PRAutoBlogger_Image_Composer_Layout {
'caption_size' => 32,
'caption_color' => '#FFFFFF',
'caption_gap' => 24,
'caption_margin_right' => 40,
'caption_max_lines' => 2,
'caption_chars_per_line' => 52,
'caption_line_height' => 1.3,
@@ -79,7 +82,6 @@ class PRAutoBlogger_Image_Composer_Layout {
'caption_color' => self::COLOR_INK,
'caption_max_lines' => 3,
'caption_chars_per_line' => 36,
'caption_side_padding' => 64,
'caption_line_height' => 1.3,
),
);

View File

@@ -91,7 +91,9 @@ class PRAutoBlogger_Post_Assembler {
* are generated. Its cost is logged with the run_id but no post_id. After all
* articles finish, this method divides the research cost evenly and inserts
* one 'llm_research' row per article so the cost breakdown popover includes
* the amortized research overhead.
* the amortized research overhead. The audit fields prompt_version and
* agent_role are carried from the original research row (v0.19.4 fix;
* prior builds left them NULL/empty — see phase-1 thread seq 49).
*
* Side effects: database SELECT, INSERT, DELETE on generation_log.
*
@@ -109,7 +111,7 @@ class PRAutoBlogger_Post_Assembler {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$research_row = $wpdb->get_row(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens, prompt_version, agent_role
FROM {$table}
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
LIMIT 1",
@@ -158,6 +160,8 @@ class PRAutoBlogger_Post_Assembler {
'estimated_cost' => $amortized_cost,
'response_status' => 'success',
'error_message' => '',
'prompt_version' => $research_row->prompt_version,
'agent_role' => $research_row->agent_role ?? '',
'created_at' => current_time( 'mysql' ),
)
);

View File

@@ -138,7 +138,8 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
* @param int $offset Pagination offset (0-based).
* @param int $limit Page size.
* @return array{results: array<int, array<string, mixed>>, totalResults: int}
* @throws \RuntimeException On HTTP error or malformed response.
* @throws \RuntimeException On HTTP error or malformed response (non-200 exceptions
* include up to 200 chars of the response body for diagnosis).
*/
private function fetch_page( string $api_key, int $offset, int $limit ): array {
$payload = array(
@@ -171,8 +172,10 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $status ) {
$raw_body = (string) wp_remote_retrieve_body( $response );
$body_excerpt = '' !== $raw_body ? ' — body: ' . substr( $raw_body, 0, 200 ) : '';
throw new \RuntimeException(
sprintf( 'Runware modelSearch returned HTTP %d', $status )
sprintf( 'Runware modelSearch returned HTTP %d%s', $status, $body_excerpt )
);
}

View File

@@ -9,7 +9,7 @@
* Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.19.3
* Version: 0.19.4
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.19.3' );
define( 'PRAUTOBLOGGER_VERSION', '0.19.4' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

View File

@@ -0,0 +1,246 @@
<?php
/**
* Tests for PRAutoBlogger_Image_Composer_Editor (GD/WP-editor rung).
*
* Validates the three main branches of cover_crop():
* - wp_get_image_editor() returns WP_Error → null returned.
* - Resize returns WP_Error → null returned.
* - Exact size not reachable (would upscale) → null returned.
* - Happy path: successful resize/save returns the expected shape.
* And compose_variants() iteration:
* - Roles with bad geometry (0-width target) are skipped.
* - Null cover_crop result for a role skips that role with a debug log.
*
* Brain Monkey stubs wp_get_image_editor, get_temp_dir so no filesystem or
* WP runtime is needed.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ImageComposerEditorTest extends BaseTestCase {
/**
* Stub get_option so PRAutoBlogger_Logger can construct without Brain Monkey
* throwing on the prautoblogger_log_level option lookup. The logger is
* instantiated whenever compose_variants() skips a role and fires a debug log.
*/
protected function setUp(): void {
parent::setUp();
$this->stub_get_option( [] ); // Logger reads prautoblogger_log_level; fall back to default.
}
/** Minimal layout geometry mirroring Layout::defaults() relevant keys. */
private function layout( int $og_w = 1200, int $og_h = 630, int $sq_w = 1080, int $sq_h = 1080 ): array {
return array(
'og' => array( 'width' => $og_w, 'height' => $og_h ),
'square' => array( 'width' => $sq_w, 'height' => $sq_h ),
);
}
/** Helper: mock WP image editor whose resize returns a WP_Error. */
private function mock_editor_resize_error(): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( new \WP_Error( 'resize_fail', 'Mock resize failure' ) );
return $editor;
}
/** Helper: mock WP image editor whose save returns a WP_Error. */
private function mock_editor_save_error( int $w, int $h ): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
$editor->method( 'get_size' )->willReturn( array( 'width' => $w, 'height' => $h ) );
$editor->method( 'save' )->willReturn( new \WP_Error( 'save_fail', 'Mock save failure' ) );
return $editor;
}
/** Helper: mock WP image editor that succeeds at the requested size. */
private function mock_editor_success( int $w, int $h ): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
$editor->method( 'get_size' )->willReturn( array( 'width' => $w, 'height' => $h ) );
$editor->method( 'save' )->willReturn( array( 'path' => '/tmp/prab_ok.png' ) );
return $editor;
}
/** Helper: mock WP image editor that resizes but returns a different size (upscale guard). */
private function mock_editor_wrong_size( int $requested_w, int $requested_h, int $actual_w, int $actual_h ): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
$editor->method( 'get_size' )->willReturn( array( 'width' => $actual_w, 'height' => $actual_h ) );
return $editor;
}
// ------------------------------------------------------------------
// cover_crop() failure paths (tested via compose_variants interface).
// ------------------------------------------------------------------
/**
* wp_get_image_editor() returns WP_Error → role skipped,
* no variants emitted.
*/
public function test_editor_wp_error_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn(
new \WP_Error( 'no_editor', 'No image editor available' )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
$this->assertSame( array(), $variants, 'wp_get_image_editor WP_Error must yield no variants' );
}
/**
* Resize step returns WP_Error → role skipped.
*/
public function test_resize_wp_error_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn( $this->mock_editor_resize_error() );
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
$this->assertSame( array(), $variants, 'Resize WP_Error must yield no variants' );
}
/**
* Exact size not reachable (WP editor silently gives a different size
* instead of upscaling) → role skipped via the size-mismatch guard.
*/
public function test_size_mismatch_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
// Editor resizes to 900×473 when 1080×1080 square was requested (would upscale).
Functions\when( 'wp_get_image_editor' )->justReturn(
$this->mock_editor_wrong_size( 1080, 1080, 900, 473 )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'square' ), $this->layout() );
$this->assertSame( array(), $variants, 'Size mismatch (upscale guard) must yield no variants' );
}
/**
* Save step returns WP_Error → role skipped.
*/
public function test_save_wp_error_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn(
$this->mock_editor_save_error( 1200, 630 )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
$this->assertSame( array(), $variants, 'Save WP_Error must yield no variants' );
}
// ------------------------------------------------------------------
// Happy path — OG resizes successfully.
// ------------------------------------------------------------------
/**
* Successful cover_crop for 'og': variant shape matches expectations.
* file_put_contents and file_get_contents are exercised via temp file
* in the test runner's writable temp dir; the editor mock does not
* actually modify the file, but the class reads it back and returns its
* bytes.
*/
public function test_successful_og_resize_returns_correct_shape(): void {
$temp_dir = sys_get_temp_dir() . '/';
Functions\when( 'get_temp_dir' )->justReturn( $temp_dir );
$og_w = 1200;
$og_h = 630;
Functions\when( 'wp_get_image_editor' )->alias(
function () use ( $og_w, $og_h ) {
return $this->mock_editor_success( $og_w, $og_h );
}
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_source_bytes', array( 'og' ), $this->layout() );
$this->assertCount( 1, $variants, 'Successful resize must emit exactly one variant' );
$variant = $variants[0];
$this->assertSame( 'og', $variant['role'] );
$this->assertSame( 'image/png', $variant['mime_type'] );
$this->assertSame( $og_w, $variant['width'] );
$this->assertSame( $og_h, $variant['height'] );
$this->assertIsString( $variant['bytes'] );
}
// ------------------------------------------------------------------
// compose_variants() geometry guard.
// ------------------------------------------------------------------
/**
* A role whose layout target has width=0 is skipped before any I/O —
* no editor call, no variant emitted.
*/
public function test_zero_width_target_skips_role_before_editor(): void {
// Make the 'og' slot have zero width so the guard fires before any I/O.
$layout = array(
'og' => array( 'width' => 0, 'height' => 630 ),
);
// wp_get_image_editor must never be called.
Functions\expect( 'wp_get_image_editor' )->never();
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $layout );
$this->assertSame( array(), $variants );
}
/**
* Role not present in layout skips cleanly.
*/
public function test_missing_role_in_layout_emits_no_variant(): void {
Functions\expect( 'wp_get_image_editor' )->never();
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'nonexistent_role' ), array() );
$this->assertSame( array(), $variants );
}
}

View File

@@ -322,4 +322,43 @@ class ImageComposerTest extends BaseTestCase {
$this->assertStringStartsWith( 'prautoblogger_', $key );
}
}
/**
* Ladder rung 2 forced via the override filter ('gd'): compose() emits the
* pass-through featured variant as first element, and fires exactly one WARNING.
* The Imagick renderer must never be called on this rung.
*
* Source: QA verdict 2026-06-11-6f76df1.md P2-1 and CTO handoff
* convo/prautoblogger/threads/2026-06-maintenance-v0194/01-cto-maintenance-handoff.md item 1.
*/
public function test_gd_rung_emits_passthrough_featured_and_single_warning(): void {
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'gd' );
// Imagick renderer must not be invoked on the gd rung.
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
$renderer->expects( $this->never() )->method( 'compose_featured' );
$renderer->expects( $this->never() )->method( 'compose_og' );
$renderer->expects( $this->never() )->method( 'compose_square' );
// Stub the WP functions that cover_crop() calls inside the GD rung.
// get_temp_dir() is called first; wp_get_image_editor() returning WP_Error
// makes cover_crop() return null for every role → compose_variants() returns []
// → the GD rung emits only the pass-through featured (array_merge($passthrough, [])).
// This is the real degradation-to-passthrough path the test is designed to assert.
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn(
new \WP_Error( 'no_editor', 'Mock: no GD editor in test environment' )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
$variants = $composer->compose( $this->image_data, $this->context() );
// Pass-through featured variant is always first.
$this->assertNotEmpty( $variants );
$this->assertSame( 'featured', $variants[0]['role'] );
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
}
}

View File

@@ -110,6 +110,8 @@ class ResearchReaperTest extends BaseTestCase {
'model' => $o->model ?? 'gemini-flash',
'prompt_tokens' => $o->prompt_tokens ?? 1000,
'completion_tokens' => $o->completion_tokens ?? 500,
'prompt_version' => $o->prompt_version ?? null,
'agent_role' => $o->agent_role ?? '',
];
}
}