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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`.

**Test Verification Baseline:** **1172 Automated Tests** (814 Pytest Backend + 308 Vitest Frontend + 50 Playwright E2E).
**Test Verification Baseline:** **1179 Automated Tests** (819 Pytest Backend + 310 Vitest Frontend + 50 Playwright E2E).

---

Expand Down Expand Up @@ -529,6 +529,13 @@ classDiagram
- `test_handle_diagnose_issue_mcp`
- `test_diagnose_issue_registered_on_mcp_server`

#### `tests/backend/test_git_commits_history.py` (5 tests)
- `test_extract_recent_commits_unit`
- `test_format_git_commit_url`
- `test_sync_single_git_repo_commits_and_pruning`
- `test_get_recent_changes_mcp_tool`
- `test_rest_api_patch_retain_commits`

#### `tests/backend/test_git_manager.py` (34 tests)
- `TestGitManager::test_get_env_token`
- `TestGitManager::test_normalize_git_url`
Expand Down Expand Up @@ -1611,6 +1618,10 @@ over the PostgresConnectionAdapter using an initialized test engine._
- cancels sync by calling /admin/api/repos/{id}/cancel-sync
- closes EventSource on unmount

#### `RetainCommitsControls.test.tsx` (2 tests)
- renders retain commits input on AddRepoModal and calls setRetainCommitsCount
- renders history retention badge on RepoListTable and prompts for edit

#### `SearchInspector.test.tsx` (11 tests)
- renders initial prompt and inputs
- switches search mode and updates UI controls
Expand Down
36 changes: 30 additions & 6 deletions app/api/routers/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from app.services.auth import Role
from app.api.routers.auth import require_role

from app.models.schemas import RepoConfig, LocalPathConfig, AutoSyncToggleRequest, KeepShallowToggleRequest, SearchRequest
from app.models.schemas import RepoConfig, LocalPathConfig, AutoSyncToggleRequest, KeepShallowToggleRequest, RetainCommitsRequest, SearchRequest
import app.services.database as db_service
import app.services.vector_store as vs_service
import app.services.indexing as idx_service
Expand All @@ -32,7 +32,7 @@ async def api_get_repos():
with db_service.get_db_connection() as conn:
rows = conn.execute("""
SELECT id, name, url, branch, commit_sha, status, last_error, last_synced,
auth_token, provider, auth_user, auto_sync, keep_shallow, webhook_secret
auth_token, provider, auth_user, auto_sync, keep_shallow, retain_commits_count, webhook_secret
FROM git_repositories
ORDER BY id DESC
""").fetchall()
Expand Down Expand Up @@ -61,11 +61,12 @@ async def api_add_repo(repo: RepoConfig):
try:
with db_service.get_db_connection() as conn:
cursor = conn.cursor()
retain_count = repo.retain_commits_count if repo.retain_commits_count is not None else 20
cursor.execute(
"""INSERT INTO git_repositories (name, url, branch, auth_token, provider, auth_user, auto_sync, keep_shallow, webhook_secret)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO git_repositories (name, url, branch, auth_token, provider, auth_user, auto_sync, keep_shallow, retain_commits_count, webhook_secret)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(repo.name.strip(), repo.url.strip(), repo.branch or "main", repo.auth_token, repo.provider or "github", repo.auth_user,
1 if repo.auto_sync else 0, 1 if repo.keep_shallow else 0, repo.webhook_secret)
1 if repo.auto_sync else 0, 1 if repo.keep_shallow else 0, max(0, retain_count), repo.webhook_secret)
)
conn.commit()
repo_id = cursor.lastrowid
Expand Down Expand Up @@ -114,6 +115,29 @@ async def api_toggle_repo_keep_shallow(repo_id: int, payload: KeepShallowToggleR
logger.error(f"Error toggling keep-shallow for repo {repo_id}: {e}")
return JSONResponse(status_code=500, content={"error": "Failed to toggle keep-shallow for repository."})

@router.patch(
"/admin/api/repos/{repo_id}/retain-commits",
dependencies=[Depends(require_role(Role.ADMIN))],
)
async def api_update_repo_retain_commits(repo_id: int, payload: RetainCommitsRequest):
try:
with db_service.get_db_connection() as conn:
cursor = conn.cursor()
val = max(0, payload.retain_commits_count)
res = cursor.execute("UPDATE git_repositories SET retain_commits_count = ? WHERE id = ?", (val, repo_id))
if res.rowcount == 0:
return JSONResponse(status_code=404, content={"error": f"Repository with ID {repo_id} not found"})
if val == 0:
# Delete any stored commits immediately if retention is disabled
row = cursor.execute("SELECT name FROM git_repositories WHERE id = ?", (repo_id,)).fetchone()
if row:
cursor.execute("DELETE FROM git_commits WHERE repo = ?", (row["name"],))
conn.commit()
return {"status": "success", "id": repo_id, "repo_id": repo_id, "retain_commits_count": val}
except Exception as e:
logger.error(f"Error updating retain_commits_count for repo {repo_id}: {e}")
return JSONResponse(status_code=500, content={"error": "Failed to update retain_commits_count for repository."})

@router.post(
"/admin/api/repos/{repo_id}/sync",
dependencies=[Depends(require_role(Role.EDITOR))],
Expand Down Expand Up @@ -218,7 +242,7 @@ async def api_delete_repo(repo_id: int):
name = row["name"]

conn.execute("DELETE FROM git_repositories WHERE id = ?", (repo_id,))
for table in ("indexed_files", "ast_symbols", "ast_relationships", "api_routes"):
for table in ("indexed_files", "ast_symbols", "ast_relationships", "api_routes", "git_commits"):
try:
conn.execute(f"DELETE FROM {table} WHERE repo = ?", (name,))
except Exception:
Expand Down
2 changes: 2 additions & 0 deletions app/mcp/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
handle_index_status,
handle_catalog_summary,
handle_search_infrastructure_docs,
handle_get_recent_changes,
)
from app.mcp.handlers.architecture_handlers import (
handle_get_architecture,
Expand Down Expand Up @@ -50,6 +51,7 @@
"handle_index_status",
"handle_catalog_summary",
"handle_search_infrastructure_docs",
"handle_get_recent_changes",
"handle_get_architecture",
"handle_manage_adr",
"handle_manage_local_file",
Expand Down
118 changes: 117 additions & 1 deletion app/mcp/handlers/repo_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from app.services.search import execute_hybrid_search, trace_symbol_path
from app.services.indexing import get_dynamic_catalog_description, sync_single_git_repo, run_full_indexing
from app.services.vector_store import get_vector_store_config
from app.services.git_manager import format_git_permalink
from app.services.git_manager import format_git_permalink, format_git_commit_url
from app.services.chunking import match_route_and_call, normalize_path_pattern
from app.models.schemas import SearchRequest, FindSymbolRequest, GetFileOutlineRequest, SyncRequest

Expand Down Expand Up @@ -233,3 +233,119 @@ def handle_search_infrastructure_docs(
"topic": topic,
"instructions": f"Perform a search using the search_docs tool for topic '{topic}' and summarize the matching container mappings, port numbers, reverse proxy routes, or setup instructions.",
})


async def handle_get_recent_changes(
repo: Annotated[str, Field(description="Repository name to inspect recent commits for.")],
filepath: Annotated[Optional[str], Field(description="Optional relative file path filter to only return commits touching this file.")] = None,
symbol: Annotated[Optional[str], Field(description="Optional AST symbol name; resolves symbol's file path and filters commits touching that file.")] = None,
limit: Annotated[int, Field(description="Max commits to return (default 10, max 50).")] = 10,
) -> str:
"""Returns recent commit history for a repository, optionally filtered by file path or symbol name."""
try:
from app.services.auth import enforce_tool_permission, Role
enforce_tool_permission(Role.VIEWER)

safe_limit = max(1, min(limit if limit is not None else 10, 50))

with _get_tools_attr("get_db_connection", get_db_connection)() as conn:
repo_row = conn.execute(
"SELECT id, name, url, provider, retain_commits_count FROM git_repositories WHERE name = ?",
(repo,)
).fetchone()

retained_limit = (
int(repo_row["retain_commits_count"])
if repo_row and "retain_commits_count" in repo_row.keys() and repo_row["retain_commits_count"] is not None
else 20
)

# Check if repository has commits in git_commits
stored_count = 0
if repo_row:
count_row = conn.execute("SELECT count(*) as c FROM git_commits WHERE repo = ?", (repo,)).fetchone()
stored_count = count_row["c"] if count_row else 0

# If retain_commits_count == 0 or no commits are stored
if not repo_row or retained_limit == 0 or stored_count == 0:
fallback_resp = {
"repo": repo,
"retained": False,
"retained_limit": retained_limit if repo_row else 0,
"commits": [],
"message": f"Commit history is not retained for repository '{repo}' (retain_commits_count={retained_limit if repo_row else 0}). Retrieve commit history directly from the Git repository."
}
return json.dumps(fallback_resp, separators=(",", ":"))

git_url = repo_row["url"]
provider = repo_row["provider"]

# Resolve filter filepath
target_filepath = None
if filepath and filepath.strip():
clean_fp = filepath.strip().lstrip("/")
target_filepath = clean_fp
elif symbol and symbol.strip():
sym_row = conn.execute(
"SELECT filepath FROM ast_symbols WHERE repo = ? AND (name = ? OR full_symbol = ?) LIMIT 1",
(repo, symbol.strip(), symbol.strip())
).fetchone()
if sym_row:
raw_fp = sym_row["filepath"]
if "://" in raw_fp:
target_filepath = raw_fp.split("://", 1)[1]
else:
target_filepath = raw_fp

# Query commits from git_commits
rows = conn.execute(
"SELECT commit_sha, author, committed_at, message, files_changed FROM git_commits WHERE repo = ? ORDER BY committed_at DESC, id DESC",
(repo,)
).fetchall()

matched_commits = []
for r in rows:
files_raw = r["files_changed"]
try:
files_list = json.loads(files_raw) if files_raw else []
except Exception:
files_list = []

if target_filepath:
norm_target = target_filepath.replace("\\", "/").lstrip("/")
matched = False
for f in files_list:
norm_f = f.replace("\\", "/").lstrip("/")
if norm_f == norm_target or norm_f.endswith("/" + norm_target) or norm_target.endswith("/" + norm_f):
matched = True
break
if not matched:
continue

commit_url = format_git_commit_url(git_url, r["commit_sha"], provider=provider)
matched_commits.append({
"sha": r["commit_sha"],
"short_sha": r["commit_sha"][:7],
"author": r["author"],
"date": r["committed_at"],
"message": r["message"],
"files_changed": files_list,
"commit_url": commit_url,
})

if len(matched_commits) >= safe_limit:
break

result = {
"repo": repo,
"retained_limit": retained_limit,
"commits": matched_commits,
}
if target_filepath:
result["filter_filepath"] = target_filepath

return json.dumps(result, separators=(",", ":"))

except Exception as e:
logger.error(f"Error in handle_get_recent_changes: {e}")
return json.dumps({"error": str(e), "repo": repo, "commits": []}, separators=(",", ":"))
7 changes: 7 additions & 0 deletions app/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
handle_index_status,
handle_catalog_summary,
handle_search_infrastructure_docs,
handle_get_recent_changes,
handle_get_architecture,
handle_manage_adr,
handle_manage_local_file,
Expand Down Expand Up @@ -92,6 +93,12 @@ def register_mcp_tools_and_resources(server=None):
description="Lists all indexed local paths and remote Git repositories, including active branches, commit SHAs, and file counts."
)(handle_list_repositories)

if "get_recent_changes" not in existing_tools:
server.tool(
name="get_recent_changes",
description="Inspect recent commits for a Git repository, optionally filtered by file path or symbol name."
)(handle_get_recent_changes)

if "sync_repository" not in existing_tools:
server.tool(
name="sync_repository",
Expand Down
4 changes: 4 additions & 0 deletions app/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class RepoConfig(BaseModel):
enabled: bool = True
auto_sync: bool = True
keep_shallow: bool = False
retain_commits_count: int = 20
webhook_secret: Optional[str] = None

class LocalPathConfig(BaseModel):
Expand Down Expand Up @@ -172,6 +173,9 @@ class AutoSyncToggleRequest(BaseModel):
class KeepShallowToggleRequest(BaseModel):
keep_shallow: bool

class RetainCommitsRequest(BaseModel):
retain_commits_count: int = Field(default=20, ge=0, le=1000)

class AutoSyncSettingsRequest(BaseModel):
interval_mins: int
global_webhook_secret: Optional[str] = None
Expand Down
20 changes: 16 additions & 4 deletions app/services/database/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ def set_ai_gateway_config(
return get_ai_gateway_config()

def ensure_git_repositories_columns(conn=None):
"""Ensures keep_shallow column exists on git_repositories table."""
"""Ensures keep_shallow and retain_commits_count columns exist on git_repositories table."""
try:
if is_postgres():
from sqlalchemy import text
Expand All @@ -458,6 +458,14 @@ def ensure_git_repositories_columns(conn=None):
sa_conn.execute(text("ALTER TABLE git_repositories ADD COLUMN keep_shallow INTEGER DEFAULT 0"))
sa_conn.commit()
logger.info("Migrated git_repositories: added keep_shallow column")

res_retain = sa_conn.execute(
text("SELECT column_name FROM information_schema.columns WHERE table_name='git_repositories' AND column_name='retain_commits_count'")
).fetchone()
if not res_retain:
sa_conn.execute(text("ALTER TABLE git_repositories ADD COLUMN retain_commits_count INTEGER DEFAULT 20"))
sa_conn.commit()
logger.info("Migrated git_repositories: added retain_commits_count column")
else:
raw_conn = get_db_connection()
try:
Expand All @@ -466,13 +474,17 @@ def ensure_git_repositories_columns(conn=None):
raw_conn.execute("ALTER TABLE git_repositories ADD COLUMN keep_shallow INTEGER DEFAULT 0")
raw_conn.commit()
logger.info("Migrated git_repositories: added keep_shallow column")
if cols and "retain_commits_count" not in cols:
raw_conn.execute("ALTER TABLE git_repositories ADD COLUMN retain_commits_count INTEGER DEFAULT 20")
raw_conn.commit()
logger.info("Migrated git_repositories: added retain_commits_count column")
finally:
raw_conn.close()
except Exception as e:
logger.debug(f"Migration error for git_repositories: {e}")

def ensure_file_summaries_columns(conn=None):
"""Ensures summary_text column exists on file_summaries table and git_repositories has keep_shallow."""
"""Ensures summary_text column exists on file_summaries table and git_repositories has keep_shallow and retain_commits_count."""
try:
ensure_git_repositories_columns(conn)
if is_postgres():
Expand Down Expand Up @@ -568,12 +580,12 @@ def set_file_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
def clear_indexing_metadata():
"""
Clears all relational index tables (indexed_files, file_summaries, ast_symbols,
ast_relationships, api_routes, api_client_calls, embedding_cache) and resets
ast_relationships, api_routes, api_client_calls, embedding_cache, git_commits) and resets
git repository sync markers so that subsequent reindexing passes fully re-process
and re-embed all sources.
"""
with get_db_connection() as conn:
for table in ("indexed_files", "file_summaries", "ast_symbols", "ast_relationships", "api_routes", "api_client_calls", "embedding_cache"):
for table in ("indexed_files", "file_summaries", "ast_symbols", "ast_relationships", "api_routes", "api_client_calls", "embedding_cache", "git_commits"):
try:
conn.execute(f"DELETE FROM {table}")
except Exception as e:
Expand Down
17 changes: 17 additions & 0 deletions app/services/database/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
Column("enabled", Integer, default=1, server_default="1"),
Column("auto_sync", Integer, default=1, server_default="1"),
Column("keep_shallow", Integer, default=0, server_default="0"),
Column("retain_commits_count", Integer, default=20, server_default="20"),
Column("webhook_secret", Text, nullable=True),
Column("status", Text, default="pending", server_default="pending"),
Column("last_error", Text, nullable=True),
Expand Down Expand Up @@ -255,6 +256,21 @@
Index("idx_pdf_cache_repo", "repo"),
)

# 16. Git Commits
git_commits = Table(
"git_commits",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("repo", Text, nullable=False),
Column("commit_sha", Text, nullable=False),
Column("author", Text, nullable=True),
Column("committed_at", Text, nullable=True),
Column("message", Text, nullable=True),
Column("files_changed", Text, nullable=True), # JSON array of relative filepaths
Index("idx_git_commits_repo_sha", "repo", "commit_sha", unique=True),
Index("idx_git_commits_repo_date", "repo", "committed_at"),
)

# Dictionary mapping table names to SQLAlchemy Table objects
TABLES: Dict[str, Table] = {t.name: t for t in metadata.tables.values()}

Expand All @@ -263,6 +279,7 @@
"TABLES",
"indexed_paths",
"git_repositories",
"git_commits",
"git_host_credentials",
"indexed_files",
"file_summaries",
Expand Down
Loading
Loading