Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,32 @@ AI-written content with embedded images, plus the GitHub Release sidebar panel f
3. Activate the plugin.
4. Go to **Tools → Release Posts** to configure your AI provider and add repositories.

## GitHub access

The plugin uses a GitHub Personal Access Token (PAT) to read release data. A PAT is optional for public repositories — without one, GitHub limits the plugin to 60 API requests per hour. Adding a PAT raises that to 5,000 per hour and is required to access private repositories.

<details>
<summary><strong>Create a fine-grained PAT and add it to WordPress</strong></summary>

A fine-grained token can be scoped to a single user or organization and to specific repositories — ideal for a "service account" that monitors a known set of releases.

1. Go to [github.com/settings/personal-access-tokens/new](https://github.com/settings/personal-access-tokens/new).
2. **Token name** — something descriptive, e.g. `My Site — GitHub Release Posts`.
3. **Expiration** — pick a value that fits your security policy.
4. **Resource owner** — choose your user account or an organization you belong to.
5. **Repository access** — choose **Only select repositories** and pick the repos you want to monitor. (Or **All repositories** if you'd rather not maintain the list here.)
6. **Repository permissions** — set all four to **Read-only**:
- **Contents** — required for releases and commit comparisons.
- **Metadata** — required (auto-selected).
- **Issues** — used during AI prompt enrichment.
- **Pull requests** — used during AI prompt enrichment.
7. Click **Generate token** and copy the `github_pat_…` value immediately — GitHub won't show it again.
8. Provide the token to the plugin in one of two ways:
- **Environment variable or constant (recommended)** — define `GITHUB_RELEASE_POSTS_PAT` as an environment variable or as a PHP constant in `wp-config.php`. The plugin reads the constant first, then the env var, then the database. When set this way, the value never lives in the WordPress database, and the Settings field becomes read-only.
- **WordPress admin** — go to **Tools → Release Posts → Settings**, paste the token into the **Personal Access Token** field, and click **Save Settings**.

</details>

## For developers

### Filters
Expand Down
45 changes: 45 additions & 0 deletions assets/js/admin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,51 @@ document.addEventListener( 'DOMContentLoaded', function () {
} );
}

// -------------------------------------------------------------------------
// Refresh accessible-repos cache.
// -------------------------------------------------------------------------
const refreshReposBtn = document.getElementById( 'ghrp-refresh-repos' );
if ( refreshReposBtn ) {
refreshReposBtn.addEventListener( 'click', function () {
const resultEl = document.getElementById( 'ghrp-refresh-repos-result' );
const spinner = refreshReposBtn.parentNode.querySelector( '.ghrp-refresh-repos-spinner' );

if ( spinner ) {
spinner.style.display = 'inline-block';
spinner.classList.add( 'is-active' );
}
if ( resultEl ) {
resultEl.textContent = '';
}

window.ctbpFetch(
'POST',
'/repos/refresh',
{},
function ( data ) {
if ( spinner ) {
spinner.classList.remove( 'is-active' );
spinner.style.display = 'none';
}
if ( resultEl ) {
var msg = ( data && data.message ) ? data.message : 'Refreshed.';
resultEl.innerHTML = validIcon( msg ) + ' ' + msg;
}
},
function ( data ) {
if ( spinner ) {
spinner.classList.remove( 'is-active' );
spinner.style.display = 'none';
}
if ( resultEl ) {
var msg = ( data && data.message ) ? data.message : 'Failed to refresh repository list.';
resultEl.innerHTML = warningIcon( msg ) + ' ' + msg;
}
}
);
} );
}

// -------------------------------------------------------------------------
// Repository inline edit — WP Quick Edit clone pattern.
// -------------------------------------------------------------------------
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' => '44d840bca2e214494f81');
<?php return array('dependencies' => array('wp-polyfill'), 'version' => 'ab2d5f8f5578ef8ac7ac');
2 changes: 1 addition & 1 deletion dist/js/admin.js

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions includes/classes/Admin/Admin_Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,16 @@ public function register_rest_routes(): void {
]
);

register_rest_route(
'ghrp/v1',
'/repos/refresh',
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'rest_refresh_accessible_repos' ],
'permission_callback' => [ $this, 'rest_permission_check' ],
]
);

register_rest_route(
'ghrp/v1',
'/wporg/validate',
Expand Down Expand Up @@ -979,6 +989,40 @@ public function rest_send_test_notification(): \WP_REST_Response|\WP_Error {
);
}

/**
* REST handler: clears and re-fetches the cached list of repositories
* accessible to the configured PAT.
*
* @return \WP_REST_Response|\WP_Error
*/
public function rest_refresh_accessible_repos(): \WP_REST_Response|\WP_Error {
if ( 'none' === $this->global_settings->get_github_pat_source() ) {
return new \WP_Error(
'ghrp_no_pat',
__( 'No Personal Access Token is configured.', 'github-release-posts' ),
[ 'status' => 400 ]
);
}

$result = ( new API_Client( $this->global_settings ) )->list_accessible_repos( true );

if ( is_wp_error( $result ) ) {
$result->add_data( [ 'status' => 502 ] );
return $result;
}

$count = count( $result );

return new \WP_REST_Response(
[
'count' => $count,
/* translators: %d: number of repositories */
'message' => sprintf( _n( '%d repository available.', '%d repositories available.', $count, 'github-release-posts' ), $count ),
],
200
);
}

/**
* Builds a test notification entry from the most recent generated post,
* or a placeholder if no posts exist yet.
Expand Down
24 changes: 20 additions & 4 deletions includes/classes/Admin/Settings_Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ private function register_github_section(): void {
* @return void
*/
public function render_github_pat_field(): void {
$masked_pat = $this->global_settings->get_masked_github_pat();
$masked_pat = $this->global_settings->get_masked_github_pat();
$source = $this->global_settings->get_github_pat_source();
$externally_set = 'constant' === $source || 'env' === $source;
$refresh_disabled = 'none' === $source;
?>
<input
type="password"
Expand All @@ -122,10 +125,23 @@ public function render_github_pat_field(): void {
value="<?php echo esc_attr( $masked_pat ); ?>"
class="regular-text"
autocomplete="new-password"
<?php disabled( $externally_set ); ?>
>
<p class="description">
<?php echo esc_html__( 'Optional. Raises the GitHub API rate limit from 60 to 5,000 requests per hour.', 'github-release-posts' ); ?>
</p>
<?php if ( ! $externally_set ) : ?>
<p class="description">
<?php echo esc_html__( 'Optional. Raises the GitHub API rate limit from 60 to 5,000 requests per hour.', 'github-release-posts' ); ?>
</p>
<?php endif; ?>
<button
type="button"
id="ghrp-refresh-repos"
class="button"
<?php disabled( $refresh_disabled ); ?>
>
<?php echo esc_html__( 'Refresh repository list', 'github-release-posts' ); ?>
</button>
<span class="spinner ghrp-refresh-repos-spinner"></span>
<span id="ghrp-refresh-repos-result" style="vertical-align: middle;" aria-live="polite"></span>
<?php
}

Expand Down
133 changes: 133 additions & 0 deletions includes/classes/GitHub/API_Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,139 @@ function ( $a, $b ) {
];
}

/**
* Lists repositories the configured PAT can access.
*
* Calls `GET /user/repos` with pagination. Caches the resulting list in a
* 5-minute transient keyed by md5(PAT) so that rotating the token
* automatically invalidates the cache. Archived repos are filtered out.
*
* Returns a flat list of `[ 'identifier' => 'owner/repo', 'owner' => ..., 'name' => ... ]`
* sorted alphabetically by owner, then by name.
*
* @param bool $force_refresh Bypass the transient cache when true.
* @return array<int, array{identifier: string, owner: string, name: string}>|\WP_Error
*/
public function list_accessible_repos( bool $force_refresh = false ): array|\WP_Error {
$pat = $this->settings->get_github_pat();
if ( '' === $pat ) {
return new \WP_Error(
'github_no_pat',
__( 'A GitHub Personal Access Token is required to list accessible repositories.', 'github-release-posts' )
);
}

$cache_key = Plugin_Constants::TRANSIENT_USER_REPOS_PREFIX . md5( $pat );

if ( ! $force_refresh ) {
$cached = get_transient( $cache_key );
if ( is_array( $cached ) ) {
return $cached;
}
}

$args = $this->build_request_args();
$repos = [];
$page = 1;
$max_page = 10; // Safety cap: 1,000 repos.

while ( $page <= $max_page ) {
$url = sprintf(
'%s/user/repos?per_page=100&sort=full_name&affiliation=owner,collaborator,organization_member&page=%d',
self::API_BASE,
$page
);

$response = wp_remote_get( $url, $args );
if ( is_wp_error( $response ) ) {
return $response;
}

$rate_limit = $this->handle_rate_limit( $response );
if ( is_wp_error( $rate_limit ) ) {
return $rate_limit;
}

$code = (int) wp_remote_retrieve_response_code( $response );
if ( 401 === $code ) {
return new \WP_Error(
'github_unauthorized',
__( 'GitHub rejected the Personal Access Token. Check that it is valid and has not expired.', 'github-release-posts' )
);
}
if ( 200 !== $code ) {
return new \WP_Error(
'github_http_error',
sprintf(
/* translators: %d: HTTP status code */
__( 'GitHub API returned HTTP %d.', 'github-release-posts' ),
$code
)
);
}

$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return new \WP_Error( 'github_parse_error', __( 'Failed to parse GitHub API response.', 'github-release-posts' ) );
}

foreach ( $data as $entry ) {
if ( ! is_array( $entry ) ) {
continue;
}
if ( ! empty( $entry['archived'] ) ) {
continue;
}

$full_name = (string) ( $entry['full_name'] ?? '' );
$owner = (string) ( $entry['owner']['login'] ?? '' );
$name = (string) ( $entry['name'] ?? '' );

if ( '' === $full_name || '' === $owner || '' === $name ) {
continue;
}

$repos[] = [
'identifier' => $full_name,
'owner' => $owner,
'name' => $name,
];
}

// Stop when the page returned fewer than the page size.
if ( count( $data ) < 100 ) {
break;
}

++$page;
}

usort(
$repos,
static function ( $a, $b ) {
$cmp = strcasecmp( $a['owner'], $b['owner'] );
return 0 !== $cmp ? $cmp : strcasecmp( $a['name'], $b['name'] );
}
);

set_transient( $cache_key, $repos, 5 * MINUTE_IN_SECONDS );

return $repos;
}

/**
* Deletes the cached accessible-repos list for the active PAT, if any.
*
* @return void
*/
public function clear_accessible_repos_cache(): void {
$pat = $this->settings->get_github_pat();
if ( '' === $pat ) {
return;
}
delete_transient( Plugin_Constants::TRANSIENT_USER_REPOS_PREFIX . md5( $pat ) );
}

/**
* Normalises a repository identifier to `owner/repo` format.
*
Expand Down
12 changes: 12 additions & 0 deletions includes/classes/Plugin_Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ class Plugin_Constants {
*/
const TRANSIENT_RATE_LIMIT_REMAINING = 'ghrp_rate_limit_remaining';

/**
* Prefix for the cached list of repositories accessible to the configured PAT.
* Full key: TRANSIENT_USER_REPOS_PREFIX . md5( PAT )
*/
const TRANSIENT_USER_REPOS_PREFIX = 'ghrp_user_repos_';

/**
* Name of the env var / PHP constant that supplies the GitHub PAT
* outside of the WordPress database.
*/
const PAT_ENV_NAME = 'GITHUB_RELEASE_POSTS_PAT';

// -------------------------------------------------------------------------
// Cron hook names
// -------------------------------------------------------------------------
Expand Down
Loading