diff --git a/.github/workflows/props-bot.yml b/.github/workflows/props-bot.yml new file mode 100644 index 0000000..671f399 --- /dev/null +++ b/.github/workflows/props-bot.yml @@ -0,0 +1,92 @@ +name: Props Bot + +on: + # This event runs anytime a PR is (re)opened, updated, marked ready for review, or labeled. + # GitHub does not allow filtering the `labeled` event by a specific label. + # However, the logic below will short-circuit the workflow when the `props-bot` label is not the one being added. + # Note: The pull_request_target event is used instead of pull_request because this workflow needs permission to comment + # on the pull request. Because this event grants extra permissions to `GITHUB_TOKEN`, any code changes within the PR + # should be considered untrusted. See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/. + pull_request_target: + types: + - opened + - synchronize + - reopened + - labeled + - ready_for_review + # This event runs anytime a comment is added or deleted. + # You cannot filter this event for PR comments only. + # However, the logic below does short-circuit the workflow for issues. + issue_comment: + types: + - created + # This event will run everytime a new PR review is initially submitted. + pull_request_review: + types: + - submitted + # This event runs anytime a PR review comment is created or deleted. + pull_request_review_comment: + types: + - created + +# Cancels all previous workflow runs for pull requests that have not completed. +concurrency: + # The concurrency group contains the workflow name and the branch name for pull requests + # or the commit hash for any other events. + group: ${{ github.workflow }}-${{ contains( fromJSON( '["pull_request_target", "pull_request_review", "pull_request_review_comment"]' ), github.event_name ) && github.head_ref || github.sha }} + cancel-in-progress: true + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + # Compiles a list of props for a pull request. + # + # Performs the following steps: + # - Collects a list of contributor props and leaves a comment. + # - Removes the props-bot label, if necessary. + props-bot: + name: Generate a list of props + runs-on: ubuntu-24.04 + permissions: + # The action needs permission `write` permission for PRs in order to add a comment. + pull-requests: write + contents: read + timeout-minutes: 20 + # The job will run when pull requests are open, ready for review and: + # + # - A comment is added to the pull request. + # - A review is created or commented on (unless PR originates from a fork). + # - The pull request is opened, synchronized, marked ready for review, or reopened. + # - The `props-bot` label is added to the pull request. + if: | + ( + github.event_name == 'issue_comment' && github.event.issue.pull_request || + ( contains( fromJSON( '["pull_request_review", "pull_request_review_comment"]' ), github.event_name ) && ! github.event.pull_request.head.repo.fork ) || + github.event_name == 'pull_request_target' && github.event.action != 'labeled' || + 'props-bot' == github.event.label.name + ) && + ( ! github.event.pull_request.draft && github.event.pull_request.state == 'open' || ! github.event.issue.draft && github.event.issue.state == 'open' ) + + steps: + - name: Gather a list of contributors + uses: WordPress/props-bot-action@trunk + with: + format: 'git' + + - name: Remove the props-bot label + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + if: ${{ github.event.action == 'labeled' && 'props-bot' == github.event.label.name }} + with: + retries: 2 + retry-exempt-status-codes: 418 + script: | + github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.ISSUE_NUMBER, + name: 'props-bot' + }); + env: + ISSUE_NUMBER: ${{ github.event.number }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c453e18..c20dfb5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,8 +49,8 @@ You can use Docker and the `wp-env` tool to set up a local development environme 2. Change into the project folder and install the development dependencies: ```bash - ## If you're using NVM, make sure to use the correct Node.js version: - nvm use + ## If you're using nvm, make sure to use the correct Node.js version: + nvm install && nvm use ## Then install the NPM dependencies: npm install diff --git a/abilities-api.php b/abilities-api.php index d79cc55..b52bee8 100644 --- a/abilities-api.php +++ b/abilities-api.php @@ -28,7 +28,3 @@ require_once WP_ABILITIES_API_DIR . 'includes/bootstrap.php'; - -if ( function_exists( 'add_action' ) ) { - add_action( 'rest_api_init', array( 'WP_REST_Abilities_Init', 'register_routes' ) ); -} diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 3f8ae29..f8fe863 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -18,10 +18,10 @@ * * Note: Do not use before the {@see 'abilities_api_init'} hook. * - * @see WP_Abilities_Registry::register() - * * @since 0.1.0 * + * @see WP_Abilities_Registry::register() + * * @param string $name The name of the ability. The name must be a string containing a namespace * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase * alphanumeric characters, dashes and the forward slash. @@ -63,10 +63,10 @@ function wp_register_ability( string $name, array $properties = array() ): ?WP_A /** * Unregisters an ability using Abilities API. * - * @see WP_Abilities_Registry::unregister() - * * @since 0.1.0 * + * @see WP_Abilities_Registry::unregister() + * * @param string $name The name of the registered ability, with its namespace. * @return ?\WP_Ability The unregistered ability instance on success, null on failure. */ @@ -77,10 +77,10 @@ function wp_unregister_ability( string $name ): ?WP_Ability { /** * Retrieves a registered ability using Abilities API. * - * @see WP_Abilities_Registry::get_registered() - * * @since 0.1.0 * + * @see WP_Abilities_Registry::get_registered() + * * @param string $name The name of the registered ability, with its namespace. * @return ?\WP_Ability The registered ability instance, or null if it is not registered. */ @@ -91,10 +91,10 @@ function wp_get_ability( string $name ): ?WP_Ability { /** * Retrieves all registered abilities using Abilities API. * - * @see WP_Abilities_Registry::get_all_registered() - * * @since 0.1.0 * + * @see WP_Abilities_Registry::get_all_registered() + * * @return \WP_Ability[] The array of registered abilities. */ function wp_get_abilities(): array { diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index 107e28f..c0505a0 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -39,10 +39,10 @@ final class WP_Abilities_Registry { * * Do not use this method directly. Instead, use the `wp_register_ability()` function. * - * @see wp_register_ability() - * * @since 0.1.0 * + * @see wp_register_ability() + * * @param string $name The name of the ability. The name must be a string containing a namespace * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase * alphanumeric characters, dashes and the forward slash. @@ -85,87 +85,33 @@ public function register( string $name, array $properties = array() ): ?WP_Abili return null; } - if ( empty( $properties['label'] ) || ! is_string( $properties['label'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties must contain a `label` string.' ), - '0.1.0' - ); - return null; - } - - if ( empty( $properties['description'] ) || ! is_string( $properties['description'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties must contain a `description` string.' ), - '0.1.0' - ); - return null; - } - - if ( isset( $properties['input_schema'] ) && ! is_array( $properties['input_schema'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties should provide a valid `input_schema` definition.' ), - '0.1.0' - ); - return null; - } - - if ( isset( $properties['output_schema'] ) && ! is_array( $properties['output_schema'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties should provide a valid `output_schema` definition.' ), - '0.1.0' - ); - return null; - } - - if ( empty( $properties['execute_callback'] ) || ! is_callable( $properties['execute_callback'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties must contain a valid `execute_callback` function.' ), - '0.1.0' - ); - return null; - } - - if ( isset( $properties['permission_callback'] ) && ! is_callable( $properties['permission_callback'] ) ) { + // The class is only used to instantiate the ability, and is not a property of the ability itself. + if ( isset( $properties['ability_class'] ) && ! is_a( $properties['ability_class'], WP_Ability::class, true ) ) { _doing_it_wrong( __METHOD__, - esc_html__( 'The ability properties should provide a valid `permission_callback` function.' ), + esc_html__( 'The ability properties should provide a valid `ability_class` that extends WP_Ability.' ), '0.1.0' ); return null; } + $ability_class = $properties['ability_class'] ?? WP_Ability::class; + unset( $properties['ability_class'] ); - if ( isset( $properties['meta'] ) && ! is_array( $properties['meta'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties should provide a valid `meta` array.' ), - '0.1.0' + try { + // WP_Ability::validate_properties() will throw an exception if the properties are invalid. + $ability = new $ability_class( + $name, + $properties ); - return null; - } - - if ( isset( $properties['ability_class'] ) && ! is_a( $properties['ability_class'], WP_Ability::class, true ) ) { + } catch ( \InvalidArgumentException $e ) { _doing_it_wrong( __METHOD__, - esc_html__( 'The ability properties should provide a valid `ability_class` that extends WP_Ability.' ), + esc_html( $e->getMessage() ), '0.1.0' ); return null; } - // The class is only used to instantiate the ability, and is not a property of the ability itself. - $ability_class = $properties['ability_class'] ?? WP_Ability::class; - unset( $properties['ability_class'] ); - - $ability = new $ability_class( - $name, - $properties - ); - $this->registered_abilities[ $name ] = $ability; return $ability; } @@ -175,10 +121,10 @@ public function register( string $name, array $properties = array() ): ?WP_Abili * * Do not use this method directly. Instead, use the `wp_unregister_ability()` function. * - * @see wp_unregister_ability() - * * @since 0.1.0 * + * @see wp_unregister_ability() + * * @param string $name The name of the registered ability, with its namespace. * @return ?\WP_Ability The unregistered ability instance on success, null on failure. */ @@ -204,10 +150,10 @@ public function unregister( string $name ): ?WP_Ability { * * Do not use this method directly. Instead, use the `wp_get_abilities()` function. * - * @see wp_get_abilities() - * * @since 0.1.0 * + * @see wp_get_abilities() + * * @return \WP_Ability[] The array of registered abilities. */ public function get_all_registered(): array { @@ -231,10 +177,10 @@ public function is_registered( string $name ): bool { * * Do not use this method directly. Instead, use the `wp_get_ability()` function. * - * @see wp_get_ability() - * * @since 0.1.0 * + * @see wp_get_ability() + * * @param string $name The name of the registered ability, with its namespace. * @return ?\WP_Ability The registered ability instance, or null if it is not registered. */ diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 2193b06..11b0aff 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -92,29 +92,20 @@ class WP_Ability { * * @access private * - * @see wp_register_ability() - * * @since 0.1.0 * + * @see wp_register_ability() + * * @param string $name The name of the ability, with its namespace. * @param array $properties An associative array of properties for the ability. This should * include `label`, `description`, `input_schema`, `output_schema`, * `execute_callback`, `permission_callback`, and `meta`. - * - * @phpstan-param array{ - * label: string, - * description: string, - * input_schema?: array, - * output_schema?: array, - * execute_callback: callable( array $input): (mixed|\WP_Error), - * permission_callback?: ?callable( array $input ): (bool|\WP_Error), - * meta?: array, - * ..., - * } $properties */ public function __construct( string $name, array $properties ) { $this->name = $name; + $this->validate_properties( $properties ); + foreach ( $properties as $property_name => $property_value ) { if ( ! property_exists( $this, $property_name ) ) { _doing_it_wrong( @@ -202,6 +193,76 @@ public function get_meta(): array { return $this->meta; } + /** + * Validates the properties used to instantiate the ability. + * + * Errors are thrown as exceptions instead of \WP_Errors to allow for simpler handling and overloading. They are then + * caught and converted to a WP_Error when by WP_Abilities_Registry::register(). + * + * @since n.e.x.t + * + * @see WP_Abilities_Registry::register() + * + * @param array $properties An associative array of properties to validate. + * + * @return void + * @throws \InvalidArgumentException if the properties are invalid. + * + * @phpstan-assert array{ + * label: string, + * description: string, + * input_schema?: array, + * output_schema?: array, + * execute_callback: callable( array $input): (mixed|\WP_Error), + * permission_callback?: ?callable( array $input ): (bool|\WP_Error), + * meta?: array, + * ..., + * } $properties + */ + protected function validate_properties( array $properties ) { + if ( empty( $properties['label'] ) || ! is_string( $properties['label'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties must contain a `label` string.' ) + ); + } + + if ( empty( $properties['description'] ) || ! is_string( $properties['description'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties must contain a `description` string.' ) + ); + } + + if ( isset( $properties['input_schema'] ) && ! is_array( $properties['input_schema'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties should provide a valid `input_schema` definition.' ) + ); + } + + if ( isset( $properties['output_schema'] ) && ! is_array( $properties['output_schema'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties should provide a valid `output_schema` definition.' ) + ); + } + + if ( empty( $properties['execute_callback'] ) || ! is_callable( $properties['execute_callback'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties must contain a valid `execute_callback` function.' ) + ); + } + + if ( isset( $properties['permission_callback'] ) && ! is_callable( $properties['permission_callback'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties should provide a valid `permission_callback` function.' ) + ); + } + + if ( isset( $properties['meta'] ) && ! is_array( $properties['meta'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties should provide a valid `meta` array.' ) + ); + } + } + /** * Validates input data against the input schema. * diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 0a3a6ca..052fb3c 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -13,6 +13,10 @@ declare( strict_types = 1 ); +if ( ! defined( 'ABSPATH' ) ) { + return; // Not in WordPress context +} + // Version of the plugin. if ( ! defined( 'WP_ABILITIES_API_VERSION' ) ) { define( 'WP_ABILITIES_API_VERSION', '0.1.0' ); @@ -34,4 +38,9 @@ // Load REST API init class for plugin bootstrap. if ( ! class_exists( 'WP_REST_Abilities_Init' ) ) { require_once __DIR__ . '/rest-api/class-wp-rest-abilities-init.php'; + + // Initialize REST API routes when WordPress is available. + if ( function_exists( 'add_action' ) ) { + add_action( 'rest_api_init', array( 'WP_REST_Abilities_Init', 'register_routes' ) ); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 85dfff7..dcd7709 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -34,7 +34,8 @@ tests_add_filter( 'muplugins_loaded', static function (): void { - require_once dirname( __DIR__ ) . '/abilities-api.php'; + // Require ( to bypass require_once ). + require dirname( __DIR__ ) . '/includes/bootstrap.php'; } ); diff --git a/tests/unit/abilities-api/wpAbilitiesRegistry.php b/tests/unit/abilities-api/wpAbilitiesRegistry.php index dff53e9..01e5ebc 100644 --- a/tests/unit/abilities-api/wpAbilitiesRegistry.php +++ b/tests/unit/abilities-api/wpAbilitiesRegistry.php @@ -383,4 +383,22 @@ public function test_get_all_registered() { $this->assertSame( $ability_two_name, $result[ $ability_two_name ]->get_name() ); $this->assertSame( $ability_three_name, $result[ $ability_three_name ]->get_name() ); } + + /** + * Direct instantiation of WP_Ability with invalid properties should throw an exception. + * + * @covers WP_Ability::__construct + * @covers WP_Ability::validate_properties + */ + public function test_wp_ability_invalid_properties_throws_exception() { + $this->expectException( \InvalidArgumentException::class ); + new WP_Ability( + 'test/invalid', + array( + 'label' => '', + 'description' => '', + 'execute_callback' => null, + ) + ); + } }