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
2 changes: 2 additions & 0 deletions docs/reference/MCP_RESPONSE_FIELDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ The `candidates` and `history` lanes shed first under budget pressure, so an abs
| `more` | Names the opt-in blocks |
| `workspace` | Workspace mode footer |

With `repo="all"`, each repo row carries `summary` (the overview essay's first sentence), `code_files` (the same count as that repo's `code_health.code_files`, or its graph's code-language files before health has run) and `symbol_count`; `total_code_files` sums them.

Opt-in blocks: `outline` (wiki page tree, two levels), `tour` (`guided_tour`, `reading_order`), `decisions` (`key_decisions`), `graph` (`community_summary`), `ownership` (`knowledge_map`), `components`, `changes`, `actions`, `tools` (`tool_surface`), `scope` (the whole `_meta.index_scope`). They are not computed unless named.

**changes.** Change Records over a 90-day `window` ending at the newest merged change (`since`, `until`): `total`, `by_category` (largest first), `by_week` (`{week, changes}` per week starting Monday, zero weeks included) and `provenance` over changes: `agent_changes`, `human_attested_changes`, `unknown_changes`, `coverage`, `agent_share` and `summary`. Below 30% coverage `agent_share` is null with `suppressed_reason`, and the agent and human-attested counts are left out. A change stored without a label counts as unknown, never human. `{"total": 0}` when no Change Records are stored; `provenance` is `{"unavailable": "attribution_off"}` with attribution off. Grouped by change and category only, never by person.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,19 @@ async def backfill_module_attribution(
return changed


async def count_health_files(session: AsyncSession, repository_id: str) -> int:
"""Code files health read, scored or not, inside the health exclude rules.

The same population :func:`get_health_metrics` returns, counted without
loading the rows.
"""
result = await session.execute(
select(HealthFileMetric.file_path).where(HealthFileMetric.repository_id == repository_id)
)
rows = result.all()
return len(_filter_excluded_paths(rows, await health_exclude_spec(session, repository_id)))


async def get_health_metrics(
session: AsyncSession,
repository_id: str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from sqlalchemy import func as sa_func
from sqlalchemy import select

from repowise.core.ingestion.languages.registry import REGISTRY
from repowise.core.persistence.crud.analysis.health.metrics import count_health_files
from repowise.core.persistence.database import get_session
from repowise.core.persistence.models import GraphNode
from repowise.server.mcp_server import _state
Expand All @@ -24,13 +26,13 @@ async def _workspace_overview(*, tools: bool = False) -> dict:
fed = Federation()
repos_info = [entry for _, entry in await fed.run(lambda ctx: _repo_entry(ctx, registry))]
cross_repo_topology = _cross_repo_topology(repos_info)
totals = sum_fields(repos_info, ("file_count", "symbol_count"))
totals = sum_fields(repos_info, ("code_files", "symbol_count"))

result: dict[str, Any] = {
"workspace": True,
"workspace_root": str(registry.workspace_root) if registry else "",
"total_repos": len(repos_info),
"total_files": totals["file_count"],
"total_code_files": totals["code_files"],
"total_symbols": totals["symbol_count"],
"repos": repos_info,
"hint": ("Use repo='<alias>' to query a specific repo. Omit repo to use the default."),
Expand Down Expand Up @@ -58,13 +60,49 @@ async def _repo_entry(ctx: Any, registry: Any) -> dict[str, Any]:
return {
"alias": ctx.alias,
"path": str(ctx.path),
"summary": ov_content.split("\n")[0].strip("# ").strip()[:200],
"file_count": await _count_nodes(session, repo_obj.id, "file"),
"summary": _lead_sentence(ov_content),
"code_files": await _code_files(session, repo_obj.id),
"symbol_count": await _count_nodes(session, repo_obj.id, "symbol"),
"is_default": registry is not None and ctx.alias == registry.get_default_alias(),
}


def _is_prose(line: str) -> bool:
"""Not blank, a heading, a table row, a list item, a quote or a code fence."""
text = line.strip()
return bool(text) and not text.startswith(("#", "|", "- ", "* ", "```", ">"))


def _lead_sentence(markdown: str, limit: int = 140) -> str:
"""The essay's first sentence of prose, past its headings."""
prose = " ".join(
line.strip() for line in markdown.splitlines() if _is_prose(line)
)
head, dot, _ = prose.partition(". ")
sentence = head + "." if dot else prose
if len(sentence) <= limit:
return sentence
return sentence[: limit - 1].rsplit(" ", 1)[0] + "…"


async def _code_files(session: Any, repository_id: str) -> int:
"""The single-repo overview's ``code_health.code_files``; before health has
run, the graph's files in a code language."""
counted = await count_health_files(session, repository_id)
if counted:
return counted
result = await session.execute(
select(sa_func.count())
.select_from(GraphNode)
.where(
GraphNode.repository_id == repository_id,
GraphNode.node_type == "file",
GraphNode.language.in_(REGISTRY.code_languages()),
)
)
return result.scalar_one()


async def _count_nodes(session: Any, repository_id: str, node_type: str) -> int:
result = await session.execute(
select(sa_func.count())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ def _workspace_repos(registry: Any) -> list[dict[str, Any]]:
if workspace_root is not None:
try:
from repowise.core.workspace import WorkspaceConfig
from repowise.core.workspace.update import sync_workspace_state_from_disk

fresh = WorkspaceConfig.load(workspace_root)
if getattr(fresh, "repos", None):
# A repo updated on its own leaves the config behind its state.json.
sync_workspace_state_from_disk(workspace_root, fresh, save_if_changed=False)
ws_config = fresh
except Exception:
pass # never fail discovery on a config reload hiccup (missing/locked file)
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/server/mcp/test_federation.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ async def test_happy_path_shapes_unchanged(workspace_mcp): # noqa: F811

overview = await get_overview(repo="all")
assert set(overview) == {
"workspace", "workspace_root", "total_repos", "total_files", "total_symbols",
"workspace", "workspace_root", "total_repos", "total_code_files", "total_symbols",
"repos", "hint", "_meta",
}
assert "tool_surface" in await get_overview(repo="all", include=["tools"])
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/server/mcp/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -2961,3 +2961,15 @@ def row(path: str, score: float, *, test: bool) -> HealthFileMetric:
mixed = _compute_kpis([prod, test], deductions={})
assert (mixed["average_health"], mixed["production_average"]) == (8.0, 6.0)
assert "production_average" not in _compute_kpis([prod], deductions={})


@pytest.mark.asyncio
async def test_workspace_rows_count_the_files_the_overview_counts(setup_mcp, health_data, session):
"""repo="all" and the single-repo overview state one file count."""
from types import SimpleNamespace

from repowise.server.mcp_server.tool_overview.health import _build_code_health
from repowise.server.mcp_server.tool_overview.workspace import _code_files

overview = await _build_code_health(session, SimpleNamespace(id=health_data))
assert await _code_files(session, health_data) == overview["code_files"]
17 changes: 17 additions & 0 deletions tests/unit/server/mcp/test_overview_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,20 @@ def test_commands_name_a_package_only_for_tasks_the_root_lacks(tmp_path):
def test_twin_module_pages_do_not_stall_the_widening():
pages = [_page("src", "1"), _page("src", "2"), _page("src/a", "1.1")]
assert [h.target_path for h in _key_module_heads(pages)] == ["src/a"]


def test_a_repo_summary_is_its_first_sentence_not_its_heading():
from repowise.server.mcp_server.tool_overview.workspace import _lead_sentence

essay = "## Project Summary\n\nRepowise indexes code. It also answers questions.\n\n## More"
assert _lead_sentence(essay) == "Repowise indexes code."
assert _lead_sentence("# Only a heading") == ""
long = _lead_sentence("word " * 100)
assert len(long) <= 140 and long.endswith("…")


def test_a_repo_summary_skips_tables_lists_and_fences():
from repowise.server.mcp_server.tool_overview.workspace import _lead_sentence

essay = "## Summary\n| a | b |\n- item\n```\nRepowise indexes code. More."
assert _lead_sentence(essay) == "Repowise indexes code."
23 changes: 23 additions & 0 deletions tests/unit/server/test_mcp_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,3 +1073,26 @@ async def test_list_repos_single_repo_mode():
"is_default": True,
}
]


def test_list_repos_reads_the_commit_a_repo_updated_on_its_own_recorded(tmp_path):
from repowise.core.workspace import WorkspaceConfig
from repowise.core.workspace.config import RepoEntry
from repowise.server.mcp_server.tool_repos import _workspace_repos

(tmp_path / "api" / ".repowise").mkdir(parents=True)
(tmp_path / "api" / ".repowise" / "state.json").write_text(
json.dumps({"last_sync_commit": "b" * 40})
)
config = WorkspaceConfig(
repos=[RepoEntry(path="api", alias="api", last_commit_at_index="a" * 40)]
)
config.save(tmp_path)
registry = SimpleNamespace(
workspace_root=tmp_path, ws_config=config, get_default_alias=lambda: "api"
)

[row] = _workspace_repos(registry)
assert row["last_commit_at_index"] == "b" * 40
# Read, not written: the config on disk is the CLI's to update.
assert WorkspaceConfig.load(tmp_path).repos[0].last_commit_at_index == "a" * 40
Loading