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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Toggle **This review** (impact subgraph) vs **Indexed architecture** (the repo m

### Pull requests

GitHub and Bitbucket via API tokens from Settings. Pick a PR and jump to a branch-range review. The screenshot uses a fixture PR so the tab is not empty.
GitHub and Bitbucket via **Sign in** (OAuth) or a token in Settings. After connecting, **My repos** lists every repository the account can access. Pick one and jump to a branch-range review. Review still runs against a **local clone** — if that clone is already a Loadpath workspace, selecting the remote repo fills the local path from `git remote`. The screenshot uses a fixture PR so the tab is not empty.

![Pull requests list](docs/screenshots/pull-requests.png)

Expand All @@ -75,7 +75,11 @@ Browse the filesystem, pick a project root. Loadpath remembers recent workspaces

### Settings

Appearance (all 24 themes), GitHub / Bitbucket tokens, and AI providers (Anthropic, OpenAI, Grok/xAI, DeepSeek, Cursor-compatible, Ollama). Residual analysis only — Loadpath does not comment every hunk.
Appearance (all 24 themes), GitHub / Bitbucket **OAuth sign-in** (or a classic PAT / app password), and AI providers (Anthropic, OpenAI, Grok/xAI, DeepSeek, Cursor-compatible, Ollama). Residual analysis only — Loadpath does not comment every hunk.

GitHub uses device flow (`repo read:user read:org`). Create an OAuth App, enable Device Flow, then set `LOADPATH_GITHUB_CLIENT_ID` or paste the client ID in Settings.

Bitbucket uses authorization code. Create an OAuth consumer whose callback is `http://127.0.0.1:7345/api/oauth/bitbucket/callback`, then set `LOADPATH_BITBUCKET_CLIENT_ID` / `LOADPATH_BITBUCKET_CLIENT_SECRET` or paste the key and secret in Settings. Access tokens are refreshed automatically. SCM sign-in and repo listing are local-only (loopback) so a tunneled MCP server does not expose private repos.

![Settings with theme grid](docs/screenshots/settings.png)

Expand Down Expand Up @@ -221,7 +225,7 @@ MCP URL: `https://your-tunnel.example/mcp` (or `http://127.0.0.1:7345/mcp` on th

**Cursor / Claude / ChatGPT / Gemini (HTTP + OAuth)** — add that MCP URL in the host’s connectors. The first connect opens a consent page on the Loadpath machine.

Tools: `list_workspaces`, `init_repo`, `index_repo`, `architecture`, `review`, `detect_repo`, `list_pull_requests`, `post_review_comment`. `review` returns the load-path brief (confidence, sinks, reviewers) — not hunk comments.
Tools: `list_workspaces`, `init_repo`, `index_repo`, `architecture`, `review`, `detect_repo`, `list_pull_requests`, `list_remote_repositories`, `post_review_comment`. `review` returns the load-path brief (confidence, sinks, reviewers) — not hunk comments.

Put `loadpath.yml` at the repo root (see [`loadpath.yml.example`](loadpath.yml.example) and [`fixtures/demo_monorepo/loadpath.yml`](fixtures/demo_monorepo/loadpath.yml)). The tool is opinionated about *your* architecture, not a generic module graph.

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ Please report vulnerabilities privately to the repository owner (`Modsofthenatio

Once this repository is public, enable [GitHub private vulnerability reporting](https://docs.github.com/code-security/security-advisories/working-with-repository-security-advisories/configuring-private-vulnerability-reporting-for-a-repository) so researchers can use Security Advisories. The `/security/advisories/new` form 404s until that setting is on.

Tokens and OAuth state live on the machine that runs Loadpath (`~/.loadpath/`); treat that host as trusted.
Tokens and OAuth state live on the machine that runs Loadpath (`~/.loadpath/`); treat that host as trusted. SCM sign-in, disconnect, and `/api/scm/repos` only accept the local Loadpath UI (loopback Origin/Host), so a tunneled MCP server does not list private repositories.
6 changes: 4 additions & 2 deletions desktop/urls.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ export function isAllowedExternalUrl(url) {
if (parsed.protocol !== "https:") return false;
if (parsed.username || parsed.password) return false;
const host = parsed.hostname.toLowerCase();
return (
return (
host === "github.com" ||
host.endsWith(".github.com") ||
host === "bitbucket.org" ||
host.endsWith(".bitbucket.org")
host.endsWith(".bitbucket.org") ||
host === "id.atlassian.com" ||
host.endsWith(".atlassian.com")
);
}

Expand Down
1 change: 1 addition & 0 deletions desktop/urls.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe("isAllowedExternalUrl", () => {
it("allows GitHub and Bitbucket https PR links", () => {
assert.equal(isAllowedExternalUrl("https://github.com/acme/demo/pull/12"), true);
assert.equal(isAllowedExternalUrl("https://bitbucket.org/acme/demo/pull-requests/3"), true);
assert.equal(isAllowedExternalUrl("https://id.atlassian.com/login"), true);
});

it("rejects credentials, other hosts, and non-https schemes", () => {
Expand Down
4 changes: 4 additions & 0 deletions src/loadpath/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ def _register_tools(mcp: MCPServer) -> None:
name="list_pull_requests",
description="List GitHub or Bitbucket pull requests using tokens stored in Loadpath settings.",
)(tools.list_pull_requests)
mcp.tool(
name="list_remote_repositories",
description="List GitHub or Bitbucket repositories the signed-in account can access.",
)(tools.list_remote_repositories)
mcp.tool(
name="post_review_comment",
description="Upsert the single Loadpath markdown brief on a pull request (updated in place).",
Expand Down
76 changes: 54 additions & 22 deletions src/loadpath/mcp/tools.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,51 @@
from __future__ import annotations

from pathlib import Path
from typing import Any
from typing import Any, Callable, TypeVar

import httpx

from loadpath.architecture.snapshot import architecture_report, summarize_index
from loadpath.config import load_config
from loadpath.detect import detect_layout, write_draft_config
from loadpath.index import index_repo
from loadpath.mcp.compact import compact_architecture, compact_review
from loadpath.providers.scm import provider_for
from loadpath.providers.scm import attach_local_paths, provider_for
from loadpath.review.engine import run_review
from loadpath.review.render import render_markdown
from loadpath.settings import AppSettings, register_workspace

_T = TypeVar("_T")


def _with_scm(provider: str, fn: Callable[[Any], _T]) -> _T | dict[str, str]:
settings = AppSettings.load()
token = settings.github_token if provider == "github" else settings.bitbucket_token
username = settings.bitbucket_username
if not token:
return {"error": f"No {provider} token configured in Loadpath settings"}
try:
return fn(provider_for(provider, token, username=username))
except httpx.HTTPStatusError as exc:
if (
provider == "bitbucket"
and exc.response is not None
and exc.response.status_code == 401
and settings.bitbucket_refresh_token
):
from loadpath.providers.oauth import refresh_bitbucket_access_token

try:
settings = refresh_bitbucket_access_token(settings)
except Exception as refresh_exc: # noqa: BLE001
return {"error": str(refresh_exc)}
token = settings.bitbucket_token
username = settings.bitbucket_username
return fn(provider_for(provider, token, username=username))
return {"error": str(exc)}
except Exception as exc: # noqa: BLE001
return {"error": str(exc)}


def _repo(path: str) -> Path | dict[str, str]:
root = Path(path).expanduser().resolve()
Expand Down Expand Up @@ -124,17 +157,21 @@ def list_pull_requests(
state: str = "open",
) -> dict[str, Any]:
"""List pull requests from GitHub or Bitbucket using tokens in ~/.loadpath/settings.json."""
result = _with_scm(provider, lambda scm: scm.list_pull_requests(repo, state=state))
if isinstance(result, dict) and result.get("error"):
return result
return {"pull_requests": [p.to_dict() for p in result]}


def list_remote_repositories(provider: str) -> dict[str, Any]:
"""List GitHub or Bitbucket repositories the saved token can access."""
settings = AppSettings.load()
token = settings.github_token if provider == "github" else settings.bitbucket_token
username = settings.bitbucket_username
if not token:
return {"error": f"No {provider} token configured in Loadpath settings"}
try:
scm = provider_for(provider, token, username=username)
prs = scm.list_pull_requests(repo, state=state)
except Exception as exc: # noqa: BLE001
return {"error": str(exc)}
return {"pull_requests": [p.to_dict() for p in prs]}
result = _with_scm(provider, lambda scm: (scm.list_repositories(), scm.current_user()))
if isinstance(result, dict) and result.get("error"):
return result
repos, profile = result
attach_local_paths(repos, [w.path for w in settings.workspaces])
return {"provider": provider, "user": profile, "repos": [r.to_dict() for r in repos]}


def post_review_comment(
Expand All @@ -146,13 +183,8 @@ def post_review_comment(
"""Upsert the single Loadpath brief comment on a pull request."""
if not markdown.strip():
return {"error": "markdown is empty"}
settings = AppSettings.load()
token = settings.github_token if provider == "github" else settings.bitbucket_token
username = settings.bitbucket_username
if not token:
return {"error": f"No {provider} token configured in Loadpath settings"}
try:
scm = provider_for(provider, token, username=username)
return scm.upsert_pull_request_comment(repo, number, markdown)
except Exception as exc: # noqa: BLE001
return {"error": str(exc)}
result = _with_scm(
provider,
lambda scm: scm.upsert_pull_request_comment(repo, number, markdown),
)
return result
20 changes: 18 additions & 2 deletions src/loadpath/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
from loadpath.providers.scm import BitbucketProvider, GitHubProvider, PullRequest, provider_for
from loadpath.providers.scm import (
BitbucketProvider,
GitHubProvider,
PullRequest,
RemoteRepo,
attach_local_paths,
parse_remote_url,
provider_for,
)

__all__ = ["BitbucketProvider", "GitHubProvider", "PullRequest", "provider_for"]
__all__ = [
"BitbucketProvider",
"GitHubProvider",
"PullRequest",
"RemoteRepo",
"attach_local_paths",
"parse_remote_url",
"provider_for",
]
Loading
Loading