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;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc