From 944c57d503ad014fbc955681d5f75b0a42d6b325 Mon Sep 17 00:00:00 2001 From: Khoi Ngo Date: Mon, 2 Mar 2026 15:43:58 +0700 Subject: [PATCH 1/4] feat: gitlock mcp --- apps/gitlock_mcp/.formatter.exs | 3 + apps/gitlock_mcp/lib/gitlock_mcp.ex | 25 ++ .../lib/gitlock_mcp/application.ex | 24 + apps/gitlock_mcp/lib/gitlock_mcp/cache.ex | 424 ++++++++++++++++++ apps/gitlock_mcp/lib/gitlock_mcp/cli.ex | 27 ++ apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex | 187 ++++++++ apps/gitlock_mcp/lib/gitlock_mcp/router.ex | 23 + apps/gitlock_mcp/lib/gitlock_mcp/server.ex | 244 ++++++++++ apps/gitlock_mcp/mix.exs | 46 ++ apps/gitlock_mcp/test/cache_test.exs | 42 ++ apps/gitlock_mcp/test/test_helper.exs | 1 + config/runtime.exs | 4 + mix.lock | 2 + 13 files changed, 1052 insertions(+) create mode 100644 apps/gitlock_mcp/.formatter.exs create mode 100644 apps/gitlock_mcp/lib/gitlock_mcp.ex create mode 100644 apps/gitlock_mcp/lib/gitlock_mcp/application.ex create mode 100644 apps/gitlock_mcp/lib/gitlock_mcp/cache.ex create mode 100644 apps/gitlock_mcp/lib/gitlock_mcp/cli.ex create mode 100644 apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex create mode 100644 apps/gitlock_mcp/lib/gitlock_mcp/router.ex create mode 100644 apps/gitlock_mcp/lib/gitlock_mcp/server.ex create mode 100644 apps/gitlock_mcp/mix.exs create mode 100644 apps/gitlock_mcp/test/cache_test.exs create mode 100644 apps/gitlock_mcp/test/test_helper.exs diff --git a/apps/gitlock_mcp/.formatter.exs b/apps/gitlock_mcp/.formatter.exs new file mode 100644 index 0000000..d304ff3 --- /dev/null +++ b/apps/gitlock_mcp/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/gitlock_mcp/lib/gitlock_mcp.ex b/apps/gitlock_mcp/lib/gitlock_mcp.ex new file mode 100644 index 0000000..463d65b --- /dev/null +++ b/apps/gitlock_mcp/lib/gitlock_mcp.ex @@ -0,0 +1,25 @@ +defmodule GitlockMCP do + @moduledoc """ + Gitlock MCP Server — codebase intelligence for AI coding agents. + + Exposes behavioral code analysis (hotspots, coupling, ownership, risk) + as MCP tools that AI coding agents like Claude Code and Cursor can use + to write safer code. + + ## How it works + + 1. Agent connects via stdio + 2. First tool call triggers repo indexing (parses git log, runs analysis) + 3. Results cached in memory — subsequent queries are instant + 4. Agent gets risk context before modifying files + + ## Tools + + - `gitlock_assess_file` — Risk assessment for a specific file + - `gitlock_hotspots` — Find riskiest files in a directory + - `gitlock_file_ownership` — Who owns this file? Knowledge silo risk? + - `gitlock_find_coupling` — What files change together with this one? + - `gitlock_review_pr` — Analyze a set of changed files together + - `gitlock_repo_summary` — Overview of codebase health + """ +end diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/application.ex b/apps/gitlock_mcp/lib/gitlock_mcp/application.ex new file mode 100644 index 0000000..daaa3ca --- /dev/null +++ b/apps/gitlock_mcp/lib/gitlock_mcp/application.ex @@ -0,0 +1,24 @@ +defmodule GitlockMCP.Application do + @moduledoc false + use Application + require Logger + + @default_port 4100 + + @impl true + def start(_type, _args) do + port = Application.get_env(:gitlock_mcp, :port, @default_port) + + children = [ + GitlockMCP.Cache, + Hermes.Server.Registry, + {GitlockMCP.Server, transport: :streamable_http}, + {Bandit, plug: GitlockMCP.Router, port: port} + ] + + Logger.info("Gitlock MCP server starting on http://localhost:#{port}/mcp") + + opts = [strategy: :one_for_one, name: GitlockMCP.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex b/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex new file mode 100644 index 0000000..a6b8dba --- /dev/null +++ b/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex @@ -0,0 +1,424 @@ +defmodule GitlockMCP.Cache do + @moduledoc """ + Holds pre-computed analysis data for the current repository. + + On first access, indexes the repo by parsing git history and running + all analyses. Results are cached in memory — subsequent tool calls + return instantly. + + The cache auto-detects the repo from the current working directory. + """ + use GenServer + require Logger + + defstruct [ + :repo_path, + :indexed_at, + :commits, + :hotspots, + :hotspot_index, + :couplings, + :coupling_index, + :knowledge_silos, + :silo_index, + :complexity_map, + :code_age, + :summary, + status: :idle + ] + + # ── Client API ─────────────────────────────────────────────── + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc "Ensures the cache is populated. Blocks until indexing completes." + def ensure_indexed(repo_path \\ nil) do + GenServer.call(__MODULE__, {:ensure_indexed, repo_path}, :infinity) + end + + @doc "Returns the risk assessment for a single file." + def assess_file(file_path) do + with :ok <- ensure_indexed() do + GenServer.call(__MODULE__, {:assess_file, file_path}) + end + end + + @doc "Returns the top hotspots, optionally filtered by directory." + def hotspots(opts \\ %{}) do + with :ok <- ensure_indexed() do + GenServer.call(__MODULE__, {:hotspots, opts}) + end + end + + @doc "Returns ownership info for a file." + def file_ownership(file_path) do + with :ok <- ensure_indexed() do + GenServer.call(__MODULE__, {:file_ownership, file_path}) + end + end + + @doc "Returns files temporally coupled to the given file." + def find_coupling(file_path, min_coupling \\ 30) do + with :ok <- ensure_indexed() do + GenServer.call(__MODULE__, {:find_coupling, file_path, min_coupling}) + end + end + + @doc "Reviews a set of changed files together." + def review_pr(changed_files) do + with :ok <- ensure_indexed() do + GenServer.call(__MODULE__, {:review_pr, changed_files}) + end + end + + @doc "Returns high-level repo health summary." + def repo_summary do + with :ok <- ensure_indexed() do + GenServer.call(__MODULE__, :repo_summary) + end + end + + # ── Server Callbacks ───────────────────────────────────────── + + @impl true + def init(_opts) do + {:ok, %__MODULE__{}} + end + + @impl true + def handle_call({:ensure_indexed, repo_path}, _from, %{status: :ready} = state) + when is_nil(repo_path) or repo_path == state.repo_path do + {:reply, :ok, state} + end + + def handle_call({:ensure_indexed, repo_path}, _from, state) do + repo = repo_path || detect_repo_path() + + case GitlockMCP.Indexer.index(repo) do + {:ok, data} -> + new_state = %__MODULE__{ + repo_path: repo, + indexed_at: DateTime.utc_now(), + status: :ready, + commits: data.commits, + hotspots: data.hotspots, + hotspot_index: Map.new(data.hotspots, &{&1.entity, &1}), + couplings: data.couplings, + coupling_index: build_coupling_index(data.couplings), + knowledge_silos: data.knowledge_silos, + silo_index: Map.new(data.knowledge_silos, &{&1.entity, &1}), + complexity_map: data.complexity_map, + code_age: data.code_age, + summary: data.summary + } + + Logger.info("Gitlock indexed #{repo} — #{length(data.hotspots)} hotspots, #{length(data.couplings)} coupling pairs, #{length(data.knowledge_silos)} silos") + {:reply, :ok, new_state} + + {:error, reason} -> + Logger.error("Gitlock indexing failed: #{inspect(reason)}") + {:reply, {:error, reason}, state} + end + end + + def handle_call({:assess_file, file_path}, _from, state) do + hotspot = Map.get(state.hotspot_index, file_path) + silo = Map.get(state.silo_index, file_path) + coupled = Map.get(state.coupling_index, file_path, []) + + risk_score = if hotspot, do: hotspot.normalized_score, else: 0 + risk_level = cond do + risk_score > 70 -> "critical" + risk_score > 40 -> "high" + risk_score > 20 -> "medium" + true -> "low" + end + + assessment = %{ + file: file_path, + risk_score: round(risk_score), + risk_level: risk_level, + revisions: if(hotspot, do: hotspot.revisions, else: 0), + complexity: if(hotspot, do: hotspot.complexity, else: 0), + loc: if(hotspot, do: hotspot.loc, else: 0), + ownership: format_ownership(silo), + coupled_files: Enum.take(coupled, 5), + recommendation: build_recommendation(file_path, hotspot, silo, coupled) + } + + {:reply, {:ok, assessment}, state} + end + + def handle_call({:hotspots, opts}, _from, state) do + dir = opts["directory"] || opts[:directory] + limit = opts["limit"] || opts[:limit] || 10 + + results = + state.hotspots + |> maybe_filter_dir(dir) + |> Enum.take(limit) + |> Enum.map(&format_hotspot/1) + + summary_text = if dir do + "#{dir} contains #{length(results)} hotspots" + else + "Repository has #{length(state.hotspots)} total hotspots, showing top #{length(results)}" + end + + {:reply, {:ok, %{hotspots: results, summary: summary_text}}, state} + end + + def handle_call({:file_ownership, file_path}, _from, state) do + silo = Map.get(state.silo_index, file_path) + + if silo do + {:reply, {:ok, format_ownership_detail(silo)}, state} + else + {:reply, {:ok, %{file: file_path, status: "no_data", message: "No ownership data — file may have very few commits"}}, state} + end + end + + def handle_call({:find_coupling, file_path, min_coupling}, _from, state) do + coupled = + Map.get(state.coupling_index, file_path, []) + |> Enum.filter(&(&1.coupling_pct >= min_coupling)) + + recommendation = if coupled == [] do + "No strong temporal coupling found for #{file_path}" + else + top = hd(coupled) + "#{file_path} is strongly coupled with #{top.file} (#{top.coupling_pct}% co-change rate). If you changed #{Path.basename(file_path)}, verify #{Path.basename(top.file)} still works correctly." + end + + {:reply, {:ok, %{file: file_path, coupled_files: coupled, recommendation: recommendation}}, state} + end + + def handle_call({:review_pr, changed_files}, _from, state) do + file_assessments = + Enum.map(changed_files, fn file -> + hotspot = Map.get(state.hotspot_index, file) + silo = Map.get(state.silo_index, file) + + %{ + file: file, + risk_score: if(hotspot, do: round(hotspot.normalized_score), else: 0), + risk_level: if(hotspot, do: to_string(hotspot.risk_factor), else: "low"), + ownership: format_ownership(silo) + } + end) + + # Find coupled files that AREN'T in the PR + missing_coupled = + changed_files + |> Enum.flat_map(fn file -> + Map.get(state.coupling_index, file, []) + |> Enum.filter(&(&1.coupling_pct >= 30)) + |> Enum.map(&Map.put(&1, :coupled_to, file)) + end) + |> Enum.reject(&(&1.file in changed_files)) + |> Enum.uniq_by(& &1.file) + |> Enum.sort_by(& &1.coupling_pct, :desc) + + # Collect suggested reviewers from knowledge silos + reviewers = + changed_files + |> Enum.map(&Map.get(state.silo_index, &1)) + |> Enum.reject(&is_nil/1) + |> Enum.map(& &1.main_author) + |> Enum.uniq() + + overall_risk = + case Enum.max_by(file_assessments, & &1.risk_score, fn -> %{risk_score: 0} end) do + %{risk_score: s} when s > 70 -> "critical" + %{risk_score: s} when s > 40 -> "high" + %{risk_score: s} when s > 20 -> "medium" + _ -> "low" + end + + recommendation = build_pr_recommendation(file_assessments, missing_coupled, reviewers) + + result = %{ + overall_risk: overall_risk, + file_assessments: file_assessments, + missing_coupled_files: Enum.take(missing_coupled, 5), + suggested_reviewers: reviewers, + recommendation: recommendation + } + + {:reply, {:ok, result}, state} + end + + def handle_call(:repo_summary, _from, state) do + hotspot_counts = + state.hotspots + |> Enum.group_by(& &1.risk_factor) + |> Map.new(fn {level, items} -> {to_string(level), length(items)} end) + + # Find riskiest directories + dir_risks = + state.hotspots + |> Enum.group_by(fn h -> h.entity |> Path.dirname() end) + |> Enum.map(fn {dir, hotspots} -> + avg_risk = Enum.map(hotspots, & &1.normalized_score) |> then(&(Enum.sum(&1) / length(&1))) + %{directory: dir, avg_risk: round(avg_risk), hotspot_files: length(hotspots)} + end) + |> Enum.sort_by(& &1.avg_risk, :desc) + |> Enum.take(5) + + silo_count = Enum.count(state.knowledge_silos, &(&1.risk_level == :high)) + high_coupling = Enum.count(state.couplings, &(&1.degree >= 30)) + + result = %{ + total_files: length(state.hotspots), + total_commits: length(state.commits), + hotspot_count: hotspot_counts, + knowledge_silos: silo_count, + high_coupling_pairs: high_coupling, + riskiest_areas: dir_risks, + summary: "Codebase with #{length(state.hotspots)} tracked files, #{length(state.commits)} commits. #{Map.get(hotspot_counts, "high", 0)} critical hotspots, #{silo_count} knowledge silos, #{high_coupling} high-coupling pairs." + } + + {:reply, {:ok, result}, state} + end + + # ── Private Helpers ────────────────────────────────────────── + + defp detect_repo_path do + cwd = File.cwd!() + + if File.dir?(Path.join(cwd, ".git")) do + cwd + else + # Walk up to find a .git directory + cwd + |> Path.split() + |> Enum.reduce_while(nil, fn _segment, _acc -> + path = Path.join(Path.split(cwd) |> Enum.take(length(Path.split(cwd)))) + + if File.dir?(Path.join(path, ".git")) do + {:halt, path} + else + {:cont, nil} + end + end) || cwd + end + end + + defp build_coupling_index(couplings) do + couplings + |> Enum.flat_map(fn c -> + [ + {c.entity, %{file: c.coupled, coupling_pct: c.degree, co_changes: c.average}}, + {c.coupled, %{file: c.entity, coupling_pct: c.degree, co_changes: c.average}} + ] + end) + |> Enum.group_by(fn {file, _} -> file end, fn {_, data} -> data end) + |> Map.new(fn {file, entries} -> + {file, Enum.sort_by(entries, & &1.coupling_pct, :desc)} + end) + end + + defp maybe_filter_dir(hotspots, nil), do: hotspots + + defp maybe_filter_dir(hotspots, dir) do + Enum.filter(hotspots, &String.starts_with?(&1.entity, dir)) + end + + defp format_hotspot(h) do + %{ + file: h.entity, + risk_score: round(h.normalized_score), + risk_level: to_string(h.risk_factor), + revisions: h.revisions, + complexity: h.complexity, + loc: h.loc + } + end + + defp format_ownership(nil), do: nil + + defp format_ownership(silo) do + %{ + main_author: silo.main_author, + ownership_pct: silo.ownership_ratio, + total_authors: silo.num_authors, + silo_risk: to_string(silo.risk_level) + } + end + + defp format_ownership_detail(silo) do + %{ + file: silo.entity, + main_author: silo.main_author, + ownership_pct: silo.ownership_ratio, + total_authors: silo.num_authors, + total_commits: silo.num_commits, + risk_level: to_string(silo.risk_level), + recommendation: "#{silo.main_author} owns #{silo.ownership_ratio}% of this file. " <> + if(silo.risk_level == :high, do: "Knowledge silo — ensure this person reviews any changes.", else: "Moderate ownership concentration.") + } + end + + defp build_recommendation(file_path, nil, nil, _coupled) do + "#{file_path} has minimal change history. Low risk." + end + + defp build_recommendation(file_path, hotspot, silo, coupled) do + parts = [] + + parts = if hotspot && hotspot.normalized_score > 70 do + ["High-risk file — #{hotspot.revisions} revisions, complexity #{hotspot.complexity}." | parts] + else + parts + end + + parts = if silo && silo.risk_level in [:high, :medium] do + ["#{silo.main_author} owns #{silo.ownership_ratio}% — consider them as reviewer." | parts] + else + parts + end + + parts = if length(coupled) > 0 do + top = hd(coupled) + ["Temporally coupled with #{top.file} (#{top.coupling_pct}% co-change rate)." | parts] + else + parts + end + + case parts do + [] -> "#{Path.basename(file_path)} appears stable and well-distributed." + _ -> Enum.reverse(parts) |> Enum.join(" ") + end + end + + defp build_pr_recommendation(assessments, missing_coupled, reviewers) do + high_risk = Enum.count(assessments, &(&1.risk_score > 70)) + parts = [] + + parts = if high_risk > 0 do + ["This PR touches #{high_risk} high-risk file(s)." | parts] + else + parts + end + + parts = if length(missing_coupled) > 0 do + files = Enum.map_join(missing_coupled, ", ", & &1.file) + ["Potentially missing coupled files: #{files}." | parts] + else + parts + end + + parts = if length(reviewers) > 0 do + ["Suggested reviewers: #{Enum.join(reviewers, ", ")}." | parts] + else + parts + end + + case parts do + [] -> "Low-risk PR. No concerns detected." + _ -> Enum.reverse(parts) |> Enum.join(" ") + end + end +end diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/cli.ex b/apps/gitlock_mcp/lib/gitlock_mcp/cli.ex new file mode 100644 index 0000000..13ddabc --- /dev/null +++ b/apps/gitlock_mcp/lib/gitlock_mcp/cli.ex @@ -0,0 +1,27 @@ +defmodule GitlockMCP.CLI do + @moduledoc """ + Entry point for running gitlock-mcp as a standalone server. + + Can be run as: + - `mix run --no-halt --app gitlock_mcp` (during development) + - Escript: `bin/gitlock-mcp` (starts HTTP server on port 4100) + + Connect AI agents via mcp-proxy: + mcp-proxy http://localhost:4100/mcp + """ + + def main(args) do + # Parse optional port arg + port = parse_port(args) + if port, do: Application.put_env(:gitlock_mcp, :port, port) + + Application.ensure_all_started(:gitlock_mcp) + + # Keep alive + Process.sleep(:infinity) + end + + defp parse_port(["--port", port | _]), do: String.to_integer(port) + defp parse_port(["-p", port | _]), do: String.to_integer(port) + defp parse_port(_), do: nil +end diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex b/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex new file mode 100644 index 0000000..54ee60d --- /dev/null +++ b/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex @@ -0,0 +1,187 @@ +defmodule GitlockMCP.Indexer do + @moduledoc """ + Orchestrates the full analysis pipeline for a repository. + + Calls into gitlock_core services to parse git history, detect hotspots, + coupling, knowledge silos, and complexity. Returns a structured map + that the Cache stores for instant tool queries. + """ + require Logger + + alias GitlockCore.Adapters.VCS.Git + alias GitlockCore.Domain.Services.HotspotDetection + alias GitlockCore.Domain.Services.CouplingDetection + alias GitlockCore.Domain.Services.KnowledgeSiloDetection + alias GitlockCore.Domain.Services.CodeAgeAnalysis + alias GitlockCore.Domain.Services.Summary + alias GitlockCore.Adapters.Complexity.DispatchAnalyzer + alias GitlockCore.Infrastructure.Workspace + + @type index_result :: %{ + commits: list(), + hotspots: list(), + couplings: list(), + knowledge_silos: list(), + complexity_map: map(), + code_age: list(), + summary: map() + } + + @doc """ + Indexes a repository by running all analyses. + + Returns `{:ok, data}` with pre-computed analysis results, or + `{:error, reason}` if git history can't be loaded. + """ + @spec index(String.t()) :: {:ok, index_result()} | {:error, term()} + def index(repo_path) do + Logger.info("Indexing repository: #{repo_path}") + start = System.monotonic_time(:millisecond) + + with {:ok, workspace} <- resolve_workspace(repo_path), + {:ok, commits} <- load_commits(workspace.path) do + + path = workspace.path + Logger.info("Loaded #{length(commits)} commits from #{path}") + + # Run analyses concurrently + tasks = %{ + hotspots: Task.async(fn -> run_hotspots(commits, path) end), + couplings: Task.async(fn -> run_couplings(commits) end), + silos: Task.async(fn -> run_knowledge_silos(commits) end), + code_age: Task.async(fn -> run_code_age(commits) end), + summary: Task.async(fn -> run_summary(commits) end), + complexity: Task.async(fn -> run_complexity(path) end) + } + + # Await all with generous timeout + results = Map.new(tasks, fn {key, task} -> + {key, Task.await(task, 120_000)} + end) + + elapsed = System.monotonic_time(:millisecond) - start + Logger.info("Indexing complete in #{elapsed}ms") + + {:ok, %{ + commits: commits, + hotspots: results.hotspots, + couplings: results.couplings, + knowledge_silos: results.silos, + complexity_map: results.complexity, + code_age: results.code_age, + summary: results.summary + }} + end + end + + # ── Private ────────────────────────────────────────────────── + + defp resolve_workspace(repo_path) do + cond do + File.dir?(Path.join(repo_path, ".git")) -> + # Local repo — use directly + {:ok, %{path: repo_path}} + + String.starts_with?(repo_path, "http") or String.starts_with?(repo_path, "git@") -> + # Remote URL — clone via workspace manager + Workspace.acquire(repo_path) + + File.dir?(repo_path) -> + # Directory without .git — try anyway + {:ok, %{path: repo_path}} + + true -> + {:error, "Cannot resolve repository: #{repo_path}"} + end + end + + defp load_commits(path) do + Git.get_commit_history(path, %{}) + end + + defp run_hotspots(commits, repo_path) do + complexity_map = build_complexity_map(repo_path) + HotspotDetection.detect_hotspots(commits, complexity_map) + end + + defp run_couplings(commits) do + CouplingDetection.detect_couplings(commits) + rescue + _ -> [] + end + + defp run_knowledge_silos(commits) do + KnowledgeSiloDetection.detect_knowledge_silos(commits) + rescue + _ -> [] + end + + defp run_code_age(commits) do + CodeAgeAnalysis.calculate_code_age(commits) + rescue + _ -> [] + end + + defp run_summary(commits) do + Summary.summarize(commits) + rescue + _ -> %{} + end + + defp run_complexity(repo_path) do + build_complexity_map(repo_path) + end + + defp build_complexity_map(repo_path) do + supported_ext = DispatchAnalyzer.supported_extensions() |> Enum.reject(&(&1 == "*")) + + case list_tracked_files(repo_path) do + {:ok, files} -> + files + |> Enum.filter(fn path -> Path.extname(path) in supported_ext end) + |> Task.async_stream( + fn file_path -> + case read_file_from_git(repo_path, file_path) do + {:ok, content} -> + case DispatchAnalyzer.analyze_content(content, file_path) do + {:ok, metrics} -> {file_path, metrics} + _ -> nil + end + + _ -> + nil + end + end, + max_concurrency: System.schedulers_online() * 2, + on_timeout: :kill_task + ) + |> Enum.reduce(%{}, fn + {:ok, {path, metrics}}, acc -> Map.put(acc, path, metrics) + _, acc -> acc + end) + + {:error, _} -> + %{} + end + end + + defp list_tracked_files(repo_path) do + case System.cmd("git", ["ls-tree", "-r", "--name-only", "HEAD"], + cd: repo_path, + stderr_to_stdout: true + ) do + {output, 0} -> {:ok, String.split(output, "\n", trim: true)} + {error, _} -> {:error, error} + end + end + + defp read_file_from_git(repo_path, file_path) do + case System.cmd("git", ["show", "HEAD:#{file_path}"], + cd: repo_path, + stderr_to_stdout: true + ) do + {content, 0} -> {:ok, content} + {error, _} -> {:error, error} + end + end +end diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/router.ex b/apps/gitlock_mcp/lib/gitlock_mcp/router.ex new file mode 100644 index 0000000..46e7fbf --- /dev/null +++ b/apps/gitlock_mcp/lib/gitlock_mcp/router.ex @@ -0,0 +1,23 @@ +defmodule GitlockMCP.Router do + @moduledoc """ + Minimal Plug router for the standalone MCP server. + + Forwards /mcp to the Hermes StreamableHTTP transport and serves + a health check at /health. + """ + use Plug.Router + + plug :match + plug :dispatch + + forward "/mcp", to: Hermes.Server.Transport.StreamableHTTP.Plug, + init_opts: [server: GitlockMCP.Server] + + get "/health" do + send_resp(conn, 200, Jason.encode!(%{status: "ok", server: "gitlock-mcp"})) + end + + match _ do + send_resp(conn, 404, "not found") + end +end diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/server.ex b/apps/gitlock_mcp/lib/gitlock_mcp/server.ex new file mode 100644 index 0000000..f1fc046 --- /dev/null +++ b/apps/gitlock_mcp/lib/gitlock_mcp/server.ex @@ -0,0 +1,244 @@ +defmodule GitlockMCP.Server do + @moduledoc """ + MCP Server that exposes Gitlock's codebase intelligence to AI agents. + + Runs as streamable HTTP inside the Phoenix app. Tools are registered + dynamically on init, and tool calls are dispatched to the Cache. + """ + use Hermes.Server, + name: "gitlock", + version: "0.1.0", + capabilities: [:tools] + + require Logger + + @impl true + def init(_client_info, frame) do + {:ok, + frame + |> register_tool("gitlock_assess_file", + input_schema: %{ + file_path: + {:required, :string, description: "Path to the file relative to repo root"} + }, + description: + "Assess the risk of modifying a specific file. Returns risk score, ownership, temporal coupling, and recommendations. Call this BEFORE modifying any file.", + annotations: %{read_only: true} + ) + |> register_tool("gitlock_hotspots", + input_schema: %{ + directory: {:optional, :string, description: "Directory to filter (e.g. lib/payments). Omit for entire repo."}, + limit: {:optional, :integer, description: "Max results (default: 10)"} + }, + description: + "Find the riskiest files in the repository or a directory. Returns files ranked by risk score (change frequency × complexity).", + annotations: %{read_only: true} + ) + |> register_tool("gitlock_file_ownership", + input_schema: %{ + file_path: + {:required, :string, description: "Path to the file relative to repo root"} + }, + description: + "Check who owns a file and whether it's a knowledge silo. Returns primary author, ownership percentage, and bus factor risk.", + annotations: %{read_only: true} + ) + |> register_tool("gitlock_find_coupling", + input_schema: %{ + file_path: + {:required, :string, description: "Path to the file relative to repo root"}, + min_coupling: + {:optional, :integer, description: "Minimum coupling percentage to include (default: 30)"} + }, + description: + "Find files that historically change together with a given file. If you modify a file, its coupled files may also need updating.", + annotations: %{read_only: true} + ) + |> register_tool("gitlock_review_pr", + input_schema: %{ + changed_files: + {:required, {:array, :string}, + description: "List of file paths that were modified"} + }, + description: + "Analyze a set of changed files as a PR. Returns overall risk, per-file assessments, missing coupled files, and suggested reviewers. Call AFTER completing changes or before submitting.", + annotations: %{read_only: true} + ) + |> register_tool("gitlock_repo_summary", + input_schema: %{}, + description: + "Get a high-level overview of repository health. Returns hotspot counts, knowledge silos, coupling pairs, and riskiest directories. Call when first working with a codebase.", + annotations: %{read_only: true} + )} + end + + @impl true + def handle_tool_call("gitlock_assess_file", %{file_path: file_path}, frame) do + case GitlockMCP.Cache.assess_file(file_path) do + {:ok, result} -> {:reply, format_assess_file(result), frame} + {:error, reason} -> {:error, inspect(reason), frame} + end + end + + def handle_tool_call("gitlock_hotspots", params, frame) do + opts = %{ + directory: Map.get(params, :directory), + limit: Map.get(params, :limit, 10) + } + + case GitlockMCP.Cache.hotspots(opts) do + {:ok, result} -> {:reply, format_hotspots(result), frame} + {:error, reason} -> {:error, inspect(reason), frame} + end + end + + def handle_tool_call("gitlock_file_ownership", %{file_path: file_path}, frame) do + case GitlockMCP.Cache.file_ownership(file_path) do + {:ok, result} -> {:reply, format_ownership(result), frame} + {:error, reason} -> {:error, inspect(reason), frame} + end + end + + def handle_tool_call("gitlock_find_coupling", params, frame) do + file_path = params.file_path + min_coupling = Map.get(params, :min_coupling, 30) + + case GitlockMCP.Cache.find_coupling(file_path, min_coupling) do + {:ok, result} -> {:reply, format_coupling(result), frame} + {:error, reason} -> {:error, inspect(reason), frame} + end + end + + def handle_tool_call("gitlock_review_pr", %{changed_files: files}, frame) do + case GitlockMCP.Cache.review_pr(files) do + {:ok, result} -> {:reply, format_review(result), frame} + {:error, reason} -> {:error, inspect(reason), frame} + end + end + + def handle_tool_call("gitlock_repo_summary", _params, frame) do + case GitlockMCP.Cache.repo_summary() do + {:ok, result} -> {:reply, format_summary(result), frame} + {:error, reason} -> {:error, inspect(reason), frame} + end + end + + # ── Formatters ─────────────────────────────────────────────── + + defp format_assess_file(a) do + coupled = Enum.map_join(a.coupled_files, "\n", fn c -> + " - #{c.file} (#{c.coupling_pct}% co-change)" + end) + + ownership = if a.ownership do + o = a.ownership + "Owner: #{o.main_author} (#{o.ownership_pct}%) — #{o.silo_risk} silo risk" + else + "No ownership data" + end + + """ + ## #{a.file} + Risk: #{a.risk_level} (score: #{a.risk_score}/100) + Revisions: #{a.revisions} | Complexity: #{a.complexity} | LOC: #{a.loc} + #{ownership} + #{if coupled != "", do: "Coupled files:\n#{coupled}", else: "No strong coupling detected"} + + Recommendation: #{a.recommendation} + """ + |> String.trim() + end + + defp format_hotspots(%{hotspots: hotspots, summary: summary}) do + rows = Enum.map_join(hotspots, "\n", fn h -> + "- #{h.file} — risk: #{h.risk_score} (#{h.risk_level}), #{h.revisions} revisions, complexity: #{h.complexity}" + end) + + "## Hotspots\n#{summary}\n\n#{rows}" + end + + defp format_ownership(%{status: "no_data"} = r), do: r.message + + defp format_ownership(r) do + """ + ## #{r.file} + Primary author: #{r.main_author} (#{r.ownership_pct}% of commits) + Contributors: #{r.total_authors} | Total commits: #{r.total_commits} + Risk level: #{r.risk_level} + + #{r.recommendation} + """ + |> String.trim() + end + + defp format_coupling(%{coupled_files: [], recommendation: rec}), do: rec + + defp format_coupling(%{file: file, coupled_files: coupled, recommendation: rec}) do + rows = Enum.map_join(coupled, "\n", fn c -> + "- #{c.file} — #{c.coupling_pct}% co-change rate (#{c.co_changes} shared commits)" + end) + + "## Files coupled with #{file}\n#{rows}\n\n#{rec}" + end + + defp format_review(r) do + files = Enum.map_join(r.file_assessments, "\n", fn a -> + "- #{a.file} — risk: #{a.risk_score} (#{a.risk_level})" + end) + + missing = if length(r.missing_coupled_files) > 0 do + "\n\nPotentially missing coupled files:\n" <> + Enum.map_join(r.missing_coupled_files, "\n", fn m -> + "- #{m.file} (#{m.coupling_pct}% coupled to #{m.coupled_to})" + end) + else + "" + end + + reviewers = if length(r.suggested_reviewers) > 0 do + "\n\nSuggested reviewers: #{Enum.join(r.suggested_reviewers, ", ")}" + else + "" + end + + "## PR Risk Assessment: #{r.overall_risk}\n#{files}#{missing}#{reviewers}\n\n#{r.recommendation}" + end + + defp format_summary(r) do + counts = format_hotspot_counts(r.hotspot_count) + + areas = if length(r.riskiest_areas) > 0 do + "\n\nRiskiest areas:\n" <> + Enum.map_join(r.riskiest_areas, "\n", fn a -> + "- #{a.directory}/ — avg risk: #{a.avg_risk}, #{a.hotspot_files} hotspot files" + end) + else + "" + end + + """ + ## Repository Health Summary + Files: #{r.total_files} | Commits: #{r.total_commits} + Hotspots: #{counts} + Knowledge silos: #{r.knowledge_silos} high-risk + High coupling pairs: #{r.high_coupling_pairs} + #{areas} + + #{r.summary} + """ + |> String.trim() + end + + defp format_hotspot_counts(counts) do + parts = + [{"high", "critical"}, {"medium", "medium"}, {"low", "low"}] + |> Enum.map(fn {key, label} -> {Map.get(counts, key, 0), label} end) + |> Enum.reject(fn {c, _} -> c == 0 end) + |> Enum.map(fn {c, l} -> "#{c} #{l}" end) + + case parts do + [] -> "none" + _ -> Enum.join(parts, ", ") + end + end +end diff --git a/apps/gitlock_mcp/mix.exs b/apps/gitlock_mcp/mix.exs new file mode 100644 index 0000000..ca22b7a --- /dev/null +++ b/apps/gitlock_mcp/mix.exs @@ -0,0 +1,46 @@ +defmodule GitlockMCP.MixProject do + use Mix.Project + + def project do + [ + app: :gitlock_mcp, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps(), + elixirc_paths: elixirc_paths(Mix.env()), + escript: escript() + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {GitlockMCP.Application, []} + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + {:gitlock_core, in_umbrella: true}, + {:hermes_mcp, "~> 0.14"}, + {:jason, "~> 1.4"}, + {:bandit, "~> 1.0"}, + {:plug, "~> 1.15"} + ] + end + + defp escript do + [ + main_module: GitlockMCP.CLI, + path: "../../bin/gitlock-mcp" + ] + end +end diff --git a/apps/gitlock_mcp/test/cache_test.exs b/apps/gitlock_mcp/test/cache_test.exs new file mode 100644 index 0000000..ef8b8ad --- /dev/null +++ b/apps/gitlock_mcp/test/cache_test.exs @@ -0,0 +1,42 @@ +defmodule GitlockMCP.CacheTest do + use ExUnit.Case, async: false + + alias GitlockMCP.Cache + + describe "Cache on a real git repo" do + test "indexes the current repo and answers queries" do + # Use the Gitlock repo itself + repo_path = Path.expand("../../../", __DIR__) + + # Should be a git repo + assert File.dir?(Path.join(repo_path, ".git")) + + # Index it + assert :ok = Cache.ensure_indexed(repo_path) + + # Hotspots should return data + assert {:ok, %{hotspots: hotspots}} = Cache.hotspots(%{limit: 5}) + assert length(hotspots) > 0 + assert hd(hotspots).risk_score > 0 + + # Assess a file that likely exists + first_hotspot = hd(hotspots) + assert {:ok, assessment} = Cache.assess_file(first_hotspot.file) + assert assessment.risk_score > 0 + + # Repo summary should work + assert {:ok, summary} = Cache.repo_summary() + assert summary.total_commits > 0 + assert summary.total_files > 0 + end + + test "returns data for unknown file without crashing" do + repo_path = Path.expand("../../../", __DIR__) + :ok = Cache.ensure_indexed(repo_path) + + assert {:ok, assessment} = Cache.assess_file("nonexistent/file.ex") + assert assessment.risk_score == 0 + assert assessment.risk_level == "low" + end + end +end diff --git a/apps/gitlock_mcp/test/test_helper.exs b/apps/gitlock_mcp/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/apps/gitlock_mcp/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/config/runtime.exs b/config/runtime.exs index 33afb0a..fc9497d 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -57,6 +57,10 @@ if config_env() == :prod do config :gitlock_phx, GitlockPhxWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], + check_origin: [ + "https://#{host}", + "https://www.#{host}" + ], http: [ # Enable IPv6 and bind on all interfaces. # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. diff --git a/mix.lock b/mix.lock index 3ba38c4..3933ff7 100644 --- a/mix.lock +++ b/mix.lock @@ -19,6 +19,7 @@ "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "hermes_mcp": {:hex, :hermes_mcp, "0.14.1", "cfe4321c21c6a5fe01e4e27d6f45037573ce9f02a296fa1112ff7b3d0ee396d9", [:mix], [{:burrito, "~> 1.0", [hex: :burrito, repo: "hexpm", optional: true]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:gun, "~> 2.2", [hex: :gun, repo: "hexpm", optional: true]}, {:peri, "~> 0.4", [hex: :peri, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "86ec898911d633d8e4a8ad94a79123beab95ba86baa1b6fe6197ab1ccf76f9a9"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -28,6 +29,7 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "peri": {:hex, :peri, "0.6.2", "3c043bfb6aa18eb1ea41d80981d19294c5e943937b1311e8e958da3581139061", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "5e0d8e0bd9de93d0f8e3ad6b9a5bd143f7349c025196ef4a3591af93ce6ecad9"}, "phoenix": {:hex, :phoenix, "1.8.0-rc.3", "6ae19e57b9c109556f1b8abdb992d96d443b0ae28e03b604f3dc6c75d9f7d35f", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "419422afc33e965c0dbf181cbedc77b4cfd024dac0db7d9d2287656043d48e24"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, From 082793c732ee29d3432a6f2364cb5426d5281e6d Mon Sep 17 00:00:00 2001 From: Khoi Ngo Date: Mon, 2 Mar 2026 15:48:38 +0700 Subject: [PATCH 2/4] fix: format --- apps/gitlock_mcp/lib/gitlock_mcp/cache.ex | 139 ++++++++++++-------- apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex | 27 ++-- apps/gitlock_mcp/lib/gitlock_mcp/router.ex | 8 +- apps/gitlock_mcp/lib/gitlock_mcp/server.ex | 111 ++++++++-------- 4 files changed, 162 insertions(+), 123 deletions(-) diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex b/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex index a6b8dba..20c1d1b 100644 --- a/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex +++ b/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex @@ -114,7 +114,10 @@ defmodule GitlockMCP.Cache do summary: data.summary } - Logger.info("Gitlock indexed #{repo} — #{length(data.hotspots)} hotspots, #{length(data.couplings)} coupling pairs, #{length(data.knowledge_silos)} silos") + Logger.info( + "Gitlock indexed #{repo} — #{length(data.hotspots)} hotspots, #{length(data.couplings)} coupling pairs, #{length(data.knowledge_silos)} silos" + ) + {:reply, :ok, new_state} {:error, reason} -> @@ -129,12 +132,14 @@ defmodule GitlockMCP.Cache do coupled = Map.get(state.coupling_index, file_path, []) risk_score = if hotspot, do: hotspot.normalized_score, else: 0 - risk_level = cond do - risk_score > 70 -> "critical" - risk_score > 40 -> "high" - risk_score > 20 -> "medium" - true -> "low" - end + + risk_level = + cond do + risk_score > 70 -> "critical" + risk_score > 40 -> "high" + risk_score > 20 -> "medium" + true -> "low" + end assessment = %{ file: file_path, @@ -161,11 +166,12 @@ defmodule GitlockMCP.Cache do |> Enum.take(limit) |> Enum.map(&format_hotspot/1) - summary_text = if dir do - "#{dir} contains #{length(results)} hotspots" - else - "Repository has #{length(state.hotspots)} total hotspots, showing top #{length(results)}" - end + summary_text = + if dir do + "#{dir} contains #{length(results)} hotspots" + else + "Repository has #{length(state.hotspots)} total hotspots, showing top #{length(results)}" + end {:reply, {:ok, %{hotspots: results, summary: summary_text}}, state} end @@ -176,7 +182,13 @@ defmodule GitlockMCP.Cache do if silo do {:reply, {:ok, format_ownership_detail(silo)}, state} else - {:reply, {:ok, %{file: file_path, status: "no_data", message: "No ownership data — file may have very few commits"}}, state} + {:reply, + {:ok, + %{ + file: file_path, + status: "no_data", + message: "No ownership data — file may have very few commits" + }}, state} end end @@ -185,14 +197,17 @@ defmodule GitlockMCP.Cache do Map.get(state.coupling_index, file_path, []) |> Enum.filter(&(&1.coupling_pct >= min_coupling)) - recommendation = if coupled == [] do - "No strong temporal coupling found for #{file_path}" - else - top = hd(coupled) - "#{file_path} is strongly coupled with #{top.file} (#{top.coupling_pct}% co-change rate). If you changed #{Path.basename(file_path)}, verify #{Path.basename(top.file)} still works correctly." - end + recommendation = + if coupled == [] do + "No strong temporal coupling found for #{file_path}" + else + top = hd(coupled) + + "#{file_path} is strongly coupled with #{top.file} (#{top.coupling_pct}% co-change rate). If you changed #{Path.basename(file_path)}, verify #{Path.basename(top.file)} still works correctly." + end - {:reply, {:ok, %{file: file_path, coupled_files: coupled, recommendation: recommendation}}, state} + {:reply, {:ok, %{file: file_path, coupled_files: coupled, recommendation: recommendation}}, + state} end def handle_call({:review_pr, changed_files}, _from, state) do @@ -277,7 +292,8 @@ defmodule GitlockMCP.Cache do knowledge_silos: silo_count, high_coupling_pairs: high_coupling, riskiest_areas: dir_risks, - summary: "Codebase with #{length(state.hotspots)} tracked files, #{length(state.commits)} commits. #{Map.get(hotspot_counts, "high", 0)} critical hotspots, #{silo_count} knowledge silos, #{high_coupling} high-coupling pairs." + summary: + "Codebase with #{length(state.hotspots)} tracked files, #{length(state.commits)} commits. #{Map.get(hotspot_counts, "high", 0)} critical hotspots, #{silo_count} knowledge silos, #{high_coupling} high-coupling pairs." } {:reply, {:ok, result}, state} @@ -356,8 +372,12 @@ defmodule GitlockMCP.Cache do total_authors: silo.num_authors, total_commits: silo.num_commits, risk_level: to_string(silo.risk_level), - recommendation: "#{silo.main_author} owns #{silo.ownership_ratio}% of this file. " <> - if(silo.risk_level == :high, do: "Knowledge silo — ensure this person reviews any changes.", else: "Moderate ownership concentration.") + recommendation: + "#{silo.main_author} owns #{silo.ownership_ratio}% of this file. " <> + if(silo.risk_level == :high, + do: "Knowledge silo — ensure this person reviews any changes.", + else: "Moderate ownership concentration." + ) } end @@ -368,24 +388,30 @@ defmodule GitlockMCP.Cache do defp build_recommendation(file_path, hotspot, silo, coupled) do parts = [] - parts = if hotspot && hotspot.normalized_score > 70 do - ["High-risk file — #{hotspot.revisions} revisions, complexity #{hotspot.complexity}." | parts] - else - parts - end + parts = + if hotspot && hotspot.normalized_score > 70 do + [ + "High-risk file — #{hotspot.revisions} revisions, complexity #{hotspot.complexity}." + | parts + ] + else + parts + end - parts = if silo && silo.risk_level in [:high, :medium] do - ["#{silo.main_author} owns #{silo.ownership_ratio}% — consider them as reviewer." | parts] - else - parts - end + parts = + if silo && silo.risk_level in [:high, :medium] do + ["#{silo.main_author} owns #{silo.ownership_ratio}% — consider them as reviewer." | parts] + else + parts + end - parts = if length(coupled) > 0 do - top = hd(coupled) - ["Temporally coupled with #{top.file} (#{top.coupling_pct}% co-change rate)." | parts] - else - parts - end + parts = + if length(coupled) > 0 do + top = hd(coupled) + ["Temporally coupled with #{top.file} (#{top.coupling_pct}% co-change rate)." | parts] + else + parts + end case parts do [] -> "#{Path.basename(file_path)} appears stable and well-distributed." @@ -397,24 +423,27 @@ defmodule GitlockMCP.Cache do high_risk = Enum.count(assessments, &(&1.risk_score > 70)) parts = [] - parts = if high_risk > 0 do - ["This PR touches #{high_risk} high-risk file(s)." | parts] - else - parts - end + parts = + if high_risk > 0 do + ["This PR touches #{high_risk} high-risk file(s)." | parts] + else + parts + end - parts = if length(missing_coupled) > 0 do - files = Enum.map_join(missing_coupled, ", ", & &1.file) - ["Potentially missing coupled files: #{files}." | parts] - else - parts - end + parts = + if length(missing_coupled) > 0 do + files = Enum.map_join(missing_coupled, ", ", & &1.file) + ["Potentially missing coupled files: #{files}." | parts] + else + parts + end - parts = if length(reviewers) > 0 do - ["Suggested reviewers: #{Enum.join(reviewers, ", ")}." | parts] - else - parts - end + parts = + if length(reviewers) > 0 do + ["Suggested reviewers: #{Enum.join(reviewers, ", ")}." | parts] + else + parts + end case parts do [] -> "Low-risk PR. No concerns detected." diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex b/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex index 54ee60d..e52bf73 100644 --- a/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex +++ b/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex @@ -40,7 +40,6 @@ defmodule GitlockMCP.Indexer do with {:ok, workspace} <- resolve_workspace(repo_path), {:ok, commits} <- load_commits(workspace.path) do - path = workspace.path Logger.info("Loaded #{length(commits)} commits from #{path}") @@ -55,22 +54,24 @@ defmodule GitlockMCP.Indexer do } # Await all with generous timeout - results = Map.new(tasks, fn {key, task} -> - {key, Task.await(task, 120_000)} - end) + results = + Map.new(tasks, fn {key, task} -> + {key, Task.await(task, 120_000)} + end) elapsed = System.monotonic_time(:millisecond) - start Logger.info("Indexing complete in #{elapsed}ms") - {:ok, %{ - commits: commits, - hotspots: results.hotspots, - couplings: results.couplings, - knowledge_silos: results.silos, - complexity_map: results.complexity, - code_age: results.code_age, - summary: results.summary - }} + {:ok, + %{ + commits: commits, + hotspots: results.hotspots, + couplings: results.couplings, + knowledge_silos: results.silos, + complexity_map: results.complexity, + code_age: results.code_age, + summary: results.summary + }} end end diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/router.ex b/apps/gitlock_mcp/lib/gitlock_mcp/router.ex index 46e7fbf..18a34f5 100644 --- a/apps/gitlock_mcp/lib/gitlock_mcp/router.ex +++ b/apps/gitlock_mcp/lib/gitlock_mcp/router.ex @@ -7,11 +7,13 @@ defmodule GitlockMCP.Router do """ use Plug.Router - plug :match - plug :dispatch + plug(:match) + plug(:dispatch) - forward "/mcp", to: Hermes.Server.Transport.StreamableHTTP.Plug, + forward("/mcp", + to: Hermes.Server.Transport.StreamableHTTP.Plug, init_opts: [server: GitlockMCP.Server] + ) get "/health" do send_resp(conn, 200, Jason.encode!(%{status: "ok", server: "gitlock-mcp"})) diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/server.ex b/apps/gitlock_mcp/lib/gitlock_mcp/server.ex index f1fc046..3a07b91 100644 --- a/apps/gitlock_mcp/lib/gitlock_mcp/server.ex +++ b/apps/gitlock_mcp/lib/gitlock_mcp/server.ex @@ -18,8 +18,7 @@ defmodule GitlockMCP.Server do frame |> register_tool("gitlock_assess_file", input_schema: %{ - file_path: - {:required, :string, description: "Path to the file relative to repo root"} + file_path: {:required, :string, description: "Path to the file relative to repo root"} }, description: "Assess the risk of modifying a specific file. Returns risk score, ownership, temporal coupling, and recommendations. Call this BEFORE modifying any file.", @@ -27,7 +26,9 @@ defmodule GitlockMCP.Server do ) |> register_tool("gitlock_hotspots", input_schema: %{ - directory: {:optional, :string, description: "Directory to filter (e.g. lib/payments). Omit for entire repo."}, + directory: + {:optional, :string, + description: "Directory to filter (e.g. lib/payments). Omit for entire repo."}, limit: {:optional, :integer, description: "Max results (default: 10)"} }, description: @@ -36,8 +37,7 @@ defmodule GitlockMCP.Server do ) |> register_tool("gitlock_file_ownership", input_schema: %{ - file_path: - {:required, :string, description: "Path to the file relative to repo root"} + file_path: {:required, :string, description: "Path to the file relative to repo root"} }, description: "Check who owns a file and whether it's a knowledge silo. Returns primary author, ownership percentage, and bus factor risk.", @@ -45,10 +45,10 @@ defmodule GitlockMCP.Server do ) |> register_tool("gitlock_find_coupling", input_schema: %{ - file_path: - {:required, :string, description: "Path to the file relative to repo root"}, + file_path: {:required, :string, description: "Path to the file relative to repo root"}, min_coupling: - {:optional, :integer, description: "Minimum coupling percentage to include (default: 30)"} + {:optional, :integer, + description: "Minimum coupling percentage to include (default: 30)"} }, description: "Find files that historically change together with a given file. If you modify a file, its coupled files may also need updating.", @@ -57,8 +57,7 @@ defmodule GitlockMCP.Server do |> register_tool("gitlock_review_pr", input_schema: %{ changed_files: - {:required, {:array, :string}, - description: "List of file paths that were modified"} + {:required, {:array, :string}, description: "List of file paths that were modified"} }, description: "Analyze a set of changed files as a PR. Returns overall risk, per-file assessments, missing coupled files, and suggested reviewers. Call AFTER completing changes or before submitting.", @@ -126,16 +125,18 @@ defmodule GitlockMCP.Server do # ── Formatters ─────────────────────────────────────────────── defp format_assess_file(a) do - coupled = Enum.map_join(a.coupled_files, "\n", fn c -> - " - #{c.file} (#{c.coupling_pct}% co-change)" - end) - - ownership = if a.ownership do - o = a.ownership - "Owner: #{o.main_author} (#{o.ownership_pct}%) — #{o.silo_risk} silo risk" - else - "No ownership data" - end + coupled = + Enum.map_join(a.coupled_files, "\n", fn c -> + " - #{c.file} (#{c.coupling_pct}% co-change)" + end) + + ownership = + if a.ownership do + o = a.ownership + "Owner: #{o.main_author} (#{o.ownership_pct}%) — #{o.silo_risk} silo risk" + else + "No ownership data" + end """ ## #{a.file} @@ -150,9 +151,10 @@ defmodule GitlockMCP.Server do end defp format_hotspots(%{hotspots: hotspots, summary: summary}) do - rows = Enum.map_join(hotspots, "\n", fn h -> - "- #{h.file} — risk: #{h.risk_score} (#{h.risk_level}), #{h.revisions} revisions, complexity: #{h.complexity}" - end) + rows = + Enum.map_join(hotspots, "\n", fn h -> + "- #{h.file} — risk: #{h.risk_score} (#{h.risk_level}), #{h.revisions} revisions, complexity: #{h.complexity}" + end) "## Hotspots\n#{summary}\n\n#{rows}" end @@ -174,32 +176,36 @@ defmodule GitlockMCP.Server do defp format_coupling(%{coupled_files: [], recommendation: rec}), do: rec defp format_coupling(%{file: file, coupled_files: coupled, recommendation: rec}) do - rows = Enum.map_join(coupled, "\n", fn c -> - "- #{c.file} — #{c.coupling_pct}% co-change rate (#{c.co_changes} shared commits)" - end) + rows = + Enum.map_join(coupled, "\n", fn c -> + "- #{c.file} — #{c.coupling_pct}% co-change rate (#{c.co_changes} shared commits)" + end) "## Files coupled with #{file}\n#{rows}\n\n#{rec}" end defp format_review(r) do - files = Enum.map_join(r.file_assessments, "\n", fn a -> - "- #{a.file} — risk: #{a.risk_score} (#{a.risk_level})" - end) - - missing = if length(r.missing_coupled_files) > 0 do - "\n\nPotentially missing coupled files:\n" <> - Enum.map_join(r.missing_coupled_files, "\n", fn m -> - "- #{m.file} (#{m.coupling_pct}% coupled to #{m.coupled_to})" - end) - else - "" - end - - reviewers = if length(r.suggested_reviewers) > 0 do - "\n\nSuggested reviewers: #{Enum.join(r.suggested_reviewers, ", ")}" - else - "" - end + files = + Enum.map_join(r.file_assessments, "\n", fn a -> + "- #{a.file} — risk: #{a.risk_score} (#{a.risk_level})" + end) + + missing = + if length(r.missing_coupled_files) > 0 do + "\n\nPotentially missing coupled files:\n" <> + Enum.map_join(r.missing_coupled_files, "\n", fn m -> + "- #{m.file} (#{m.coupling_pct}% coupled to #{m.coupled_to})" + end) + else + "" + end + + reviewers = + if length(r.suggested_reviewers) > 0 do + "\n\nSuggested reviewers: #{Enum.join(r.suggested_reviewers, ", ")}" + else + "" + end "## PR Risk Assessment: #{r.overall_risk}\n#{files}#{missing}#{reviewers}\n\n#{r.recommendation}" end @@ -207,14 +213,15 @@ defmodule GitlockMCP.Server do defp format_summary(r) do counts = format_hotspot_counts(r.hotspot_count) - areas = if length(r.riskiest_areas) > 0 do - "\n\nRiskiest areas:\n" <> - Enum.map_join(r.riskiest_areas, "\n", fn a -> - "- #{a.directory}/ — avg risk: #{a.avg_risk}, #{a.hotspot_files} hotspot files" - end) - else - "" - end + areas = + if length(r.riskiest_areas) > 0 do + "\n\nRiskiest areas:\n" <> + Enum.map_join(r.riskiest_areas, "\n", fn a -> + "- #{a.directory}/ — avg risk: #{a.avg_risk}, #{a.hotspot_files} hotspot files" + end) + else + "" + end """ ## Repository Health Summary From dc492060429095221ac849faa4a3728ff0178c93 Mon Sep 17 00:00:00 2001 From: Khoi Ngo Date: Wed, 4 Mar 2026 16:04:20 +0700 Subject: [PATCH 3/4] feat: mcp --- apps/gitlock_mcp/lib/gitlock_mcp/cache.ex | 35 +-- apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex | 73 +++-- apps/gitlock_mcp/lib/gitlock_mcp/server.ex | 56 ++-- apps/gitlock_mcp/test/cache_test.exs | 283 ++++++++++++++++++-- apps/gitlock_mcp/test/indexer_test.exs | 91 +++++++ apps/gitlock_mcp/test/server_test.exs | 214 +++++++++++++++ 6 files changed, 669 insertions(+), 83 deletions(-) create mode 100644 apps/gitlock_mcp/test/indexer_test.exs create mode 100644 apps/gitlock_mcp/test/server_test.exs diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex b/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex index 20c1d1b..6a0c64d 100644 --- a/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex +++ b/apps/gitlock_mcp/lib/gitlock_mcp/cache.ex @@ -131,7 +131,7 @@ defmodule GitlockMCP.Cache do silo = Map.get(state.silo_index, file_path) coupled = Map.get(state.coupling_index, file_path, []) - risk_score = if hotspot, do: hotspot.normalized_score, else: 0 + risk_score = if hotspot, do: hotspot.risk_score, else: 0 risk_level = cond do @@ -218,7 +218,7 @@ defmodule GitlockMCP.Cache do %{ file: file, - risk_score: if(hotspot, do: round(hotspot.normalized_score), else: 0), + risk_score: if(hotspot, do: round(hotspot.risk_score), else: 0), risk_level: if(hotspot, do: to_string(hotspot.risk_factor), else: "low"), ownership: format_ownership(silo) } @@ -276,7 +276,7 @@ defmodule GitlockMCP.Cache do state.hotspots |> Enum.group_by(fn h -> h.entity |> Path.dirname() end) |> Enum.map(fn {dir, hotspots} -> - avg_risk = Enum.map(hotspots, & &1.normalized_score) |> then(&(Enum.sum(&1) / length(&1))) + avg_risk = Enum.map(hotspots, & &1.risk_score) |> then(&(Enum.sum(&1) / length(&1))) %{directory: dir, avg_risk: round(avg_risk), hotspot_files: length(hotspots)} end) |> Enum.sort_by(& &1.avg_risk, :desc) @@ -302,23 +302,10 @@ defmodule GitlockMCP.Cache do # ── Private Helpers ────────────────────────────────────────── defp detect_repo_path do - cwd = File.cwd!() - - if File.dir?(Path.join(cwd, ".git")) do - cwd - else - # Walk up to find a .git directory - cwd - |> Path.split() - |> Enum.reduce_while(nil, fn _segment, _acc -> - path = Path.join(Path.split(cwd) |> Enum.take(length(Path.split(cwd)))) - - if File.dir?(Path.join(path, ".git")) do - {:halt, path} - else - {:cont, nil} - end - end) || cwd + # Use git to find the actual repo root + case System.cmd("git", ["rev-parse", "--show-toplevel"], stderr_to_stdout: true) do + {path, 0} -> String.trim(path) + _ -> File.cwd!() end end @@ -326,8 +313,8 @@ defmodule GitlockMCP.Cache do couplings |> Enum.flat_map(fn c -> [ - {c.entity, %{file: c.coupled, coupling_pct: c.degree, co_changes: c.average}}, - {c.coupled, %{file: c.entity, coupling_pct: c.degree, co_changes: c.average}} + {c.entity, %{file: c.coupled, coupling_pct: c.degree, co_changes: c.windows}}, + {c.coupled, %{file: c.entity, coupling_pct: c.degree, co_changes: c.windows}} ] end) |> Enum.group_by(fn {file, _} -> file end, fn {_, data} -> data end) @@ -345,7 +332,7 @@ defmodule GitlockMCP.Cache do defp format_hotspot(h) do %{ file: h.entity, - risk_score: round(h.normalized_score), + risk_score: round(h.risk_score), risk_level: to_string(h.risk_factor), revisions: h.revisions, complexity: h.complexity, @@ -389,7 +376,7 @@ defmodule GitlockMCP.Cache do parts = [] parts = - if hotspot && hotspot.normalized_score > 70 do + if hotspot && hotspot.risk_score > 70 do [ "High-risk file — #{hotspot.revisions} revisions, complexity #{hotspot.complexity}." | parts diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex b/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex index e52bf73..88de69e 100644 --- a/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex +++ b/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex @@ -138,32 +138,61 @@ defmodule GitlockMCP.Indexer do case list_tracked_files(repo_path) do {:ok, files} -> - files - |> Enum.filter(fn path -> Path.extname(path) in supported_ext end) - |> Task.async_stream( - fn file_path -> - case read_file_from_git(repo_path, file_path) do - {:ok, content} -> - case DispatchAnalyzer.analyze_content(content, file_path) do - {:ok, metrics} -> {file_path, metrics} - _ -> nil - end - - _ -> - nil - end - end, - max_concurrency: System.schedulers_online() * 2, - on_timeout: :kill_task - ) - |> Enum.reduce(%{}, fn - {:ok, {path, metrics}}, acc -> Map.put(acc, path, metrics) - _, acc -> acc - end) + source_files = Enum.filter(files, fn path -> Path.extname(path) in supported_ext end) + + # Write files to a temp dir so DispatchAnalyzer.analyze_file/1 can read them + tmp_dir = Path.join(System.tmp_dir!(), "gitlock_complexity_#{:rand.uniform(100_000)}") + File.mkdir_p!(tmp_dir) + + try do + # Extract files from git into temp dir + source_files + |> Task.async_stream( + fn file_path -> + tmp_path = Path.join(tmp_dir, file_path) + File.mkdir_p!(Path.dirname(tmp_path)) + + case read_file_from_git(repo_path, file_path) do + {:ok, content} -> File.write!(tmp_path, content); file_path + _ -> nil + end + end, + max_concurrency: System.schedulers_online() * 2, + on_timeout: :kill_task + ) + |> Enum.reduce([], fn + {:ok, nil}, acc -> acc + {:ok, path}, acc -> [path | acc] + _, acc -> acc + end) + + # Now analyze from the temp dir + source_files + |> Task.async_stream( + fn file_path -> + tmp_path = Path.join(tmp_dir, file_path) + + case DispatchAnalyzer.analyze_file(tmp_path) do + {:ok, metrics} -> {file_path, metrics} + _ -> nil + end + end, + max_concurrency: System.schedulers_online() * 2, + on_timeout: :kill_task + ) + |> Enum.reduce(%{}, fn + {:ok, {path, metrics}}, acc -> Map.put(acc, path, metrics) + _, acc -> acc + end) + after + File.rm_rf(tmp_dir) + end {:error, _} -> %{} end + rescue + _ -> %{} end defp list_tracked_files(repo_path) do diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/server.ex b/apps/gitlock_mcp/lib/gitlock_mcp/server.ex index 3a07b91..1387724 100644 --- a/apps/gitlock_mcp/lib/gitlock_mcp/server.ex +++ b/apps/gitlock_mcp/lib/gitlock_mcp/server.ex @@ -10,6 +10,7 @@ defmodule GitlockMCP.Server do version: "0.1.0", capabilities: [:tools] + alias Hermes.Server.Response require Logger @impl true @@ -74,51 +75,54 @@ defmodule GitlockMCP.Server do @impl true def handle_tool_call("gitlock_assess_file", %{file_path: file_path}, frame) do case GitlockMCP.Cache.assess_file(file_path) do - {:ok, result} -> {:reply, format_assess_file(result), frame} - {:error, reason} -> {:error, inspect(reason), frame} + {:ok, result} -> {:reply, text_response(format_assess_file(result)), frame} + {:error, reason} -> {:reply, error_response(inspect(reason)), frame} end end def handle_tool_call("gitlock_hotspots", params, frame) do opts = %{ - directory: Map.get(params, :directory), - limit: Map.get(params, :limit, 10) + directory: Map.get(params, :directory) || Map.get(params, "directory"), + limit: to_integer(Map.get(params, :limit) || Map.get(params, "limit"), 10) } case GitlockMCP.Cache.hotspots(opts) do - {:ok, result} -> {:reply, format_hotspots(result), frame} - {:error, reason} -> {:error, inspect(reason), frame} + {:ok, result} -> {:reply, text_response(format_hotspots(result)), frame} + {:error, reason} -> {:reply, error_response(inspect(reason)), frame} end end def handle_tool_call("gitlock_file_ownership", %{file_path: file_path}, frame) do case GitlockMCP.Cache.file_ownership(file_path) do - {:ok, result} -> {:reply, format_ownership(result), frame} - {:error, reason} -> {:error, inspect(reason), frame} + {:ok, result} -> {:reply, text_response(format_ownership(result)), frame} + {:error, reason} -> {:reply, error_response(inspect(reason)), frame} end end def handle_tool_call("gitlock_find_coupling", params, frame) do - file_path = params.file_path - min_coupling = Map.get(params, :min_coupling, 30) + file_path = params[:file_path] || params["file_path"] + min_coupling = to_integer(Map.get(params, :min_coupling) || Map.get(params, "min_coupling"), 30) case GitlockMCP.Cache.find_coupling(file_path, min_coupling) do - {:ok, result} -> {:reply, format_coupling(result), frame} - {:error, reason} -> {:error, inspect(reason), frame} + {:ok, result} -> {:reply, text_response(format_coupling(result)), frame} + {:error, reason} -> {:reply, error_response(inspect(reason)), frame} end end - def handle_tool_call("gitlock_review_pr", %{changed_files: files}, frame) do + def handle_tool_call("gitlock_review_pr", params, frame) do + files = params[:changed_files] || params["changed_files"] + files = if is_binary(files), do: Jason.decode!(files), else: files + case GitlockMCP.Cache.review_pr(files) do - {:ok, result} -> {:reply, format_review(result), frame} - {:error, reason} -> {:error, inspect(reason), frame} + {:ok, result} -> {:reply, text_response(format_review(result)), frame} + {:error, reason} -> {:reply, error_response(inspect(reason)), frame} end end def handle_tool_call("gitlock_repo_summary", _params, frame) do case GitlockMCP.Cache.repo_summary() do - {:ok, result} -> {:reply, format_summary(result), frame} - {:error, reason} -> {:error, inspect(reason), frame} + {:ok, result} -> {:reply, text_response(format_summary(result)), frame} + {:error, reason} -> {:reply, error_response(inspect(reason)), frame} end end @@ -236,6 +240,24 @@ defmodule GitlockMCP.Server do |> String.trim() end + defp to_integer(nil, default), do: default + defp to_integer(val, _default) when is_integer(val), do: val + defp to_integer(val, default) when is_binary(val) do + case Integer.parse(val) do + {n, _} -> n + :error -> default + end + end + defp to_integer(_, default), do: default + + defp text_response(text) do + Response.tool() |> Response.text(text) + end + + defp error_response(message) do + Response.tool() |> Response.error(message) + end + defp format_hotspot_counts(counts) do parts = [{"high", "critical"}, {"medium", "medium"}, {"low", "low"}] diff --git a/apps/gitlock_mcp/test/cache_test.exs b/apps/gitlock_mcp/test/cache_test.exs index ef8b8ad..13dab38 100644 --- a/apps/gitlock_mcp/test/cache_test.exs +++ b/apps/gitlock_mcp/test/cache_test.exs @@ -3,40 +3,283 @@ defmodule GitlockMCP.CacheTest do alias GitlockMCP.Cache - describe "Cache on a real git repo" do - test "indexes the current repo and answers queries" do - # Use the Gitlock repo itself - repo_path = Path.expand("../../../", __DIR__) + # All cache tests use the Gitlock repo itself as the test subject. + # This is intentional — dogfooding the analysis tool on its own codebase. - # Should be a git repo - assert File.dir?(Path.join(repo_path, ".git")) + setup_all do + repo_path = Path.expand("../../../", __DIR__) + git_path = Path.join(repo_path, ".git") - # Index it + assert File.exists?(git_path), + "Test must run inside a git repo (no .git at #{repo_path})" + + :ok = Cache.ensure_indexed(repo_path) + %{repo_path: repo_path} + end + + # ── ensure_indexed ─────────────────────────────────────────── + + describe "ensure_indexed/1" do + test "indexes the current repo successfully", %{repo_path: repo_path} do assert :ok = Cache.ensure_indexed(repo_path) + end - # Hotspots should return data - assert {:ok, %{hotspots: hotspots}} = Cache.hotspots(%{limit: 5}) - assert length(hotspots) > 0 - assert hd(hotspots).risk_score > 0 + test "re-indexing with same path returns :ok without re-running" do + assert :ok = Cache.ensure_indexed() + end - # Assess a file that likely exists - first_hotspot = hd(hotspots) - assert {:ok, assessment} = Cache.assess_file(first_hotspot.file) - assert assessment.risk_score > 0 + test "returns error for invalid repo path" do + assert {:error, _reason} = Cache.ensure_indexed("/nonexistent/path/to/repo") + end + end + + # ── repo_summary ───────────────────────────────────────────── - # Repo summary should work + describe "repo_summary/0" do + test "returns summary with expected keys" do assert {:ok, summary} = Cache.repo_summary() + + assert is_integer(summary.total_commits) assert summary.total_commits > 0 + assert is_integer(summary.total_files) assert summary.total_files > 0 + assert is_map(summary.hotspot_count) + assert is_integer(summary.knowledge_silos) + assert is_integer(summary.high_coupling_pairs) + assert is_list(summary.riskiest_areas) + assert is_binary(summary.summary) + end + + test "riskiest areas have expected shape" do + {:ok, summary} = Cache.repo_summary() + + Enum.each(summary.riskiest_areas, fn area -> + assert is_binary(area.directory) + assert is_number(area.avg_risk) + assert is_integer(area.hotspot_files) + end) + end + end + + # ── hotspots ───────────────────────────────────────────────── + + describe "hotspots/1" do + test "returns hotspots with default limit" do + assert {:ok, %{hotspots: hotspots, summary: summary}} = Cache.hotspots() + + assert is_list(hotspots) + assert length(hotspots) <= 10 + assert is_binary(summary) + end + + test "respects limit option" do + assert {:ok, %{hotspots: hotspots}} = Cache.hotspots(%{limit: 3}) + assert length(hotspots) <= 3 + end + + test "hotspot entries have expected shape" do + {:ok, %{hotspots: [h | _]}} = Cache.hotspots(%{limit: 1}) + + assert is_binary(h.file) + assert is_number(h.risk_score) + assert is_binary(h.risk_level) + assert is_integer(h.revisions) + assert is_number(h.complexity) + assert is_integer(h.loc) + end + + test "filters by directory" do + {:ok, %{hotspots: hotspots}} = Cache.hotspots(%{directory: "apps/gitlock_mcp"}) + + Enum.each(hotspots, fn h -> + assert String.starts_with?(h.file, "apps/gitlock_mcp") + end) + end + + test "returns empty list for nonexistent directory" do + {:ok, %{hotspots: hotspots}} = Cache.hotspots(%{directory: "nonexistent/dir"}) + assert hotspots == [] + end + + test "summary reflects directory filter" do + {:ok, %{summary: summary}} = Cache.hotspots(%{directory: "apps/gitlock_core"}) + assert summary =~ "apps/gitlock_core" + end + + test "accepts string keys for options" do + assert {:ok, %{hotspots: hotspots}} = Cache.hotspots(%{"limit" => 2}) + assert length(hotspots) <= 2 + end + end + + # ── assess_file ────────────────────────────────────────────── + + describe "assess_file/1" do + test "returns assessment for a known file" do + {:ok, %{hotspots: [h | _]}} = Cache.hotspots(%{limit: 1}) + {:ok, assessment} = Cache.assess_file(h.file) + + assert assessment.file == h.file + assert is_integer(assessment.risk_score) + assert assessment.risk_level in ["low", "medium", "high", "critical"] + assert is_integer(assessment.revisions) + assert is_number(assessment.complexity) + assert is_integer(assessment.loc) + assert is_binary(assessment.recommendation) + assert is_list(assessment.coupled_files) end - test "returns data for unknown file without crashing" do - repo_path = Path.expand("../../../", __DIR__) - :ok = Cache.ensure_indexed(repo_path) + test "returns zero-risk assessment for unknown file" do + {:ok, assessment} = Cache.assess_file("does/not/exist.ex") - assert {:ok, assessment} = Cache.assess_file("nonexistent/file.ex") + assert assessment.file == "does/not/exist.ex" assert assessment.risk_score == 0 assert assessment.risk_level == "low" + assert assessment.revisions == 0 + assert assessment.complexity == 0 + assert assessment.loc == 0 + end + + test "ownership is nil for unknown file" do + {:ok, assessment} = Cache.assess_file("does/not/exist.ex") + assert is_nil(assessment.ownership) + end + + test "risk_level maps correctly to risk_score" do + {:ok, %{hotspots: hotspots}} = Cache.hotspots() + + Enum.each(hotspots, fn h -> + {:ok, a} = Cache.assess_file(h.file) + + expected_level = + cond do + a.risk_score > 70 -> "critical" + a.risk_score > 40 -> "high" + a.risk_score > 20 -> "medium" + true -> "low" + end + + assert a.risk_level == expected_level, + "#{a.file}: score #{a.risk_score} should be #{expected_level}, got #{a.risk_level}" + end) + end + end + + # ── file_ownership ─────────────────────────────────────────── + + describe "file_ownership/1" do + test "returns ownership data for a known file" do + {:ok, %{hotspots: [h | _]}} = Cache.hotspots(%{limit: 1}) + {:ok, ownership} = Cache.file_ownership(h.file) + + assert ownership.file == h.file + assert is_binary(ownership.main_author) + assert is_number(ownership.ownership_pct) + assert is_integer(ownership.total_authors) + assert is_integer(ownership.total_commits) + assert is_binary(ownership.risk_level) + assert is_binary(ownership.recommendation) + end + + test "returns no_data status for unknown file" do + {:ok, ownership} = Cache.file_ownership("nonexistent/unknown.ex") + + assert ownership.status == "no_data" + assert is_binary(ownership.message) + end + end + + # ── find_coupling ──────────────────────────────────────────── + + describe "find_coupling/2" do + test "returns coupling data with recommendation" do + {:ok, %{hotspots: [h | _]}} = Cache.hotspots(%{limit: 1}) + {:ok, result} = Cache.find_coupling(h.file) + + assert result.file == h.file + assert is_list(result.coupled_files) + assert is_binary(result.recommendation) + end + + test "returns empty coupled_files for isolated file" do + {:ok, result} = Cache.find_coupling("nonexistent/isolated.ex") + + assert result.coupled_files == [] + assert result.recommendation =~ "No strong temporal coupling" + end + + test "respects min_coupling threshold" do + {:ok, %{hotspots: [h | _]}} = Cache.hotspots(%{limit: 1}) + + {:ok, high_threshold} = Cache.find_coupling(h.file, 99) + {:ok, low_threshold} = Cache.find_coupling(h.file, 1) + + assert length(high_threshold.coupled_files) <= length(low_threshold.coupled_files) + end + end + + # ── review_pr ──────────────────────────────────────────────── + + describe "review_pr/1" do + test "returns PR review for known files" do + {:ok, %{hotspots: hotspots}} = Cache.hotspots(%{limit: 3}) + files = Enum.map(hotspots, & &1.file) + + {:ok, review} = Cache.review_pr(files) + + assert review.overall_risk in ["low", "medium", "high", "critical"] + assert is_list(review.file_assessments) + assert length(review.file_assessments) == length(files) + assert is_list(review.missing_coupled_files) + assert is_list(review.suggested_reviewers) + assert is_binary(review.recommendation) + end + + test "file assessments have expected shape" do + {:ok, %{hotspots: [h | _]}} = Cache.hotspots(%{limit: 1}) + {:ok, review} = Cache.review_pr([h.file]) + + [assessment | _] = review.file_assessments + + assert assessment.file == h.file + assert is_integer(assessment.risk_score) + assert is_binary(assessment.risk_level) + end + + test "returns low risk for unknown files" do + {:ok, review} = Cache.review_pr(["nonexistent/a.ex", "nonexistent/b.ex"]) + + assert review.overall_risk == "low" + + Enum.each(review.file_assessments, fn a -> + assert a.risk_score == 0 + end) + end + + test "handles empty file list" do + {:ok, review} = Cache.review_pr([]) + + assert review.overall_risk == "low" + assert review.file_assessments == [] + assert review.missing_coupled_files == [] + assert review.suggested_reviewers == [] + end + + test "handles single file PR" do + {:ok, %{hotspots: [h | _]}} = Cache.hotspots(%{limit: 1}) + {:ok, review} = Cache.review_pr([h.file]) + + assert length(review.file_assessments) == 1 + end + + test "suggested reviewers are strings" do + {:ok, %{hotspots: hotspots}} = Cache.hotspots(%{limit: 5}) + files = Enum.map(hotspots, & &1.file) + {:ok, review} = Cache.review_pr(files) + + Enum.each(review.suggested_reviewers, fn reviewer -> + assert is_binary(reviewer) + end) end end end diff --git a/apps/gitlock_mcp/test/indexer_test.exs b/apps/gitlock_mcp/test/indexer_test.exs new file mode 100644 index 0000000..6423f7d --- /dev/null +++ b/apps/gitlock_mcp/test/indexer_test.exs @@ -0,0 +1,91 @@ +defmodule GitlockMCP.IndexerTest do + use ExUnit.Case, async: false + + alias GitlockMCP.Indexer + + @repo_path Path.expand("../../../", __DIR__) + + describe "index/1" do + test "successfully indexes the current repo" do + assert {:ok, data} = Indexer.index(@repo_path) + + assert is_list(data.commits) + assert length(data.commits) > 0 + + assert is_list(data.hotspots) + assert length(data.hotspots) > 0 + + assert is_list(data.couplings) + assert is_list(data.knowledge_silos) + assert is_map(data.complexity_map) + assert is_list(data.code_age) + assert is_list(data.summary) + end + + test "returns error for nonexistent path" do + assert {:error, _} = Indexer.index("/tmp/nonexistent_repo_#{:rand.uniform(999_999)}") + end + + test "summary is a list of statistics" do + {:ok, data} = Indexer.index(@repo_path) + + assert is_list(data.summary) + assert length(data.summary) > 0 + + Enum.each(data.summary, fn stat -> + assert Map.has_key?(stat, :statistic) or Map.has_key?(stat, "statistic") + assert Map.has_key?(stat, :value) or Map.has_key?(stat, "value") + end) + end + + test "hotspots include expected fields" do + {:ok, data} = Indexer.index(@repo_path) + + [h | _] = data.hotspots + assert Map.has_key?(h, :entity) + assert Map.has_key?(h, :revisions) + assert Map.has_key?(h, :risk_score) + assert Map.has_key?(h, :risk_factor) + assert Map.has_key?(h, :complexity) + assert Map.has_key?(h, :loc) + end + + test "knowledge silos include author info" do + {:ok, data} = Indexer.index(@repo_path) + + if length(data.knowledge_silos) > 0 do + [silo | _] = data.knowledge_silos + assert Map.has_key?(silo, :entity) + assert Map.has_key?(silo, :main_author) + assert Map.has_key?(silo, :ownership_ratio) + assert Map.has_key?(silo, :risk_level) + assert Map.has_key?(silo, :num_authors) + assert Map.has_key?(silo, :num_commits) + end + end + + test "couplings include entity pairs and degree" do + {:ok, data} = Indexer.index(@repo_path) + + if length(data.couplings) > 0 do + [c | _] = data.couplings + assert Map.has_key?(c, :entity) + assert Map.has_key?(c, :coupled) + assert Map.has_key?(c, :degree) + assert Map.has_key?(c, :windows) + end + end + + test "complexity_map keys are file paths" do + {:ok, data} = Indexer.index(@repo_path) + + if map_size(data.complexity_map) > 0 do + {path, _metrics} = Enum.at(data.complexity_map, 0) + assert is_binary(path) + assert String.contains?(path, "/") or String.ends_with?(path, ".ex") + end + end + + + end +end diff --git a/apps/gitlock_mcp/test/server_test.exs b/apps/gitlock_mcp/test/server_test.exs new file mode 100644 index 0000000..eb79965 --- /dev/null +++ b/apps/gitlock_mcp/test/server_test.exs @@ -0,0 +1,214 @@ +defmodule GitlockMCP.ServerTest do + use ExUnit.Case, async: false + + # Tests that the Cache produces data shapes compatible with the Server's + # formatters. Since the formatters are private, we verify the contract + # by checking that Cache output has all required keys. + + setup_all do + repo_path = Path.expand("../../../", __DIR__) + :ok = GitlockMCP.Cache.ensure_indexed(repo_path) + :ok + end + + # ── repo_summary formatter contract ────────────────────────── + + describe "repo_summary formatter contract" do + test "result has all keys needed by format_summary/1" do + {:ok, r} = GitlockMCP.Cache.repo_summary() + + assert is_map(r.hotspot_count) + assert is_list(r.riskiest_areas) + assert is_binary(r.summary) + assert is_integer(r.total_files) + assert is_integer(r.total_commits) + assert is_integer(r.knowledge_silos) + assert is_integer(r.high_coupling_pairs) + end + + test "hotspot_count values are integers" do + {:ok, r} = GitlockMCP.Cache.repo_summary() + + Enum.each(r.hotspot_count, fn {key, count} -> + assert is_binary(key), "hotspot_count key should be string, got: #{inspect(key)}" + assert is_integer(count), "hotspot_count value should be integer, got: #{inspect(count)}" + end) + end + + test "riskiest_areas entries have directory, avg_risk, hotspot_files" do + {:ok, r} = GitlockMCP.Cache.repo_summary() + + Enum.each(r.riskiest_areas, fn area -> + assert is_binary(area.directory) + assert is_number(area.avg_risk) + assert is_integer(area.hotspot_files) + end) + end + end + + # ── assess_file formatter contract ─────────────────────────── + + describe "assess_file formatter contract" do + test "result has all keys needed by format_assess_file/1" do + {:ok, %{hotspots: [h | _]}} = GitlockMCP.Cache.hotspots(%{limit: 1}) + {:ok, a} = GitlockMCP.Cache.assess_file(h.file) + + assert is_binary(a.file) + assert is_binary(a.risk_level) + assert is_integer(a.risk_score) + assert is_integer(a.revisions) + assert is_number(a.complexity) + assert is_integer(a.loc) + assert is_list(a.coupled_files) + assert is_binary(a.recommendation) + # ownership can be nil or a map + assert is_nil(a.ownership) or is_map(a.ownership) + end + + test "ownership map has expected keys when present" do + {:ok, %{hotspots: [h | _]}} = GitlockMCP.Cache.hotspots(%{limit: 1}) + {:ok, a} = GitlockMCP.Cache.assess_file(h.file) + + if a.ownership do + assert is_binary(a.ownership.main_author) + assert is_number(a.ownership.ownership_pct) + assert is_integer(a.ownership.total_authors) + assert is_binary(a.ownership.silo_risk) + end + end + + test "coupled_files entries have file, coupling_pct, co_changes" do + {:ok, %{hotspots: hotspots}} = GitlockMCP.Cache.hotspots() + + # Find a file with couplings if any exist + file_with_coupling = + Enum.find(hotspots, fn h -> + {:ok, a} = GitlockMCP.Cache.assess_file(h.file) + length(a.coupled_files) > 0 + end) + + if file_with_coupling do + {:ok, a} = GitlockMCP.Cache.assess_file(file_with_coupling.file) + + Enum.each(a.coupled_files, fn c -> + assert is_binary(c.file) + assert is_number(c.coupling_pct) + assert is_integer(c.co_changes) + end) + end + end + end + + # ── hotspots formatter contract ────────────────────────────── + + describe "hotspots formatter contract" do + test "result has hotspots list and summary string" do + {:ok, result} = GitlockMCP.Cache.hotspots(%{limit: 3}) + + assert is_list(result.hotspots) + assert is_binary(result.summary) + end + + test "hotspot entries have all keys needed by format_hotspots/1" do + {:ok, %{hotspots: [h | _]}} = GitlockMCP.Cache.hotspots(%{limit: 1}) + + assert is_binary(h.file) + assert is_number(h.risk_score) + assert is_binary(h.risk_level) + assert is_integer(h.revisions) + assert is_number(h.complexity) + end + end + + # ── ownership formatter contract ───────────────────────────── + + describe "ownership formatter contract" do + test "known file has all keys needed by format_ownership/1" do + {:ok, %{hotspots: [h | _]}} = GitlockMCP.Cache.hotspots(%{limit: 1}) + {:ok, r} = GitlockMCP.Cache.file_ownership(h.file) + + assert is_binary(r.file) + assert is_binary(r.main_author) + assert is_number(r.ownership_pct) + assert is_integer(r.total_authors) + assert is_integer(r.total_commits) + assert is_binary(r.risk_level) + assert is_binary(r.recommendation) + end + + test "unknown file has status and message for no_data branch" do + {:ok, r} = GitlockMCP.Cache.file_ownership("nonexistent.ex") + + assert r.status == "no_data" + assert is_binary(r.message) + end + end + + # ── coupling formatter contract ────────────────────────────── + + describe "coupling formatter contract" do + test "result has file, coupled_files, recommendation" do + {:ok, %{hotspots: [h | _]}} = GitlockMCP.Cache.hotspots(%{limit: 1}) + {:ok, r} = GitlockMCP.Cache.find_coupling(h.file) + + assert is_binary(r.file) + assert is_list(r.coupled_files) + assert is_binary(r.recommendation) + end + + test "empty coupling recommendation mentions 'No strong temporal coupling'" do + {:ok, r} = GitlockMCP.Cache.find_coupling("nonexistent.ex") + assert r.recommendation =~ "No strong temporal coupling" + end + end + + # ── review_pr formatter contract ───────────────────────────── + + describe "review_pr formatter contract" do + test "result has all keys needed by format_review/1" do + {:ok, %{hotspots: hotspots}} = GitlockMCP.Cache.hotspots(%{limit: 2}) + files = Enum.map(hotspots, & &1.file) + {:ok, r} = GitlockMCP.Cache.review_pr(files) + + assert is_binary(r.overall_risk) + assert is_list(r.file_assessments) + assert is_list(r.missing_coupled_files) + assert is_list(r.suggested_reviewers) + assert is_binary(r.recommendation) + end + + test "file_assessments have file, risk_score, risk_level" do + {:ok, %{hotspots: [h | _]}} = GitlockMCP.Cache.hotspots(%{limit: 1}) + {:ok, r} = GitlockMCP.Cache.review_pr([h.file]) + + [a | _] = r.file_assessments + assert is_binary(a.file) + assert is_integer(a.risk_score) + assert is_binary(a.risk_level) + end + + test "missing_coupled_files entries have file, coupling_pct, coupled_to" do + {:ok, summary} = GitlockMCP.Cache.repo_summary() + + if summary.high_coupling_pairs > 0 do + {:ok, %{hotspots: hotspots}} = GitlockMCP.Cache.hotspots() + + coupled_file = + Enum.find(hotspots, fn h -> + {:ok, r} = GitlockMCP.Cache.find_coupling(h.file, 30) + length(r.coupled_files) > 0 + end) + + if coupled_file do + {:ok, r} = GitlockMCP.Cache.review_pr([coupled_file.file]) + + Enum.each(r.missing_coupled_files, fn m -> + assert is_binary(m.file) + assert is_number(m.coupling_pct) + assert is_binary(m.coupled_to) + end) + end + end + end + end +end From 6abe6bfb02f26ad33bb1c988c63361ffc5c763c3 Mon Sep 17 00:00:00 2001 From: Khoi Ngo Date: Wed, 4 Mar 2026 16:09:10 +0700 Subject: [PATCH 4/4] fix: format --- apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex | 8 ++++++-- apps/gitlock_mcp/lib/gitlock_mcp/server.ex | 6 +++++- apps/gitlock_mcp/test/indexer_test.exs | 2 -- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex b/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex index 88de69e..e05c4f8 100644 --- a/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex +++ b/apps/gitlock_mcp/lib/gitlock_mcp/indexer.ex @@ -153,8 +153,12 @@ defmodule GitlockMCP.Indexer do File.mkdir_p!(Path.dirname(tmp_path)) case read_file_from_git(repo_path, file_path) do - {:ok, content} -> File.write!(tmp_path, content); file_path - _ -> nil + {:ok, content} -> + File.write!(tmp_path, content) + file_path + + _ -> + nil end end, max_concurrency: System.schedulers_online() * 2, diff --git a/apps/gitlock_mcp/lib/gitlock_mcp/server.ex b/apps/gitlock_mcp/lib/gitlock_mcp/server.ex index 1387724..dfc9463 100644 --- a/apps/gitlock_mcp/lib/gitlock_mcp/server.ex +++ b/apps/gitlock_mcp/lib/gitlock_mcp/server.ex @@ -101,7 +101,9 @@ defmodule GitlockMCP.Server do def handle_tool_call("gitlock_find_coupling", params, frame) do file_path = params[:file_path] || params["file_path"] - min_coupling = to_integer(Map.get(params, :min_coupling) || Map.get(params, "min_coupling"), 30) + + min_coupling = + to_integer(Map.get(params, :min_coupling) || Map.get(params, "min_coupling"), 30) case GitlockMCP.Cache.find_coupling(file_path, min_coupling) do {:ok, result} -> {:reply, text_response(format_coupling(result)), frame} @@ -242,12 +244,14 @@ defmodule GitlockMCP.Server do defp to_integer(nil, default), do: default defp to_integer(val, _default) when is_integer(val), do: val + defp to_integer(val, default) when is_binary(val) do case Integer.parse(val) do {n, _} -> n :error -> default end end + defp to_integer(_, default), do: default defp text_response(text) do diff --git a/apps/gitlock_mcp/test/indexer_test.exs b/apps/gitlock_mcp/test/indexer_test.exs index 6423f7d..976178a 100644 --- a/apps/gitlock_mcp/test/indexer_test.exs +++ b/apps/gitlock_mcp/test/indexer_test.exs @@ -85,7 +85,5 @@ defmodule GitlockMCP.IndexerTest do assert String.contains?(path, "/") or String.ends_with?(path, ".ex") end end - - end end