drupalorg-1.0.x-dev/src/ProjectService.php
src/ProjectService.php
<?php
namespace Drupal\drupalorg;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\node\Entity\Node;
use Drupal\node\NodeInterface;
use Psr\Log\LoggerInterface;
/**
* Project related helper methods.
*/
class ProjectService {
use StringTranslationTrait;
/**
* Types of projects.
*
* This comes from "project_project_node_types" result in D7.
*
* @var array
*/
const PROJECT_TYPES = [
'project_module',
'project_theme',
'project_distribution',
'project_theme_engine',
'project_general',
'project_drupalorg',
'project_translation',
'project_core',
];
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected Connection $connection;
/**
* Construct method.
*
* @param \Drupal\Core\Database\Connection $connection
* The database connection.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* Entity type manager service.
* @param \Psr\Log\LoggerInterface $logger
* The logger.
*/
public function __construct(Connection $connection, protected EntityTypeManagerInterface $entityTypeManager, protected LoggerInterface $logger) {
$this->connection = $connection;
}
/**
* Retrieve a project given a value and a field.
*
* @param string $field
* Field to query.
* @param string $value
* Value to check.
* @param string|null $type
* Type of project node to query, otherwise all project types.
*
* @return \Drupal\node\NodeInterface|null
* Project node or null.
*/
protected function getProjectBy(string $field, string $value, ?string $type = NULL): ?NodeInterface {
$project_types = is_null($type) ? self::PROJECT_TYPES : [$type];
$project_id = $this->entityTypeManager->getStorage('node')->getQuery()
->accessCheck(FALSE)
->condition('type', $project_types, 'IN')
->condition($field, $value)
->range(0, 1)
->execute();
if (!empty($project_id)) {
return $this->entityTypeManager->getStorage('node')->load(reset($project_id));
}
return NULL;
}
/**
* Retrieve a project from its composer username.
*
* @param string $composer_namespace
* Composer namespace of the project.
*
* @return \Drupal\node\NodeInterface|null
* Project node or null.
*/
public function getProjectByComposerNamespace(string $composer_namespace): ?NodeInterface {
return $this->getProjectBy('field_composer_namespace', $composer_namespace, 'project_module');
}
/**
* Retrieve a project from its machine name.
*
* @param string $machine_name
* Machine name of the project.
*
* @return \Drupal\node\NodeInterface|null
* Project node or null.
*/
public function getProjectByMachineName(string $machine_name): ?NodeInterface {
return $this->getProjectBy('field_project_machine_name', $machine_name);
}
/**
* Generates a link to a project by the given machine name.
*
* @param string $text
* Machine name of the project.
*
* @return string
* Original text or link to the project if found.
*/
public function getProjectLinkFromMachineName($text) {
$project_name = $text ?? '';
$project_name = strip_tags($project_name);
$project_name = str_replace(["\r", "\n", ' '], '', $project_name);
$project = $this->getProjectByMachineName($project_name);
if ($project) {
$text = $project->toLink()->toString();
// Sandbox projects.
if (is_numeric($project_name)) {
$text .= ' <small>(' . $project_name . ')</small>';
}
}
else {
$text = $project_name ?: $this->t('<none>');
}
return $text;
}
/**
* Get the maintainers of a given project.
*
* @param \Drupal\node\NodeInterface $project
* Project to get maintainers from.
*
* @return array
* Array of users that are maintainers of the project and their levels.
*/
public function getProjectMaintainers(NodeInterface $project): array {
if (!$this->isProject($project)) {
throw new \Exception('The entity is not a project.');
}
$query = $this->connection
->select('drupalorg_project_maintainer', 'pm')
->condition('pm.nid', $project->id())
->fields('pm');
return $query->execute()->fetchAllAssoc('uid');
}
/**
* Return whether a given user is a maintainer of a project node.
*
* @param \Drupal\Core\Session\AccountInterface $account
* User to check.
* @param \Drupal\node\NodeInterface $node
* Node to check.
* @param array $levels_to_check
* (Optional) Levels to check. If NULL, all levels will be checked.
*
* @return int
* Added levels of maintainer properties.
*/
public function isProjectMaintainer(AccountInterface $account, NodeInterface $node, ?array $levels_to_check = NULL) {
$added_levels = 0;
$maintainers = $this->getProjectMaintainers($node);
$user_id = $account->id();
if (!empty($maintainers[$user_id])) {
$levels = $maintainers[$user_id];
if (is_null($levels_to_check)) {
$levels_to_check = [
'update_project',
'administer_maintainers',
'write_to_vcs',
'manage_releases',
'maintain_issues',
];
}
foreach ($levels_to_check as $level) {
if (isset($levels->{$level})) {
$added_levels += (int) $levels->{$level};
}
}
}
return $added_levels;
}
/**
* Get the repository information of a given project.
*
* @param \Drupal\node\NodeInterface $project
* Project to get repository information from.
*
* @return array
* Repository namespace, name and id.
*/
public function getProjectRepositoryInformation(NodeInterface $project): array {
$query = $this->connection
->select('drupalorg_project_repositories', 'pr')
->condition('pr.drupal_project_nid', $project->id())
->fields('pr');
$info = $query->execute()->fetchAssoc();
return [
'drupal_project_nid' => $info['drupal_project_nid'] ?? NULL,
'gitlab_project_id' => $info['gitlab_project_id'] ?? NULL,
'gitlab_namespace' => $info['gitlab_namespace'] ?? NULL,
'gitlab_project_name' => $info['gitlab_project_name'] ?? NULL,
];
}
/**
* Get a Drupal project from the GitLab path.
*
* @param string $path
* Path within GitLab.
*
* It can be a GitLab project ID, the project name, or the
* namespace/project_name.
*
* @return \Drupal\node\NodeInterface|null
* Project node or null.
*/
public function getProjectByRepositoryPath($path): ?NodeInterface {
$gitlab_project_id = is_numeric($path) ? (int) $path : NULL;
// Assume 'project' namespace if none given.
$namespace = 'project';
$name = !is_numeric($path) ? $path : NULL;
if (str_contains($path, '/')) {
[$namespace, $name] = explode('/', $path);
}
if (!$gitlab_project_id && !$name) {
return NULL;
}
$query = $this->connection
->select('drupalorg_project_repositories', 'pr')
->fields('pr', ['drupal_project_nid']);
if ($gitlab_project_id) {
$query->condition('pr.gitlab_project_id', $gitlab_project_id);
}
else {
$query->condition('pr.gitlab_namespace', $namespace);
$query->condition('pr.gitlab_project_name', $name);
}
$project = $query->execute()->fetchCol();
if (count($project) === 1) {
return $this->entityTypeManager->getStorage('node')->load($project[0]);
}
return NULL;
}
/**
* Return the types of node that are considered projects.
*
* @return array
* Types of project.
*/
public function projectNodeTypes(): array {
return self::PROJECT_TYPES;
}
/**
* Return whether a node is a project type or not.
*
* @param \Drupal\node\NodeInterface $node
* Node to check.
*
* @return bool
* True if it's a project, false otherwise.
*/
public function isProject(NodeInterface $node): bool {
return in_array($node->bundle(), $this->projectNodeTypes());
}
/**
* Determine if a given node is a sandbox or full project.
*
* @param \Drupal\node\NodeInterface $node
* A fully-loaded node object representing the project.
*
* @return bool
* TRUE if the given node is a sandbox project, FALSE if full.
*/
public function isSandbox(NodeInterface $node): bool {
if ($node->hasField('field_project_type')) {
$project_type = $node->get('field_project_type')->value ?? NULL;
return $project_type === 'sandbox';
}
return FALSE;
}
/**
* Get “branches” for a project.
*
* Each branch identifier is everything up to and including the final dot in
* the version number.
*
* @param \Drupal\node\NodeInterface $project_node
* Project node.
* @param bool $only_supported
* Only return the supported branches if TRUE, otherwise all.
*
* @return \stdClass[]
* Array keyed by branch.
*
* @throws \Exception
*/
public function getVersions(NodeInterface $project_node, $only_supported = FALSE): array {
$query = $this->connection->select('drupalorg_project_release_supported_versions', 'prsv')
->fields('prsv', ['branch', 'supported', 'recommended_release', 'latest_release', 'latest_security_release'])
->condition('prsv.nid', $project_node->id());
if ($only_supported) {
$query->condition('prsv.supported', TRUE);
}
return $query->execute()->fetchAllAssoc('branch');
}
/**
* Gets project usage information broken down by branch and date.
*
* @param \Drupal\node\NodeInterface $project_node
* Project node.
* @param int $week
* Timestamp of the desired week.
* @param bool $only_current
* Only include modern Drupal usage data.
*
* @return \stdClass[]
* Array keyed by branch.
*/
public function getWeeklyUsage(NodeInterface $project_node, int $week, bool $only_current = TRUE): array {
$query = $this->connection
->select('project_usage_week_release', 'puwr')
->fields('puwr', ['project_id', 'release_id', 'count', 'timestamp'])
->condition('puwr.project_id', $project_node->id())
->condition('puwr.timestamp', $week);
$results = $query
->execute()
->fetchAllAssoc('release_id');
if (empty($results)) {
return [];
}
$raw_usage = [];
foreach ($results as $row) {
$raw_usage[$row->release_id] = $row->count;
}
$release_ids = array_keys($raw_usage);
$releases = Node::loadMultiple($release_ids);
$branches_usage = [];
if (!empty($releases) && !empty($raw_usage)) {
foreach ($releases as $release) {
if ($this->includeInResults($release, $only_current)) {
$branch = $this->getBranchFromVersion($release);
if ($branch) {
if (empty($branches_usage[$branch])) {
$branches_usage[$branch] = 0;
}
$branches_usage[$branch] += $raw_usage[$release->id()];
}
}
}
}
return $branches_usage;
}
/**
* Gets project usage information broken down by branch and date.
*
* @param \Drupal\node\NodeInterface $project_node
* Project node.
* @param bool $only_current
* Include only modern Drupal usage.
* @param int|null $weeks
* If given, only data newer than the give "weeks" ago.
*
* @return \stdClass[]
* Array keyed by branch.
*/
public function getTotalUsage(NodeInterface $project_node, bool $only_current = TRUE, ?int $weeks = NULL): array {
$query = $this->connection
->select('project_usage_week_release', 'puwr')
->fields('puwr', ['project_id', 'release_id', 'count', 'timestamp'])
->condition('puwr.project_id', $project_node->id());
if ($weeks) {
$query->condition('puwr.timestamp', $weeks, '>');
}
// All time data.
$results = $query
->execute()
->fetchAll();
if (empty($results)) {
return [];
}
// The below logic is very similar to the one in "getWeeklyUsage" but the
// array structures are different, sorted by timestamps.
$raw_usage = [];
$branches_usage = [];
$releases = [];
foreach ($results as $row) {
// Store raw usage.
if (empty($raw_usage[$row->timestamp])) {
$raw_usage[$row->timestamp] = [];
}
$raw_usage[$row->timestamp][$row->release_id] = $row->count;
// Fill up releases array.
if (!isset($releases[$row->release_id])) {
$releases[$row->release_id] = Node::load($row->release_id);
}
// And prepare the branch arrays to hold the results.
if (empty($branches_usage[$row->timestamp])) {
$branches_usage[$row->timestamp] = [];
}
$release = $releases[$row->release_id] ?? NULL;
if ($release && $this->includeInResults($release, $only_current)) {
$branch = $this->getBranchFromVersion($release);
if ($branch) {
if (empty($branches_usage[$row->timestamp][$branch])) {
$branches_usage[$row->timestamp][$branch] = 0;
}
$branches_usage[$row->timestamp][$branch] += $raw_usage[$row->timestamp][$release->id()];
}
}
}
return $branches_usage;
}
/**
* Returns a branch name from the version field of a release.
*
* @param \Drupal\node\NodeInterface $node
* Release to check.
*
* @return string
* Name of the branch.
*/
protected function getBranchFromVersion(NodeInterface $node): string {
$branch = NULL;
$version = $node->get('field_release_version')->getValue();
if ($version) {
$version = $version[0]['value'] ?? NULL;
if (!is_null($version)) {
$branch = $this->getBranchFromReleaseVersion($version) . 'x';
}
}
return $branch;
}
/**
* Calculate if a release should be included in the results.
*
* @param \Drupal\node\NodeInterface $node
* Release to check.
* @param bool $only_current
* Include only modern Drupal or not.
*
* @return bool
* Result of the evaluation.
*/
protected function includeInResults(NodeInterface $node, bool $only_current): bool {
$include_in_result = FALSE;
if ($node->isPublished() && $node->hasField('field_release_category')) {
$category = $node->get('field_release_category')->getValue()[0]['value'] ?? NULL;
if ($only_current && $category === 'current') {
// Only modern Drupal releases.
$include_in_result = TRUE;
}
elseif (!$only_current) {
// All releases.
$include_in_result = TRUE;
}
}
return $include_in_result;
}
/**
* Get the “branch” of a version. For grouping releases, not VCS.
*
* @param string $version
* The version number to process.
*
* @return string
* The version number with everything after the last '.' stripped.
*/
protected function getBranchFromReleaseVersion($version) {
return preg_replace('#\.[^.]*$#', '.', $version);
}
/**
* Update the project logo URL, if needed.
*
* @param \Drupal\node\NodeInterface $project_node
* Project node.
* @param string $avatar_url
* 'avatar_url' value from GitLab project API.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
public function updateLogo(NodeInterface $project_node, string $avatar_url): void {
$existing_logo = $project_node->get('field_logo_url')->uri;
if ($existing_logo != $avatar_url) {
$this->logger->notice('Updating @project logo from "@existing_logo" → "@to"', [
'@project' => $project_node->get('field_project_machine_name')->value,
'@existing_logo' => $existing_logo ?? '',
'@to' => $avatar_url,
]);
$project_node->set('field_logo_url', $avatar_url)->save();
}
}
/**
* Get project type information.
*
* @param \Drupal\node\NodeInterface $project_node
* Project node.
*
* @return array
* Associative array with 'singular', 'plural', and 'url' keys.
*/
public function getProjectType(NodeInterface $project_node): array {
$default_url = Url::fromUserInput('/project/' . $project_node->bundle());
switch ($project_node->bundle()) {
case 'project_general':
if (
is_array($project_node->get('field_project_composer_types')->value) &&
in_array('drupal-recipe', $project_node->get('field_project_composer_types')->value)
) {
return [
'singular' => t('Recipe'),
'plural' => t('Recipes'),
'url' => Url::fromUserInput('/browse/recipes'),
];
}
else {
return [
'singular' => t('General project'),
'plural' => t('General projects'),
'url' => $default_url,
];
}
case 'project_module':
return [
'singular' => 'Module',
'plural' => 'Modules',
'url' => $default_url,
];
case 'project_theme':
return [
'singular' => 'Theme',
'plural' => 'Themes',
'url' => $default_url,
];
case 'project_distribution':
return [
'singular' => 'Distribution',
'plural' => 'Distributions',
'url' => $default_url,
];
case 'project_theme_engine':
return [
'singular' => 'Theme engine',
'plural' => 'Theme engines',
'url' => $default_url,
];
case 'project_drupalorg':
return [
'singular' => 'Community project',
'plural' => 'Community projects',
'url' => $default_url,
];
case 'project_translation':
return [
'singular' => 'Translation',
'plural' => 'Translations',
'url' => $default_url,
];
case 'project_core':
return [
'singular' => 'Drupal Core',
'plural' => 'Drupal Cores',
'url' => $default_url,
];
}
throw new \Exception('The entity is not a project.');
}
}
