Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 24 additions & 27 deletions src/loadpath/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ class ResidualRequest(BaseModel):
review: dict[str, Any] = Field(default_factory=dict)


def require_repo_path(path: str | None) -> Path:
if not (path or "").strip():
raise HTTPException(400, "repo_path is required")
root = Path(path).expanduser().resolve()
if not root.is_dir():
raise HTTPException(404, f"Repo not found: {root}")
return root


def create_app(
public_url: str | None = None,
oauth_pin: str | None = None,
Expand Down Expand Up @@ -168,11 +177,11 @@ def put_settings(body: SettingsUpdate) -> dict[str, Any]:
current.ai.provider = body.ai_provider
if _should_update_secret(body.ai_api_key, current.ai.api_key):
current.ai.api_key = body.ai_api_key or ""
if body.ai_model is not None:
current.ai.model = body.ai_model
if body.ai_base_url is not None:
current.ai.base_url = body.ai_base_url
if body.workspaces is not None:
if _should_update_secret(body.ai_model, current.ai.model):
current.ai.model = body.ai_model or ""
if _should_update_secret(body.ai_base_url, current.ai.base_url):
current.ai.base_url = body.ai_base_url or ""
if body.workspaces:
from loadpath.settings import Workspace

current.workspaces = [Workspace.model_validate(w) for w in body.workspaces]
Expand All @@ -181,9 +190,7 @@ def put_settings(body: SettingsUpdate) -> dict[str, Any]:

@app.post("/api/index")
def api_index(body: IndexRequest) -> dict[str, Any]:
root = Path(body.repo_path).expanduser().resolve()
if not root.is_dir():
raise HTTPException(404, f"Repo not found: {root}")
root = require_repo_path(body.repo_path)
store = index_repo(root, incremental=body.incremental, draft_config=True)
register_workspace(root)
summary = summarize_index(store, load_config(root))
Expand All @@ -192,9 +199,7 @@ def api_index(body: IndexRequest) -> dict[str, Any]:

@app.get("/api/index")
def api_index_status(repo_path: str) -> dict[str, Any]:
root = Path(repo_path).expanduser().resolve()
if not root.is_dir():
raise HTTPException(404, f"Repo not found: {root}")
root = require_repo_path(repo_path)
report = architecture_report(root)
report.pop("nodes", None)
report.pop("edges", None)
Expand Down Expand Up @@ -229,9 +234,7 @@ def api_repos() -> dict[str, Any]:

@app.post("/api/review")
def api_review(body: ReviewRequest) -> dict[str, Any]:
root = Path(body.repo_path).expanduser().resolve()
if not root.is_dir():
raise HTTPException(404, f"Repo not found: {root}")
root = require_repo_path(body.repo_path)
try:
review = run_review(
root,
Expand All @@ -251,7 +254,7 @@ def api_review(body: ReviewRequest) -> dict[str, Any]:

@app.get("/api/reviews")
def api_reviews(repo_path: str) -> dict[str, Any]:
root = Path(repo_path).expanduser().resolve()
root = require_repo_path(repo_path)
db = default_db_path(root)
if not db.is_file():
return {"reviews": []}
Expand All @@ -262,7 +265,7 @@ def api_reviews(repo_path: str) -> dict[str, Any]:

@app.get("/api/reviews/{review_id}")
def api_review_get(review_id: str, repo_path: str) -> dict[str, Any]:
root = Path(repo_path).expanduser().resolve()
root = require_repo_path(repo_path)
store = GraphStore(default_db_path(root))
item = store.get_review(review_id)
store.close()
Expand All @@ -272,7 +275,7 @@ def api_review_get(review_id: str, repo_path: str) -> dict[str, Any]:

@app.get("/api/graph")
def api_graph(repo_path: str, scope: str = "full") -> dict[str, Any]:
root = Path(repo_path).expanduser().resolve()
root = require_repo_path(repo_path)
db = default_db_path(root)
if not db.is_file():
raise HTTPException(409, "Index the repo first")
Expand All @@ -287,14 +290,12 @@ def api_graph(repo_path: str, scope: str = "full") -> dict[str, Any]:

@app.get("/api/architecture")
def api_architecture(repo_path: str) -> dict[str, Any]:
root = Path(repo_path).expanduser().resolve()
if not root.is_dir():
raise HTTPException(404, f"Repo not found: {root}")
root = require_repo_path(repo_path)
return architecture_report(root)

@app.get("/api/config")
def api_config(repo_path: str) -> dict[str, Any]:
root = Path(repo_path).expanduser().resolve()
root = require_repo_path(repo_path)
cfg = load_config(root)
return {
"contexts": {k: vars(v) for k, v in cfg.contexts.items()},
Expand Down Expand Up @@ -323,18 +324,14 @@ def api_prs(body: PRListRequest) -> dict[str, Any]:

@app.post("/api/init")
def api_init(body: InitRequest) -> dict[str, Any]:
root = Path(body.repo_path).expanduser().resolve()
if not root.is_dir():
raise HTTPException(404, f"Repo not found: {root}")
root = require_repo_path(body.repo_path)
layout = write_draft_config(root, overwrite=body.overwrite)
register_workspace(root)
return layout

@app.get("/api/detect")
def api_detect(repo_path: str) -> dict[str, Any]:
root = Path(repo_path).expanduser().resolve()
if not root.is_dir():
raise HTTPException(404, f"Repo not found: {root}")
root = require_repo_path(repo_path)
return detect_layout(root)

@app.post("/api/prs/comment")
Expand Down
62 changes: 62 additions & 0 deletions src/loadpath/static/assets/index-DASg3n-q.js

Large diffs are not rendered by default.

62 changes: 0 additions & 62 deletions src/loadpath/static/assets/index-gOpwxVBZ.js

This file was deleted.

2 changes: 1 addition & 1 deletion src/loadpath/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="./assets/index-gOpwxVBZ.js"></script>
<script type="module" crossorigin src="./assets/index-DASg3n-q.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DA_Qg5fM.css">
</head>
<body>
Expand Down
69 changes: 69 additions & 0 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

import shutil
import socket
import threading
from collections.abc import Iterator
from pathlib import Path

import pytest
import uvicorn

from loadpath.server.app import create_app
from tests.conftest import prepare_review_repo


def _free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port


@pytest.fixture
def live_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[str, Path]]:
original_home = Path.home()
monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(original_home / ".cache" / "ms-playwright"))
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
repo = prepare_review_repo(tmp_path)
pretty = tmp_path / "acme-billing"
shutil.copytree(repo, pretty)
port = _free_port()
server = uvicorn.Server(
uvicorn.Config(create_app(), host="127.0.0.1", port=port, log_level="warning")
)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
for _ in range(200):
if server.started:
break
thread.join(0.05)
if not server.started:
pytest.skip("uvicorn failed to start")
try:
yield f"http://127.0.0.1:{port}", pretty
finally:
server.should_exit = True
thread.join(timeout=5)


@pytest.fixture
def browser_page():
pytest.importorskip("playwright")
from playwright.sync_api import sync_playwright

try:
pw = sync_playwright().start()
browser = pw.chromium.launch(headless=True)
except Exception as exc: # noqa: BLE001
pytest.skip(f"Chromium not available: {exc}")
context = browser.new_context(viewport={"width": 1440, "height": 900})
context.grant_permissions(["clipboard-read", "clipboard-write"])
page = context.new_page()
try:
yield page
finally:
browser.close()
pw.stop()
60 changes: 60 additions & 0 deletions tests/e2e/test_api_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,63 @@ def test_api_lists_github_and_bitbucket_prs(tmp_path, monkeypatch):
bb = client.post("/api/prs", json={"provider": "bitbucket", "repo": "acme/demo"})
assert bb.status_code == 200, bb.text
assert bb.json()["pull_requests"][0]["provider"] == "bitbucket"


def test_blank_and_missing_repo_path_rejected(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
client = TestClient(create_app())
for method, url, payload in (
("POST", "/api/index", {"repo_path": ""}),
("POST", "/api/index", {"repo_path": " "}),
("POST", "/api/review", {"repo_path": "", "base": "HEAD"}),
("POST", "/api/init", {"repo_path": ""}),
):
r = client.request(method, url, json=payload)
assert r.status_code == 400, r.text
assert r.json()["detail"] == "repo_path is required"

for url in ("/api/architecture", "/api/graph", "/api/config", "/api/detect", "/api/index"):
r = client.get(url, params={"repo_path": ""})
assert r.status_code == 400, r.text
assert r.json()["detail"] == "repo_path is required"

missing = client.post("/api/index", json={"repo_path": "/no/such/loadpath-repo"})
assert missing.status_code == 404
assert "not found" in missing.json()["detail"].lower()


def test_settings_empty_model_does_not_wipe(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
client = TestClient(create_app())
saved = client.put(
"/api/settings",
json={"ai_provider": "grok", "ai_model": "grok-keep", "ai_base_url": "https://example.invalid"},
)
assert saved.json()["ai"]["model"] == "grok-keep"
kept = client.put(
"/api/settings",
json={"ai_provider": "grok", "ai_model": "", "ai_base_url": "", "github_token": ""},
)
body = kept.json()
assert body["ai"]["provider"] == "grok"
assert body["ai"]["model"] == "grok-keep"
assert body["ai"]["base_url"] == "https://example.invalid"


def test_settings_empty_workspaces_do_not_wipe(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
client = TestClient(create_app())
workspace = {"path": str(tmp_path / "acme-billing"), "name": "acme-billing"}
seeded = client.put("/api/settings", json={"workspaces": [workspace]})
expected = seeded.json()["workspaces"]
assert len(expected) == 1
assert expected[0]["path"] == workspace["path"]
assert expected[0]["name"] == workspace["name"]
omitted = client.put("/api/settings", json={"ai_provider": "grok"})
assert omitted.json()["workspaces"] == expected
emptied = client.put("/api/settings", json={"ai_provider": "none", "workspaces": []})
assert emptied.json()["workspaces"] == expected

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading