В этой статье расскажу, как мне удалось «влезть» в плагин GitHub Copilot, обойти ограничения проприетарного инструмента и научить его помогать писать unit-тесты буквально в пару кликов. Думаю это будет полезно тем, кто хочет узнать как влезать в работу сторонних плагинов, ускорить написание тестов и тем, кто ищет способы прокачать работу с ИИ-помощниками в своих проектах.

По всем правилам приличия представлюсь — меня зовут Перевалов Данил. Начнём с предыстории, ведь все мы любим предыстории…

Мы, как и многие компании на рынке, решили, что надо попробовать применять ИИ-помощников в разработке и, если получится, за счёт этого снизить стоимость и время разработки. 

Нюанс работы ИИ-помощников заключается в том, что это не строгий алгоритм, и один и тот же запрос может выдавать немного (а иногда и «много») разный результат. К тому же правильность этого результата совершенно не гарантирована. К сожалению, или к счастью, нельзя дать ИИ-помощнику Swagger-схему и дизайн, а он бы сгенерировал полностью рабочий код фичи с учётом всех нюансов и тонкостей существующего проекта. Это всё, на данный момент, сильно сужает область применимости ИИ-помощников в разработке, что является проблемой. 

Но нам «повезло» и у нас в Android команде была ещё одна проблема — написание unit-тестов отъедает достаточно много времени. В теории, объединение этих двух проблем могло бы дать интересные результаты. Соответственно, а почему бы не генерировать unit-тесты с помощью нейросетей? Допустим нейросеть сгенерирует что-то не так. Ошибка в тестах хоть и является неприятной, но далеко не столь критичной, как в основном коде.

А почему GitHub Copilot?

Так как Android-разработчики творят свою «чёрную магию» в Android Studio, которая является форком IDEA, то изначальный выбор ИИ-помощника был в основном между Gemini и GitHub Copilot. Ведь оба помощника имеют плагин для интеграции с IntelliJ IDEA, что сильно упростит жизнь разработчика. По крайней мере, в теории. 

Как вы могли понять из названия статьи, наш окончательный выбор пал на GitHub Copilot. Во-первых он позволяет выбирать ИИ-помощника который будет генерировать ответ. Как я понимаю, в зависимости от уровня подписки будут доступны разные ИИ-помощники, в платном варианте станет доступен, в том числе, и Gemini. К тому же, в нашей компании, он хорошо зарекомендовал себя в Web-разработке.

Во-вторых, GitHub Copilot, так сказать, «от разработчиков для разработчиков». Это даёт некую надежду, что его делают люди, понимающие проблемы разработчиков.

Согласитесь, звучит недурно. Поэтому: качаем плагин, авторизуемся и можно приступать. В качестве помощника я выбрал GPT-4o. Потому что, ну… Он был выбран по умолчанию и кто я такой, чтобы спорить с умными дядями из GitHub, правильно? Правильно. (Забегая сильно вперёд — использование моделей от Claude всё же предпочтительнее в случае сложного кода. GPT-4o обычно справляется быстрее и его хватает для большинства ситуаций, но в случае со сложным кодом модели от Claude косячат сильно реже.)

Если кто-то не видел, как выглядит встроенный в плагин чат, то выглядит он как-то так:

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

Пробуем генерировать Unit-тесты

Как любая долгая дорога начинается с фразы «посидим на дорожку», так и работа с ИИ-помощниками начинается с промпта. В теории, у Copilot есть отдельная команда /tests, но она не особо учитывает особенности проекта и его Code Style. Поэтому, было принято волевое решение — написать промпт самому. Промпт-инженер из меня так себе, да и англичанин тоже, но я смог выдать это:

Write tests.
Follow these rules:

Test all public methods.
Cover all normal, edge, and corner cases.
Each test should cover only one case.
Use backtick-style method names in the format: should <method name> with <result>.
Include parameter names in test method names when helpful.
Structure each test using the logical pattern: Given (Arrange), When (Act), Then (Assert). Separate each block with a blank line.
Declare all variables in the Arrange section (even if used later).
Use expected and actual variable names for result comparison.
Declare the expected variable at the very beginning of the test method without separating it with blank lines from other initializations.
Use the MockK library for mocking.
Use the @MockDispatchersAndSchedulers annotation if the code uses Dispatchers or Schedulers.
If the code uses Kotlin Coroutines or Kotlin Flow, wrap the test in runTest.
Limit each line to 120 characters.
Use verify(exactly = <n>) to check the number of invocations.
Add // FIXME: comments only if the code has known or suspected issues.
Do not label the blocks with comments (e.g., // Arrange).
Do not add any comments inside test method bodies.

Чтобы не раздувать промпт до огромных размеров я воспользовался такой полезной функцией Copilot, как добавление Reference. По сути, это файл который будет передан ИИ-помощнику вместе с вашим промптом. Это позволяет не копипастить постоянно код, а просто прикреплять файлы. Добавляем файл, просим написать тест на конкретный метод. Удобно? Удобно! Если ничего не написать про прикреплённый файл, то он будет использован как дополнительный контекст. В идеале, в нашем случае, это должен быть файл с эталонными тестами. Это позволяет не описывать в промпте ну уж совсем неважные детали. Правда таких файлов может быть всего 5.

Дальше я начал путешествовать по проекту и заставлял Copilot генерировать для меня unit-тесты на всё подряд. И я пришёл к выводу, что в целом это работает, хоть и с нюансами. Нюансы могут быть следующими:

  • Недостаточное покрытие. Что и в целом «Ну и ладно», часть кода он покрыл, а значит сократил время работы разработчика.

  • Сгенерированный тест не проходит. Это уже чуть более неприятно, но у кого всегда с первого раза проходят написанные unit-тесты? Пусть первым бросит в монитор камень. Править тесты зачастую быстрее, чем писать с нуля, а значит это всё ещё сократит время работы разработчика.

  • Сopilot сгенерировал белиберду. Согласен, «Ну такое». Тем не менее, обычно белиберда генерируется только в теле тестового метода, а значит можно скопировать код тестового класса с моками и готовыми тест-кейсами и написать тело самому. Тут конечно выгода уже так себе, но сколько-то времени это сократит.

  • Тест, на самом деле, ничего не проверяет. Бывают ситуации, когда с точки зрения семантики тест абсолютно корректен. Вроде всё разбито на этапы: подготовка, действия и проверки. Но если присмотреться, то, например, тест может проверять, что не был брошен Exception, который этим методом не может быть брошен в принципе. И вот как бы тест есть — а смысла в нём нет. Самое обидное, что такой тест может «убеждать» автоматические проверки на покрытие, что метод покрыт, но на деле тест ничего и не проверил. Это действительно спорный пункт. Но в какой-то степени это закрывается Code Review. Так что можно попробовать закрыть на это глаза.

В большинстве случаев ошибки происходили, если код, на который нужно написать тесты, либо слишком большой по объёму, либо слишком сложный с точки зрения ветвлений (if, when, switch и им подобные). Я не особо разбираюсь в ИИ-помощниках, но могу предположить, что это из-за пресловутого ограничения контекста, и ему просто не хватает памяти/контекста, чтобы уместить весь код и все ветвления у себя в «голове». Из-за этого же в классах с большим количеством кода приходилось последовательно указывать имена методов, на которые я хочу получить тест, а не просить сгенерировать тест сразу на весь файл. 

Правда, пока я бегал по проекту и баловался генерацией тестов, я понял, что это крайне неудобно. Постоянно переключаться, писать промпт, вручную выделять метод — слишком много лишних движений.

Комфортабельность бытия

В общем и целом, генерация одного теста выглядела так: 

  1. Создаём новый чат. Это то, что рекомендуется в документации Copilot. Опять же, видимо, из-за того, что контекст весьма ограничен и если вести диалог слишком долго, то он начинает путаться, забывать, что ему говорили, или вообще начинает нести полную чушь. 

  2. Открываем нужный файл. Если вы ещё ни разу не открывали файл за сессию IDEA, то он, конечно, отобразится прикрепленным в интерфейсе GitHub Copilot, но в реальности может не отправиться. Это особенности Virtual File системы IDEA.

  3. Вызов контекстного меню -> GitHub Copilot -> Reference File In Chat.

  4. Копируем промпт из приложения заметок и вставляем в окошко чата.

  5. Если класс большой, то дописываем имя метода, на который надо сгенерировать тесты.

  6. Добавляем как Reference файл с эталонными тестами для дополнительного контекста.

  7. Ждём.

  8. Копируем сгенерированный код и вставляем в проект.

  9. Если класс большой, то повторяем шаги для каждого из методов.

И вот как-то не очень удобно. Наверное, сходу звучит не страшно, но когда повторяешь раз 50 подряд, то начинаешь сильно сомневаться, что «от разработчиков для разработчиков» — это преимущество, а не сарказм.

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

  • Где хранить эти промпты? Просто передавать и хранить их в чатиках — вариант не очень. Можно завести отдельный документ с промптами, но кто и когда их будет обновлять? Да и в целом —  постоянно переключаться между вкладками или окнами, чтобы скопировать промпт, не очень удобно. 

  • Стоит ли прикреплять все дополнительные Reference файлы каждый раз? Он пока один, и всё не так страшно, но я вполне могу представить, что их со временем станет больше. Прикреплять их по одному достаточно мучительно для психики.

Хочется просто взять, выделить метод, нажать кнопку «сгенерируй тесты», и тесты просто генерируются. Ну или если файл небольшой, то, опять же, через контекстное меню файла нажать «сгенерируй тесты», и они, опять же, генерируются.

Что по этому поводу предлагает Copilot?

Само собой, сходу пилить своё решение — идея гиблая. Посему стоит посмотреть, что может предложить сам Copilot, для облегчения работы с ним. После недолгих поисков я накопал следующее:

  • «Кастомные» инструкции. GitHub Copilot имеет возможность задавать дополнительный контекст проекта для всех вопросов в чат и команд. Это крайне полезная функция, но, к сожалению, не совсем то, что нам нужно.

  • «Кастомные» команды. В GitHub Copilot есть заранее прописанные сокращённые команды, которые под капотом содержат длинные промпты. Например, /tests, /docs и т.п. Поэтому я попробовал поискать способ добавлять свои команды, чтобы разработчикам не пришлось постоянно держать открытым список промптов, копируя нужные. К сожалению, такой возможности нет, и GitHub не планируют добавлять такую возможность.  

  • Copilot для CLI. У GitHub Copilot есть отдельный инструмент для терминала. Поэтому возникла мысль сделать обёртку над командами терминала, которые под капотом уже имели бы кастомный промпт с дополнительными возможностями. К сожалению, это оказался весьма закрытый инструмент, который позволяет, разве что, объяснять работу команд терминала на bash и подсказывать другие команды для него.

  • Copilot API. У GitHub Copilot есть свой API. Поэтому были мысли просто делать запросы напрямую через него, что также позволило бы упростить работу с повседневными промптами. К сожалению, в этом API есть только аналитические запросы по статистике использования и т.п. Без возможности что-то генерировать. Хотя, наверное, в этом бы и не было смысла. Ведь тогда уж проще сделать свои запросы в нужный ИИ-помощник напрямую.

Сначала я даже немного подрасстроился. GitHub Copilot оказался достаточно закрытой экосистемой, которую очень сложно модифицировать и расширять. Но IntelliJ IDEA имеет другой подход, обязывающий разработчиков плагинов быть максимально открытыми. Стоит попробовать «зайти через эту дверь».

Исследуем возможности IDEA плагина Copilot

В IntelliJ IDEA есть возможность подключить любой сторонний плагин к своему плагину как зависимость. Это позволяет получать доступ к коду подключенного плагина, например, GitHub Copilot, и вызывать его методы как у обычной библиотеки. Делается это достаточно просто.

В build.gradle.kts добавляем код:

dependencies {
    intellijPlatform {
        …
        plugins("com.github.copilot:$copilotVersion")
    } 
    …
}

В plugin.xml добавляем:

<depends>com.github.copilot</depends>

И вуаля! Мы можем вызывать код плагина GitHub Copilot. Правда, на момент, когда я начал исследовать код плагина Copilot, был один нюанс… Я не знал о возможности подключения плагинов друг к другу. Поэтому я по-спартански установил плагин GitHub Copilot, нашёл его jar в папке с Android Studio и пытался исследовать декомпилированный код в этом jar. Но вы главное, не будьте мной и сразу подключайте плагин через зависимость. Это сильно экономит нервы.

В Android-команде ЦИАН уже есть собственный плагин для Android Studio, поэтому подключаем код плагина GitHub Copilot к нему. Также есть надежда, что в дальнейшем это упростит уход от GitHub Copilot, если в этом будет нужда, так как часть работы делает наш плагин.

Итак, основная задумка такова — как-то влезть в работу GitHub Copilot плагина, понять, как он работает, и уже отталкиваясь от этого, добавить свои функции. Чтобы с чего-то начать, я выделил для себя ключевые функции, которые мне были интересны:

  • Создание нового чата. 

  • Добавление Reference файла. 

  • Отправка промпта.

Не буду утомлять вас описанием процесса копания в чужом коде (хотя хотелось бы) и сразу примерно опишу логику работы плагина GitHub Copilot. 

В плагине есть Singleton, который отвечает за текущую сессию, в которой и происходят все действия. Чтобы общаться с этим Singleton, у плагина есть набор Listener, от которых он унаследован и зарегистрирован в MessageBus. У каждого Listener есть object, который пошлёт сообщение в MessageBus. Action, которые мы вызываем в рамках IDEA при кликах на менюшки, как раз вызывают эти самые Listener. Хотя, думаю, просто по текстовому описанию это звучит не очень понятно. «Со следующей фразы, как правило, начинаются отношения длиною в жизнь, поэтому держите себя в руках, но “Давайте я нарисую вам схему”».

И вот тут, на этапе экспериментов с плагином, я обнаружил две интересные вещи. Одна, как положено, плохая, другая хорошая. Начнём с хорошей, будем оптимистами.

Хорошая интересная вещь

У плагина Copilot, если запустить его в Sandbox-версии IDEA открываются debug возможности, о чём сам плагин нам любезно сообщает.

Если нажать на «Open Debug Server», то мы попадём на интересную, но крайне уродливую веб-страницу, на которой можно поглядеть на общение плагина с сервером, включая логи.

Я сходу не нашёл её полезной, но она явно может пригодиться в анализе потенциальных проблем. Зато я нашёл интересными дополнительные debug-команды в чате Copilot.

В особенности меня заинтересовала /debug.promt которая позволяет посмотреть «сырой» промпт, который Copilot отсылает непосредственно ИИ-помощнику.

Плохая интересная вещь

Самым неприятным открытием стало то, что добавление Reference файла не всегда работает хорошо. Иногда добавляется, а иногда нет. Самое смешное, что когда я начинал эксперименты — всё было отлично. Но увы, потом плагин обновился, и это стало работать как-то случайно. Мне это, честно говоря, не понравилось. Поэтому я принял волевое решение — поменять работу с Reference файлами.

Вместо использования встроенной в плагин функции прикрепления Reference файлов я воспользовался вышеописанной командой /debug.promt и посмотрел, в каком виде Copilot отправляет Reference файлы ИИ-помощнику. Оказалось всё просто: он добавляет ключевые фразы и символы перед и после текста файла.

Consider the additional context:
Code excerpt from file <путь к файлу>:
```<язык на котором написан файл>
<текст файла>
```

Я решил, что и сам могу считать текст файла и добавить к нему пару строк. Это будет проще, чем пытаться решить проблему со встроенной в плагин функцией. Правда из-за этого можно наткнуться на ограничения Copilot на размер промпта. Так что, надеюсь, у вас в проекте нет огромных файлов.

Обёртка

На основе этих знаний я создал object-обёртку, которая позволила мне общаться с плагином GitHub Copilot.

object CopilotPluginWrapper {

    fun createNewChat(project: Project) {
        ChatSessionCreateRequestListener.onSessionCreateRequest(project)
    }

    fun sendPrompt(project: Project, prompt: String) {
        EntryPointListener.onEntryPoint(prompt, project)
    }
}

Названия методов в обёртке говорят сами за себя, но на всякий случай поясню:

  1. createNewChat — создаёт новый чат внутри Copilot.

  2. sendPrompt — отправляет сообщение с заданным промптом.

Как общаться с Copilot мы поняли. Настало время решить, а что мы вообще собираемся делать?

Расширяем возможности GitHub Copilot для IDEA

Наша основная задача — сделать так, чтобы генерация тестов была в 1-2 клика. Иначе это неудобно. Поэтому общая задумка такая — при вызове контекстного меню для выбранного метода или файла мы показываем пункт меню со списком заранее подготовленных и обкатанных промптов. Разработчику достаточно кликнуть на нужный промпт, чтобы всё заработало.

Для начала стоит определиться где и в каком формате будут храниться промпты. Я выбрал формат JSON такого вида:

@Serializable
data class CopilotCustomActionModel(
    @SerializedName("name")
    val name: String, // Имя prompt’а в интерфейсе IDEA
    @SerializedName("description")
    val description: String, // Описание
    @SerializedName("filePrompt")
    val filePrompt: String, // Префикс для prompt’а если генерируем тесты на файл
    @SerializedName("prompt")
    val prompt: String, // Наш prompt
    @SerializedName("additionalFiles")
    val additionalFiles: List<String> = emptyList() // Список Reference файлов
)

В целом, тут всё понятно. Разве что, позволю себе пару уточнений — Reference файлы передаются как путь до файла относительно root проекта, а также filePrompt нужен, чтобы явно указать Copilot на какой файл нужны тесты. В случае с тестами текст такой "Write tests for file {file}".

Храниться эти промпты будут прямо в репозитории проекта. В нашем случае в папке .cian/prompts. 

Теперь нам нужно создать два AnAction, один для чудовищ, другой для людей выбора промпта из списка, и другой для выполнения этого промпта: CopilotFunActionGroup и CopilotFunCustomAction. Начнём с первого. 

CopilotFunActionGroup

Для начала, нам было бы неплохо понять: «А на какой метод мы хотим генерировать тесты?». Очевидным ответом является: над которым вызвали контекстное меню, для генерации тестов. Поэтому нам нужно найти имя текущего метода. Благо, у IDEA для этого есть система PSI-файлов. Достаточно из пришедшего AnActionEvent достать выделенный элемент, а затем найти в его родителях первого, кто является функцией — KtFunctionElementType. Родителя мы ищем исключительно для удобства, чтобы при открытии контекстного меню можно было кликнуть в любом месте тела метода, а не целиться в имя функции.

private fun getMethodName(event: AnActionEvent): String? {
    // Достаём выделенный элемент 
    val file = event.getData(CommonDataKeys.PSI_FILE)!!
    val editor = event.getData(CommonDataKeys.EDITOR)!!
    val element = file.findElementAt(editor.selectionModel.leadSelectionOffset)
    // Получаем родительскую функцию
    val method = PsiTreeUtil.findFirstParent(element, false) { 
        it.elementType is KtFunctionElementType 
    }
    // Получаем её имя
    val methodName = (method as? KtNamedFunction)?.name
    return methodName
}

Теперь надо получить список CopilotFunCustomAction, которые можно выполнить. Пока это только генерация Unit-тестов, но в дальнейшем может быть как, например, генерация документации, так и рефакторинг классов по определённым правилам, например, генерация ViewModel на основе Presenter. Для этого просто обращаемся к нашей папке с промптами — .cian/prompts. Считываем оттуда все файлы и декодируем их из JSON-формата в объект CopilotCustomActionModel. Ну и передаём CopilotCustomActionModel в свежесозданный CopilotFunCustomAction.

private fun getCustomActions(
    promptsDir: File, 
    methodName: String
): Array<AnAction> {
    // Считываем файлы
    return promptsDir.listFiles()!!
        .map { it.readLines().joinToString("\n") }
        // Декодируем JSON
        .map { Json.decodeFromString<CopilotCustomActionModel>(it) }
        // Создаём CopilotFunCustomAction
        .map { CopilotFunCustomAction(it, methodName) }
        .toTypedArray()
}

Теперь в методе update вызываем методы getMethodName и getCustomActions и в поле children записываем получившиеся CopilotFunCustomAction.

override fun update(event: AnActionEvent) {
    val methodName = getMethodName(event)
    val promptsDir = Path(event.project!!.basePath!!, DIR_PROMPTS).toFile()
    children = if (promptsDir.isExistDirectory() && methodName != null) {
        getCustomActions(promptsDir, methodName)
    } else {
        emptyArray<AnAction>()
    }
    event.presentation.isEnabled = children.isNotEmpty()
}

Тут может возникнуть мысль, что как-то жирновато считывать все файлы из папки при каждом update, то есть вызове контекстного меню. Почему бы не закешировать их в каком-нибудь поле? Вполне может быть, что так стоит сделать. Считывание при каждом update удобно тем, что можно в реальном времени менять промпты и изменения сразу применяются. Да и десяток файлов с промптами он считывает мгновенно и проблем не возникает. С другой стороны, если их будет сильно больше — уже стоит задуматься. Я вот решил считывать каждый раз и вполне готов к вашему справедливому осуждению (но не сильному, я так-то не железный).

Если интересно. итоговый файл с кодом выглядит так.
class CopilotFunActionGroup : DefaultActionGroup() {

    private var children: Array<AnAction> = emptyArray<AnAction>()

    override fun getChildren(event: AnActionEvent?): Array<AnAction> {
        return children
    }

    override fun getActionUpdateThread(): ActionUpdateThread {
        return ActionUpdateThread.BGT
    }

    override fun update(event: AnActionEvent) {
        val methodName = getMethodName(event)

        val promptsDir = Path(event.project!!.basePath!!, DIR_PROMPTS).toFile()
        children = if (promptsDir.isExistDirectory && methodName != null) {
            getCustomActions(promptsDir, methodName)
        } else {
            emptyArray<AnAction>()
        }

        event.presentation.isEnabled = children.isNotEmpty()
    }

    private fun getMethodName(event: AnActionEvent): String? {
        val file = event.getData(CommonDataKeys.PSI_FILE)!!
        val editor = event.getData(CommonDataKeys.EDITOR)!!
        val element = file.findElementAt(editor.selectionModel.leadSelectionOffset)
        val method = PsiTreeUtil.findFirstParent(element, false) { 
            it.elementType is KtFunctionElementType 
        }
        val methodName = (method as? KtNamedFunction)?.name
        return methodName
    }

    private fun getCustomActions(
        promptsDir: File, 
        methodName: String
    ): Array<AnAction> {
        return promptsDir.listFiles()!!
            .map { it.readLines().joinToString("\n") }
            .map { Json.decodeFromString<CopilotCustomActionModel>(it) }
            .map { CopilotFunCustomAction(it, methodName) }
            .toTypedArray()
    }
}

CopilotFunCustomAction

Теперь нам надо выполнить непосредственно действия, связанные с Copilot. Для начала формируем промпт для Reference файлов. Тут всё просто: считываем файл и добавляем ключевые фразы до и после текста файла.

private fun createAdditionalFilePrompt(project: Project): String {
    return model.additionalFiles.joinToString("\n") { filePath ->
        val file = Path(project.basePath!!, filePath).toFile()
        buildString {
            appendLine("Consider the additional context:")
            appendLine("Code excerpt from file $filePath:")
            appendLine("```kotlin")
            file.readLines().forEach { appendLine(it) }
            appendLine("```")
            appendLine("\n")
        }
    }
}

Похожим образом формируем промпт для текущего файла.

private fun createSelectedFilePrompt(selectedFile: VirtualFile): String {
    return buildString {
        appendLine("Target file:")
        appendLine("Code excerpt from file ${selectedFile.path}:")
        appendLine("```kotlin")
        selectedFile.toNioPath().toFile().readLines().forEach { appendLine(it) }
        appendLine("```")
        appendLine("\n\n")
    }
}

Ну и осталось собрать всё вышеописанное в методе actionPerformed, а именно: открыть окно с Copilot, создать новый чат, прикрепить Reference файлы и текущий открытый файл, сформировать из этого итоговый промпт и, наконец, отправить его.

override fun actionPerformed(event: AnActionEvent) {
   val project = event.project!!
   // Открываем окно GitHub Copilot Chat
   getInstance(project).getToolWindow("GitHub Copilot Chat")?.show()
   // Создаем новый чат
   CopilotPluginWrapper.createNewChat(project)

   // Прикрепляем Reference файлы
   val additionalFilesPrompt = createAdditionalFilePrompt(project)

   // Прикрепляем текущий открытый файл
   val selectedFile = event.getData(CommonDataKeys.VIRTUAL_FILE)!!
   val selectedFilePrompt = createSelectedFilePrompt(selectedFile)

   // Формируем итоговый prompt
   val prompt = buildString {
       appendLine(selectedFilePrompt)
       appendLine(additionalFilesPrompt)
       append(model.filePrompt.replace("{file}", selectedFile.name))
       appendLine(" for method $methodName")
       appendLine(model.prompt)
   }

   // Отправляем prompt
   CopilotPluginWrapper.sendPrompt(project, prompt)
}
Итоговый файл.
class CopilotFunCustomAction(
   val model: CopilotCustomActionModel,
   private val methodName: String
) : AnAction(model.name, model.description, null) {

   override fun actionPerformed(event: AnActionEvent) {
       val project = event.project!!
       // Открываем окно GitHub Copilot Chat
       getInstance(project).getToolWindow("GitHub Copilot Chat")?.show()
       // Создаем новый чат
       CopilotPluginWrapper.createNewChat(project)

       // Прикрепляем Reference файлы
       val additionalFilesPrompt = createAdditionalFilePrompt(project)

       // Прикрепляем текущий открытый файл
       val selectedFile = event.getData(CommonDataKeys.VIRTUAL_FILE)!!
       val selectedFilePrompt = createSelectedFilePrompt(selectedFile)

       // Формируем итоговый prompt
       val prompt = buildString {
           appendLine(selectedFilePrompt)
           appendLine(additionalFilesPrompt)
           append(model.filePrompt.replace("{file}", selectedFile.name))
           appendLine(" for method $methodName")
           appendLine(model.prompt)
       }

       // Отправляем prompt
       CopilotPluginWrapper.sendPrompt(project, prompt)
   }

   private fun createAdditionalFilePrompt(project: Project): String {
       return model.additionalFiles.joinToString("\n") { filePath ->
           val file = Path(project.basePath!!, filePath).toFile()
           buildString {
               appendLine("Consider the additional context:")
               appendLine("Code excerpt from file $filePath:")
               appendLine("```kotlin")
               file.readLines().forEach { appendLine(it) }
               appendLine("```")
               appendLine("\n")
           }
       }
   }

   private fun createSelectedFilePrompt(selectedFile: VirtualFile): String {
       return buildString {
           appendLine("Target file:")
           appendLine("Code excerpt from file ${selectedFile.path}:")
           appendLine("```kotlin")
           selectedFile.toNioPath().toFile().readLines().forEach { appendLine(it) }
           appendLine("```")
           appendLine("\n\n")
       }
   }
}

Регистрируем Action

Осталось дело за малым — зарегистрировать наш Action, чтобы IDEA о нём знала. Для этого в файле plugin.xml добавляем следующие строки.

<group id="ru.cian.plugin.copilot.CopilotActionGroup"
    class="ru.cian.plugin.copilot.CopilotActionGroup" 
    text="Cian Copilot"
    description="Custom actions for GitHub Copilot" popup="true">
        <add-to-group group-id="EditorPopupMenu" anchor="after" 
            relative-to-action="$Paste" />
</group>

Я решил, что лучшее место для нашего Action — сразу по пунктом вставки. Не надо далеко тянуться, да и место «козырное». 

Результаты

В итоге генерация unit-тестов выглядит как-то так:

Согласитесь, — весьма удобно и достаточно быстро.

По аналогии с генерацией тестов для метода я также сделал генерацию и для файла в целом, если вызвать контекстное меню в списке файлов. С вашего позволения разбирать их код я не буду. Он сильно похож, на код генерации для метода, но немного проще, так как не надо пытаться доставать имя метода из редактора. Поэтому просто приведу код для интересующихся.

CopilotFileActionGroup
class CopilotFileActionGroup : DefaultActionGroup() {

    private var children: Array<AnAction> = emptyArray<AnAction>()

    override fun getChildren(event: AnActionEvent?): Array<AnAction> {
        return children
    }

    override fun getActionUpdateThread(): ActionUpdateThread {
        return ActionUpdateThread.BGT
    }

    override fun update(event: AnActionEvent) {
        val promptsDir = Path(event.project!!.basePath!!, DIR_PROMPTS).toFile()
        children = if (promptsDir.isDirectory && promptsDir.exists()) {
            getCustomActions(promptsDir)
        } else {
            emptyArray<AnAction>()
        }
        val file = event.getData(CommonDataKeys.VIRTUAL_FILE)

        event.presentation.isEnabled = children.isNotEmpty() && file != null
    }

    private fun getCustomActions(promptsDir: File): Array<AnAction> {
        return promptsDir.listFiles()!!
            .map { it.readLines().joinToString("\n") }
            .map { Json.decodeFromString<CopilotCustomActionModel>(it) }
            .map { CopilotFileCustomAction(it) }
            .toTypedArray()
    }
}
CopilotFileCustomAction
class CopilotFileCustomAction(
    val model: CopilotCustomActionModel
) : AnAction(model.name, model.description, null) {

    override fun actionPerformed(event: AnActionEvent) {
        val project = event.project!!
        getInstance(project).getToolWindow("GitHub Copilot Chat")?.show()

        CopilotPluginWrapper.createNewChat(project)
        val additionalFilesPrompt = model.additionalFiles.joinToString("\n") { filePath ->
            val file = Path(project.basePath!!, filePath).toFile()
            buildString {
                appendLine("Consider the additional context:")
                appendLine("Code excerpt from file $filePath:")
                appendLine("```kotlin")
                file.readLines().forEach { appendLine(it) }
                appendLine("```")
                appendLine("\n")
            }
        }

        val selectedFile = event.getData(CommonDataKeys.VIRTUAL_FILE) ?: return
        val selectedFilePrompt = buildString {
            appendLine("Target file:")
            appendLine("Code excerpt from file ${selectedFile.path}:")
            appendLine("```kotlin")
            selectedFile.toNioPath().toFile().readLines().forEach { appendLine(it) }
            appendLine("```")
            appendLine("\n\n")
        }

        val prompt = buildString {
            appendLine(selectedFilePrompt)
            appendLine(additionalFilesPrompt)
            appendLine(model.filePrompt.replace("{file}", selectedFile.name))
            appendLine("Test all methods in the file")
            appendLine(model.prompt)
        }

        CopilotPluginWrapper.sendPrompt(project, prompt)
    }
}
plugin.xml
<group id="ru.cian.plugin.copilot.CopilotFileActionGroup"
    class="ru.cian.plugin.copilot.CopilotFileActionGroup" 
    text="Cian Copilot"
    description="Custom actions on files for GitHub copilot" popup="true">
        <add-to-group group-id="ProjectViewPopupMenu" anchor="after" 
            relative-to-action="$Paste" />
</group>

А как вам такой способ генерации unit-тестов?

Комментарии (0)


  1. olku
    16.09.2025 15:19

    Сейчас плагин умеет в режим агента, открытый файл наводится как контекст автоматически. Дело лишь за промтом с правилами.


    1. princeparadoxes Автор
      16.09.2025 15:19

      Да, режим агента улучшил ситуацию с отрытым файлом. В нём текущий файл прикрепляется всегда и без проблем.