drupalorg-1.0.x-dev/src/Plugin/QueueWorker/DrupalOrgContributionActivityWebhookQueueWorker.php
src/Plugin/QueueWorker/DrupalOrgContributionActivityWebhookQueueWorker.php
<?php
namespace Drupal\drupalorg\Plugin\QueueWorker;
use Drupal\contribution_records\SourceLink;
use Drupal\Core\Link;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\Core\Session\AccountSwitcherInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\drupalorg\Traits\GitLabClientTrait;
use Drupal\drupalorg\UserService;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines 'drupalorg_contribution_activity_webhook_queue_worker' queue worker.
*
* Run via `drush` like this:
* `drush queue:run drupalorg_contribution_activity_webhook_queue_worker`.
*
* @QueueWorker(
* id = "drupalorg_contribution_activity_webhook_queue_worker",
* title = @Translation("Issue Activity Webhook Queue Worker")
* )
*/
class DrupalOrgContributionActivityWebhookQueueWorker extends QueueWorkerBase implements ContainerFactoryPluginInterface {
use StringTranslationTrait;
use GitLabClientTrait;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('logger.factory')->get('drupalorg'),
$container->get('drupalorg.user_service'),
$container->get('account_switcher')
);
}
/**
* {@inheritdoc}
*/
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
protected LoggerInterface $logger,
protected UserService $userService,
protected AccountSwitcherInterface $accountSwitcher,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public function processItem($data) {
if (!$this->validateItem($data)) {
return FALSE;
}
// All current webhooks events rely on this service, but the module is not
// a hard dependency, so just check if it's available.
if (!\Drupal::moduleHandler()->moduleExists('contribution_records')) {
return FALSE;
}
switch ($data['event_name']) {
case 'merge_request_update':
// We are not processing anything MR-related yet.
break;
case 'issue_update':
$this->processContributionUpdate($data);
break;
case 'drupalorg_issue_update':
$this->processDrupalOrgIssueUpdate($data);
break;
case 'drupalorg_issue_migrated':
$this->processDrupalOrgIssueMigrated($data);
break;
case 'comment_update':
case 'drupalorg_comment_update':
$this->processCommentUpdate($data);
break;
case 'emoji':
$this->processEmoji($data);
break;
}
}
/**
* Process the contribution update item from gitlab.
*
* @param array $data
* Data coming from the webhook.
*/
protected function processContributionUpdate(array $data) {
$source_link = $this->getSourceLink($data['url']);
if ($source_link->isValid()) {
$entity = $source_link->getLinkedEntity();
$action = $data['action'] ?? '-';
$markup = [];
// Detect automated comment and if not found, treat this as an "open"
// issue action to get the automated messages.
if (!$this->detectAutomatedNote($data, $source_link)) {
$data['action'] = 'open';
}
// Contribution records information.
if (
!empty($data['action']) &&
in_array($data['action'], ['open', 'close']) &&
!empty($data['url'])
) {
$markup[] = t('<b>@attribute_link</b> to help maintainers grant credit. You can edit your attribution at any time.', [
'@attribute_link' => Link::createFromRoute(t('Attribute your contribution'), 'contribution_records.process', [],
[
'query' => ['source_link' => $data['url']],
'absolute' => TRUE,
]
)->toString(),
]);
}
// Fork management information.
if (!empty($data['action']) && in_array($data['action'], ['open'])) {
// Forks management only makes sense if the source is an issue.
if (str_contains($data['url'], '/issues/')) {
$markup[] = t('<b>@issue_forks_management</b> from the issue management page. You will also find the git commands for contributing to this issue.', [
'@issue_forks_management' => Link::createFromRoute(t('Manage forks, branches, merge requests, and access'), 'drupalorg.issue_fork_management', [],
[
'query' => ['source_link' => $data['url']],
'absolute' => TRUE,
]
)->toString(),
]);
}
}
$markup = !empty($markup) ? implode('<br><br>' . PHP_EOL, $markup) : '';
// If the event is first creation, add the link to the description if
// it's not there already.
if ($action === 'open') {
$this->addNote($markup, $data, $source_link);
}
// If the event is to close, merge or reopen the contribution, add a
// comment with link and sync the contribution record.
elseif (in_array($action, ['close', 'merge', 'reopen'])) {
if ($entity) {
$source_link->syncContribRecord($entity);
}
$this->addNote($markup, $data, $source_link);
}
// On contribution update, just sync contributors.
elseif ($action === 'update') {
if ($entity) {
$source_link->syncContribRecord($entity);
}
$this->addNote($markup, $data, $source_link);
}
}
else {
$this->logger->error($this->t('Could not find a valid source for: @url // Error: @error', [
'@url' => $source_link->getLink(),
'@error' => $source_link->getError(),
]));
}
}
/**
* Process the emoji item.
*
* @param array $data
* Data coming from the webhook.
*/
protected function processEmoji(array $data) {
if (
$data['emoji'] === 'heavy_plus_sign' &&
(
// These are the two messages that can contain fork information.
str_contains($data['note'], $this->automatedNoteHeading($this->t('Fork created:'))) ||
str_contains($data['note'], $this->automatedNoteHeading($this->t('Fork information:')))
)
) {
$fork = $this->findFork($data['issue_link']);
if ($fork) {
$this->addUserToFork($fork['id'], $data['user_id']);
}
}
}
/**
* Process the comment update item.
*
* @param array $data
* Data coming from the webhook.
*/
protected function processCommentUpdate(array $data) {
$source_link = $this->getSourceLink($data['url']);
if (
$source_link->isValid() &&
(
// Only issue comments processed for now.
$source_link->getSourceType() === 'issue-gitlab' ||
$source_link->getSourceType() === 'issue-drupalorg'
)
) {
// Sync the contributors on every comment.
$entity = $source_link->getLinkedEntity();
if ($entity) {
$source_link->syncContribRecord($entity);
}
// We might allow some custom actions in comments.
if (
$source_link->getSourceType() === 'issue-gitlab' &&
!empty($data['comment']) &&
str_starts_with($data['comment'], '/do:') &&
!empty($data['user']['username'])
) {
// This only happens in GitLab issues.
// Extra project and issue information.
[
'project' => $project_id,
'issue_iid' => $issue_iid,
'error' => $error,
] = $this->getProjectAndIssueIdFromUrl($source_link->getLink());
$gitlab_user = $data['user'];
$gitlab_user_id = $gitlab_user['id'];
$drupal_user = $this->userService->getUserByGitUsername($gitlab_user['username']);
// Make sure we have a matching user to do the requests as that user.
if ($drupal_user) {
$comment = trim($data['comment']);
$comment_id = $data['comment_id'] ?? NULL;
$client = $this->getGitLabClient();
switch ($comment) {
case '/do:fork':
$fork = $this->findFork($source_link->getLink());
if (empty($fork)) {
$fork = $this->createFork($source_link->getLink(), $gitlab_user_id);
$message = $this->t("A [fork](@fork) was created for this issue", [
'@fork' => $fork['web_url'],
]);
}
else {
// Fork already exist, this message might be verbose.
$message = $this->t("A [fork](@fork) already exists for this issue", [
'@fork' => $fork['web_url'],
]);
}
$body = $this->automatedNoteHeading($this->t('Fork information:')) . $this->t("@message. Go to the @link page for this issue to find the git commands to checkout a branch on this fork. React to this message with :heavy_plus_sign: to gain access.", [
'@message' => $message,
'@link' => Link::createFromRoute(t('fork management'), 'drupalorg.issue_fork_management', [],
[
'query' => ['source_link' => $source_link->getLink()],
'absolute' => TRUE,
]
)->toString(),
]);
if ($comment_id) {
$client->issues()->updateNote($project_id, $issue_iid, $comment_id, $body);
}
else {
$client->issues()->addNote($project_id, $issue_iid, $body);
}
// We are not creating a branch as we can't make assumptions
// about it. They can do it in the fork management page.
break;
case '/do:access':
$fork = $this->findFork($source_link->getLink());
if (!empty($fork) && $this->addUserToFork($fork['id'], $gitlab_user_id)) {
if ($comment_id) {
$client->issues()->updateNote(
$project_id,
$issue_iid,
$comment_id,
$this->t('*Access to the fork granted.*')
);
}
}
else {
if ($comment_id) {
$client->issues()->updateNote(
$project_id,
$issue_iid,
$comment_id,
empty($fork) ? $this->t('*Access denied: no fork exist yet.*') : $this->t('*Access denied: try again later.*')
);
}
}
break;
}
// Other more-complex possibilities:
// /do:credit [@username|@git_username|email,...]
// /do:attribute volunteer and "Lullabot" and "Drupal Association".
}
}
}
}
/**
* Validate item array to make sure all key elements are there.
*
* @param array $data
* Item to validate.
*
* @return bool
* Whether the item was valid or not.
*/
protected function validateItem(array $data) {
if (empty($data['event_name'])) {
return FALSE;
}
$allowed_events = [
'emoji',
'issue_update',
'comment_update',
'merge_request_update',
'drupalorg_issue_update',
'drupalorg_comment_update',
'drupalorg_issue_migrated',
];
if (!in_array($data['event_name'], $allowed_events)) {
return FALSE;
}
// GitLab events.
if (in_array($data['event_name'], [
'issue_update',
'comment_update',
'merge_request_update',
])) {
if (
empty($data['iid']) ||
empty($data['project_id']) ||
empty($data['url'])
) {
return FALSE;
}
}
// Drupal.org events.
elseif (in_array($data['event_name'], [
'drupalorg_issue_update',
'drupalorg_comment_update',
])) {
if (
empty($data['url'])
) {
return FALSE;
}
}
// Issue migration.
elseif (in_array($data['event_name'], [
'drupalorg_issue_migrated',
])) {
if (
empty($data['url']) ||
empty($data['new_url'])
) {
return FALSE;
}
}
// Emoji.
elseif (in_array($data['event_name'], [
'emoji',
])) {
if (
empty($data['emoji']) ||
empty($data['user_id']) ||
empty($data['issue_link']) ||
empty($data['note'])
) {
return FALSE;
}
}
return TRUE;
}
/**
* Process an issue update event from drupal.org.
*
* The logic to add first comment and last depending on the issue status
* actually happens on D7 www.drupal.org itself, so we mostly update the
* draft flag and sync contributors here.
*
* @param array $data
* Data from the webhook.
*/
protected function processDrupalOrgIssueUpdate(array $data) {
$source_link = $this->getSourceLink($data['url']);
if ($source_link->isValid()) {
$entity = $source_link->getLinkedEntity();
if ($entity) {
$action = $data['action'] ?? '-';
if (in_array($action, ['close', 'update', 'reopen'])) {
$source_link->syncContribRecord($entity);
}
}
else {
// No entity created yet, go ahead and create it then regardless of
// the action. It will also sync contributors on save.
$source_link->createContribRecord();
}
}
}
/**
* Process an issue migrated event from drupal.org.
*
* We will need to update the link of the contribution record.
*
* @param array $data
* Data from the webhook.
*/
protected function processDrupalOrgIssueMigrated(array $data) {
$source_link = $this->getSourceLink($data['url']);
$entity = $source_link?->getLinkedEntity();
if ($entity) {
$result = $source_link->updateSourceLink($entity, $data['new_url']);
if ($result) {
$this->logger->info($this->t('Contribution Record for @url is now linked to @new_url', [
'@url' => $data['url'],
'@new_url' => $data['new_url'],
]));
}
else {
$this->logger->error($this->t('Could not link the existing Contribution Record for @url to @new_url', [
'@url' => $data['url'],
'@new_url' => $data['new_url'],
]));
}
}
else {
$this->logger->error($this->t('Could not find an existing Contribution Record for: @url', [
'@url' => $data['url'],
]));
}
}
/**
* Returns the source_link service already setup with the link in the array.
*
* @param string $url
* Url for the source link.
*
* @return \Drupal\contribution_records\SourceLink
* SourceLink object for the given link.
*/
protected function getSourceLink(string $url) {
/** @var \Drupal\contribution_records\SourceLink $source_link_instance */
$source_link_instance = \Drupal::classResolver(SourceLink::class);
return $source_link_instance->setLink($url);
}
/**
* Adds a note to an issue from the given markup.
*
* @param string $markup
* Markup of the note.
* @param array $data
* Array of data containing the project_id and iid.
* @param \Drupal\contribution_records\SourceLink $source_link
* Source link object.
*/
protected function addNote(string $markup, array $data, SourceLink $source_link) {
if (!empty($markup)) {
$markup = $this->automatedNoteHeading($this->t('Issue tools:')) . $markup;
// For now, we only consider issues and merge requests.
$method = ($source_link->getSourceType() === 'issue-gitlab') ? 'issues' : 'mergeRequests';
$this->getGitLabClient()->{$method}()->addNote($data['project_id'], $data['iid'], $markup);
}
}
/**
* Detects whether the automated note was added to an issue or not.
*
* Useful in case webhooks were down, and they are back up. It will only
* check the first page of notes.
*
* @param array $data
* Array of data containing the project_id and iid.
* @param \Drupal\contribution_records\SourceLink $source_link
* Source link object.
*
* @return bool
* Whether the automated message was found within the issue.
*/
protected function detectAutomatedNote($data, SourceLink $source_link) {
$string_to_find = $this->hiddenComment();
$detected = FALSE;
// For now, we only consider issues and merge requests.
$method = ($source_link->getSourceType() === 'issue-gitlab') ? 'issues' : 'mergeRequests';
// Migrated issues have information for forks and contribution records.
if ($method === 'issues') {
$issue = $this->getGitLabClient()->issues()->show($data['project_id'], $data['iid']);
$description = $issue['description'] ?? '';
$detected = (str_contains($description, $string_to_find));
}
if (!$detected) {
$notes = $this->getGitLabClient()->{$method}()->showNotes($data['project_id'], $data['iid']) ?? [];
foreach ($notes as $note) {
$body = $note['body'];
if (str_contains($body, $string_to_find)) {
$detected = TRUE;
}
}
}
return $detected;
}
}
