Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5b7898f
Mark tested up to WordPress 7.1; stop shipping bin/
jakemgold Oct 5, 2026
e173d0b
Emit the block markup the editor saves
jakemgold Oct 5, 2026
2915e1f
Neutralize AI-written text WordPress would read as markup
jakemgold Oct 5, 2026
b9b6505
Keep post-release tags newer than their release
jakemgold Oct 5, 2026
ab4f631
Use the token for the tracked repo's links whatever their casing
jakemgold Oct 5, 2026
5623501
Match plural security keywords again
jakemgold Oct 5, 2026
6edce6a
Reset only the run summary's errors at the start of a run
jakemgold Oct 5, 2026
2500e71
Keep anonymous lookups out of the site's rate-limit handling
jakemgold Oct 5, 2026
776cb7d
Re-check a token quickly after a transient failure
jakemgold Oct 5, 2026
554b874
Clear the AI-failure notice once the failure is resolved
jakemgold Oct 5, 2026
40f0e77
Ignore IME commits and key repeat in Enter-to-add
jakemgold Oct 5, 2026
039147b
Stop shipping languages/.gitkeep
jakemgold Oct 5, 2026
d1a3d53
Changelog: valid block markup, neutralized AI text
jakemgold Oct 5, 2026
dcf09cd
Treat Escape on a confirmation dialog as Cancel
jakemgold Oct 5, 2026
06c1115
Never save a cut-off or bodiless AI response as a post
jakemgold Oct 5, 2026
9436b86
Unwrap AI responses wrapped in a code fence
jakemgold Oct 5, 2026
3187e67
Give never-activated sites their option rows
jakemgold Oct 5, 2026
4ba3d80
Discard a stale queue and record a rate-limit stop
jakemgold Oct 5, 2026
48a933b
Explain a renamed or transferred repository
jakemgold Oct 5, 2026
7050900
Keep manual drafts drafts under ghrp_post_status
jakemgold Oct 5, 2026
6625a94
Name the plugin correctly in the AI disclosure
jakemgold Oct 5, 2026
438f434
Translate the editor panel; correct the help text
jakemgold Oct 5, 2026
360ab42
Readme: accurate disclosures; changelog for pre-existing fixes
jakemgold Oct 5, 2026
0eff363
Write first-boot defaults only once scheduling succeeds
jakemgold Oct 5, 2026
a69e199
Keep releases queued by an interrupted run
jakemgold Oct 5, 2026
f351d3a
Keep list and heading content intact when converting
jakemgold Oct 5, 2026
2e01d40
Only a normal finish becomes a post
jakemgold Oct 5, 2026
bab7f3d
Keep lists and quotes with no 1:1 block as HTML
jakemgold Oct 5, 2026
e70dd36
Skip queued releases only for removed or paused repositories
jakemgold Oct 5, 2026
2db1297
Read heading ids with an HTML parser
jakemgold Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,10 @@ README.md

# Stray archives
*.zip

# One-time SVN maintenance scripts (run from a developer checkout, never on a site)
bin

# Placeholder that keeps the empty languages/ directory in git. WordPress.org's
# Plugin Check rejects hidden files; the directory itself still ships.
.gitkeep
27 changes: 26 additions & 1 deletion assets/js/admin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,14 +337,19 @@ document.addEventListener( 'DOMContentLoaded', function () {
// "Settings saved." appears, and the repository is never added.
if ( repoInput ) {
repoInput.addEventListener( 'keydown', function ( e ) {
if ( e.key !== 'Enter' ) {
// An Enter that commits an IME composition is not a submit.
if ( e.key !== 'Enter' || e.isComposing || e.keyCode === 229 ) {
return;
}
// The picker's own handler selects a highlighted option.
if ( repoState.open && repoState.activeId ) {
return;
}
e.preventDefault();
// A held key repeats Enter: add once.
if ( e.repeat ) {
return;
}
const addButton = repoInput.form
? repoInput.form.querySelector( 'button[name="ghrp_add_repo"]' )
: null;
Expand Down Expand Up @@ -1498,6 +1503,9 @@ document.addEventListener( 'DOMContentLoaded', function () {
if ( conflictCancel ) {
conflictCancel.removeEventListener( 'click', onCancel );
}
if ( conflictDialog ) {
conflictDialog.removeEventListener( 'cancel', onEscape );
}
};
/* eslint-enable no-use-before-define */
const onConfirm = () => {
Expand All @@ -1516,6 +1524,13 @@ document.addEventListener( 'DOMContentLoaded', function () {
enableRowActions( btn );
btn.focus();
};
// Escape closes the dialog without either button: treat it as
// Cancel, or this row's confirm handler stays attached and also
// fires on the next row's confirm.
const onEscape = ( event ) => {
event.preventDefault();
onCancel();
};

if ( conflictConfirm ) {
conflictConfirm.addEventListener( 'click', onConfirm );
Expand All @@ -1525,6 +1540,7 @@ document.addEventListener( 'DOMContentLoaded', function () {
}

if ( conflictDialog ) {
conflictDialog.addEventListener( 'cancel', onEscape );
conflictDialog.showModal();
} else if (
// eslint-disable-next-line no-alert -- intentional fallback when <dialog> is unsupported.
Expand Down Expand Up @@ -1643,13 +1659,21 @@ document.addEventListener( 'DOMContentLoaded', function () {
enableRowActions( btn );
btn.focus();
}
// Escape closes the dialog without either button: treat it as Cancel,
// or this row's confirm handler stays attached and also fires on the
// next confirm.
function onEscape( event ) {
event.preventDefault();
onCancel();
}
function cleanup() {
if ( versionConfirm ) {
versionConfirm.removeEventListener( 'click', onConfirm );
}
if ( versionCancel ) {
versionCancel.removeEventListener( 'click', onCancel );
}
versionDialog.removeEventListener( 'cancel', onEscape );
}

if ( versionConfirm ) {
Expand All @@ -1658,6 +1682,7 @@ document.addEventListener( 'DOMContentLoaded', function () {
if ( versionCancel ) {
versionCancel.addEventListener( 'click', onCancel );
}
versionDialog.addEventListener( 'cancel', onEscape );

versionDialog.showModal();
}
Expand Down
2 changes: 1 addition & 1 deletion dist/js/admin.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('wp-polyfill'), 'version' => '1f3b4b107c65a70130d8');
<?php return array('dependencies' => array('wp-polyfill'), 'version' => '4e737f5e0e7d426d3c0e');
2 changes: 1 addition & 1 deletion dist/js/admin.js

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions includes/classes/AI/AI_Processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ private function send_failure_email( ReleaseData $data, \WP_Error $error ): void
* @return void
*/
private function clear_failure_count( ReleaseData $data ): void {
self::clear_failure_notice( $data->identifier, $data->tag );

$counts = (array) get_option( Plugin_Constants::OPTION_AI_FAILURE_COUNTS, [] );

if ( ! isset( $counts[ $data->identifier ][ $data->tag ] ) ) {
Expand All @@ -284,6 +286,26 @@ private function clear_failure_count( ReleaseData $data ): void {
update_option( Plugin_Constants::OPTION_AI_FAILURE_COUNTS, $counts, false );
}

/**
* Clears the "AI generation keeps failing" notice once the failure it
* reports is resolved: the release generated, or its repository removed.
*
* @param string $identifier Repository identifier.
* @param string $tag Release tag, or '' for any release of the repository.
* @return void
*/
private static function clear_failure_notice( string $identifier, string $tag = '' ): void {
$notice = get_transient( Cache_Keys::ai_failure_notice() );
if ( ! is_array( $notice ) || ( $notice['identifier'] ?? '' ) !== $identifier ) {
return;
}
if ( '' !== $tag && ( $notice['tag'] ?? '' ) !== $tag ) {
return;
}

delete_transient( Cache_Keys::ai_failure_notice() );
}

/**
* Removes all failure-count entries for a repository.
*
Expand All @@ -295,6 +317,8 @@ private function clear_failure_count( ReleaseData $data ): void {
* @return void
*/
public static function clear_failure_counts_for_identifier( string $identifier ): void {
self::clear_failure_notice( $identifier );

$counts = (array) get_option( Plugin_Constants::OPTION_AI_FAILURE_COUNTS, [] );
if ( ! isset( $counts[ $identifier ] ) ) {
return;
Expand Down
74 changes: 67 additions & 7 deletions includes/classes/AI/Connectors/WP_AI_Client_Connector.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public function test_connection(): true|\WP_Error {
if ( ! $this->is_available() ) {
return new \WP_Error(
'ghrp_wp_ai_client_unavailable',
__( 'The WordPress AI Client API is not available. On WordPress 7.0+ it is built in — make sure at least one AI connector is activated under Settings → AI Credentials. On older versions, install and activate the wp-ai-client plugin.', 'auto-release-posts-for-github' )
__( 'The WordPress AI Client API is not available. On WordPress 7.0+ it is built in — make sure at least one AI connector is activated under Settings → Connectors. On older versions, install and activate the wp-ai-client plugin.', 'auto-release-posts-for-github' )
);
}

Expand All @@ -114,7 +114,7 @@ public function generate_post( ReleaseData $data, string $prompt ): GeneratedPos

// Temporarily override the default request timeout for this call.
// Blog post generation typically needs 60-120s, well above the 30s default.
// The filter must remain installed until generate_text() returns —
// The filter must remain installed until generate_text_result() returns —
// the underlying HTTP request reads the timeout at request-fire time,
// not at builder construction.
$timeout_override = static function () {
Expand All @@ -125,7 +125,9 @@ public function generate_post( ReleaseData $data, string $prompt ): GeneratedPos
$builder = wp_ai_client_prompt( $prompt ) // phpcs:ignore
->using_max_tokens( 16384 );
$this->configure_model( $builder );
$response = $builder->generate_text();
$result = $builder->generate_text_result();

return is_wp_error( $result ) ? $result : $this->post_from_result( $result, $data );
} catch ( \Throwable $e ) {
// The WP AI Client builder throws on transport/quota/provider errors
// rather than returning a WP_Error. Convert it so the caller
Expand All @@ -143,20 +145,78 @@ public function generate_post( ReleaseData $data, string $prompt ): GeneratedPos
} finally {
remove_filter( 'wp_ai_client_default_request_timeout', $timeout_override );
}
}

if ( is_wp_error( $response ) ) {
return $response;
/**
* Turns a generation result into a post — or an error when the result
* is not a complete post, so it is never saved, cached, or published.
*
* @param object $result The builder's GenerativeAiResult.
* @param ReleaseData $data Structured release data.
* @return GeneratedPost|\WP_Error
*/
private function post_from_result( object $result, ReleaseData $data ): GeneratedPost|\WP_Error {
$stopped = self::incomplete_reason( $result );
if ( '' !== $stopped ) {
return new \WP_Error(
'ghrp_wp_ai_client_incomplete',
match ( $stopped ) {
'length' => __( 'The AI response was cut off at the length limit, so no post was saved. Please try again.', 'auto-release-posts-for-github' ),
'content_filter' => __( 'The AI provider stopped the response early (content filter), so no post was saved.', 'auto-release-posts-for-github' ),
default => __( 'The AI provider did not finish the response, so no post was saved. Please try again.', 'auto-release-posts-for-github' ),
}
);
}

$text = (string) $response;
$text = (string) $result->toText();
if ( '' === trim( $text ) ) {
return new \WP_Error(
'ghrp_wp_ai_client_empty_response',
__( 'WordPress Connectors returned an empty response. Please try again.', 'auto-release-posts-for-github' )
);
}

return $this->parse_response( $text, $data );
$post = $this->parse_response( $text, $data );

// A title with no body — a refusal ("I'm sorry, but…"), or a reply
// that stopped after the metadata lines — is not a post.
if ( '' === trim( wp_strip_all_tags( $post->content ) ) ) {
return new \WP_Error(
'ghrp_wp_ai_client_no_body',
__( 'The AI response had no post body, so no post was saved. Please try again.', 'auto-release-posts-for-github' )
);
}

return $post;
}

/**
* Why a generation did not finish normally, or '' when it did.
*
* The text of a response is returned even when the provider stopped at
* the token limit, a content filter, or an error — a post cut off
* mid-sentence (and mid-list) was saved and, on repositories set to
* publish, published. Only a normal stop becomes a post.
*
* @param object $result The builder's GenerativeAiResult.
* @return string 'length', 'content_filter', 'other', or ''.
*/
private static function incomplete_reason( object $result ): string {
$candidates = method_exists( $result, 'getCandidates' ) ? (array) $result->getCandidates() : [];
$candidate = $candidates[0] ?? null;
if ( ! is_object( $candidate ) || ! method_exists( $candidate, 'getFinishReason' ) ) {
return '';
}

$reason = $candidate->getFinishReason();
if ( $reason->isStop() ) {
return '';
}
if ( $reason->isLength() ) {
return 'length';
}

return $reason->isContentFilter() ? 'content_filter' : 'other';
}

/**
Expand Down
16 changes: 14 additions & 2 deletions includes/classes/AI/GeneratedPost.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public function __construct(
* @return self
*/
public static function from_raw_text( string $raw, ReleaseData $data, string $provider_slug ): self {
$raw = trim( $raw );
$raw = self::strip_code_fence( trim( $raw ) );
$lines = explode( "\n", $raw );

// Line 1 is the title by contract; strip any tags and cap the length so a
Expand Down Expand Up @@ -88,7 +88,7 @@ public static function from_raw_text( string $raw, ReleaseData $data, string $pr
while ( isset( $lines[ $body_start ] ) && '' === trim( $lines[ $body_start ] ) ) {
++$body_start;
}
$body = trim( implode( "\n", array_slice( $lines, $body_start ) ) );
$body = self::strip_code_fence( trim( implode( "\n", array_slice( $lines, $body_start ) ) ) );

// Validate slug keywords — should be lowercase hyphenated words, no HTML.
if ( preg_match( '/</', $slug_keywords ) || strlen( $slug_keywords ) > 80 ) {
Expand Down Expand Up @@ -132,4 +132,16 @@ public static function from_raw_text( string $raw, ReleaseData $data, string $pr
excerpt: $excerpt,
);
}

/**
* Removes a Markdown code fence (``` or ```html) wrapped around the whole
* text, which some models add around HTML output. Left in place, the
* fence became the post title, or visible "```html" paragraphs.
*
* @param string $text Response text or body.
* @return string
*/
private static function strip_code_fence( string $text ): string {
return preg_match( '/^```[a-z]*[ \t]*\R(.*?)\R?```\s*$/is', $text, $fenced ) ? trim( $fenced[1] ) : $text;
}
}
9 changes: 4 additions & 5 deletions includes/classes/AI/Release_Enricher.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,10 @@ public function enrich( string $body, ReleaseData $data ): string {
// pull a private issue's text into a (possibly auto-published)
// post. Off-repo references are fetched anonymously: public
// content still enriches, private content stays private.
$result = $this->api_client->fetch_issue(
$ref['identifier'],
$ref['number'],
$ref['identifier'] === $data->identifier
);
// GitHub identifiers are case-insensitive, and generated release
// notes link with the canonical casing.
$same_repo = 0 === strcasecmp( $ref['identifier'], $data->identifier );
$result = $this->api_client->fetch_issue( $ref['identifier'], $ref['number'], $same_repo );

if ( is_wp_error( $result ) ) {
continue;
Expand Down
5 changes: 4 additions & 1 deletion includes/classes/AI/Release_Significance.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ private function has_security_keyword( string $text ): bool {
// Whole-word matching: as a bare substring, "rce" is inside
// "source", "resource" and "force", and "cve" inside "curve" —
// enough to classify most ordinary release notes as security.
if ( preg_match( '/\b' . preg_quote( $keyword, '/' ) . '\b/iu', $text ) ) {
$word = str_ends_with( $keyword, 'y' )
? preg_quote( substr( $keyword, 0, -1 ), '/' ) . '(?:y|ies)'
: preg_quote( $keyword, '/' ) . '(?:e?s)?';
if ( preg_match( '/\b' . $word . '\b/iu', $text ) ) {
return true;
}
}
Expand Down
32 changes: 27 additions & 5 deletions includes/classes/Activator.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ public static function activate(): void {
// skipping defaults and cron registration.
//
// A network activation fires this hook ONCE, in the main site's
// context, while the cron event is per-site. Subsites are provisioned
// lazily instead: ensure_cron_event() runs from Plugin::init() on every
// request, so each site schedules its own check the first time it
// boots (settings accessors already supply defaults for absent options).
// context, while options and the cron event are per-site. Subsites are
// provisioned lazily instead: ensure_cron_event() runs from
// Plugin::init() on every request, so each site writes its defaults
// and schedules its own check the first time it boots.
self::write_default_options();
self::register_cron_event();
}
Expand All @@ -60,6 +60,24 @@ public static function ensure_cron_event(): void {
return;
}

// A site with no scheduled check was never activated here either, so
// give it the option rows activation would have written. Without them
// the first Settings save hit a core quirk — update_option() on a
// missing row sanitizes twice — so the token was encrypted twice and
// an unchecked box could not be saved at all. Only once scheduling
// succeeds: with an unregistered check frequency it fails on every
// request, and the rows must not be rewritten on every request too.
if ( self::schedule_check() ) {
self::write_default_options();
}
}

/**
* Schedules the recurring release check at the configured interval.
*
* @return bool Whether the event was scheduled.
*/
private static function schedule_check(): bool {
/**
* Filters the WP-Cron schedule used for release checks.
*
Expand All @@ -85,6 +103,8 @@ public static function ensure_cron_event(): void {
)
);
}

return false !== $result;
}

/**
Expand Down Expand Up @@ -122,6 +142,8 @@ private static function register_cron_event(): void {
// Clear stale event first (handles crash-reinstall scenario).
wp_clear_scheduled_hook( Plugin_Constants::CRON_HOOK_RELEASE_CHECK );

self::ensure_cron_event();
if ( ! wp_next_scheduled( Plugin_Constants::CRON_HOOK_RELEASE_CHECK ) ) {
self::schedule_check();
}
}
}
Loading