Skip to content

Commit 7f6ce32

Browse files
authored
fix: codeowners property and add regression unit tests (#639)
# Motivation <!-- Why is this change necessary? --> Codebase.G -> ctx refactor caused a breaking change in codebase.codeowners property that was not caught due to missing test coverage. # Content <!-- Please include a summary of the change --> Fix the codeowners property Add a regression unit test that would have caught the error # Testing <!-- How was the change tested? --> Added a regression unit test # Please check the following before marking your PR as ready for review - [x] I have added tests for my changes - [x] I have updated the documentation or added new documentation as needed Will add codeowners integration tests in a follow up PR
1 parent 4cf2c3b commit 7f6ce32

File tree

6 files changed

+220
-21
lines changed

6 files changed

+220
-21
lines changed

src/codegen/sdk/core/codebase.py

Lines changed: 131 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@
3030
from codegen.sdk._proxy import proxy_property
3131
from codegen.sdk.ai.client import get_openai_client
3232
from codegen.sdk.codebase.codebase_ai import generate_system_prompt, generate_tools
33-
from codegen.sdk.codebase.codebase_context import GLOBAL_FILE_IGNORE_LIST, CodebaseContext
33+
from codegen.sdk.codebase.codebase_context import (
34+
GLOBAL_FILE_IGNORE_LIST,
35+
CodebaseContext,
36+
)
3437
from codegen.sdk.codebase.config import ProjectConfig, SessionOptions
3538
from codegen.sdk.codebase.diff_lite import DiffLite
3639
from codegen.sdk.codebase.flagging.code_flag import CodeFlag
@@ -110,7 +113,21 @@
110113

111114

112115
@apidoc
113-
class Codebase(Generic[TSourceFile, TDirectory, TSymbol, TClass, TFunction, TImport, TGlobalVar, TInterface, TTypeAlias, TParameter, TCodeBlock]):
116+
class Codebase(
117+
Generic[
118+
TSourceFile,
119+
TDirectory,
120+
TSymbol,
121+
TClass,
122+
TFunction,
123+
TImport,
124+
TGlobalVar,
125+
TInterface,
126+
TTypeAlias,
127+
TParameter,
128+
TCodeBlock,
129+
]
130+
):
114131
"""This class provides the main entrypoint for most programs to analyzing and manipulating codebases.
115132
116133
Attributes:
@@ -180,7 +197,10 @@ def __init__(
180197

181198
# Initialize project with repo_path if projects is None
182199
if repo_path is not None:
183-
main_project = ProjectConfig.from_path(repo_path, programming_language=ProgrammingLanguage(language.upper()) if language else None)
200+
main_project = ProjectConfig.from_path(
201+
repo_path,
202+
programming_language=ProgrammingLanguage(language.upper()) if language else None,
203+
)
184204
projects = [main_project]
185205
else:
186206
main_project = projects[0]
@@ -286,7 +306,10 @@ def files(self, *, extensions: list[str] | Literal["*"] | None = None) -> list[T
286306
else:
287307
files = []
288308
# Get all files with the specified extensions
289-
for filepath, _ in self._op.iter_files(extensions=None if extensions == "*" else extensions, ignore_list=GLOBAL_FILE_IGNORE_LIST):
309+
for filepath, _ in self._op.iter_files(
310+
extensions=None if extensions == "*" else extensions,
311+
ignore_list=GLOBAL_FILE_IGNORE_LIST,
312+
):
290313
files.append(self.get_file(filepath, optional=False))
291314
# Sort files alphabetically
292315
return sort_editables(files, alphabetical=True, dedupe=False)
@@ -298,9 +321,12 @@ def codeowners(self) -> list["CodeOwner[TSourceFile]"]:
298321
Returns:
299322
list[CodeOwners]: A list of CodeOwners objects in the codebase.
300323
"""
301-
if self.G.codeowners_parser is None:
324+
if self.ctx.codeowners_parser is None:
302325
return []
303-
return CodeOwner.from_parser(self.G.codeowners_parser, lambda *args, **kwargs: self.files(*args, **kwargs))
326+
return CodeOwner.from_parser(
327+
self.ctx.codeowners_parser,
328+
lambda *args, **kwargs: self.files(*args, **kwargs),
329+
)
304330

305331
@property
306332
def directories(self) -> list[TDirectory]:
@@ -814,7 +840,14 @@ def reset(self, git_reset: bool = False) -> None:
814840
self.reset_logs()
815841
self.ctx.undo_applied_diffs()
816842

817-
def checkout(self, *, commit: str | GitCommit | None = None, branch: str | None = None, create_if_missing: bool = False, remote: bool = False) -> CheckoutResult:
843+
def checkout(
844+
self,
845+
*,
846+
commit: str | GitCommit | None = None,
847+
branch: str | None = None,
848+
create_if_missing: bool = False,
849+
remote: bool = False,
850+
) -> CheckoutResult:
818851
"""Checks out a git branch or commit and syncs the codebase graph to the new state.
819852
820853
This method discards any pending changes, performs a git checkout of the specified branch or commit,
@@ -939,7 +972,12 @@ def create_pr(self, title: str, body: str) -> PullRequest:
939972
raise ValueError(msg)
940973
self._op.stage_and_commit_all_changes(message=title)
941974
self._op.push_changes()
942-
return self._op.remote_git_repo.create_pull(head_branch_name=self._op.git_cli.active_branch.name, base_branch_name=self._op.default_branch, title=title, body=body)
975+
return self._op.remote_git_repo.create_pull(
976+
head_branch_name=self._op.git_cli.active_branch.name,
977+
base_branch_name=self._op.default_branch,
978+
title=title,
979+
body=body,
980+
)
943981

944982
####################################################################################################################
945983
# GRAPH VISUALIZATION
@@ -1064,15 +1102,27 @@ def get_finalized_logs(self) -> str:
10641102

10651103
@contextmanager
10661104
@noapidoc
1067-
def session(self, sync_graph: bool = True, commit: bool = True, session_options: SessionOptions = SessionOptions()) -> Generator[None, None, None]:
1105+
def session(
1106+
self,
1107+
sync_graph: bool = True,
1108+
commit: bool = True,
1109+
session_options: SessionOptions = SessionOptions(),
1110+
) -> Generator[None, None, None]:
10681111
with self.ctx.session(sync_graph=sync_graph, commit=commit, session_options=session_options):
10691112
yield None
10701113

10711114
@noapidoc
1072-
def _enable_experimental_language_engine(self, async_start: bool = False, install_deps: bool = False, use_v8: bool = False) -> None:
1115+
def _enable_experimental_language_engine(
1116+
self,
1117+
async_start: bool = False,
1118+
install_deps: bool = False,
1119+
use_v8: bool = False,
1120+
) -> None:
10731121
"""Debug option to enable experimental language engine for the current codebase."""
10741122
if install_deps and not self.ctx.language_engine:
1075-
from codegen.sdk.core.external.dependency_manager import get_dependency_manager
1123+
from codegen.sdk.core.external.dependency_manager import (
1124+
get_dependency_manager,
1125+
)
10761126

10771127
logger.info("Cold installing dependencies...")
10781128
logger.info("This may take a while for large repos...")
@@ -1086,7 +1136,12 @@ def _enable_experimental_language_engine(self, async_start: bool = False, instal
10861136

10871137
logger.info("Cold starting language engine...")
10881138
logger.info("This may take a while for large repos...")
1089-
self.ctx.language_engine = get_language_engine(self.ctx.projects[0].programming_language, self.ctx, use_ts=True, use_v8=use_v8)
1139+
self.ctx.language_engine = get_language_engine(
1140+
self.ctx.projects[0].programming_language,
1141+
self.ctx,
1142+
use_ts=True,
1143+
use_v8=use_v8,
1144+
)
10901145
self.ctx.language_engine.start(async_start=async_start)
10911146
# Wait for the language engine to be ready
10921147
self.ctx.language_engine.wait_until_ready(ignore_error=False)
@@ -1112,7 +1167,13 @@ def ai_client(self) -> OpenAI:
11121167
self._ai_helper = get_openai_client(key=self.ctx.secrets.openai_api_key)
11131168
return self._ai_helper
11141169

1115-
def ai(self, prompt: str, target: Editable | None = None, context: Editable | list[Editable] | dict[str, Editable | list[Editable]] | None = None, model: str = "gpt-4o") -> str:
1170+
def ai(
1171+
self,
1172+
prompt: str,
1173+
target: Editable | None = None,
1174+
context: Editable | list[Editable] | dict[str, Editable | list[Editable]] | None = None,
1175+
model: str = "gpt-4o",
1176+
) -> str:
11161177
"""Generates a response from the AI based on the provided prompt, target, and context.
11171178
11181179
A method that sends a prompt to the AI client along with optional target and context information to generate a response.
@@ -1139,7 +1200,10 @@ def ai(self, prompt: str, target: Editable | None = None, context: Editable | li
11391200
raise MaxAIRequestsError(msg, threshold=self.ctx.session_options.max_ai_requests)
11401201

11411202
params = {
1142-
"messages": [{"role": "system", "content": generate_system_prompt(target, context)}, {"role": "user", "content": prompt}],
1203+
"messages": [
1204+
{"role": "system", "content": generate_system_prompt(target, context)},
1205+
{"role": "user", "content": prompt},
1206+
],
11431207
"model": model,
11441208
"functions": generate_tools(),
11451209
"temperature": 0,
@@ -1293,7 +1357,10 @@ def from_repo(
12931357

12941358
# Initialize and return codebase with proper context
12951359
logger.info("Initializing Codebase...")
1296-
project = ProjectConfig.from_repo_operator(repo_operator=repo_operator, programming_language=ProgrammingLanguage(language.upper()) if language else None)
1360+
project = ProjectConfig.from_repo_operator(
1361+
repo_operator=repo_operator,
1362+
programming_language=ProgrammingLanguage(language.upper()) if language else None,
1363+
)
12971364
codebase = Codebase(projects=[project], config=config, secrets=secrets)
12981365
logger.info("Codebase initialization complete")
12991366
return codebase
@@ -1442,7 +1509,16 @@ def create_pr_comment(self, pr_number: int, body: str) -> None:
14421509
"""Create a comment on a pull request"""
14431510
return self._op.create_pr_comment(pr_number, body)
14441511

1445-
def create_pr_review_comment(self, pr_number: int, body: str, commit_sha: str, path: str, line: int | None = None, side: str = "RIGHT", start_line: int | None = None) -> None:
1512+
def create_pr_review_comment(
1513+
self,
1514+
pr_number: int,
1515+
body: str,
1516+
commit_sha: str,
1517+
path: str,
1518+
line: int | None = None,
1519+
side: str = "RIGHT",
1520+
start_line: int | None = None,
1521+
) -> None:
14461522
"""Create a review comment on a pull request.
14471523
14481524
Args:
@@ -1462,6 +1538,42 @@ def create_pr_review_comment(self, pr_number: int, body: str, commit_sha: str, p
14621538

14631539
# The last 2 lines of code are added to the runner. See codegen-backend/cli/generate/utils.py
14641540
# Type Aliases
1465-
CodebaseType = Codebase[SourceFile, Directory, Symbol, Class, Function, Import, Assignment, Interface, TypeAlias, Parameter, CodeBlock]
1466-
PyCodebaseType = Codebase[PyFile, PyDirectory, PySymbol, PyClass, PyFunction, PyImport, PyAssignment, Interface, TypeAlias, PyParameter, PyCodeBlock]
1467-
TSCodebaseType = Codebase[TSFile, TSDirectory, TSSymbol, TSClass, TSFunction, TSImport, TSAssignment, TSInterface, TSTypeAlias, TSParameter, TSCodeBlock]
1541+
CodebaseType = Codebase[
1542+
SourceFile,
1543+
Directory,
1544+
Symbol,
1545+
Class,
1546+
Function,
1547+
Import,
1548+
Assignment,
1549+
Interface,
1550+
TypeAlias,
1551+
Parameter,
1552+
CodeBlock,
1553+
]
1554+
PyCodebaseType = Codebase[
1555+
PyFile,
1556+
PyDirectory,
1557+
PySymbol,
1558+
PyClass,
1559+
PyFunction,
1560+
PyImport,
1561+
PyAssignment,
1562+
Interface,
1563+
TypeAlias,
1564+
PyParameter,
1565+
PyCodeBlock,
1566+
]
1567+
TSCodebaseType = Codebase[
1568+
TSFile,
1569+
TSDirectory,
1570+
TSSymbol,
1571+
TSClass,
1572+
TSFunction,
1573+
TSImport,
1574+
TSAssignment,
1575+
TSInterface,
1576+
TSTypeAlias,
1577+
TSParameter,
1578+
TSCodeBlock,
1579+
]

src/codegen/sdk/core/codeowner.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class CodeOwner(
3737
files_source: A callable that returns an iterable of all files in the codebase.
3838
"""
3939

40+
_instance_iterator: Iterator[TFile]
4041
owner_type: Literal["USERNAME", "TEAM", "EMAIL"]
4142
owner_value: str
4243
files_source: Callable[FilesParam, Iterable[TFile]]
@@ -91,7 +92,11 @@ def name(self) -> str:
9192
return self.owner_value
9293

9394
def __iter__(self) -> Iterator[TFile]:
94-
return iter(self.files_generator())
95+
self._instance_iterator = iter(self.files_generator())
96+
return self
97+
98+
def __next__(self) -> str:
99+
return next(self._instance_iterator)
95100

96101
def __repr__(self) -> str:
97102
return f"CodeOwner(owner_type={self.owner_type}, owner_value={self.owner_value})"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import pytest
2+
3+
from codegen.sdk.code_generation.codegen_sdk_codebase import get_codegen_sdk_codebase
4+
5+
6+
@pytest.fixture(scope="module")
7+
def codebase():
8+
return get_codegen_sdk_codebase()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import pytest
2+
from codeowners import CodeOwners
3+
4+
5+
@pytest.fixture
6+
def example_codeowners_file_contents() -> str:
7+
return """# CODEOWNERS file example
8+
9+
/src/codemods @user-a
10+
/src/codegen @org/team1
11+
"""
12+
13+
14+
def test_codebase_codeowners(codebase, example_codeowners_file_contents):
15+
codebase.ctx.codeowners_parser = CodeOwners(example_codeowners_file_contents)
16+
17+
assert isinstance(codebase.codeowners, list)
18+
assert len(codebase.codeowners) == 2
19+
codeowners_by_name = {codeowner.name: codeowner for codeowner in codebase.codeowners}
20+
assert codeowners_by_name["@user-a"].owner_type == "USERNAME"
21+
assert codeowners_by_name["@org/team1"].owner_type == "TEAM"
22+
23+
for _file in codeowners_by_name["@org/team1"]:
24+
assert _file.filepath.startswith("src/codegen")
25+
26+
for _file in codeowners_by_name["@user-a"]:
27+
assert _file.filepath.startswith("src/codemods")
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from unittest.mock import MagicMock, create_autospec, patch
2+
3+
import pytest
4+
5+
from codegen.sdk.codebase.codebase_context import CodebaseContext
6+
from codegen.sdk.codebase.factory.get_session import get_codebase_session
7+
8+
9+
@pytest.fixture(autouse=True)
10+
def context_mock():
11+
mock_context = create_autospec(CodebaseContext, instance=True)
12+
for attr in CodebaseContext.__annotations__:
13+
if not hasattr(mock_context, attr):
14+
setattr(mock_context, attr, MagicMock(name=attr))
15+
with patch("codegen.sdk.core.codebase.CodebaseContext", return_value=mock_context):
16+
yield mock_context
17+
18+
19+
@pytest.fixture
20+
def codebase(context_mock, tmpdir):
21+
"""Create a simple codebase for testing."""
22+
# language=python
23+
content = """
24+
def hello():
25+
print("Hello, world!")
26+
27+
class Greeter:
28+
def greet(self):
29+
hello()
30+
"""
31+
with get_codebase_session(tmpdir=tmpdir, files={"src/main.py": content}, verify_output=False) as codebase:
32+
yield codebase
33+
34+
35+
def test_codeowners_property(context_mock, codebase):
36+
context_mock.codeowners_parser.paths = [(..., ..., [("test", "test")], ..., ...)]
37+
codebase.files = MagicMock()
38+
assert isinstance(codebase.codeowners, list)
39+
assert len(codebase.codeowners) == 1
40+
assert callable(codebase.codeowners[0].files_source)
41+
assert codebase.codeowners[0].files_source() == codebase.files.return_value

tests/unit/codegen/sdk/core/test_codeowner.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,13 @@ def test_from_parser_method(fake_files):
6969
# Create a fake parser with a paths attribute.
7070
fake_parser = MagicMock()
7171
fake_parser.paths = [
72-
("pattern1", "ignored", [("USERNAME", "alice"), ("TEAM", "devs")], "ignored", "ignored"),
72+
(
73+
"pattern1",
74+
"ignored",
75+
[("USERNAME", "alice"), ("TEAM", "devs")],
76+
"ignored",
77+
"ignored",
78+
),
7379
("pattern2", "ignored", [("EMAIL", "[email protected]")], "ignored", "ignored"),
7480
]
7581

0 commit comments

Comments
 (0)