WordPress plugin updater via Bitbucket API (Tutorial)

How do you manage a WordPress plugin when the plugin is hosted in a private Bitbucket repository? There are many ways to handle that, but in this post, we will use the Bitbucket API.

In our example, we are going to use Bitbucket repository with git tag. This way we will be able to extract the latest plugin version by doing an API call to tag endpoint. The connection with the Bitbucket API will be through Basic authentication.

Getting started

The first step is going to be declaring a class which will handle everything about the update feature.

$bb_plugin = array(
            'plugin_file' => path-to-root-plugin-file.php,
            'plugin_slug' => plugin-slug,
            'bb_host' => 'https://api.bitbucket.org',
            'bb_download_host' => 'http://bitbucket.org',
            'bb_owner' => 'username',
            'bb_password' => 'password',
            'bb_project_name' => 'project_name',
            'bb_repo_name' => 'repo_name'
        );

        new Bitbucket_Plugin_Updater( $bb_plugin );

In the constructor, we will save the arguments and add a few hooks to let WordPress know to look for these methods when handling the plugin update through Bitbucket API.

/**
	 * Add filters to check plugin version
	 *
	 * Arpu_Bitbucket_Plugin_Updater constructor.
	 *
	 * @param $bb_plugin
	 */
	function __construct( $bb_plugin ) {
		$this->plugin_file   = $bb_plugin['plugin_file'];
		$this->plugin_slug   = $bb_plugin['plugin_slug'];
		$this->host          = $bb_plugin['bb_host'];
		$this->download_host = $bb_plugin['bb_download_host'];
		$this->username      = $bb_plugin['bb_owner'];
		$this->password      = $bb_plugin['bb_password'];
		$this->project_name  = $bb_plugin['bb_project_name'];
		$this->repo          = $bb_plugin['bb_repo_name'];
		$this->init_plugin_data();

		add_filter( "pre_set_site_transient_update_plugins", array( $this, "bb_set_transient" ) );
		add_filter( "plugins_api", array( $this, "bb_set_plugin_info" ), 10, 3 );
		add_filter( "upgrader_post_install", array( $this, "bb_post_install" ), 10, 3 );
		add_filter( "upgrader_pre_install", array( $this, "bb_pre_install" ), 10, 3 );
		add_filter( "http_request_args", array( $this, "bb_request_args" ), 10, 2 );
	}

In the next section, we will explain the meaning of all those hooks.

Hooks

For your information, we have some links to another method in these hooks, we will cover that after the hooks section. Which will mostly cover getting information from Bitbucket API.

add_filter( "pre_set_site_transient_update_plugins", array( $this, "bb_set_transient" ) );

pre_set_site_transient_update_plugins is used to get the latest plugin information from the Bitbucket API. Runs in a cron thread, or in a visitor thread if triggered by _maybe_update_plugins(), or in an auto-update thread.

In our example, we are going to check the version via Bitbucket API.

/**
	 * Get the plugin version information from Bitbucket API
	 */
	public function bb_set_transient( $transient ) {
		// If we have checked the plugin data before, don't re-check
		if ( empty( $transient->checked ) || ! isset( $transient->checked[ $this->slug ] ) ) {
			return $transient;
		}

		// default - don't update the plugin
		$do_update = 0;

		// if bitbucket live
		if ( $this->git_repository_is_live() ) {
			// Get plugin & Bitbucket release information
			$this->get_repo_release_info();

			// Check the versions if we need to do an update
			$do_update = version_compare( $this->check_version_name( $this->version ),
				$transient->checked[ $this->slug ] );
		}

		// Update the transient to include our updated plugin data
		if ( $do_update == 1 ) {
			$package             = $this->get_download_url();
			$this->download_link = $package;

			$obj                                = new \stdClass();
			$obj->plugin                        = $this->slug;
			$obj->slug                          = $this->real_slug;
			$obj->new_version                   = $this->version;
			$obj->url                           = "website_url";
			$obj->package                       = $this->download_link;
			$transient->response[ $this->slug ] = $obj;
		}

		return $transient;
	}

add_filter( "plugins_api", array( $this, "bb_set_plugin_info" ), 10, 3 );

plugins_api hook is used to filter the plugin data which will be shown in “show detail” lightbox.

In our example we used Parsedown package to strip the field content from a markdown file.

public function bb_set_plugin_info( $false, $action, $response ) {
		if ( 'plugin_information' == $action && $response->slug == $this->plugin_slug ) {
			// Get plugin & Bitbucket release information
			$this->init_plugin_data();

			if ( $this->git_repository_is_live() ) {
				$this->get_repo_release_info();

				// Add our plugin information
				$response->last_updated = $this->commit_date;
				$response->slug         = $this->real_slug;
				$response->plugin_name  = $this->plugin_data["Name"];
				$response->version      = $this->version;
				$response->author       = $this->plugin_data["AuthorName"];
				$response->homepage     = "testurl";
				$response->name         = $this->plugin_data['Name'];

				// This is our release download zip file
				$response->download_link = $this->get_download_url();

				$change_log = $this->change_log;

				$matches = null;
				preg_match_all( "/[##|-].*/", $this->change_log, $matches );
				if ( ! empty( $matches ) ) {
					if ( is_array( $matches ) ) {
						if ( count( $matches ) > 0 ) {
							$change_log = '<p>';
							foreach ( $matches[0] as $match ) {
								if ( strpos( $match, '##' ) !== false ) {
									$change_log .= '<br>';
								}
								$change_log .= $match . '<br>';
							}
							$change_log .= '</p>';
						}
					}
				}


				// Create tabs in the lightbox
				$response->sections = array(
					'description' => $this->plugin_data["Description"],
					'changelog'   => Parsedown::instance()->parse( $change_log )
				);

				// Gets the required version of WP if available
				$matches = null;
				preg_match( "/requires:\s([\d\.]+)/i", $this->change_log, $matches );
				if ( ! empty( $matches ) ) {
					if ( is_array( $matches ) ) {
						if ( count( $matches ) > 1 ) {
							$response->requires = $matches[1];
						}
					}
				}

				// Gets the tested version of WP if available
				$matches = null;
				preg_match( "/tested:\s([\d\.]+)/i", $this->change_log, $matches );
				if ( ! empty( $matches ) ) {
					if ( is_array( $matches ) ) {
						if ( count( $matches ) > 1 ) {
							$response->tested = $matches[1];
						}
					}
				}

				return $response;
			}
		}

		return $false;
	}

add_filter( "upgrader_post_install", array( $this, "bb_post_install" ), 10, 3 );

upgrader_post_install is used to perform extra custom action after the plugin update.

In our example, we replaced the plugin directory name.

/**
	 * Perform additional actions to successfully install our plugin
	 */
	public function bb_post_install( $true, $hook_extra, $result ) {
		// Since we are hosted in Bitbucket, our plugin folder would have a dirname of
		// reponame-tagname change it to our original one:
		global $wp_filesystem;

		$plugin_folder = WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $this->real_slug . DIRECTORY_SEPARATOR;
		$wp_filesystem->move( $result['destination'], $plugin_folder );
		$result['destination'] = $plugin_folder;

		// Re-activate plugin if needed
		if ( $this->plugin_activated ) {
			activate_plugin( $this->real_slug );
		}

		return $result;
	}

add_filter( "upgrader_pre_install", array( $this, "bb_pre_install" ), 10, 3 );

upgrader_pre_install is used to do actions before WordPress begins with updating.

In our example we used this hook to check the plugin status.

/**
	 * Check if plugin is activated
	 *
	 * @param $true
	 * @param $args
	 */
	public function bb_pre_install( $true, $args ) {
		$this->plugin_activated = is_plugin_active( $this->slug );
	}

add_filter( "http_request_args", array( $this, "bb_request_args" ), 10, 2 );

http_request_args is used to add Bitbucket credentials in the call to the Bitbucket API. Otherwise Bitbucket won’t respond back to the calls. This is only used when WordPress requests the ZIP file from the Bitbucket API.

/**
	 * Add bitbucket credentials to request url
	 *
	 * @param $r
	 * @param $url
	 *
	 * @return mixed
	 */
	public function bb_request_args( $r, $url ) {

		if ( strpos( $url, $this->check_download_url() ) !== false ) {
			$r['headers'] = array( 'Authorization' => 'Basic ' . base64_encode( "$this->username:$this->password" ) );
		}

		return $r;
	}

Bitbucket API

In the previous hooks, we used methods to communicate with the Bitbucket API. Now we will clarify these.

git_repository_is_live is used to check if the bitbucket repository is live.

/**
	 * Check if the Bitbucket repository is live
	 *
	 * @return bool
	 */
	public function git_repository_is_live() {
		$new_url = $this->host . "/2.0/repositories/" . $this->project_name . "/" . $this->repo;

		$request = wp_remote_get( $new_url, array( 'headers' => $this->get_headers() ) );

		if ( ! is_wp_error( $request ) && $request['response']['code'] == 200 ) {
			return true;
		}

		return false;
	}

get_repo_release_info is used to get the latest information from the Bitbucket API.

/**
	 * Get information regarding our plugin from Bitbucket
	 */
	private function get_repo_release_info() {
		// Only do this once
		if ( ! empty( $this->bb_api_result ) ) {
			return;
		}

		// Query the Bitbucket API
		$url = $this->get_tag_url();

		$result = $this->get_bb_data( $url );

		if ( $result['response']['code'] == 200 ) {
			$decoded_result = json_decode( $result['body'] );

			$this->bb_api_result = $decoded_result;

			// first one is correct
			$latest_tag = current( $decoded_result->values );

			$changelog = $this->get_changelog_content( $latest_tag->target->hash );

			if ( $changelog !== false ) {
				$this->change_log = $changelog;
			} else {
				$this->change_log = $latest_tag->target->message;
			}

			$this->version     = $latest_tag->name;
			$this->commit_date = date( 'Y-m-d H:i:s', strtotime( $latest_tag->target->date ) );
		}
	}

get_bb_data is used to return the Bitbucket API Response.

/**
	 * Returns Bitbucket API response
	 * 
	 * @param $url
	 *
	 * @return array|\WP_Error
	 */
	private function get_bb_data( $url ) {
		$headers = array( 'Authorization' => 'Basic ' . base64_encode( "$this->username:$this->password" ) );
		$result  = wp_remote_get( $url, array( 'headers' => $headers ) );

		return $result;
	}

get_changelog_content is used to retrieve CHANGELOG.md from the latest git tag version. We save every plugin detail in that file.

/**
	 * Get content of changelog.md file from bitbucket
	 *
	 * @param $commit_hash
	 *
	 * @return string content of changelog
	 *          bool    false if wp errors
	 */
	protected function get_changelog_content( $commit_hash ) {
		$changelog = wp_remote_get( 'https://bitbucket.org/' . $this->project_name . '/' . $this->repo . '/raw/' . $commit_hash . '/CHANGELOG.md',
			array( 'headers' => $this->get_headers() ) );

		if ( is_wp_error( $changelog ) ) {
			return false;
		}

		return $changelog['body'];
	}

Example CHANGELOG.md file

# CHANGELOG

requires: 4.6
tested: 4.9.6

## v2.0.2
- FIX cronjob skipping existing attributes

get_download_url is used to download the plugin zip file.

check_download_url is used to validate a string against the built download url.

get_tag_url is used to retrieve the latest tag in the Bitbucket repository.

/**
* Returns ZIP download url
**/
public function get_download_url() {
		return "{$this->download_host}/{$this->project_name}/{$this->repo}/get/{$this->version}.zip";
	}

/**
* Returns download URL to validate against a string
**/
	public function check_download_url() {
		return "{$this->download_host}/{$this->project_name}/{$this->repo}/get/";
	}

/**
* Returns url to check latest tag version in the Bitbucket repository.
**/
	public function get_tag_url() {
		return "{$this->host}/2.0/repositories/{$this->project_name}/{$this->repo}/refs/tags?sort=-target.date";
	}

Conclusion

Managing a plugin through Bitbucket is a life saver when you have the plugin installed on more than a few websites. With a simple new git tag, every website will get a notification about the new release. The update part is the same as any other WordPress.org plugins: simply click on the update link in the plugins page.