
How I Created an AI Agent to select tests in PRs (Part 1)
Tired of pipelines running every test on every PR? I built an AI agent that selects only relevant tests — first article of the series
🇧🇷 A Portuguese version of this article is also available on dev.to
One of the most common things I've run into in large projects I've worked on is pipelines with tons of tests, taking forever to run. So I thought: why not have an AI agent analyze my PRs and, based on the changes, select only the tests that actually need to run? In this article I'll show you how I did it, and I'll say upfront this will probably turn into a series of articles.
(If you're not familiar with the term, what I'm describing here is a simplified version of Test Impact Analysis: instead of running the entire suite on every PR, the goal is to map out which tests are actually related to what changed.)
Prompts used
One piece of feedback I got on my last few articles was to be more explicit about the prompts I used to build the project, and for this one I only used two:
could you help me build a project where I have an AI agent that analyzes the lines of code changed in a PR and selects the tests related to that change?
As a response to this first question, it generated a diagram of how this could basically work, with no pipeline integration or anything like that:

After that, I simply asked it to create a prompt for Claude Code (the AI I use) and, after pasting in the generated prompt, the first version of the project was created with the following layout:
1
2
3
4
5
6
7
8
9
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 modulesThe
diff_parser.py deserves a special mention: instead of simply looking at which lines changed in the diff, it uses an AST (Abstract Syntax Tree) to understand which functions and classes were changed. This difference matters a lot — two modified lines within the same function count as a single symbol change, which makes test selection more precise than a purely textual comparison.Shining a spotlight on our
ai_agent.py, since that's where the magic happens, there are a few interesting arguments worth understanding:1
Relevance = Literal["low", "medium", "high"]Having a relevance filter is important to help the AI, when analyzing the available tests, decide which ones actually need to run. In practice, this filter also becomes a confidence dial for whoever is using the tool: in a more critical CI pipeline, it makes sense to only run tests marked
high; for a more exploratory check, it might be worth including low and medium as well.The prompt used was this one:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
_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: []
"""One point worth highlighting in this prompt is the "one level of transitivity": the agent doesn't just check whether a test directly references the changed symbol, but also whether it references something that, in turn, references the changed symbol (for example, a helper called by the modified function). This helps catch test cases that would otherwise slip through a simple name-based search.
Choosing the AI agent
The first change I made was asking the AI to adapt the code so the AI provider could be passed as a parameter, instead of being hardcoded:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
"""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}")
The use of
Protocol here is what makes it possible to swap providers without touching the rest of the code: any class that implements complete(system_prompt, user_prompt) works, so AIAgent never needs to know whether it's talking to Claude, Gemini, or ChatGPT.This way, it's up to whoever is using the package to choose which AI to use, which can be passed as a parameter at execution time:
1
2
3
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: claudeValidating whether what was generated is actually correct
For the first validation, I searched a few public GitHub repositories and, in this case, picked this PR: https://github.com/pallets/flask/pull/6043
1
python main.py --repo pallets/flask --pr 6043 --provider geminiAnd I got the following result:

But now comes the tricky part—we always have to ask ourselves when we use AI:
Does this response actually make sense?
And, right off the bat, you can tell it doesn't — it's not correct. When we pass a different repository, it keeps searching the local repository, completely ignoring the repository from the given PR. To fix this, we need to make sure that, whenever it's an external repository, the agent fetches the information directly from it — and for that, we'll temporarily clone a branch of the repository:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
"""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"
Notice the
--depth 1 and --single-branch flags on the clone: since the goal here is just to scan the repository's tests at that specific point in time, there's no reason to pull the entire commit history — a shallow clone solves the problem faster and uses a lot less disk space.Running the same command, we get the following result:

In short, it selected:
1
2
3
4
5
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 -vIt’s clear that with AI, instead of running the entire suite, we could run just 48% of it, reducing execution time and providing much faster feedback to those developing the feature.
Conclusion
Since this article is already getting quite long, I’ll stop here for this first stage. To recap, the focus of this first part was to refine the prompt used by the AI to generate the code and, based on that, review what was generated. In this part, we made two significant changes:
- Adapted the code to use different AI providers, so that the project would not be tied to a single model.
- Adjusted the test search so that it would consider the tests available in the repository associated with the specified PR, rather than in the local repository.
Next steps
This project is still very much at an early stage, and I plan to keep evolving it in upcoming articles. Already during this first part, while reviewing what was generated and testing it on real repositories, a few new ideas came up that I want to explore:
- Pipeline integration: today the project runs standalone, via command line, but the natural next step is figuring out how to plug this directly into CI/CD — for example, as a GitHub Actions action that runs automatically on every opened PR and comments with the selected tests, or even triggers just that relevant subset directly.
- Performance on the AI side: with larger repositories, the number of available tests to evaluate grows quite a bit, which means bigger prompts and longer response times. I want to investigate ways to optimize this step — whether by filtering candidates before sending them to the AI, splitting the evaluation into smaller batches, or even testing different models for this specific classification task.
If you want to follow the project's evolution, the code is open in my repository: ai-agent-test-selector
In the next article, I'll focus on the prompt used by the AI to actually select the tests, plus adapting the wrapper to read repositories that aren't just in Python, but in other languages too.
Any questions about any part of the code, the prompt, or the architecture? Leave a comment — I'd love to know what you thought and swap ideas about other scenarios where this could be applied.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article