DEV Community

Cover image for Como criei um agente de IA para selecionar testes automaticamente em PRs (Parte 1)

Como criei um agente de IA para selecionar testes automaticamente em PRs (Parte 1)

🇺🇸 You can also read the English version of this article on AWS Community Builders.

Uma das coisas mais comuns em grandes projetos pelos quais já passei são pipelines com muitos testes, levando muito tempo para serem executados. E com isso eu pensei: por que não ter um agente de IA para analisar meus PRs e, a partir das modificações, selecionar apenas os testes que precisam ser executados? Nesse artigo vou te mostrar como eu fiz isso e já adianto que provavelmente isso vai virar uma série de artigos.

(Se você não conhece o termo, o que estou descrevendo aqui é uma versão simplificada de **Test Impact Analysis: em vez de rodar a suíte inteira a cada PR, o objetivo é mapear quais testes realmente têm relação com o que foi alterado.)

Prompts utilizados

Um dos feedbacks que recebi nos meus últimos artigos foi para deixar mais explícitos os prompts que utilizei para criar o projeto, e nesse eu usei apenas dois:

claude poderia me ajudar a fazer um projeto onde vou ter um agente de IA para analisar as linhas de códigos que foram modificadas em um PR e selecionar testes relacionados a essa modificação?

Como resposta para essa primeira pergunta, ele criou pra mim um diagrama de como basicamente isso poderia funcionar, sem nenhuma integração a pipeline ou coisa do tipo:

Workflow

Depois disso, simplesmente pedi para ele criar um prompt para o Claude Code (a IA que utilizo) e, depois de colar o prompt gerado:

Magic

A primeira versão do projeto foi criado no seguinte formato:

main.py                  CLI entrypoint — wires everything together
config.py                Loads & validates ANTHROPIC_API_KEY / GITHUB_TOKEN
src/
  github_client.py       Fetches PR files & metadata from the GitHub API (with retries)
  diff_parser.py         Maps diff hunks to changed functions/classes via AST
  test_discovery.py      Indexes pytest test functions across the repo
  ai_agent.py            Calls Claude to select relevant tests
  reporter.py            Renders results as a table or JSON
tests/                   Unit tests for the parser & discovery modules
Enter fullscreen mode Exit fullscreen mode

Vale destacar o diff_parser.py: em vez de simplesmente olhar quais linhas mudaram no diff, ele usa AST (Abstract Syntax Tree) para entender quais funções e classes foram alteradas. Essa diferença importa bastante — duas linhas modificadas dentro da mesma função valem como uma única mudança de símbolo, o que deixa a seleção de testes mais precisa do que uma comparação puramente textual.

Dando destaque ao nosso ai_agent.py, pois é nele que acontece a mágica, temos alguns argumentos interessantes de entender:

Relevance = Literal["low", "medium", "high"]
Enter fullscreen mode Exit fullscreen mode

Ter um filtro de relevância é importante para auxiliar a IA no momento em que ela for analisar, entre os testes disponíveis, quais realmente precisam ser executados. Na prática, esse filtro também vira um dial de confiança para quem está usando a ferramenta: em um pipeline de CI mais crítico, faz sentido rodar só os testes marcados como high; em uma verificação mais exploratória, pode valer incluir low e medium também.

O prompt utilizado foi esse:

_SYSTEM_PROMPT = """\
You are a senior software engineer who selects which pytest tests are relevant \
to review for a given pull request.

You will receive a JSON object with:
- "min_relevance": the minimum relevance level to include ("low", "medium", or "high")
- "changed_symbols": a list of functions/classes/methods (or whole files) modified in the PR,
  each with file_path, symbol_name, symbol_type, and lines_changed
- "available_tests": a list of test functions discovered in the repository, each with
  test_path, test_name, imports, and source_references

Decide which of the available tests are relevant to the changed symbols by considering:
- Whether a test's source_references directly name a changed symbol
- Whether a test's imports point to the module containing a changed symbol
- One level of transitivity: a test that references something which itself references
  a changed symbol (e.g. a helper that calls the changed function)
- The semantic similarity between test names/paths and changed symbol names

Respond with ONLY a JSON array (no prose, no markdown fences, no text outside the array).
Each element must have exactly this shape:
{
  "test_path": "<string>",
  "test_name": "<string>",
  "relevance": "<low|medium|high>",
  "confidence_score": <float between 0.0 and 1.0>,
  "reason": "<short string explaining why this test is relevant>"
}

Only include tests whose relevance is greater than or equal to the given min_relevance
("low" < "medium" < "high"). If no tests are relevant, return an empty array: []
"""
Enter fullscreen mode Exit fullscreen mode

Um ponto que vale destacar nesse prompt é a "um nível de transitividade": o agente não olha só se um teste referencia diretamente o símbolo alterado, mas também se ele referencia algo que, por sua vez, referencia o símbolo alterado (por exemplo, um helper chamado pela função modificada). Isso ajuda a pegar casos de teste que passariam despercebidos numa busca só por nome.

Seleção do AI Agent

A primeira mudança que fiz foi pedir para a IA adaptar o código de forma que fosse possível passar qual IA utilizar como parâmetro, em vez de deixá-la fixa:

"""Thin wrappers so AIAgent can call Claude, Gemini, or ChatGPT interchangeably."""

from typing import Protocol

from config import Config

class LLMProvider(Protocol):
    """Something that can turn a system + user prompt into a text completion."""

    def complete(self, system_prompt: str, user_prompt: str) -> str: ...

class ClaudeProvider:
    """Calls Anthropic's Claude models."""

    def __init__(self, api_key: str, model: str) -> None:
        from anthropic import Anthropic

        self._client = Anthropic(api_key=api_key)
        self._model = model

    def complete(self, system_prompt: str, user_prompt: str) -> str:
        response = self._client.messages.create(
            model=self._model,
            max_tokens=4096,
            system=system_prompt,
            messages=[{"role": "user", "content": user_prompt}],
        )
        return "".join(block.text for block in response.content if getattr(block, "type", None) == "text")

class GeminiProvider:
    """Calls Google's Gemini models."""

    def __init__(self, api_key: str, model: str) -> None:
        from google import genai

        self._client = genai.Client(api_key=api_key)
        self._model = model

    def complete(self, system_prompt: str, user_prompt: str) -> str:
        from google.genai import types

        response = self._client.models.generate_content(
            model=self._model,
            contents=user_prompt,
            config=types.GenerateContentConfig(system_instruction=system_prompt),
        )
        return response.text or ""

class ChatGPTProvider:
    """Calls OpenAI's ChatGPT models."""

    def __init__(self, api_key: str, model: str) -> None:
        from openai import OpenAI

        self._client = OpenAI(api_key=api_key)
        self._model = model

    def complete(self, system_prompt: str, user_prompt: str) -> str:
        response = self._client.chat.completions.create(
            model=self._model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
        )
        return response.choices[0].message.content or ""

def create_provider(config: Config) -> LLMProvider:
    """Builds the LLMProvider for config.provider, using the matching API key and model."""
    if config.provider == "claude":
        assert config.anthropic_api_key is not None
        return ClaudeProvider(api_key=config.anthropic_api_key, model=config.claude_model)
    if config.provider == "gemini":
        assert config.gemini_api_key is not None
        return GeminiProvider(api_key=config.gemini_api_key, model=config.gemini_model)
    if config.provider == "chatgpt":
        assert config.openai_api_key is not None
        return ChatGPTProvider(api_key=config.openai_api_key, model=config.chatgpt_model)
    raise ValueError(f"Unknown provider: {config.provider!r}")

Enter fullscreen mode Exit fullscreen mode

O uso do Protocol aqui é o que permite trocar de provedor sem alterar o restante do código: qualquer classe que implemente complete(system_prompt, user_prompt) serve, então AIAgent nunca precisa saber se está falando com Claude, Gemini ou ChatGPT.

Dessa forma, fica a critério de quem está utilizando o pacote selecionar qual IA será usada, podendo isso ser passado como parâmetro no momento da execução:

python main.py --pr 42 --repo owner/repo --provider gemini
python main.py --pr 42 --repo owner/repo --provider chatgpt
python main.py --pr 42 --repo owner/repo               # default: claude
Enter fullscreen mode Exit fullscreen mode

Validando se o que foi gerado realmente está certo

Para a primeira validação, pesquisei alguns repositórios públicos no GitHub e, nesse caso, selecionei este PR: https://github.com/pallets/flask/pull/6043

python main.py --repo pallets/flask  --pr 6043  --provider gemini 
Enter fullscreen mode Exit fullscreen mode

E obtive o seguinte resultado:

First result

Só que agora entra a parte complicada, sempre temos nos perguntar quando utilizamos IA:

Essa resposta faz sentido?

E, de cara, dá para ver que não — não está certo. Quando passamos um repositório diferente, ele continua buscando no repositório local, ignorando completamente o repositório do PR informado. Para corrigir isso, precisamos garantir que, sempre que for um repositório externo, o agente acesse as informações diretamente dele e para isso, vamos clonar temporariamente uma branch do repositório:

"""Clones a single branch of a GitHub repository into a temporary directory for local scanning."""

import logging
import shutil
import subprocess
import tempfile

logger = logging.getLogger(__name__)

class RepoFetchError(RuntimeError):
    """Raised when the target repository/branch cannot be cloned."""

class RepoFetcher:
    """Shallow-clones a single branch of a repository so its tests can be scanned locally."""

    def __init__(self, token: str | None = None) -> None:
        self._token = token
        self._tmp_dir: str | None = None

    def fetch(self, repo: str, ref: str) -> str:
        """Clones `repo` (owner/name) at branch `ref` and returns the local checkout path."""
        self._tmp_dir = tempfile.mkdtemp(prefix="ai-test-selector-")
        cmd = [
            "git",
            "clone",
            "--depth",
            "1",
            "--branch",
            ref,
            "--single-branch",
            self._clone_url(repo),
            self._tmp_dir,
        ]
        result = subprocess.run(cmd, capture_output=True, text=True)
        if result.returncode != 0:
            shutil.rmtree(self._tmp_dir, ignore_errors=True)
            self._tmp_dir = None
            raise RepoFetchError(f"Failed to clone {repo}@{ref}: {result.stderr.strip()}")
        return self._tmp_dir

    def cleanup(self) -> None:
        """Removes the temporary checkout, if one was created."""
        if self._tmp_dir:
            shutil.rmtree(self._tmp_dir, ignore_errors=True)
            self._tmp_dir = None

    def _clone_url(self, repo: str) -> str:
        if self._token:
            return f"https://x-access-token:{self._token}@github.com/{repo}.git"
        return f"https://github.com/{repo}.git"

Enter fullscreen mode Exit fullscreen mode

Repare no --depth 1 e --single-branch do clone: como o objetivo aqui é só escanear os testes do repositório naquele ponto específico, não faz sentido trazer todo o histórico de commits — um shallow clone resolve o problema mais rápido e consumindo bem menos espaço em disco.

Rodando o mesmo comando, temos o seguinte resultado:

Correct results

Em resumo, ele selecionou:

Selected 192/403 tests (avg confidence: 0.76)
pytest tests/test_appctx.py tests/test_async.py tests/test_basic.py tests/test_blueprints.py tests/test_cli.py tests/test_converters.py
tests/test_helpers.py tests/test_json.py tests/test_logging.py tests/test_regression.py tests/test_reqctx.py tests/test_request.py
tests/test_session_interface.py tests/test_signals.py tests/test_subclassing.py tests/test_templating.py tests/test_testing.py
tests/test_user_error_handler.py tests/test_views.py -v
Enter fullscreen mode Exit fullscreen mode

É possível ver que com a IA, ao invés de rodarmos toda a suite, poderíamos rodar apenas 48% dela, reduzindo o tempo de execução e retornando um feedback muito mais rápido para quem está desenvolvendo a feature.

Conclusão

Como o artigo já está ficando grande, vou parar por aqui nesta primeira etapa. Revisando, o foco desta primeira parte foi refinar o prompt utilizado pela IA para gerar o código e, a partir disso, revisar o que foi gerado. Nela tivemos duas mudanças significativas:

  • Adaptação do código para utilizar diferentes provedores de IA, de forma que o projeto não ficasse preso a um único modelo.
  • Ajuste na busca dos testes, para que ela considerasse os testes disponíveis no repositório relacionado ao PR informado, e não no repositório local.

Próximos passos

Esse projeto ainda está bem no início, e pretendo continuar evoluindo ele nos próximos artigos. Já durante essa primeira parte, revisando o que foi gerado e testando em repositórios reais, surgiram algumas ideias novas que quero explorar:

  • Integração com pipelines: hoje o projeto roda de forma isolada, via linha de comando, mas o próximo passo natural é pensar em como plugar isso direto no CI/CD — por exemplo, como uma action do GitHub Actions que roda automaticamente a cada PR aberto e comenta com os testes selecionados, ou até já dispara só o subconjunto relevante.
  • Performance na parte de IA: com repositórios maiores, o número de testes disponíveis para avaliar cresce bastante, e isso significa prompts maiores e mais tempo de resposta. Quero investigar formas de otimizar essa etapa — seja filtrando os candidatos antes de mandar para a IA, seja quebrando a avaliação em lotes menores, ou até testando modelos diferentes para essa tarefa específica de classificação.

Se você quiser acompanhar a evolução do projeto (ou já colocar a mão na massa também), o código está aberto no meu repositório: ai-agent-test-selector

No próximo artigo, vou focar no prompt utilizado pela IA para selecionar os testes propriamente dito, além de adaptar o wrapper para ler repositórios que não sejam apenas em Python, mas também em outras linguagens.

Ficou alguma dúvida sobre alguma parte do código, do prompt ou da arquitetura? Deixa nos comentários — quero muito saber o que vocês acharam e trocar ideia sobre outros cenários onde isso poderia ser aplicado.

Top comments (1)

Collapse
 
capituincode profile image
Aline Santana He4rt Developers

Maravilhoso.