diff --git a/composer-update-tags.php b/composer-update-tags.php new file mode 100755 index 0000000000000000000000000000000000000000..b5957a40a6f56da666a43bbdbb0e0a46cf62f0f1 --- /dev/null +++ b/composer-update-tags.php @@ -0,0 +1,56 @@ +#!/usr/bin/env php +name . $matches[6]; + echo $module . ($line === $new_line ? ' already at ' : ' setting to ') . $latest_tag->name . "\n"; + $line = $new_line; + } + else { + echo uw_wcms_tools_shell_color('Warning: No tag, left on branch: ' . $module . ".\n", 'red'); + } +} +file_put_contents('composer.json', $composer_file); + +echo uw_wcms_tools_shell_color("Done.\n", 'green'); diff --git a/gitlab-make-semvar.php b/gitlab-make-semvar.php new file mode 100755 index 0000000000000000000000000000000000000000..a803cf4b13b6cb96356a61bbd7646996acb07106 --- /dev/null +++ b/gitlab-make-semvar.php @@ -0,0 +1,39 @@ +#!/usr/bin/env php +path_with_namespace . "\n"; + uw_wcms_tools_create_semvar($project); +} diff --git a/gitlab-tag-release.php b/gitlab-tag-release.php new file mode 100755 index 0000000000000000000000000000000000000000..69ec54a3c7b0c42e5327bf969417ad93537d3d60 --- /dev/null +++ b/gitlab-tag-release.php @@ -0,0 +1,32 @@ +#!/usr/bin/env php + $arg[0], 'path' => $arg[1]], + ]; +} + +foreach ($projects as $project) { + uw_wcms_tools_gitlab_tag_release($project['namespace'], $project['path']); +} diff --git a/uw_wcms_tools.gitlab.inc b/uw_wcms_tools.gitlab.inc index c28da4c14ffd8f3d84f37248bba2bfd6149c1497..271f7199405908415274cf6ee3206da203fd1007 100644 --- a/uw_wcms_tools.gitlab.inc +++ b/uw_wcms_tools.gitlab.inc @@ -4,10 +4,11 @@ * @file * Functions for interacting with the Gitlab API. * + * @see https://git.uwaterloo.ca/help/api/api_resources.md + * @see https://git.uwaterloo.ca/help/api/branches.md + * @see https://git.uwaterloo.ca/help/api/projects.md * @see https://git.uwaterloo.ca/help/api/README.md * @see https://git.uwaterloo.ca/help/api/services.md - * @see https://git.uwaterloo.ca/help/api/projects.md - * @see https://git.uwaterloo.ca/help/api/api_resources.md */ require_once 'uw_wcms_tools.lib.inc'; @@ -221,8 +222,8 @@ function uw_wcms_tools_get_group($id): ?object { /** * Return the tag in a project which is highest accounting to version_compare(). * - * @param int $project_id - * The project ID of the project to search. + * @param int|string $project_id + * The project ID or NAMESPACE/PROJECT_PATH of the project to search. * @param string $branch_filter * If set to a Drupal branch name (e.g. "7.x-1.x"), remove tags that don't * match (e.g. not like "7.x-1.1"). @@ -233,10 +234,10 @@ function uw_wcms_tools_get_group($id): ?object { * The tag or NULL if there are no tags. */ function uw_wcms_tools_get_tag_latest($project_id, $branch_filter = NULL, $full_release_only = FALSE) { - $project_id = (int) $project_id; - if ($project_id < 1) { + if (!preg_match('/^(\d+|[a-z0-9_]+\/[a-z0-9_]+)$/', $project_id)) { return; } + $project_id = urlencode($project_id); $tags = uw_wcms_tools_query_gitlab_api('projects/' . $project_id . '/repository/tags', array('per_page' => 100)); $tags = $tags['body']; @@ -253,13 +254,14 @@ function uw_wcms_tools_get_tag_latest($project_id, $branch_filter = NULL, $full_ elseif (preg_match('/^\d+\.x$/', $branch_filter)) { $branch_filter = $branch_filter . '-'; } + // Semvar filter. Whether given as 1.2.3 or 1.2.x, will filter as "1.2.". + elseif (preg_match('/^(\d+\.\d+\.)(x|\d+)$/', $branch_filter, $matches)) { + $branch_filter = $matches[1]; + } // Filter on Drupal major core version only by setting it as integer. elseif (is_int($branch_filter)) { $branch_filter = $branch_filter . '.'; } - else { - $branch_filter = NULL; - } if ($branch_filter) { foreach ($tags as $key => $value) { if (strpos($value->name, $branch_filter) !== 0) { @@ -292,6 +294,30 @@ function uw_wcms_tools_get_tag_latest($project_id, $branch_filter = NULL, $full_ return $latest; } +/** + * Return the most recent value in an using version_compare(). + * + * @param array $versions + * An array of versions to compare. + * + * @return string|null + * The most recent version or NULL if $versions is empty. + */ +function uw_wcms_tools_version_compare_array(array $versions): ?string { + if (!$versions) { + return NULL; + } + + $latest = (string) array_shift($versions); + foreach ($versions as $version) { + $version = (string) $version; + if (version_compare($version, $latest, '>')) { + $latest = $version; + } + } + return $latest; +} + /** * Return the latest commits for the default branch of a project. * @@ -503,3 +529,370 @@ function uw_wcms_tools_gitlab_project_create(string $group, string $path): array return $r; } + +/** + * Returns an array of branch objects in a project with caching. + * + * @param int $project_id + * The project ID of the project to search. + * + * @return object[]|null + * An array of branch objects or NULL on error. + */ +function uw_wcms_tools_get_branches(int $project_id): ?array { + if ($project_id < 1) { + return []; + } + static $cache = []; + if (!array_key_exists($project_id, $cache)) { + $query = ['per_page' => 100]; + $result = uw_wcms_tools_query_gitlab_api('projects/' . $project_id . '/repository/branches', $query); + if ($result['http_status'] === 200) { + $cache[$project_id] = $result['body']; + } + else { + $cache[$project_id] = NULL; + } + } + return $cache[$project_id]; +} + +/** + * Returns an array of branch names in a project with caching. + * + * @param int $project_id + * The project ID of the project to search. + * + * @return string[]|null + * An array of branch names or NULL on error. + */ +function uw_wcms_tools_get_branch_names(int $project_id): ?array { + $branches = uw_wcms_tools_get_branches($project_id); + if ($branches) { + $branch_names = []; + foreach ($branches as $branch) { + $branch_names[] = $branch->name; + } + return $branch_names; + } + return NULL; +} + +/** + * Return a valid semvar version of a branch name. + * + * @param string $branch + * The branch name. + * + * @return string|null + * If $branch is a valid semvar, return it. If it is an "8.x" branch name, + * return the semvar equivalent, for example, 8.x-2.x becomes 2.0.x. + * Otherwise, return NULL. + */ +function uw_wcms_tools_get_semvar_branch_name(string $branch): ?string { + // Already a valid semvar branch. + if (preg_match('/^\d+\.\d+\.x$/', $branch)) { + return $branch; + } + // Convert "8.x" branch name into semvar. + elseif (preg_match('/^8.x-(\d+)\.x$/', $branch, $matches)) { + return $matches[1] . '.0.x'; + } + // Invalid branch name. + return NULL; +} + +/** + * Create semvar branches for each 8.x branch and move default if it is 8.x. + * + * @param object $project + * The project object. + */ +function uw_wcms_tools_create_semvar(stdClass $project): void { + $branches = uw_wcms_tools_get_branch_names($project->id); + + // Create semvar for each 8.x, unless it already exists. + foreach ($branches as $branch_orig) { + $branch_semvar = uw_wcms_tools_get_semvar_branch_name($branch_orig); + if ($branch_semvar && $branch_semvar !== $branch_orig) { + echo $branch_orig . ' -> ' . $branch_semvar . ': '; + if (in_array($branch_semvar, $branches, TRUE)) { + echo uw_wcms_tools_shell_color("Already exists.\n", 'blue'); + } + else { + echo 'Creating... '; + $params = [ + 'branch' => $branch_semvar, + 'ref' => $branch_orig, + ]; + $created_branch = uw_wcms_tools_query_gitlab_api('projects/' . $project->id . '/repository/branches', $params, 'POST'); + if ($created_branch['http_status'] === 201) { + echo uw_wcms_tools_shell_color("Done.\n", 'green'); + } + else { + echo uw_wcms_tools_shell_color("Error.\n", 'red'); + var_dump($created_branch); + } + } + } + } + + // If the default branch is 8.x, set to the corresponding semvar. + $branch_semvar = uw_wcms_tools_get_semvar_branch_name($project->default_branch); + if ($branch_semvar && $branch_semvar !== $project->default_branch) { + echo 'Update default: ' . $project->default_branch . ' -> ' . $branch_semvar . ' '; + $params = [ + 'default_branch' => $branch_semvar, + ]; + $response = uw_wcms_tools_query_gitlab_api('projects/' . $project->id, $params, 'PUT'); + if ($response['http_status'] === 200) { + echo uw_wcms_tools_shell_color("Done.\n", 'green'); + } + else { + echo uw_wcms_tools_shell_color("Error.\n", 'red'); + var_dump($response); + } + } +} + +/** + * Return the most recent semvar branch. + * + * @param int $project_id + * The project ID. + * + * @return string|null + * The branch name or NULL if there are none that match. + */ +function uw_wcms_tools_get_branch_latest(int $project_id): ?string { + $branches = []; + foreach (uw_wcms_tools_get_branches($project_id) as $branch) { + if (preg_match('/^\d+\.\d+\.x$/', $branch->name)) { + $branches[] = $branch->name; + } + } + + return uw_wcms_tools_version_compare_array($branches); +} + +/** + * Return the default branch if semvar otherwise the most recent semvar branch. + * + * @param int $project_id + * The project ID of the project to search. + * + * @return string|null + * The branch name or NULL if there are none that match. + */ +function uw_wcms_tools_get_branch_default_or_latest(int $project_id): ?string { + $project = uw_wcms_tools_get_project($project_id); + + $branch = uw_wcms_tools_get_semvar_branch_name($project->default_branch); + if (!$branch) { + $branch = uw_wcms_tools_get_branch_latest($project->id); + } + return $branch; +} + +/** + * Return the current development and release branches. + * + * The dev branch is the default branch if it is valid, otherwise, the most + * recent valid branch. The release branch is the corresponding release branch. + * + * @param int $project_id + * The project ID. + * + * @return array|null + * NULL if no valid branches, otherwise an array with keys: + * - dev: The semvar current development branch. + * - rel: The semvar current release branch. + * - tag_latest: The latest tag. + * - tag_next: The next tag. + */ +function uw_wcms_tools_get_current_branches(int $project_id): ?array { + $dev_branch = uw_wcms_tools_get_branch_default_or_latest($project_id); + + // Return early if no semvar branch. + if (!$dev_branch) { + return NULL; + } + + $tag_latest = uw_wcms_tools_get_tag_latest($project_id, $dev_branch); + return [ + 'dev' => $dev_branch, + 'rel' => 'prod/' . $dev_branch, + 'tag_latest' => $tag_latest, + 'tag_next' => uw_wcms_tools_next_semvar($tag_latest->name ?? $dev_branch), + ]; +} + +/** + * Merge project development to release branch and tag as next patch level. + * + * @param string $namespace + * The GitLab namespace of the project to act on. + * @param string $path + * The GitLab path of the project to act on. + */ +function uw_wcms_tools_gitlab_tag_release(string $namespace, string $path): void { + // Load project. + $project = uw_wcms_tools_get_project($namespace, $path); + if (!$project) { + throw new Exception('Invalid project.'); + } + + echo uw_wcms_tools_shell_color('Project: ' . $project->path_with_namespace . "\n", 'blue'); + + echo "Ensuring this project has semvar branches...\n"; + uw_wcms_tools_create_semvar($project); + + $current_branches = uw_wcms_tools_get_current_branches($project->id); + + $branch_names = uw_wcms_tools_get_branch_names($project->id); + + // Update or create release branch. + if (in_array($current_branches['rel'], $branch_names)) { + echo 'Merge ' . $current_branches['dev'] . ' into ' . $current_branches['rel'] . '... '; + + $release_branch = uw_wcms_tools_query_gitlab_api('projects/' . $project->id . '/repository/branches/' . urlencode($current_branches['rel'])); + if ($release_branch['http_status'] !== 200) { + echo uw_wcms_tools_shell_color("Error.\n", 'red'); + var_dump($release_branch); + throw new Exception('Unable to load release branch.'); + } + + $dev_branch = uw_wcms_tools_query_gitlab_api('projects/' . $project->id . '/repository/branches/' . urlencode($current_branches['dev'])); + if ($dev_branch['http_status'] !== 200) { + echo uw_wcms_tools_shell_color("Error.\n", 'red'); + var_dump($dev_branch); + throw new Exception('Unable to load development branch.'); + } + + // Create array of commit hashes of release branch tip and its parents. + $tip_and_parents_of_release = array_merge([$release_branch['body']->commit->id], $release_branch['body']->commit->parent_ids); + // Test if there is development since the last merge. + if (in_array($dev_branch['body']->commit->id, $tip_and_parents_of_release)) { + echo uw_wcms_tools_shell_color("No development. Nothing to tag.\n", 'green'); + // Nothing to tag, so return now. + return; + } + else { + // Create merge request. + $params = [ + 'source_branch' => $current_branches['dev'], + 'target_branch' => $current_branches['rel'], + 'title' => 'Tag ' . $current_branches['tag_next'], + ]; + $merge_request = uw_wcms_tools_query_gitlab_api('projects/' . $project->id . '/merge_requests', $params, 'POST'); + if ($merge_request['http_status'] !== 201) { + echo uw_wcms_tools_shell_color("Error.\n", 'red'); + var_dump($merge_request); + throw new Exception('Unable to create merge request.'); + } + + // Accept (do) merge request. + // Accept will fail if it happens too soon after create. + sleep(1); + $params = [ + 'sha' => $dev_branch['body']->commit->id, + ]; + $merge_request = uw_wcms_tools_query_gitlab_api('projects/' . $project->id . '/merge_requests/' . $merge_request['body']->iid . '/merge', $params, 'PUT'); + if ($merge_request['http_status'] !== 200) { + echo uw_wcms_tools_shell_color("Error.\n", 'red'); + var_dump($merge_request); + throw new Exception('Unable to accept merge request.'); + } + + // Update release branch. + $release_branch = uw_wcms_tools_query_gitlab_api('projects/' . $project->id . '/repository/branches/' . urlencode($current_branches['rel'])); + if ($release_branch['http_status'] !== 200) { + echo uw_wcms_tools_shell_color("Error.\n", 'red'); + var_dump($release_branch); + throw new Exception('Unable to load release branch after merge.'); + } + echo uw_wcms_tools_shell_color("Done.\n", 'green'); + } + } + else { + echo 'Create ' . $current_branches['rel'] . ' from ' . $current_branches['dev'] . '... '; + $params = [ + 'branch' => $current_branches['rel'], + 'ref' => $current_branches['dev'], + ]; + $release_branch = uw_wcms_tools_query_gitlab_api('projects/' . $project->id . '/repository/branches', $params, 'POST'); + if ($release_branch['http_status'] === 201) { + echo uw_wcms_tools_shell_color("Done.\n", 'green'); + } + else { + echo uw_wcms_tools_shell_color("Error.\n", 'red'); + var_dump($release_branch); + throw new Exception('Branch creation failed.'); + } + } + + // Create tag. + echo 'Tag ' . $current_branches['rel'] . ' as ' . $current_branches['tag_next'] . '... '; + // Check that there is not already a tag pointing at the branch tip. + $params = [ + 'type' => 'tag', + ]; + $release_branch_tip_tags = uw_wcms_tools_query_gitlab_api('projects/' . $project->id . '/repository/commits/' . $release_branch['body']->commit->id . '/refs', $params); + if ($release_branch_tip_tags['http_status'] !== 200) { + echo uw_wcms_tools_shell_color("Error.\n", 'red'); + var_dump($release_branch_tip_tags); + throw new Exception('Reading tags at branch tip failed.'); + } + elseif ($release_branch_tip_tags['body']) { + echo uw_wcms_tools_shell_color("Branch tip already part of a tag. Nothing created.\n", 'yellow'); + } + else { + // Do tag creation. + $params = [ + 'tag_name' => $current_branches['tag_next'], + 'ref' => $release_branch['body']->commit->id, + ]; + $new_tag = uw_wcms_tools_query_gitlab_api('projects/' . $project->id . '/repository/tags', $params, 'POST'); + if ($new_tag['http_status'] === 201) { + echo uw_wcms_tools_shell_color("Done.\n", 'green'); + } + else { + echo uw_wcms_tools_shell_color("Error.\n", 'red'); + var_dump($new_tag); + throw new Exception('Tag creation failed.'); + } + } +} + +/** + * Return an array of WCMS projects in uw_base_profile composer.json. + * + * @return array[] + * An array of project arrays with keys 'namespace' and 'path'. + */ +function uw_wcms_tools_gitlab_get_profile_projects(): array { + // Get composer.json from development branch in uw_base_profile. + $project = uw_wcms_tools_get_project('wcms', 'uw_base_profile'); + $file_path = 'composer.json'; + $params = [ + 'ref' => uw_wcms_tools_get_branch_default_or_latest($project->id), + ]; + $composer = uw_wcms_tools_query_gitlab_api('projects/' . $project->id . '/repository/files/' . urlencode($file_path) . '/raw', $params); + if ($composer['http_status'] !== 200) { + throw new Exception('Unable to load ' . $file_path . ' from uw_base_profile.'); + } + + // Make an array of all projects in the 'wcms' group. + $profile_projects = []; + foreach (['require', 'require-dev'] as $type) { + if (isset($composer['body']->$type)) { + foreach (array_keys((array) $composer['body']->$type) as $project) { + list($project_arr['namespace'], $project_arr['path']) = explode('/', $project, 2); + if ($project_arr['namespace'] === 'wcms') { + $profile_projects[] = $project_arr; + } + } + } + } + return $profile_projects; +} diff --git a/uw_wcms_tools.lib.inc b/uw_wcms_tools.lib.inc index 45dc91947d41db4dbafbcfa7b42a77cd6bc2a69b..e4c283edcb6ba818b42b7b37d7eacd0c8e92593d 100644 --- a/uw_wcms_tools.lib.inc +++ b/uw_wcms_tools.lib.inc @@ -56,6 +56,41 @@ function uw_wcms_tools_next_version($current = NULL) { return $core . '.x-' . $major . '.' . $minor; } +/** + * Return the first or next semantic version number. + * + * @param string $version + * The current version number or branch. + * + * @return string|null + * If $version is a semvar version, return the next patch leve. If it is a + * branch, such as 1.2.x return the first version on that branch. Otherwise, + * return NULL. + */ +function uw_wcms_tools_next_semvar(string $version): ?string { + // Version number provided. + if (preg_match('/^(\d+)\.(\d+)\.(\d+)(-(?:alpha|beta|rc)\d+)?$/', $version, $matches)) { + $major = (int) $matches[1]; + $minor = (int) $matches[2]; + $patch = (int) $matches[3]; + + // Increment minor if version does not end with -alphaN, -betaN, or -rcN. + if (!isset($matches[4])) { + $patch++; + } + } + // Branch provided. + elseif (preg_match('/^(\d+)\.(\d+)\.x$/', $version, $matches)) { + $major = (int) $matches[1]; + $minor = (int) $matches[2]; + $patch = 0; + } + else { + return NULL; + } + return $major . '.' . $minor . '.' . $patch; +} + /** * Parse a site makefile. * @@ -146,3 +181,30 @@ function uw_wcms_tools_get_profile_makefile() { function uw_wcms_tools_remove_local_mod_version($tag) { return preg_replace('/-uw_(wcms|os)\d+$/', '', $tag); } + +/** + * Return text with shell coloring. + * + * @param string $string + * The string to color. + * @param string $color + * The color to make the text. + * + * @return string + * $string starting with the color code and ending in the reset code. + */ +function uw_wcms_tools_shell_color(string $string, string $color): string { + static $colors = [ + 'black' => 30, + 'red' => 31, + 'green' => 32, + 'yellow' => 33, + 'blue' => 34, + 'purple' => 35, + 'cyan' => 36, + 'white' => 37, + ]; + static $reset = "\e[0m"; + + return "\e[0;" . $colors[$color] . 'm' . $string . $reset; +}