From daec2c0acb1a9604845f3f4f19478495e308c6d1 Mon Sep 17 00:00:00 2001 From: Graphify Contributor Date: Fri, 24 Jul 2026 01:34:13 +0200 Subject: [PATCH] feat: add GitLab merge request support --- README.md | 34 +++++ graphify/gitlab.py | 291 +++++++++++++++++++++++++++++++++++++++++++ graphify/prs.py | 187 ++++++++++++++++++--------- graphify/serve.py | 58 +++++---- tests/test_gitlab.py | 210 +++++++++++++++++++++++++++++++ tests/test_prs.py | 35 +++++- 6 files changed, 726 insertions(+), 89 deletions(-) create mode 100644 graphify/gitlab.py create mode 100644 tests/test_gitlab.py diff --git a/README.md b/README.md index 0459f57cf..b08696541 100644 --- a/README.md +++ b/README.md @@ -457,6 +457,35 @@ python -m graphify.serve graphify-out/graph.json --transport http --host 0.0.0.0 The MCP server gives your assistant structured access: `query_graph`, `get_node`, `get_neighbors`, `shortest_path`, `list_prs`, `get_pr_impact`, `triage_prs`. +### GitHub and GitLab change requests + +The change-request tools support GitHub pull requests and GitLab merge requests while +keeping their existing MCP names for compatibility. Graphify uses +`GRAPHIFY_VCS_PROVIDER` when set; otherwise it detects GitLab from the configured URL +or the current `origin` remote. GitHub continues to use the `gh` CLI; GitLab uses +the REST API directly and does not require `glab`. + +For a self-hosted or explicitly configured GitLab project: + +```bash +GRAPHIFY_VCS_PROVIDER=gitlab \ +GITLAB_URL=https://gitlab.example.com \ +GITLAB_PROJECT=group/subgroup/project \ +GITLAB_TOKEN="${GITLAB_TOKEN}" \ +python -m graphify.serve --graph graphify-out/graph.json +``` + +`GITLAB_TOKEN`, `GL_TOKEN`, and `PRIVATE_TOKEN` are checked in that order. To reuse +an existing Git Credential Manager entry without putting a token in the MCP +configuration, opt in with `GRAPHIFY_GITLAB_USE_GIT_CREDENTIALS=true`. The credential +is read non-interactively and cached only in memory for the lifetime of the server. + +The `repo` argument accepted by `list_prs`, `get_pr_impact`, and `triage_prs` +overrides `GITLAB_PROJECT` for an individual call. Gitlink changes are expanded to +the files changed inside a submodule when both referenced commits exist in the local +submodule checkout; otherwise Graphify reports the submodule path itself. + + ### Shared HTTP server `--transport stdio` (the default) spawns one local server per developer. `--transport http` serves the same tools over the MCP Streamable HTTP transport, so a single shared process can serve the graph for the whole team — clients point their IDE MCP config at `http://:8080/mcp` instead of running graphify locally. @@ -519,6 +548,11 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe | `GRAPHIFY_GOOGLE_WORKSPACE` | Auto-enable Google Workspace export | optional — set to `1` | | `GRAPHIFY_TRIAGE_BACKEND` | Backend for `graphify prs --triage` | optional — auto-detected from available keys | | `GRAPHIFY_TRIAGE_MODEL` | Model override for triage | optional — e.g. `claude-opus-4-7` | +| `GRAPHIFY_VCS_PROVIDER` | Change-request provider: `github` or `gitlab` | optional — otherwise inferred from configuration/origin | +| `GITLAB_URL` | GitLab base URL, including self-hosted instances | required for explicit GitLab configuration | +| `GITLAB_PROJECT` | URL-style GitLab project path, e.g. `group/project` | required unless supplied via `repo` or inferred from origin | +| `GITLAB_TOKEN`, `GL_TOKEN`, or `PRIVATE_TOKEN` | GitLab API token | required for private projects unless credential-manager fallback is enabled | +| `GRAPHIFY_GITLAB_USE_GIT_CREDENTIALS` | Read an existing Git credential non-interactively and keep it only in memory | optional — `true`, `1`, or `yes` | | `GRAPHIFY_QUERY_LOG_ENABLE` | Set to `1` to turn on the local query log at `~/.cache/graphify-queries.log` (records each query/path/explain question + corpus path). Off by default — nothing is written unless you opt in (#1797) | optional | | `GRAPHIFY_QUERY_LOG` | Enable the query log and write it to this path instead of the default | optional — off unless this or `_ENABLE` is set | | `GRAPHIFY_QUERY_LOG_DISABLE` | Set to `1` to force the query log off (wins over the enable vars) | optional | diff --git a/graphify/gitlab.py b/graphify/gitlab.py new file mode 100644 index 000000000..dad9f0390 --- /dev/null +++ b/graphify/gitlab.py @@ -0,0 +1,291 @@ +"""GitLab REST API adapter for Graphify change-request analysis.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode, urlparse +from urllib.request import Request, urlopen + + +@dataclass(frozen=True) +class GitLabConfig: + """Connection details resolved without persisting credentials.""" + + base_url: str + project: str + token: str | None = None + + +def _origin_url() -> str | None: + """Return the current repository's origin URL when Git is available.""" + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + return result.stdout.strip() if result.returncode == 0 else None + + +@lru_cache(maxsize=8) +def _git_credential_token(base_url: str) -> str | None: + """Read an existing Git credential in memory without logging or persisting it.""" + parsed = urlparse(base_url) + if not parsed.hostname: + return None + host = parsed.hostname + (f":{parsed.port}" if parsed.port else "") + credential_input = f"protocol={parsed.scheme or 'https'}\nhost={host}\n\n" + credential_env = os.environ.copy() + # MCP servers have no interactive terminal. Prevent Git Credential Manager + # from trying to open a prompt and retaining the stdio request indefinitely. + credential_env["GCM_INTERACTIVE"] = "Never" + credential_env["GIT_TERMINAL_PROMPT"] = "0" + try: + result = subprocess.run( + ["git", "credential", "fill"], + input=credential_input, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=15, + env=credential_env, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + values = dict( + line.split("=", 1) + for line in result.stdout.splitlines() + if "=" in line + ) + return values.get("password") + + +def _parse_remote(remote: str) -> tuple[str, str] | None: + """Extract ``(base_url, project_path)`` from HTTP or SSH Git remotes.""" + value = remote.strip() + if not value: + return None + if value.startswith(("http://", "https://", "ssh://")): + parsed = urlparse(value) + if not parsed.hostname: + return None + scheme = parsed.scheme if parsed.scheme in ("http", "https") else "https" + project = parsed.path.lstrip("/") + base_url = f"{scheme}://{parsed.hostname}" + if parsed.port: + base_url += f":{parsed.port}" + else: + match = re.match(r"^(?:[^@]+@)?([^:]+):(.+)$", value) + if not match: + return None + base_url = f"https://{match.group(1)}" + project = match.group(2) + if project.endswith(".git"): + project = project[:-4] + return (base_url.rstrip("/"), project.strip("/")) if project else None + + +def is_gitlab_repository(repo: str | None = None) -> bool: + """Detect GitLab from an explicit URL, configured URL, or current origin.""" + explicit_url = os.environ.get("GITLAB_URL", "") + candidates = [repo or "", explicit_url, _origin_url() or ""] + return any("gitlab" in candidate.lower() for candidate in candidates) + + +def resolve_config(repo: str | None = None) -> GitLabConfig: + """Resolve GitLab host, project and optional token from environment/origin.""" + parsed_repo = _parse_remote(repo) if repo and "://" in repo else None + + base_url = os.environ.get("GITLAB_URL", "").rstrip("/") + if not base_url and parsed_repo: + base_url = parsed_repo[0] + + project = os.environ.get("GITLAB_PROJECT", "") + if repo: + project = parsed_repo[1] if parsed_repo else repo.strip("/") + + # Avoid spawning Git for every API request when MCP configuration already + # identifies the host and project. Git for Windows can retain its child + # process when invoked from a non-interactive stdio server. + if not base_url or not project: + origin = _origin_url() + parsed_origin = _parse_remote(origin) if origin else None + if not base_url and parsed_origin: + base_url = parsed_origin[0] + if not project and parsed_origin: + project = parsed_origin[1] + if project.endswith(".git"): + project = project[:-4] + + if not base_url or not project: + raise RuntimeError( + "Cannot resolve GitLab URL/project. Set GITLAB_URL and GITLAB_PROJECT " + "or run Graphify inside a GitLab repository." + ) + token = ( + os.environ.get("GITLAB_TOKEN") + or os.environ.get("GL_TOKEN") + or os.environ.get("PRIVATE_TOKEN") + ) + use_git_credentials = os.environ.get( + "GRAPHIFY_GITLAB_USE_GIT_CREDENTIALS", "" + ).lower() in {"1", "true", "yes"} + if not token and use_git_credentials: + token = _git_credential_token(base_url) + return GitLabConfig(base_url=base_url, project=project, token=token) + + +def _request( + config: GitLabConfig, + path: str, + query: dict[str, Any] | None = None, +) -> tuple[Any, Any]: + """Perform one authenticated GitLab API request and decode its JSON body.""" + url = f"{config.base_url}/api/v4{path}" + if query: + url += "?" + urlencode(query) + headers = {"Accept": "application/json", "User-Agent": "graphify-gitlab"} + if config.token: + headers["PRIVATE-TOKEN"] = config.token + request = Request(url, headers=headers) + try: + with urlopen(request, timeout=30) as response: # noqa: S310 - configured GitLab host + return json.loads(response.read().decode("utf-8")), response.headers + except HTTPError as exc: + if exc.code in (401, 403) and not config.token: + raise RuntimeError( + "GitLab API authentication required. Set GITLAB_TOKEN or GL_TOKEN " + "in the MCP server environment." + ) from exc + raise RuntimeError(f"GitLab API request failed with HTTP {exc.code}.") from exc + except (URLError, TimeoutError, json.JSONDecodeError) as exc: + raise RuntimeError(f"GitLab API request failed: {exc}") from exc + + +def _project_path(config: GitLabConfig) -> str: + return "/projects/" + quote(config.project, safe="") + + +def _expand_submodule_diff(path: str, diff: str) -> list[str]: + """Expand a GitLab gitlink diff using commits already present locally.""" + old_match = re.search(r"^-Subproject commit ([0-9a-f]{40})$", diff, re.MULTILINE) + new_match = re.search(r"^\+Subproject commit ([0-9a-f]{40})$", diff, re.MULTILINE) + if not old_match or not new_match: + return [] + root = Path.cwd().resolve() + candidate = (root / path).resolve() + try: + candidate.relative_to(root) + except ValueError: + return [] + if not candidate.is_dir(): + return [] + try: + result = subprocess.run( + [ + "git", "-C", str(candidate), "diff", "--name-only", + old_match.group(1), new_match.group(1), + ], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + if result.returncode != 0: + return [] + prefix = path.rstrip("/").replace("\\", "/") + return [ + f"{prefix}/{changed.strip().replace(chr(92), '/')}" + for changed in result.stdout.splitlines() + if changed.strip() + ] + + +def get_default_branch(repo: str | None = None) -> str: + """Return the GitLab project's configured default branch.""" + config = resolve_config(repo) + data, _ = _request(config, _project_path(config)) + return data.get("default_branch") or "main" + + +def list_open_merge_requests(repo: str | None = None, limit: int = 50) -> list[dict]: + """Return open merge requests for the resolved GitLab project.""" + config = resolve_config(repo) + data, _ = _request( + config, + _project_path(config) + "/merge_requests", + {"state": "opened", "scope": "all", "per_page": min(limit, 100)}, + ) + return data + + +def get_merge_request(number: int, repo: str | None = None) -> dict: + """Return one merge request by project-local IID.""" + config = resolve_config(repo) + data, _ = _request(config, _project_path(config) + f"/merge_requests/{number}") + return data + + +def get_merge_request_files(number: int, repo: str | None = None) -> list[str]: + """Return every changed path in a merge request, following GitLab pagination.""" + config = resolve_config(repo) + files: list[str] = [] + page = 1 + while True: + data, headers = _request( + config, + _project_path(config) + f"/merge_requests/{number}/diffs", + {"per_page": 100, "page": page}, + ) + for diff in data: + path = diff.get("old_path") if diff.get("deleted_file") else diff.get("new_path") + expanded = _expand_submodule_diff(path or "", diff.get("diff") or "") + if expanded: + for changed in expanded: + if changed not in files: + files.append(changed) + continue + if path and path not in files: + files.append(path) + next_page = headers.get("X-Next-Page") if headers else None + if not next_page: + break + page = int(next_page) + return files + + +def parse_pipeline_status(item: dict) -> str: + """Map a GitLab head-pipeline status to Graphify's CI vocabulary.""" + pipeline = item.get("head_pipeline") or {} + status = (pipeline.get("status") or "").lower() + if status == "success": + return "SUCCESS" + if status in {"failed", "canceled"}: + return "FAILURE" + if status in { + "created", "waiting_for_resource", "preparing", "pending", "running", + "scheduled", "manual", + }: + return "PENDING" + return "NONE" diff --git a/graphify/prs.py b/graphify/prs.py index 9534e6c00..6c67a236c 100644 --- a/graphify/prs.py +++ b/graphify/prs.py @@ -1,16 +1,15 @@ -"""graphify prs — graph-aware PR dashboard. +"""graphify prs — graph-aware change-request dashboard. -Fast terminal overview of open PRs with CI/review state, worktree mapping, -and optional graph-impact analysis (which communities a PR touches) and -Opus-powered triage ranking. +Fast terminal overview of open pull or merge requests with CI/review state, +worktree mapping, optional graph-impact analysis, and triage ranking. Usage: - graphify prs # dashboard of all open PRs - graphify prs # deep dive on one PR - graphify prs --triage # Opus ranks your review queue - graphify prs --worktrees # show worktree → branch → PR mapping - graphify prs --conflicts # PRs sharing graph communities (merge-order risk) - graphify prs --base # filter to PRs targeting this base (default: v8) + graphify prs # dashboard of all open change requests + graphify prs # deep dive on one change request + graphify prs --triage # rank the review queue + graphify prs --worktrees # show worktree → branch → change request mapping + graphify prs --conflicts # requests sharing graph communities (merge-order risk) + graphify prs --base # filter by target branch (default: v8) """ from __future__ import annotations @@ -77,6 +76,7 @@ class PRInfo: communities_touched: list[int] = field(default_factory=list) nodes_affected: int = 0 files_changed: list[str] = field(default_factory=list) + provider: str = "github" @property def status(self) -> str: @@ -138,6 +138,15 @@ def _ci_icon(status: str) -> str: # ── GitHub data fetching ────────────────────────────────────────────────────── +def _detect_provider(repo: str | None = None) -> str: + """Resolve the VCS provider from environment or the current Git remote.""" + explicit = os.environ.get("GRAPHIFY_VCS_PROVIDER", "").strip().lower() + if explicit in {"github", "gitlab"}: + return explicit + from graphify.gitlab import is_gitlab_repository + return "gitlab" if is_gitlab_repository(repo) else "github" + + def _gh(*args: str) -> list | dict | None: try: result = subprocess.run( @@ -155,18 +164,27 @@ def _gh(*args: str) -> list | dict | None: def _detect_default_branch(repo: str | None = None) -> str: - """Auto-detect the repo's default branch via gh, then git, then fall back to 'main'.""" - # Try gh first — works for any repo, not just the current directory - args = ["repo", "view", "--json", "defaultBranchRef"] - if repo: - args += ["--repo", repo] - data = _gh(*args) - if data and data.get("defaultBranchRef", {}).get("name"): - return data["defaultBranchRef"]["name"] + """Auto-detect the default branch via provider, then git, then main.""" + provider = _detect_provider(repo) + if provider == "gitlab": + from graphify.gitlab import get_default_branch + try: + return get_default_branch(repo) + except RuntimeError: + pass + else: + # gh works for any GitHub repo, not just the current directory. + args = ["repo", "view", "--json", "defaultBranchRef"] + if repo: + args += ["--repo", repo] + data = _gh(*args) + if data and data.get("defaultBranchRef", {}).get("name"): + return data["defaultBranchRef"]["name"] # Fall back to git symbolic-ref for the current repo try: result = subprocess.run( ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], + stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5 ) if result.returncode == 0: @@ -195,8 +213,42 @@ def _parse_ci(rollup: list) -> str: return "NONE" +def _github_pr(item: dict, expected_base: str) -> PRInfo: + """Convert one GitHub API/CLI item into Graphify's provider-neutral model.""" + updated = datetime.fromisoformat(item["updatedAt"].replace("Z", "+00:00")) + return PRInfo( + number=item["number"], title=item["title"], branch=item["headRefName"], + base_branch=item["baseRefName"], + author=item["author"]["login"] if item.get("author") else "?", + is_draft=item.get("isDraft", False), + review_decision=item.get("reviewDecision") or "", + ci_status=_parse_ci(item.get("statusCheckRollup") or []), + updated_at=updated, expected_base=expected_base, provider="github", + ) + + +def _gitlab_pr(item: dict, expected_base: str) -> PRInfo: + """Convert one GitLab merge request into Graphify's provider-neutral model.""" + from graphify.gitlab import parse_pipeline_status + updated = datetime.fromisoformat(item["updated_at"].replace("Z", "+00:00")) + author = item.get("author") or {} + return PRInfo( + number=item["iid"], title=item["title"], branch=item["source_branch"], + base_branch=item["target_branch"], + author=author.get("username") or author.get("name") or "?", + is_draft=item.get("draft", item.get("work_in_progress", False)), + review_decision="APPROVED" if item.get("approved") is True else "", + ci_status=parse_pipeline_status(item), updated_at=updated, + expected_base=expected_base, provider="gitlab", + ) + + def fetch_prs(repo: str | None = None, base: str | None = None, limit: int = 50) -> list[PRInfo]: resolved_base = base or _detect_default_branch(repo) + if _detect_provider(repo) == "gitlab": + from graphify.gitlab import list_open_merge_requests + return [_gitlab_pr(item, resolved_base) for item in list_open_merge_requests(repo, limit)] + args = [ "pr", "list", "--state", "open", "--limit", str(limit), "--json", "number,title,headRefName,baseRefName,author,isDraft," @@ -209,25 +261,37 @@ def fetch_prs(repo: str | None = None, base: str | None = None, limit: int = 50) if raw is None: raise RuntimeError("gh CLI not found or not authenticated. Run: gh auth login") - prs = [] - for item in raw: - updated = datetime.fromisoformat(item["updatedAt"].replace("Z", "+00:00")) - prs.append(PRInfo( - number=item["number"], - title=item["title"], - branch=item["headRefName"], - base_branch=item["baseRefName"], - author=item["author"]["login"] if item.get("author") else "?", - is_draft=item.get("isDraft", False), - review_decision=item.get("reviewDecision") or "", - ci_status=_parse_ci(item.get("statusCheckRollup") or []), - updated_at=updated, - expected_base=resolved_base, - )) - return prs + return [_github_pr(item, resolved_base) for item in raw] + + +def fetch_pr(number: int, repo: str | None = None) -> PRInfo | None: + """Fetch one GitHub PR or GitLab merge request by project-local number.""" + base = _detect_default_branch(repo) + if _detect_provider(repo) == "gitlab": + from graphify.gitlab import get_merge_request + try: + return _gitlab_pr(get_merge_request(number, repo), base) + except RuntimeError: + return None + args = [ + "pr", "view", str(number), "--json", + "number,title,headRefName,baseRefName,author,isDraft," + "reviewDecision,statusCheckRollup,updatedAt", + ] + if repo: + args += ["--repo", repo] + data = _gh(*args) + return _github_pr(data, base) if isinstance(data, dict) else None def fetch_pr_files(number: int, repo: str | None = None) -> list[str]: + if _detect_provider(repo) == "gitlab": + from graphify.gitlab import get_merge_request_files + try: + return get_merge_request_files(number, repo) + except RuntimeError: + return [] + args = ["pr", "diff", str(number), "--name-only"] if repo: args += ["--repo", repo] @@ -282,15 +346,23 @@ def compute_pr_impact(files: list[str], G: "nx.Graph") -> tuple[list[int], int]: return sorted(comms), nodes +def change_request_reference(pr: PRInfo, *, qualified: bool = False) -> str: + """Return a provider-native pull/merge request reference.""" + kind = "MR" if pr.provider == "gitlab" else "PR" + marker = "!" if pr.provider == "gitlab" else "#" + compact = f"{marker}{pr.number}" + return f"{kind} {compact}" if qualified else compact + + def format_prs_text(prs: list["PRInfo"], base: str) -> str: """Plain-text PR summary for MCP output (no ANSI).""" actionable = [p for p in prs if p.base_branch == base] wrong = len(prs) - len(actionable) - lines = [f"Open PRs targeting {base}: {len(actionable)} ({wrong} on wrong base, not shown)\n"] + lines = [f"Open change requests targeting {base}: {len(actionable)} ({wrong} on wrong base, not shown)\n"] for p in sorted(actionable, key=lambda x: (_STATUS_ORDER.index(x.status) if x.status in _STATUS_ORDER else 99, x.days_old)): impact = f" blast_radius={p.blast_radius}" if p.blast_radius else "" lines.append( - f"#{p.number} [{p.status}] CI={p.ci_status} review={p.review_decision or 'none'} " + f"{change_request_reference(p)} [{p.status}] CI={p.ci_status} review={p.review_decision or 'none'} " f"age={p.days_old}d author={p.author}{impact}\n {p.title}" ) return "\n\n".join(lines) @@ -303,6 +375,7 @@ def fetch_worktrees() -> dict[str, str]: try: result = subprocess.run( ["git", "worktree", "list", "--porcelain"], + stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10 ) if result.returncode != 0: @@ -416,14 +489,14 @@ def render_dashboard(prs: list[PRInfo], base: str = "v8", show_wrong_base: bool actionable.sort(key=lambda p: (_STATUS_ORDER.index(p.status) if p.status in _STATUS_ORDER else 99, p.days_old)) print() - print(bold(f" graphify prs · base: {base} · {len(actionable)} PRs")) + print(bold(f" graphify prs · base: {base} · {len(actionable)} change requests")) print() if not actionable: - print(dim(" No open PRs targeting this base branch.")) + print(dim(" No open change requests targeting this base branch.")) else: # Header - print(f" {'#':>4} {'CI':2} {'STATUS':13} {'UPDATED':8} {'IMPACT':22} TITLE") + print(f" {'ID':>4} {'CI':2} {'STATUS':13} {'UPDATED':8} {'IMPACT':22} TITLE") print(f" {'─'*4} {'─'*2} {'─'*13} {'─'*8} {'─'*22} {'─'*40}") for pr in actionable: @@ -434,7 +507,7 @@ def render_dashboard(prs: list[PRInfo], base: str = "v8", show_wrong_base: bool wt = f" {cyan('⬡')}" if pr.worktree_path else " " draft = dim(" [draft]") if pr.is_draft else "" title = _truncate(pr.title, 52) - num = _pad(bold(f"#{pr.number}"), 6) + num = _pad(bold(change_request_reference(pr)), 6) print(f" {num}{wt} {ci_str} {status_str} {age:>6} {impact} {title}{draft}") # Summary line @@ -459,9 +532,9 @@ def render_dashboard(prs: list[PRInfo], base: str = "v8", show_wrong_base: bool print() if wrong_base and show_wrong_base: - print(dim(f" ── {len(wrong_base)} PRs targeting wrong base ──")) + print(dim(f" ── {len(wrong_base)} change requests targeting wrong base ──")) for pr in sorted(wrong_base, key=lambda p: p.number, reverse=True): - print(dim(f" #{pr.number:4} base={pr.base_branch:12} {_truncate(pr.title, 60)}")) + print(dim(f" {change_request_reference(pr):>5} base={pr.base_branch:12} {_truncate(pr.title, 60)}")) print() @@ -480,10 +553,10 @@ def render_worktrees(prs: list[PRInfo], worktrees: dict[str, str]) -> None: if pr: status = _status_color(pr.status) print(f" {cyan(path)}") - print(f" {dim('branch:')} {branch} -> PR {bold(f'#{pr.number}')} [{status}] {_truncate(pr.title, 50)}") + print(f" {dim('branch:')} {branch} -> {bold(change_request_reference(pr, qualified=True))} [{status}] {_truncate(pr.title, 50)}") else: print(f" {cyan(path)}") - print(f" {dim('branch:')} {branch} {dim('(no open PR)')}") + print(f" {dim('branch:')} {branch} {dim('(no open change request)')}") print() @@ -505,26 +578,26 @@ def render_conflicts( conflicts = {c: ps for c, ps in comm_to_prs.items() if len(ps) > 1} if not conflicts: - print(green("\n No community overlap between open PRs - safe to merge in any order.\n")) + print(green("\n No community overlap between open change requests - safe to merge in any order.\n")) return print() - print(bold(" Community conflicts (PRs sharing the same graph community)")) + print(bold(" Community conflicts (change requests sharing the same graph community)")) print() labels = community_labels or {} for comm, ps in sorted(conflicts.items(), key=lambda x: -len(x[1])): comm_label_str = "" if comm in labels and labels[comm]: comm_label_str = dim(" — " + ", ".join(labels[comm])) - print(f" {yellow(f'Community {comm}')}{comm_label_str} ({len(ps)} PRs overlap)") + print(f" {yellow(f'Community {comm}')}{comm_label_str} ({len(ps)} change requests overlap)") for pr in ps: - print(f" #{pr.number:4} {_pad(_status_color(pr.status), 13)} {_truncate(pr.title, 55)}") + print(f" {change_request_reference(pr):>5} {_pad(_status_color(pr.status), 13)} {_truncate(pr.title, 55)}") print() def render_pr_detail(pr: PRInfo, repo: str | None = None) -> None: print() - print(bold(f" PR #{pr.number} · {_status_color(pr.status)}")) + print(bold(f" {change_request_reference(pr, qualified=True)} · {_status_color(pr.status)}")) print(f" {pr.title}") print() print(f" {dim('branch:')} {pr.branch} -> {pr.base_branch}") @@ -593,22 +666,22 @@ def triage_with_opus(prs: list[PRInfo], base: str) -> None: candidates = [p for p in prs if p.base_branch == base and p.status not in ("WRONG-BASE", "STALE")] if not candidates: - print(dim(" No actionable PRs to triage.")) + print(dim(" No actionable change requests to triage.")) return lines = [] for pr in candidates: impact = f", blast_radius={pr.blast_radius}" if pr.blast_radius else "" lines.append( - f"PR #{pr.number} [{pr.status}] CI={pr.ci_status} review={pr.review_decision or 'none'} " + f"{change_request_reference(pr, qualified=True)} [{pr.status}] CI={pr.ci_status} review={pr.review_decision or 'none'} " f"age={pr.days_old}d author={pr.author}{impact}\n title: {pr.title}" ) prompt = ( - "You are a senior engineer helping triage a PR review queue. " - "Given these open PRs, rank them by review priority for the repo maintainer. " - "For each PR give: priority number, one sentence on what action to take and why. " - "Be direct and specific. Format each as: #.\n\n" + "You are a senior engineer helping triage a change-request review queue. " + "Given these open pull or merge requests, rank them by review priority for the repo maintainer. " + "For each change request give: priority number, one sentence on what action to take and why. " + "Be direct and specific. Preserve each supplied PR/MR reference in the response.\n\n" + "\n\n".join(lines) ) @@ -729,7 +802,7 @@ def cmd_prs(argv: list[str]) -> None: for pr in prs: pr.worktree_path = worktrees.get(pr.branch) - # Graph impact is expensive (concurrent gh pr diff calls) — only fetch when + # Graph impact is expensive (concurrent provider diff calls) — only fetch when # the user actually needs it: deep dive, triage, and conflict detection. community_labels: dict[int, list[str]] = {} needs_impact = graph_path.exists() and (pr_number is not None or do_triage or do_conflicts) @@ -739,7 +812,7 @@ def cmd_prs(argv: list[str]) -> None: if pr_number is not None: match = next((p for p in prs if p.number == pr_number), None) if not match: - print(red(f" PR #{pr_number} not found in open PRs."), file=sys.stderr) + print(red(f" Change request {pr_number} not found in open change requests."), file=sys.stderr) sys.exit(1) render_pr_detail(match, repo) return diff --git a/graphify/serve.py b/graphify/serve.py index f32a91673..e1e5b6b5d 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -1272,30 +1272,30 @@ async def list_tools() -> list[types.Tool]: types.Tool( name="list_prs", description=( - "List open GitHub PRs with CI status, review state, and graph impact " - "(which communities each PR touches, blast radius). Use this before starting " - "work to check if a PR already covers the area you're about to change." + "List open pull or merge requests with CI status, review state, and graph " + "impact (which communities each change touches, blast radius). Supports " + "GitHub and GitLab based on configuration or the current origin remote." ), inputSchema={ "type": "object", "properties": { - "base": {"type": "string", "description": "Base branch to filter PRs by (auto-detected if omitted)"}, - "repo": {"type": "string", "description": "GitHub repo (owner/repo). Defaults to current repo."}, + "base": {"type": "string", "description": "Base branch to filter change requests by (auto-detected if omitted)"}, + "repo": {"type": "string", "description": "Repository/project path. Defaults to current repo."}, }, }, ), types.Tool( name="get_pr_impact", description=( - "Get detailed graph impact for a specific PR: which files it changes, " + "Get detailed graph impact for a specific pull or merge request: which files it changes, " "which knowledge-graph communities are affected, and how many nodes are touched. " "Use this to assess merge risk or check for overlap with your current work." ), inputSchema={ "type": "object", "properties": { - "pr_number": {"type": "integer", "description": "PR number to analyse"}, - "repo": {"type": "string", "description": "GitHub repo (owner/repo). Defaults to current repo."}, + "pr_number": {"type": "integer", "description": "Pull or merge request number to analyse"}, + "repo": {"type": "string", "description": "Repository/project path. Defaults to current repo."}, }, "required": ["pr_number"], }, @@ -1303,15 +1303,15 @@ async def list_tools() -> list[types.Tool]: types.Tool( name="triage_prs", description=( - "Return all actionable open PRs (correct base, not stale) with full graph impact data " + "Return all actionable open pull or merge requests (correct base, not stale) with full graph impact data " "so you can reason about review priority, merge order, and conflict risk. " - "Call this when the user asks 'what PRs should I review?' or 'what's ready to merge?'" + "Call this when the user asks which change requests are ready to review or merge." ), inputSchema={ "type": "object", "properties": { - "base": {"type": "string", "description": "Base branch to filter PRs by (auto-detected if omitted)"}, - "repo": {"type": "string", "description": "GitHub repo (owner/repo). Defaults to current repo."}, + "base": {"type": "string", "description": "Base branch to filter change requests by (auto-detected if omitted)"}, + "repo": {"type": "string", "description": "Repository/project path. Defaults to current repo."}, }, }, ), @@ -1538,26 +1538,21 @@ def _tool_list_prs(arguments: dict) -> str: return format_prs_text(prs, base) def _tool_get_pr_impact(arguments: dict) -> str: - from graphify.prs import fetch_pr_files, compute_pr_impact, _gh, _parse_ci + from graphify.prs import fetch_pr, fetch_pr_files, compute_pr_impact, change_request_reference number = int(arguments["pr_number"]) repo = arguments.get("repo") or None - # Use gh pr view directly — works for any base branch, not just the default - view_args = ["pr", "view", str(number), "--json", - "title,headRefName,baseRefName,author,isDraft,reviewDecision,statusCheckRollup,updatedAt"] - if repo: - view_args += ["--repo", repo] - pr_data = _gh(*view_args) - if pr_data is None: - return f"PR #{number} not found or gh not authenticated." + pr = fetch_pr(number, repo) + if pr is None: + return f"Change request {number} not found or provider authentication failed." files = fetch_pr_files(number, repo) if not files: - return f"PR #{number}: no changed files found (may require gh auth)." + return f"Change request {number}: no changed files found." comms, nodes = compute_pr_impact(files, G) - ci = _parse_ci(pr_data.get("statusCheckRollup") or []) + reference = change_request_reference(pr, qualified=True) lines = [ - f"PR #{number}: {pr_data['title']}", - f"CI: {ci} Review: {pr_data.get('reviewDecision') or 'none'}", - f"Base: {pr_data['baseRefName']} Author: {(pr_data.get('author') or {}).get('login', '?')}", + f"{reference}: {pr.title}", + f"CI: {pr.ci_status} Review: {pr.review_decision or 'none'}", + f"Base: {pr.base_branch} Author: {pr.author}", f"\nGraph impact: {nodes} nodes across {len(comms)} communities", f"Communities touched: {comms}", f"Files changed ({len(files)}):", @@ -1569,7 +1564,10 @@ def _tool_get_pr_impact(arguments: dict) -> str: def _tool_triage_prs(arguments: dict) -> str: from concurrent.futures import ThreadPoolExecutor, as_completed - from graphify.prs import fetch_prs, fetch_worktrees, fetch_pr_files, compute_pr_impact, _STATUS_ORDER, _detect_default_branch + from graphify.prs import ( + fetch_prs, fetch_worktrees, fetch_pr_files, compute_pr_impact, + change_request_reference, _STATUS_ORDER, _detect_default_branch, + ) repo = arguments.get("repo") or None base = arguments.get("base") or _detect_default_branch(repo) try: @@ -1581,7 +1579,7 @@ def _tool_triage_prs(arguments: dict) -> str: pr.worktree_path = worktrees.get(pr.branch) actionable = [p for p in prs if p.base_branch == base and p.status not in ("WRONG-BASE", "STALE")] if not actionable: - return f"No actionable PRs targeting {base}." + return f"No actionable change requests targeting {base}." # Fetch diffs concurrently then compute graph impact using in-memory G workers = min(8, len(actionable)) with ThreadPoolExecutor(max_workers=workers) as pool: @@ -1596,7 +1594,7 @@ def _tool_triage_prs(arguments: dict) -> str: pr.files_changed = files pr.communities_touched, pr.nodes_affected = compute_pr_impact(files, G) header = ( - f"Actionable PRs targeting {base}: {len(actionable)}\n" + f"Actionable change requests targeting {base}: {len(actionable)}\n" "Rank these by review priority. Higher blast_radius = more graph communities affected = higher merge risk.\n" ) lines = [header] @@ -1604,7 +1602,7 @@ def _tool_triage_prs(arguments: dict) -> str: impact = f" blast_radius={p.blast_radius}" if p.blast_radius else "" wt = f" worktree={p.worktree_path}" if p.worktree_path else "" lines.append( - f"PR #{p.number} [{p.status}] CI={p.ci_status} review={p.review_decision or 'none'} " + f"{change_request_reference(p, qualified=True)} [{p.status}] CI={p.ci_status} review={p.review_decision or 'none'} " f"age={p.days_old}d author={p.author}{impact}{wt}\n title: {p.title}" ) return "\n\n".join(lines) diff --git a/tests/test_gitlab.py b/tests/test_gitlab.py new file mode 100644 index 000000000..68100528e --- /dev/null +++ b/tests/test_gitlab.py @@ -0,0 +1,210 @@ +"""Tests for the GitLab change-request provider.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from graphify import gitlab +from graphify.prs import fetch_pr_files, fetch_prs + + +class TestGitLabConfig: + def test_parse_https_remote(self): + assert gitlab._parse_remote( + "https://gitlab.example.com/group/sub/project.git" + ) == ("https://gitlab.example.com", "group/sub/project") + + def test_parse_ssh_remote(self): + assert gitlab._parse_remote( + "git@gitlab.example.com:group/sub/project.git" + ) == ("https://gitlab.example.com", "group/sub/project") + + def test_resolve_from_origin_and_environment_token(self): + with patch.dict( + "os.environ", + {"GITLAB_TOKEN": "secret"}, + clear=True, + ), patch( + "graphify.gitlab._origin_url", + return_value="https://gitlab.example.com/group/project.git", + ): + config = gitlab.resolve_config() + assert config.base_url == "https://gitlab.example.com" + assert config.project == "group/project" + assert config.token == "secret" + + def test_explicit_project_uses_configured_url(self): + with patch.dict( + "os.environ", + {"GITLAB_URL": "https://gitlab.example.com"}, + clear=True, + ): + config = gitlab.resolve_config("group/project") + assert config.project == "group/project" + + def test_complete_environment_skips_origin_lookup(self): + environment = { + "GITLAB_URL": "https://gitlab.example.com", + "GITLAB_PROJECT": "group/project", + } + with patch.dict("os.environ", environment, clear=True), patch( + "graphify.gitlab._origin_url" + ) as origin: + config = gitlab.resolve_config() + assert config.project == "group/project" + origin.assert_not_called() + + def test_opt_in_uses_git_credential_manager(self): + environment = { + "GITLAB_URL": "https://gitlab.example.com", + "GRAPHIFY_GITLAB_USE_GIT_CREDENTIALS": "true", + } + with patch.dict("os.environ", environment, clear=True), patch( + "graphify.gitlab._git_credential_token", return_value="credential-token" + ): + config = gitlab.resolve_config("group/project") + assert config.token == "credential-token" + + def test_git_credential_parser_returns_password(self): + gitlab._git_credential_token.cache_clear() + result = type( + "Result", + (), + { + "returncode": 0, + "stdout": "protocol=https\nhost=gitlab.example.com\npassword=token\n", + }, + )() + with patch("graphify.gitlab.subprocess.run", return_value=result) as run: + assert gitlab._git_credential_token( + "https://gitlab.example.com" + ) == "token" + assert gitlab._git_credential_token( + "https://gitlab.example.com" + ) == "token" + assert run.call_count == 1 + assert run.call_args.kwargs["env"]["GCM_INTERACTIVE"] == "Never" + assert run.call_args.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" + + +class TestPipelineStatus: + @pytest.mark.parametrize( + ("status", "expected"), + [ + ("success", "SUCCESS"), + ("failed", "FAILURE"), + ("canceled", "FAILURE"), + ("running", "PENDING"), + ("pending", "PENDING"), + ("skipped", "NONE"), + (None, "NONE"), + ], + ) + def test_status_mapping(self, status, expected): + item = {"head_pipeline": {"status": status}} if status else {} + assert gitlab.parse_pipeline_status(item) == expected + + +class TestGitLabApiMapping: + def test_list_open_merge_requests(self): + payload = [{"iid": 7, "title": "Refactor"}] + with patch( + "graphify.gitlab.resolve_config", + return_value=gitlab.GitLabConfig("https://gitlab.example.com", "g/p"), + ), patch("graphify.gitlab._request", return_value=(payload, {})) as request: + assert gitlab.list_open_merge_requests(limit=20) == payload + assert request.call_args.args[1] == "/projects/g%2Fp/merge_requests" + assert request.call_args.args[2]["state"] == "opened" + + def test_changed_files_follow_pagination_and_deduplicate(self): + first = ( + [{"new_path": "src/a.py", "deleted_file": False}], + {"X-Next-Page": "2"}, + ) + second = ( + [ + {"new_path": "src/a.py", "deleted_file": False}, + {"old_path": "src/deleted.py", "deleted_file": True}, + ], + {"X-Next-Page": ""}, + ) + config = gitlab.GitLabConfig("https://gitlab.example.com", "g/p") + with patch("graphify.gitlab.resolve_config", return_value=config), patch( + "graphify.gitlab._request", side_effect=[first, second] + ): + files = gitlab.get_merge_request_files(9) + assert files == ["src/a.py", "src/deleted.py"] + + def test_expand_submodule_diff_returns_prefixed_files( + self, tmp_path, monkeypatch + ): + child = tmp_path / "modules" / "service" + child.mkdir(parents=True) + monkeypatch.chdir(tmp_path) + result = type( + "Result", + (), + {"returncode": 0, "stdout": "src/a.py\nsrc/b.py\n"}, + )() + diff = ( + "@@ -1 +1 @@\n" + "-Subproject commit " + "a" * 40 + "\n" + "+Subproject commit " + "b" * 40 + "\n" + ) + with patch("graphify.gitlab.subprocess.run", return_value=result) as run: + files = gitlab._expand_submodule_diff("modules/service", diff) + assert files == [ + "modules/service/src/a.py", + "modules/service/src/b.py", + ] + assert run.call_args.kwargs["stdin"] is gitlab.subprocess.DEVNULL + + def test_expand_submodule_diff_rejects_workspace_escape( + self, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + diff = ( + "-Subproject commit " + "a" * 40 + "\n" + "+Subproject commit " + "b" * 40 + "\n" + ) + with patch("graphify.gitlab.subprocess.run") as run: + assert gitlab._expand_submodule_diff("../outside", diff) == [] + run.assert_not_called() + + +class TestProviderDispatch: + def test_fetch_prs_maps_gitlab_merge_request(self): + payload = [{ + "iid": 42, + "title": "Modernise persistence", + "source_branch": "feature/refactor", + "target_branch": "develop", + "author": {"username": "alice"}, + "draft": False, + "updated_at": "2026-07-23T10:00:00Z", + "head_pipeline": {"status": "success"}, + }] + with patch.dict( + "os.environ", {"GRAPHIFY_VCS_PROVIDER": "gitlab"}, clear=False + ), patch( + "graphify.gitlab.list_open_merge_requests", return_value=payload + ): + changes = fetch_prs(base="develop") + assert len(changes) == 1 + change = changes[0] + assert change.number == 42 + assert change.provider == "gitlab" + assert change.base_branch == "develop" + assert change.ci_status == "SUCCESS" + + def test_fetch_pr_files_dispatches_to_gitlab(self): + with patch.dict( + "os.environ", {"GRAPHIFY_VCS_PROVIDER": "gitlab"}, clear=False + ), patch( + "graphify.gitlab.get_merge_request_files", + return_value=["src/a.py"], + ) as fetch: + assert fetch_pr_files(42, "group/project") == ["src/a.py"] + fetch.assert_called_once_with(42, "group/project") diff --git a/tests/test_prs.py b/tests/test_prs.py index 4acc343e6..0d73162d8 100644 --- a/tests/test_prs.py +++ b/tests/test_prs.py @@ -20,6 +20,7 @@ fetch_pr_files, fetch_worktrees, format_prs_text, + change_request_reference, _detect_default_branch, ) @@ -37,6 +38,7 @@ def make_pr( ci_status: str = "SUCCESS", updated_at: datetime | None = None, expected_base: str = "v8", + provider: str = "github", ) -> PRInfo: """Build a minimal PRInfo with sensible defaults.""" if updated_at is None: @@ -52,6 +54,7 @@ def make_pr( ci_status=ci_status, updated_at=updated_at, expected_base=expected_base, + provider=provider, ) @@ -309,7 +312,7 @@ def test_contains_pr_metadata_and_count_header(self): out = format_prs_text(prs, base="v8") # Count header: 2 actionable, 1 on wrong base - assert "Open PRs targeting v8: 2" in out + assert "Open change requests targeting v8: 2" in out assert "(1 on wrong base, not shown)" in out # PR numbers and titles included @@ -327,9 +330,35 @@ def test_contains_pr_metadata_and_count_header(self): def test_empty_pr_list(self): out = format_prs_text([], base="v8") - assert "Open PRs targeting v8: 0" in out + assert "Open change requests targeting v8: 0" in out assert "(0 on wrong base, not shown)" in out + def test_uses_gitlab_merge_request_reference(self): + mr = make_pr( + number=104, + title="Add GitLab support", + provider="gitlab", + base_branch="v8", + expected_base="v8", + ) + + out = format_prs_text([mr], base="v8") + + assert "!104" in out + assert "#104" not in out + + +class TestChangeRequestReference: + def test_github_reference(self): + pr = make_pr(number=7, provider="github") + assert change_request_reference(pr) == "#7" + assert change_request_reference(pr, qualified=True) == "PR #7" + + def test_gitlab_reference(self): + mr = make_pr(number=8, provider="gitlab") + assert change_request_reference(mr) == "!8" + assert change_request_reference(mr, qualified=True) == "MR !8" + # ── _detect_default_branch ──────────────────────────────────────────────────── @@ -453,6 +482,7 @@ def test_fetch_worktrees_decodes_output_as_utf8(self): fetch_worktrees() _args, kwargs = mock_run.call_args assert kwargs.get("encoding") == "utf-8" + assert kwargs.get("stdin") is subprocess.DEVNULL def test_detect_default_branch_decodes_output_as_utf8(self): # Force the git symbolic-ref fallback: gh returns None -> git subprocess runs. @@ -462,3 +492,4 @@ def test_detect_default_branch_decodes_output_as_utf8(self): _detect_default_branch() _args, kwargs = mock_run.call_args assert kwargs.get("encoding") == "utf-8" + assert kwargs.get("stdin") is subprocess.DEVNULL