From 6e1fe25f6adc3909588404c747ca3a73112da8f8 Mon Sep 17 00:00:00 2001 From: honeydew78 Date: Fri, 5 Jun 2026 13:55:45 +0530 Subject: [PATCH 1/2] test: add pytest suite and update requirements --- requirements.txt | 1 + tests/__init__.py | 1 + tests/test_api.py | 37 +++++++++++++++++++++++++++++++++++++ tests/test_graph_engine.py | 37 +++++++++++++++++++++++++++++++++++++ tests/test_parser.py | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_api.py create mode 100644 tests/test_graph_engine.py create mode 100644 tests/test_parser.py diff --git a/requirements.txt b/requirements.txt index 084d7a9..306314c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ networkx>=3.0 fastapi>=0.110.0 uvicorn>=0.28.0 httpx>=0.27.0 +pytest>=8.0.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..b88fe76 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,37 @@ +import pytest +from fastapi.testclient import TestClient +from app import app + +client = TestClient(app) + +def test_webhook_missing_header(): + response = client.post("/webhook", json={}) + assert response.status_code == 400 + assert response.json()["detail"] == "Missing X-GitHub-Event header" + +def test_webhook_ignored_event(): + response = client.post( + "/webhook", + headers={"X-GitHub-Event": "issues"}, + json={"action": "opened"} + ) + assert response.status_code == 200 + assert "Ignored event" in response.json()["status"] + +def test_webhook_ignored_action(): + response = client.post( + "/webhook", + headers={"X-GitHub-Event": "pull_request"}, + json={"action": "closed"} + ) + assert response.status_code == 200 + assert "Ignored action" in response.json()["status"] + +def test_webhook_accepted_action(): + response = client.post( + "/webhook", + headers={"X-GitHub-Event": "pull_request"}, + json={"action": "opened"} + ) + assert response.status_code == 202 + assert response.json()["status"] == "Analysis queued" diff --git a/tests/test_graph_engine.py b/tests/test_graph_engine.py new file mode 100644 index 0000000..7c943ba --- /dev/null +++ b/tests/test_graph_engine.py @@ -0,0 +1,37 @@ +import pytest +import networkx as nx +from graph_engine import resolve_relative_import, build_dependency_graph, get_impacted_files + +def test_resolve_relative_import(): + assert resolve_relative_import("src/flask/app.py", ".testing") == "src.flask.testing" + assert resolve_relative_import("src/flask/app.py", "..utils") == "src.utils" + +def test_build_dependency_graph(): + files_data = { + "file_b.py": b"def compute(): pass", + "file_a.py": b"from file_b import compute", + "file_c.py": b"import file_a" + } + graph = build_dependency_graph(files_data) + + # Nodes + assert graph.has_node("file_b.py") + assert graph.has_node("file_a.py") + assert graph.has_node("file_c.py") + + # Edges (supplier -> consumer) + assert graph.has_edge("file_b.py", "file_a.py") + assert graph.has_edge("file_a.py", "file_c.py") + +def test_get_impacted_files(): + graph = nx.DiGraph() + graph.add_edge("file_b", "file_a") + graph.add_edge("file_a", "file_c") + + impacted = get_impacted_files(graph, "file_b") + assert "file_a" in impacted + assert "file_c" in impacted + + impacted_a = get_impacted_files(graph, "file_a") + assert "file_b" not in impacted_a + assert "file_c" in impacted_a diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..129c97d --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,36 @@ +import pytest +from parser import extract_functions, find_functions_using_symbol + +def test_extract_functions(): + code = b""" +def foo(): + pass + +class Bar: + def method(self): + pass + +async def async_foo(): + pass +""" + funcs = extract_functions(code, "python") + + assert "foo" in funcs + assert "method" in funcs + assert "async_foo" in funcs + + assert funcs["foo"] == (2, 3) + assert funcs["method"] == (6, 7) + assert funcs["async_foo"] == (9, 10) + +def test_find_functions_using_symbol(): + code = b""" +def outer(): + modified_target() + +def safe_func(): + pass +""" + at_risk = find_functions_using_symbol(code, "modified_target", "python") + assert "outer" in at_risk + assert "safe_func" not in at_risk From fb1eb80c1e97e35c816f3410288216b9697df4ce Mon Sep 17 00:00:00 2001 From: honeydew78 Date: Fri, 5 Jun 2026 14:16:55 +0530 Subject: [PATCH 2/2] feat: add CLI runner and configure GitHub Actions workflow --- .github/workflows/diff-guard.yml | 39 ++++++ cli.py | 219 +++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 .github/workflows/diff-guard.yml create mode 100644 cli.py diff --git a/.github/workflows/diff-guard.yml b/.github/workflows/diff-guard.yml new file mode 100644 index 0000000..92d0d3d --- /dev/null +++ b/.github/workflows/diff-guard.yml @@ -0,0 +1,39 @@ +name: Diff-Guard CI + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + analyze-risk: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Crucial: Fetches all history so both base and head commits are available locally + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Diff-Guard Analysis + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python cli.py \ + --base ${{ github.event.pull_request.base.sha }} \ + --head ${{ github.event.pull_request.head.sha }} \ + --post-comment \ + --pr-number ${{ github.event.pull_request.number }} \ + --github-repository ${{ github.repository }} diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..6b5a3c8 --- /dev/null +++ b/cli.py @@ -0,0 +1,219 @@ +import subprocess +import argparse +import sys +import os + +# Import existing helpers from our app and parser +from app import get_changed_line_numbers, format_markdown_report, post_comment_to_github +from graph_engine import build_dependency_graph, get_impacted_files +from parser import extract_functions, find_functions_using_symbol +import networkx as nx + +def git_list_files(repo_path: str, commit: str) -> list[str]: + """ + Lists all Python files in the repository at a specific commit. + """ + try: + result = subprocess.run( + ["git", "ls-tree", "-r", "--name-only", commit], + cwd=repo_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + text=True + ) + return [line.strip() for line in result.stdout.splitlines() if line.strip().endswith(".py")] + except subprocess.CalledProcessError as e: + print(f"Error listing files for commit {commit}: {e.stderr.strip()}", file=sys.stderr) + sys.exit(1) + +def git_show(repo_path: str, commit: str, file_path: str) -> bytes: + """ + Gets the contents of a file at a specific commit. + Returns empty bytes if the file doesn't exist at that commit. + """ + try: + result = subprocess.run( + ["git", "show", f"{commit}:{file_path}"], + cwd=repo_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True + ) + return result.stdout + except subprocess.CalledProcessError: + # File might be newly created or deleted, return empty bytes + return b"" + +def git_diff_files(repo_path: str, base: str, head: str) -> list[str]: + """ + Gets the list of modified/added/deleted Python files between base and head. + """ + try: + result = subprocess.run( + ["git", "diff", "--name-only", base, head], + cwd=repo_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + text=True + ) + return [line.strip() for line in result.stdout.splitlines() if line.strip().endswith(".py")] + except subprocess.CalledProcessError as e: + print(f"Error running git diff: {e.stderr.strip()}", file=sys.stderr) + sys.exit(1) + +def run_analysis(repo_path: str, base: str, head: str) -> str: + """ + Executes the Diff-Guard risk analysis locally by reading from Git. + """ + # 1. List and fetch all files at the base commit + base_file_paths = git_list_files(repo_path, base) + base_files = {} + for f in base_file_paths: + base_files[f] = git_show(repo_path, base, f) + + # 2. Build dependency graph + graph = build_dependency_graph(base_files) + + # 3. List modified files + modified_files = [] + modified_functions_by_file = {} + + diff_file_paths = git_diff_files(repo_path, base, head) + for file_path in diff_file_paths: + base_code = git_show(repo_path, base, file_path) + head_code = git_show(repo_path, head, file_path) + + if not base_code: + # File newly added in head + head_funcs = extract_functions(head_code, "python") + changed_funcs = [(f_name, "added") for f_name in head_funcs.keys()] + if changed_funcs: + modified_files.append(file_path) + modified_functions_by_file[file_path] = changed_funcs + elif not head_code: + # File deleted in head + base_funcs = extract_functions(base_code, "python") + changed_funcs = [(f_name, "deleted") for f_name in base_funcs.keys()] + if changed_funcs: + modified_files.append(file_path) + modified_functions_by_file[file_path] = changed_funcs + else: + # File modified + if base_code != head_code: + base_funcs = extract_functions(base_code, "python") + head_funcs = extract_functions(head_code, "python") + + base_changed, head_changed = get_changed_line_numbers(base_code, head_code) + changed_funcs = [] + + # Check for modified or deleted functions + for f_name, (start_b, end_b) in base_funcs.items(): + if f_name not in head_funcs: + changed_funcs.append((f_name, "deleted")) + else: + start_h, end_h = head_funcs[f_name] + b_intersect = any(line in base_changed for line in range(start_b, end_b + 1)) + h_intersect = any(line in head_changed for line in range(start_h, end_h + 1)) + if b_intersect or h_intersect: + changed_funcs.append((f_name, "modified")) + + # Check for newly added functions + for f_name, (start_h, end_h) in head_funcs.items(): + if f_name not in base_funcs: + changed_funcs.append((f_name, "added")) + + if changed_funcs: + modified_files.append(file_path) + modified_functions_by_file[file_path] = changed_funcs + + # 4. Compute blast radius upstream + all_impacted_files = set() + impact_paths = {} + at_risk_functions = {} + + for m_file in modified_files: + impacted = get_impacted_files(graph, m_file) + all_impacted_files.update(impacted) + + paths_dict = {} + for imp_file in impacted: + try: + path = nx.shortest_path(graph, source=m_file, target=imp_file) + paths_dict[imp_file] = path + except nx.NetworkXNoPath: + paths_dict[imp_file] = [m_file, imp_file] + impact_paths[m_file] = paths_dict + + # 5. Scan direct consumers for at-risk functions + for m_file in modified_files: + changed_entities = modified_functions_by_file.get(m_file, []) + for imp_file, path in impact_paths.get(m_file, {}).items(): + if len(path) == 2: # Direct consumer + # Get consumer file contents at head state + code_bytes = git_show(repo_path, head, imp_file) + if code_bytes: + for c_func_name, c_type in changed_entities: + if c_type in ("modified", "deleted"): + found_funcs = find_functions_using_symbol(code_bytes, c_func_name) + if found_funcs: + if imp_file not in at_risk_functions: + at_risk_functions[imp_file] = [] + at_risk_functions[imp_file].extend(found_funcs) + + # Dedup at-risk functions list + for k in at_risk_functions: + at_risk_functions[k] = list(dict.fromkeys(at_risk_functions[k])) + + # 6. Format markdown report + # We pass 0 as dummy PR number for CLI display, or we will override it in main() + return format_markdown_report( + 0, modified_files, modified_functions_by_file, + all_impacted_files, impact_paths, at_risk_functions + ) + +def main(): + parser = argparse.ArgumentParser(description="Diff-Guard Local CLI Analysis") + parser.add_argument("--base", required=True, help="Base commit or branch (e.g. main)") + parser.add_argument("--head", required=True, help="Head commit or branch (e.g. feature)") + parser.add_argument("--repo", default=".", help="Path to local Git repository (default: current directory)") + parser.add_argument("--post-comment", action="store_true", help="Post report to GitHub pull request") + parser.add_argument("--pr-number", type=int, help="GitHub Pull Request number") + parser.add_argument("--github-repository", help="GitHub repository name (e.g., owner/repo)") + + args = parser.parse_args() + + report_md = run_analysis(args.repo, args.base, args.head) + + print("\n--- Diff-Guard Analysis Report ---") + print(report_md) + print("----------------------------------\n") + + if args.post_comment: + if not args.pr_number or not args.github_repository: + print("Error: --pr-number and --github-repository are required when using --post-comment", file=sys.stderr) + sys.exit(1) + + token = os.getenv("GITHUB_TOKEN") + if not token: + print("Error: GITHUB_TOKEN environment variable is required to post comments", file=sys.stderr) + sys.exit(1) + + # Update dummy PR number in report string + report_md = report_md.replace("PR: #0", f"PR: #{args.pr_number}") + + # Override the global GITHUB_TOKEN inside app.py module's context so it can authenticate + import app + app.GITHUB_TOKEN = token + + parts = args.github_repository.split("/") + if len(parts) != 2: + print(f"Error: Invalid repository format '{args.github_repository}'. Expected 'owner/repo'", file=sys.stderr) + sys.exit(1) + + owner, repo_name = parts + post_comment_to_github(owner, repo_name, args.pr_number, report_md) + +if __name__ == "__main__": + main()