Как стать автором
Обновить

PHP: управляем комментариями

Уровень сложностиСредний

Существуют десятки инструментов для контроля качества исходного кода, но комментарии в коде зачастую остаются без должного внимания. Например, php-cs-fixer может добавлять лицензии в файлы, исправлять пробелы и следить, чтобы PHPDoc не содержал дублирующей информации. Однако этого далеко не всегда достаточно.

В моей практике возникло несколько кейсов, когда понадобился инструмент, способный делать больше. Расскажу о них ниже.


1. Управление техническим долгом

Будучи лидом на проекте, раз в два месяца мне нужно было составлять тикеты на ликвидацию технического долга. Большую часть проблем я, конечно, не помнил, поэтому открывал PHPStorm и через Shift+Shift искал все вхождения TODO и FIXME. Затем вручную добавлял их в тикет.

Такой процесс был неудобным:

  • Сложно было убедиться, что ничего не пропущено и не продублировано.

  • После закрытия тикета нужно было следить, чтобы соответствующие TODO и FIXME были убраны.

Вначале я написал простую утилиту, которая находила такие комментарии и выводила их списком в консоль. Это выглядело так:

пример списка
пример списка

Но этот процесс всё ещё требовал ручного труда для создания тикетов и проверки удаления комментариев. Захотелось автоматизировать этот процесс.

Подключение трекера задач

На проекте использовался YouTrack, и я не нашёл библиотек для работы с его API на PHP. Поэтому я реализовал возможность подключения плагинов, потому что по опыту моего использования нет смысла тащить такую частную логику в общий репозиторий.

Код плагина
<?php

namespace App\Main\Plugin;

use SavinMikhail\CommentsDensity\AnalyzeComments\Analyzer\DTO\Output\CommentDTO;
use SavinMikhail\CommentsDensity\AnalyzeComments\Analyzer\DTO\Output\Report;
use SavinMikhail\CommentsDensity\AnalyzeComments\Comments\FixMeComment;
use SavinMikhail\CommentsDensity\AnalyzeComments\Comments\TodoComment;
use SavinMikhail\CommentsDensity\AnalyzeComments\Config\DTO\Config;
use SavinMikhail\CommentsDensity\AnalyzeComments\File\FileEditor;
use SavinMikhail\CommentsDensity\Plugin\PluginInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class OpenIssue implements PluginInterface
{
    private const YOUTRACK_URL = 'https://yt';
    private const AUTHORIZATION_TOKEN = '';
    private const PROJECT_ID = '59-178';
    private const STAGE = 'Второй этап';
    private const BRANCH_NAME = 'develop';
    private const GITLAB_PROJECT_URL = 'https://gitlab/backend';

    public function handle(Report $report, Config $config): void
    {
        $httpClient = HttpClient::create();
        foreach ($report->comments as $comment) {
            if (!in_array($comment->commentType, [TodoComment::NAME, FixMeComment::NAME], true)) {
                continue;
            }

            if ($this->hasIssueUrl($comment->content)) {
                continue;
            }

            $draftId = $this->createDraft($httpClient, $comment);
            $issueId = $this->createIssueFromDraft($httpClient, $draftId);
            $issueUrl = self::YOUTRACK_URL . '/issue/' . $issueId;
            $newComment = new CommentDTO(
                commentType: $comment->commentType,
                commentTypeColor: $comment->commentTypeColor,
                file: $comment->file,
                line: $comment->line,
                content: rtrim($comment->content) . " ($issueUrl)",
            );
            (new FileEditor())->updateCommentInFile($newComment);
        }
    }

    private function buildDescription(CommentDTO $comment): string
    {
        $gitlabUrl = self::GITLAB_PROJECT_URL . '/-/blob/' . self::BRANCH_NAME . $comment->file . '?ref_type=heads#L' . $comment->line;
        return
            "**Comment**: $comment->content \n"
            . "**File**: $comment->file \n"
            . "**Line**: $comment->line \n"
            . "**Gitlab url**: $gitlabUrl \n";
    }

    private function buildSummary(): string
    {
        return 'Сфера > ' . self::STAGE . ' > Back > Техдолг';
    }

    private function createDraft(HttpClientInterface $httpClient, CommentDTO $comment): string
    {
        $response = $httpClient->request(
            'POST',
            self::YOUTRACK_URL . '/api/users/me/drafts?$top=-1&fields=$type,applicableActions(description,executing,id,name,userInputType),attachments($type,author(fullName,id,ringId),comment(id,visibility($type)),created,id,imageDimensions(height,width),issue(id,project(id,ringId),visibility($type)),mimeType,name,removed,size,thumbnailURL,url,visibility($type,implicitPermittedUsers($type,avatarUrl,banBadge,banned,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),permittedGroups($type,allUsersGroup,icon,id,name,ringId),permittedUsers($type,avatarUrl,banBadge,banned,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId))),canAddPublicComment,canUpdateVisibility,comments(attachments($type,author(fullName,id,ringId),comment(id,visibility($type)),created,id,imageDimensions(height,width),issue(id,project(id,ringId),visibility($type)),mimeType,name,removed,size,thumbnailURL,url,visibility($type,implicitPermittedUsers($type,avatarUrl,banBadge,banned,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),permittedGroups($type,allUsersGroup,icon,id,name,ringId),permittedUsers($type,avatarUrl,banBadge,banned,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId))),id),created,description,externalIssue(key,name,url),fields($type,hasStateMachine,id,isUpdatable,name,projectCustomField($type,bundle(id),canBeEmpty,emptyFieldText,field(fieldType(isMultiValue,valueType),id,localizedName,name,ordinal),id,isEstimation,isPublic,isSpentTime,ordinal,size),value($type,archived,avatarUrl,buildIntegration,buildLink,color(background,id),description,fullName,id,isResolved,localizedName,login,markdownText,minutes,name,presentation,ringId,text)),hasEmail,hiddenAttachmentsCount,id,idReadable,isDraft,links(direction,id,issuesSize,linkType(aggregation,directed,localizedName,localizedSourceToTarget,localizedTargetToSource,name,sourceToTarget,targetToSource,uid),trimmedIssues($type,comments($type),created,id,idReadable,isDraft,numberInProject,project(id,ringId),reporter(id),resolved,summary,voters(hasVote),votes,watchers(hasStar)),unresolvedIssuesSize),mentionedArticles(idReadable,summary),mentionedIssues(idReadable,resolved,summary),mentionedUsers($type,avatarUrl,banBadge,banned,canReadProfile,fullName,id,isLocked,login,name,ringId),messages,numberInProject,project($type,id,isDemo,leader(id),name,plugins(helpDeskSettings(enabled),timeTrackingSettings(enabled,estimate(field(id,name),id),timeSpent(field(id,name),id)),vcsIntegrationSettings(processors(enabled,migrationFailed,server(enabled,url),upsourceHubResourceKey,url))),ringId,shortName,team($type,allUsersGroup,icon,id,name,ringId)),reporter($type,avatarUrl,banBadge,banned,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),resolved,summary,tags(color(id),id,isUpdatable,isUsable,name,owner(id),query),updated,updater($type,avatarUrl,banBadge,banned,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),usesMarkdown,visibility($type,implicitPermittedUsers($type,avatarUrl,banBadge,banned,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId),permittedGroups($type,allUsersGroup,icon,id,name,ringId),permittedUsers($type,avatarUrl,banBadge,banned,email,fullName,id,isLocked,issueRelatedGroup(icon),login,name,online,profiles(general(trackOnlineStatus)),ringId)),voters(hasVote),votes,watchers(hasStar),widgets(base,indexPath,place,pluginName)',
            [
                'headers' => [
                    'Content-Type' => 'application/json',
                    'Authorization' => 'Bearer ' . self::AUTHORIZATION_TOKEN,
                ],
                'json' => [
                    'summary' => $this->buildSummary(),
                    'description' => $this->buildDescription($comment),
                    'project' => ['id' => self::PROJECT_ID],
                ],
            ]
        );
        $response = $response->toArray();
        return $response['id']; // looks like 68-107353
    }

    private function createIssueFromDraft(HttpClientInterface $httpClient, string $draftId): string
    {
        $response = $httpClient->request(
            'POST',
            self::YOUTRACK_URL . "/api/issues?draftId={$draftId}&\$top=-1&fields=id,idReadable,numberInProject,messages",
            [
                'headers' => [
                    'Content-Type' => 'application/json',
                    'Authorization' => 'Bearer ' . self::AUTHORIZATION_TOKEN,
                ],
                'json' =>  [
                    'issueId' => $draftId,
                ],
            ]
        );
        $response = $response->toArray();
        return $response['idReadable']; // looks like mossphere-287
    }

    private function hasIssueUrl(string $commentContent): bool
    {
        return (bool)preg_match('/https:\/\/yt\.kr\.digital\/issue\/\w+-\d+/', $commentContent);
    }
}

Пример автоматически созданного тикета:

Автоматически созданный тикет
Автоматически созданный тикет

Ссылка на тикет была добавлена к комментарию, что позволяет избежать дублирования задач и посмотреть кто и когда должен это исправить.

Обновление комментария
Обновление комментария

Можно было бы написать плагин который бы проверял каждый урл на предмет не завершена ли задача и удалять коммент, но мне показалось это оверхедом на данном этапе
Если ссылка на трекер уже есть в комментарии, то он игнорируется (новый тикет не создастся)

2. Борьба с бессмысленными комментариями

На одном проекте мне достался код с бессмысленными комментариями вроде:

Неизвестный разработчик, зачем!?
Неизвестный разработчик, зачем!?

Такие комментарии не только раздражали, но и мешали линтеру отформатировать код по стандарту PER-CS 2.0.

Для борьбы с этим я добавил поддержку регулярных комментариев (RegularComment) и написал плагин для обработки

Код плагина
<?php

namespace App\Main\Plugin;

use SavinMikhail\CommentsDensity\AnalyzeComments\Analyzer\DTO\Output\CommentDTO;
use SavinMikhail\CommentsDensity\AnalyzeComments\Analyzer\DTO\Output\Report;
use SavinMikhail\CommentsDensity\AnalyzeComments\Comments\RegularComment;
use SavinMikhail\CommentsDensity\AnalyzeComments\Config\DTO\Config;
use SavinMikhail\CommentsDensity\AnalyzeComments\File\FileEditor;
use SavinMikhail\CommentsDensity\Plugin\PluginInterface;

final readonly class RemoveRelaxComment implements PluginInterface
{
    public function handle(Report $report, Config $config): void
    {
        foreach ($report->comments as $comment) {
            if ($comment->commentType !== RegularComment::NAME) {
                continue;
            }

            if (!str_contains($comment->content, '// relax')) {
                continue;
            }

            $newComment = new CommentDTO(
                commentType: $comment->commentType,
                commentTypeColor: $comment->commentTypeColor,
                file: $comment->file,
                line: $comment->line,
                content: '',
            );
            (new FileEditor())->updateCommentInFile($newComment);
        }
    }
}

3. Отсутствие PHPDoc

На одном проекте стандарт оформления кода требовал обязательного наличия PHPDoc к каждому методу и классу. Я постоянно забывал про это, из-за чего мои МРы часто отклоняли. Чтобы упростить проверку, я добавил тип комментария MissingDocBlock, а контроль недостающей документации реализовал через механизм thresholds

4. Борьба с поясняющими комментариями

Комментарии, поясняющие код, зачастую являются признаком того, что сам код плохо читается. Хорошо написанный код должен быть самодокументируемым. Инструмент позволяет выявлять такие комментарии и предупреждать разработчиков.
Этого можно достигнуть установив лимит на количество regular комментариев, а те, что все-таки нужно оставить можно заигнорировав, добавив в baseline

Скрипт завершается с ошибкой, если лимит превышен
Скрипт завершается с ошибкой, если лимит превышен

5. Поиск закомментированного кода

В будущем я хочу научить инструмент находить закомментированный код. Такие строки часто забывают удалить, и они засоряют кодовую базу.

Заключение

Comments Density — это инструмент, который помогает:

  • Автоматизировать управление комментариями.

  • Сократить технический долг.

  • Поддерживать стандарты оформления кода.

Буду рад любой обратной связи :)

Ссылка на репозиторий: Comments Density.

Теги:
Хабы:
Данная статья не подлежит комментированию, поскольку её автор ещё не является полноправным участником сообщества. Вы сможете связаться с автором только после того, как он получит приглашение от кого-либо из участников сообщества. До этого момента его username будет скрыт псевдонимом.