Skip to content

Commit 09b797a

Browse files
authored
Github Checks (import) (#750)
1 parent f64f455 commit 09b797a

File tree

2 files changed

+260
-0
lines changed

2 files changed

+260
-0
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Github Checks
2+
3+
This application is a GitHub integration that analyzes import cycles in Python codebases. It automatically runs when a pull request is labeled and checks for potentially problematic import patterns in the modified codebase.
4+
5+
## Features
6+
7+
- Analyzes import relationships in Python codebases
8+
- Detects circular import dependencies
9+
- Identifies problematic cycles with mixed static and dynamic imports
10+
- Automatically comments on pull requests with detailed analysis
11+
12+
## How It Works
13+
14+
1. The app creates a directed graph representing import relationships in the codebase
15+
16+
```python
17+
for imp in codebase.imports:
18+
if imp.from_file and imp.to_file:
19+
G.add_edge(
20+
imp.to_file.filepath,
21+
imp.from_file.filepath,
22+
color="red" if getattr(imp, "is_dynamic", False) else "black",
23+
label="dynamic" if getattr(imp, "is_dynamic", False) else "static",
24+
is_dynamic=getattr(imp, "is_dynamic", False),
25+
)
26+
```
27+
28+
1. It identifies strongly connected components (cycles) in the import graph
29+
30+
```python
31+
cycles = [scc for scc in nx.strongly_connected_components(G) if len(scc) > 1]
32+
```
33+
34+
1. It specifically flags cycles that contain both static and dynamic imports
35+
36+
```python
37+
dynamic_count = sum(1 for e in edges.values() if e["color"] == "red")
38+
static_count = sum(1 for e in edges.values() if e["color"] == "black")
39+
40+
if dynamic_count > 0 and static_count > 0:
41+
mixed_imports[(from_file, to_file)] = {
42+
"dynamic": dynamic_count,
43+
"static": static_count,
44+
"edges": edges,
45+
}
46+
```
47+
48+
1. Results are posted as a comment on the pull request
49+
50+
```python
51+
if problematic_loops:
52+
message.append("\n### ⚠️ Problematic Import Cycles")
53+
message.append("(Cycles with mixed static and dynamic imports)")
54+
for i, cycle in enumerate(problematic_loops, 1):
55+
message.append(f"\n#### Problematic Cycle #{i}")
56+
message.append("Mixed imports:")
57+
for (from_file, to_file), imports in cycle["mixed_imports"].items():
58+
message.append(f"\nFrom: `{from_file}`")
59+
message.append(f"To: `{to_file}`")
60+
message.append(f"- Static imports: {imports['static']}")
61+
message.append(f"- Dynamic imports: {imports['dynamic']}")
62+
63+
create_pr_comment(codebase, event.pull_request, number, "\n".join(message))
64+
```
65+
66+
## Setup
67+
68+
1. Ensure you have the following dependencies:
69+
70+
- Python 3.13
71+
- Modal
72+
- Codegen
73+
- NetworkX
74+
- python-dotenv
75+
76+
1. Set up your environment variables in a `.env` file
77+
78+
- `GITHUB_TOKEN`: Your GitHub token, configured with `repo` and `workflow` scopes
79+
80+
1. Deploy the app using Modal:
81+
82+
```bash
83+
modal deploy app.py
84+
```
85+
86+
## Technical Details
87+
88+
The application uses Codegen to parse the codebase and a combination of NetworkX and Codegen to analyze the import relationships. The app is structured as a Modal App with a FastAPI server.
89+
The analysis runs when a pull request is labeled (`pull_request:labeled` event).
90+
91+
## Output Format
92+
93+
The analysis results are posted as a markdown-formatted comment on the pull request, including:
94+
95+
- Summary statistics
96+
- Detailed cycle information
97+
- Warning indicators for problematic import patterns
98+
99+
## Learn More
100+
101+
- [Codegen Documentation](https://docs.codegen.com)
102+
- [Detecting Import Loops](https://docs.codegen.com/blog/fixing-import-loops)
103+
104+
## Contributing
105+
106+
Feel free to submit issues and enhancement requests!
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import logging
2+
3+
import modal
4+
from codegen import CodegenApp, Codebase
5+
from codegen.extensions.github.types.events.pull_request import PullRequestLabeledEvent
6+
from codegen.extensions.tools.github.create_pr_comment import create_pr_comment
7+
from dotenv import load_dotenv
8+
import networkx as nx
9+
10+
load_dotenv()
11+
12+
logging.basicConfig(level=logging.INFO)
13+
logger = logging.getLogger(__name__)
14+
15+
cg = CodegenApp(name="codegen-github-checks")
16+
17+
18+
def create_graph_from_codebase(repo_path):
19+
"""Create a directed graph representing import relationships in a codebase."""
20+
codebase = Codebase.from_repo(repo_path)
21+
G = nx.MultiDiGraph()
22+
23+
for imp in codebase.imports:
24+
if imp.from_file and imp.to_file:
25+
G.add_edge(
26+
imp.to_file.filepath,
27+
imp.from_file.filepath,
28+
color="red" if getattr(imp, "is_dynamic", False) else "black",
29+
label="dynamic" if getattr(imp, "is_dynamic", False) else "static",
30+
is_dynamic=getattr(imp, "is_dynamic", False),
31+
)
32+
return G
33+
34+
35+
def convert_all_calls_to_kwargs(codebase):
36+
for file in codebase.files:
37+
for function_call in file.function_calls:
38+
function_call.convert_args_to_kwargs()
39+
40+
print("All function calls have been converted to kwargs")
41+
42+
43+
def find_import_cycles(G):
44+
"""Identify strongly connected components (cycles) in the import graph."""
45+
cycles = [scc for scc in nx.strongly_connected_components(G) if len(scc) > 1]
46+
print(f"🔄 Found {len(cycles)} import cycles.")
47+
48+
for i, cycle in enumerate(cycles, 1):
49+
print(f"\nCycle #{i}: Size {len(cycle)} files")
50+
print(f"Total number of imports in cycle: {G.subgraph(cycle).number_of_edges()}")
51+
52+
print("\nFiles in this cycle:")
53+
for file in cycle:
54+
print(f" - {file}")
55+
56+
return cycles
57+
58+
59+
def find_problematic_import_loops(G, cycles):
60+
"""Identify cycles with both static and dynamic imports between files."""
61+
problematic_cycles = []
62+
63+
for i, scc in enumerate(cycles):
64+
if i == 2:
65+
continue
66+
67+
mixed_imports = {}
68+
for from_file in scc:
69+
for to_file in scc:
70+
if G.has_edge(from_file, to_file):
71+
edges = G.get_edge_data(from_file, to_file)
72+
dynamic_count = sum(1 for e in edges.values() if e["color"] == "red")
73+
static_count = sum(1 for e in edges.values() if e["color"] == "black")
74+
75+
if dynamic_count > 0 and static_count > 0:
76+
mixed_imports[(from_file, to_file)] = {
77+
"dynamic": dynamic_count,
78+
"static": static_count,
79+
"edges": edges,
80+
}
81+
82+
if mixed_imports:
83+
problematic_cycles.append({"files": scc, "mixed_imports": mixed_imports, "index": i})
84+
85+
print(f"Found {len(problematic_cycles)} cycles with potentially problematic imports.")
86+
87+
for i, cycle in enumerate(problematic_cycles):
88+
print(f"\n⚠️ Problematic Cycle #{i + 1} (Index {cycle['index']}): Size {len(cycle['files'])} files")
89+
print("\nFiles in cycle:")
90+
for file in cycle["files"]:
91+
print(f" - {file}")
92+
print("\nMixed imports:")
93+
for (from_file, to_file), imports in cycle["mixed_imports"].items():
94+
print(f"\n From: {from_file}")
95+
print(f" To: {to_file}")
96+
print(f" Static imports: {imports['static']}")
97+
print(f" Dynamic imports: {imports['dynamic']}")
98+
99+
return problematic_cycles
100+
101+
102+
@cg.github.event("pull_request:labeled")
103+
def handle_pr(event: PullRequestLabeledEvent):
104+
codebase = Codebase.from_repo(event.repository.get("full_name"), commit=event.pull_request.head.sha)
105+
106+
G = create_graph_from_codebase(event.repository.get("full_name"))
107+
cycles = find_import_cycles(G)
108+
problematic_loops = find_problematic_import_loops(G, cycles)
109+
110+
# Build comment message
111+
message = ["### Import Cycle Analysis - GitHub Check\n"]
112+
113+
if problematic_loops:
114+
message.append("\n### ⚠️ Potentially Problematic Import Cycles")
115+
message.append("Cycles with mixed static and dynamic imports, which might recquire attention.")
116+
for i, cycle in enumerate(problematic_loops, 1):
117+
message.append(f"\n#### Problematic Cycle {i}")
118+
for (from_file, to_file), imports in cycle["mixed_imports"].items():
119+
message.append(f"\nFrom: `{from_file}`")
120+
message.append(f"To: `{to_file}`")
121+
message.append(f"- Static imports: {imports['static']}")
122+
message.append(f"- Dynamic imports: {imports['dynamic']}")
123+
else:
124+
message.append("\nNo problematic import cycles found! 🎉")
125+
126+
create_pr_comment(
127+
codebase,
128+
event.pull_request.number,
129+
"\n".join(message),
130+
)
131+
132+
return {
133+
"message": "PR event handled",
134+
"num_files": len(codebase.files),
135+
"num_functions": len(codebase.functions),
136+
}
137+
138+
139+
base_image = (
140+
modal.Image.debian_slim(python_version="3.13")
141+
.apt_install("git")
142+
.pip_install(
143+
"codegen",
144+
)
145+
)
146+
147+
app = modal.App("codegen-test")
148+
149+
150+
@app.function(image=base_image, secrets=[modal.Secret.from_dotenv()])
151+
@modal.asgi_app()
152+
def fastapi_app():
153+
print("Starting codegen fastapi app")
154+
return cg.app

0 commit comments

Comments
 (0)