drupalorg-1.0.x-dev/src/Traits/GitLabClientTrait.php
src/Traits/GitLabClientTrait.php
<?php
namespace Drupal\drupalorg\Traits;
use Drupal\Component\Utility\Html;
use Drupal\contribution_records\SourceLink\GitDrupalCodeBase;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Gitlab\Client;
/**
* Helper function to get a Gitlab client instance.
*
* @todo Use https://git.drupalcode.org/project/gitlab_api instead.
*/
trait GitLabClientTrait {
use StringTranslationTrait;
/**
* Return a gitlab client instance.
*
* @return \Gitlab\Client
* Client instance.
*/
protected function getGitLabClient(): Client {
$config = \Drupal::config('drupalorg.gitlab_settings');
if (
empty($config->get('host')) ||
empty($config->get('token')) ||
$config->get('token') === 'CHANGE-ME'
) {
throw new \Exception('GitLab host or token not set.');
}
$gitlab_client = new Client();
$gitlab_client->setUrl($config->get('host'));
$gitlab_client->authenticate($config->get('token'), Client::AUTH_HTTP_TOKEN);
return $gitlab_client;
}
/**
* Returns the URL of the instance of GitLab in use.
*
* @return string
* URL of the instance.
*/
public function getGitLabUrl(): string {
return \Drupal::config('drupalorg.gitlab_settings')->get('host');
}
/**
* Returns the heading to prepend to issue notes sent by the system.
*
* @param string $heading
* Heading string.
*
* @return string
* Heading string formatted as a note.
*/
public function automatedNoteHeading(string $heading = 'Drupal.org'): string {
$heading = Html::escape($heading);
$comment = $this->hiddenComment();
return ">>> [!note] $heading\n<br>$comment\n";
}
/**
* Returns a hidden comment to insert and detect in some processes.
*
* @return string
* Hidden comment.
*/
public function hiddenComment(): string {
return '<!-- Drupal.org comment -->';
}
/**
* Find the fork for the given issue.
*
* @param string $issue_link
* Issue link.
*
* @return array
* Fork information.
*/
protected function findFork(string $issue_link): array {
$return = [];
try {
[
'project' => $project_id,
'issue_iid' => $issue_iid,
'error' => $error,
] = $this->getProjectAndIssueIdFromUrl($issue_link);
$client = $this->getGitLabClient();
$project = $client->projects()->show($project_id);
if ($project && $issue_iid) {
$fork_name_pattern = $project['path'] . '-' . $issue_iid;
$forks = $client->projects()->forks($project_id, [
'search' => $fork_name_pattern,
]);
if (!empty($forks)) {
foreach ($forks as $index => $fork) {
// Returned forks need to be 100% match. eg: abc-1 vs abc-11.
if ($fork['name'] === $fork_name_pattern) {
$return = $fork;
}
}
}
}
}
catch (\Throwable $e) {
// Could not find it. Function will fail in the next check.
}
return $return;
}
/**
* Adds the current user to a fork as member.
*
* @param int $fork_id
* ID of the fork.
* @param string $gitlab_user_id
* GitLab user ID.
*
* @return bool
* Whether the user was added or not.
*/
protected function addUserToFork($fork_id, $gitlab_user_id): bool {
if (!is_numeric($fork_id)) {
return FALSE;
}
$added = FALSE;
try {
$client = $this->getGitLabClient();
$client->projects()->addMember(
$fork_id,
$gitlab_user_id,
$this->getForkAccessLevel()
);
$added = TRUE;
}
catch (\Throwable $e) {
if ($e->getMessage() !== 'Member already exists') {
\Drupal::logger('drupalorg')->error('Error adding member to fork. Message: @message', [
'@message' => $e->getMessage(),
]);
}
else {
$added = TRUE;
}
}
return $added;
}
/**
* Creates a fork.
*
* @param string $source_link_param
* Source link.
* @param int $gitlab_user_id
* GitLab's user ID.
*
* @return array
* Fork created.
*/
protected function createFork($source_link_param, $gitlab_user_id): array {
$fork = [];
try {
[
'project' => $project_id,
'issue_iid' => $issue_iid,
'error' => $error,
] = $this->getProjectAndIssueIdFromUrl($source_link_param);
$client = $this->getGitLabClient();
$project = $client->projects()->show($project_id);
$fork_name = $project['path'] . '-' . $issue_iid;
$fork = $client->projects()->fork($project_id, [
'namespace' => 'issue',
'path' => $fork_name,
'name' => $fork_name,
]);
if (!empty($fork['name'])) {
// Try adding the user to the fork as well.
$fork['user_added'] = $this->addUserToFork($fork['id'], $gitlab_user_id);
}
}
catch (\Throwable $e) {
\Drupal::logger('drupalorg')->error('Error creating fork. Message: @message', [
'@message' => $e->getMessage(),
]);
}
return $fork;
}
/**
* Default access level for fork members.
*
* @see https://docs.gitlab.com/api/access_requests/#valid-access-levels
*
* @return string
* Access level within the fork.
*/
protected function getForkAccessLevel(): string {
return '30';
}
/**
* Default options for forks.
*
* @return array
* Options for fork creation.
*/
protected function getForkDefaultOptions(): array {
return [
'merge_requests_access_level' => 'enabled',
'squash_option' => 'default_on',
'issues_access_level' => 'disabled',
'forking_access_level' => 'disabled',
'builds_access_level' => 'enabled',
'wiki_access_level' => 'disabled',
'snippets_access_level' => 'disabled',
'pages_access_level' => 'disabled',
'operations_access_level' => 'disabled',
'monitor_access_level' => 'disabled',
'environments_access_level' => 'disabled',
'feature_flags_access_level' => 'disabled',
'infrastructure_access_level' => 'disabled',
'releases_access_level' => 'disabled',
'container_registry_enabled' => FALSE,
'shared_runners_enabled' => TRUE,
'request_access_enabled' => FALSE,
'auto_devops_enabled' => FALSE,
'packages_enabled' => FALSE,
'service_desk_enabled' => FALSE,
'remove_source_branch_after_merge' => FALSE,
];
}
/**
* Parses the URL and extracts the project and issue id from it.
*
* @param string|null $source_link_param
* Link to parse.
*
* @return array
* Array containing the project and the issue id.
*/
protected function getProjectAndIssueIdFromUrl(?string $source_link_param = NULL): array {
$info = [
'project' => NULL,
'issue_iid' => NULL,
'error' => NULL,
];
if (empty($source_link_param)) {
$info['error'] = $this->t('"source_link" parameter is required.');
return $info;
}
// Format: https://git.drupalcode.org/project/config_notify/-/issues/10
$url_parts = parse_url($source_link_param);
// Only Gitlab issues have the fork management done via this page. If the
// source link is an MR, that means that the forking and branching is taken
// care of, and for issues coming from www.drupal.org, the fork management
// is still done via the issue page.
$contribution_records_config = \Drupal::config('contribution_records.settings');
$accepted_domains = [GitDrupalCodeBase::DOMAIN];
if ($contribution_records_config->get('allow_dev_sources')) {
$accepted_domains[] = $contribution_records_config->get('gitlab_dev_source');
}
if (!in_array($url_parts['host'], $accepted_domains)) {
$info['error'] = $this->t('"source_link" has an invalid domain. Only issues from the following domains are accepted: @domains.', [
'@domains' => implode(', ', $accepted_domains),
]);
return $info;
}
if (
empty($url_parts['host']) ||
empty($url_parts['scheme']) ||
empty($url_parts['path']) ||
(
// Valid namespace check.
!str_starts_with($url_parts['path'], '/project/') &&
!str_starts_with($url_parts['path'], '/sandbox/')
) ||
!str_contains($url_parts['path'], '-/issues/')
) {
$info['error'] = $this->t('"source_link" is not a valid link.');
return $info;
}
// URL seems valid, extract the project name and the issue id.
$path = explode('/', trim($url_parts['path'], '/'));
if (
count($path) !== 5 ||
(
$path[0] !== 'project' &&
$path[0] !== 'sandbox'
) ||
$path[2] !== '-' ||
$path[3] !== 'issues' ||
!is_numeric($path[4])
) {
$info['error'] = $this->t('"source_link" is not a valid link.');
return $info;
}
// We know for sure that the URL is well formatted.
$info['project'] = $path[0] . '/' . $path[1];
$info['issue_iid'] = (int) $path[4];
return $info;
}
}
