monorepoly 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
monorepoly/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Org-wide code and activity index for LLM agents."""
monorepoly/__main__.py ADDED
@@ -0,0 +1,161 @@
1
+ """Command line entry point: `monorepoly <command> [--debug]`."""
2
+
3
+ import functools
4
+ import importlib
5
+ import logging
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from types import ModuleType
10
+ from typing import TYPE_CHECKING
11
+
12
+ from monorepoly import config, skill, wizard
13
+ from monorepoly.exceptions import MonorepolyConfigError
14
+
15
+ if TYPE_CHECKING:
16
+ from monorepoly.indexer import IndexContext
17
+
18
+ _LOGGER = logging.getLogger(__name__)
19
+
20
+ _CACHE_DIR = Path.home() / '.cache' / 'monorepoly'
21
+ _DEFAULT_PORT = '8080'
22
+ _BACKTICKED = re.compile(r'`([^`]*)`')
23
+ _FALSE_WORDS = frozenset({'0', 'false', 'no'})
24
+ _TRUE_WORDS = frozenset({'1', 'true', 'yes'})
25
+
26
+
27
+ class _BacktickMarkupFormatter(logging.Formatter):
28
+ """Renders backticked log values as highlighted rich markup in the terminal."""
29
+
30
+ def format(self, record: logging.LogRecord) -> str:
31
+ from rich.markup import escape
32
+
33
+ # Escape first, so values cannot inject markup; escaping never touches backticks.
34
+ return _BACKTICKED.sub(r'[bold cyan]\1[/]', escape(super().format(record)))
35
+
36
+
37
+ def _parse_flag(value: object) -> bool:
38
+ """Returns `value` as a bool, raising on anything but a bool or a known true/false word."""
39
+ # Fire turns `--flag=false` into the truthy string 'false'; only 'False' becomes a bool.
40
+ if isinstance(value, bool):
41
+ return value
42
+ word = value.lower() if isinstance(value, str) else None
43
+ if word in _TRUE_WORDS:
44
+ return True
45
+ if word in _FALSE_WORDS:
46
+ return False
47
+ raise MonorepolyConfigError(f"Expected a boolean flag value, got '{value}'!")
48
+
49
+
50
+ class _Commands:
51
+ """Indexes the GitLab group and serves the index to agents."""
52
+
53
+ def __init__(self, debug: bool = False) -> None:
54
+ self._debug = debug
55
+
56
+ @functools.cached_property
57
+ def _settings(self) -> config.Settings:
58
+ # Lazy, so `install_skill` works before the indexer is configured.
59
+ return config.load_settings()
60
+
61
+ def install_skill(self, skills_dir: str = str(skill.SKILLS_DIR)) -> None:
62
+ """Installs the bundled Claude Code skill, replacing an older copy."""
63
+ skill.install_skill(dry_run=self._debug, skills_dir=Path(skills_dir))
64
+
65
+ def setup(self, server_url: str = '', client_id: str = '') -> None:
66
+ """Registers the server in Claude Code: hosted with `--server_url`, else prompts locally."""
67
+ from rich.console import Console
68
+
69
+ if server_url:
70
+ wizard.run_hosted(
71
+ client_id=client_id, console=Console(), dry_run=self._debug, server_url=server_url
72
+ )
73
+ return
74
+ # A local setup checks MongoDB, GitLab and Google credentials, and registers `serve`.
75
+ _load('mcp_server', extra='server')
76
+ wizard.run(console=Console(), dry_run=self._debug)
77
+
78
+ def index_org(self, include_archived: bool | str = False) -> None:
79
+ """Indexes every project in GITLAB_GROUP_ID, archived ones only if asked."""
80
+ indexer = _load('indexer', extra='indexer')
81
+ indexer.index_org(
82
+ ctx=self._index_context(),
83
+ group_id=self._settings.gitlab_group_id,
84
+ include_archived=_parse_flag(include_archived),
85
+ )
86
+
87
+ def index_project(self, path: str) -> None:
88
+ """Indexes the code and history of one `group/project`."""
89
+ indexer = _load('indexer', extra='indexer')
90
+ ctx = self._index_context()
91
+ ctx.store.ensure_indexes()
92
+ indexer.index_project(ctx=ctx, project=ctx.gitlab.project(path))
93
+
94
+ def sync_activity(self, path: str) -> None:
95
+ """Syncs issues, merge requests and discussions of one `group/project`."""
96
+ indexer = _load('indexer', extra='indexer')
97
+ ctx = self._index_context()
98
+ ctx.store.ensure_indexes()
99
+ indexer.sync_activity(ctx=ctx, project=ctx.gitlab.project(path))
100
+
101
+ def serve(self) -> None:
102
+ """Runs the MCP server on stdio."""
103
+ _load('mcp_server', extra='server').serve(self._settings)
104
+
105
+ def serve_http(self) -> None:
106
+ """Runs the hosted MCP server on HTTP for GitLab OAuth users, on port PORT."""
107
+ # Cloud Run tells the container its port in PORT.
108
+ port = int(os.environ.get('PORT', _DEFAULT_PORT))
109
+ hosted = _load('hosted', extra='server')
110
+ hosted.serve_http(port=port, settings=config.load_server_settings())
111
+
112
+ def _index_context(self) -> IndexContext:
113
+ settings = self._settings
114
+ modules = ('embed', 'gitlab', 'indexer', 'judge', 'licensing', 'store')
115
+ embed, gitlab, indexer, judge, licensing, store = (
116
+ _load(name, extra='indexer') for name in modules
117
+ )
118
+ return indexer.IndexContext(
119
+ cache_dir=_CACHE_DIR,
120
+ embedder=embed.make_embedder(project=settings.google_cloud_project),
121
+ gitlab=gitlab.make_gitlab(token=settings.gitlab_token, url=settings.gitlab_url),
122
+ gitlab_token=settings.gitlab_token,
123
+ judge=judge.make_judge(),
124
+ license=licensing.from_environ(os.environ),
125
+ # `debug` is a dry run: everything is computed, nothing is written.
126
+ store=store.Store(
127
+ db=store.connect(db=settings.mongodb_db, uri=settings.mongodb_uri),
128
+ dry_run=self._debug,
129
+ ),
130
+ )
131
+
132
+
133
+ def _load(module: str, *, extra: str) -> ModuleType:
134
+ """Returns `monorepoly.<module>`, or says which extra installs what it is missing."""
135
+ try:
136
+ return importlib.import_module(f'monorepoly.{module}')
137
+ except ModuleNotFoundError as err:
138
+ if (err.name or '').startswith('monorepoly'):
139
+ raise
140
+ raise MonorepolyConfigError(
141
+ f'`{err.name}` is missing, install the `{extra}` extra:'
142
+ f" pip install 'monorepoly[{extra}]'"
143
+ ) from err
144
+
145
+
146
+ def main() -> None:
147
+ import fire
148
+ from rich.console import Console
149
+ from rich.logging import RichHandler
150
+
151
+ # stdout carries the MCP protocol in `serve`, so all logging goes to stderr.
152
+ handler = RichHandler(console=Console(stderr=True), markup=True)
153
+ handler.setFormatter(_BacktickMarkupFormatter())
154
+ logging.basicConfig(handlers=[handler], level=logging.WARNING)
155
+ # Only our own progress at INFO: the SDKs and their HTTP clients log every request there.
156
+ logging.getLogger('monorepoly').setLevel(logging.INFO)
157
+ fire.Fire(_Commands)
158
+
159
+
160
+ if __name__ == '__main__':
161
+ main()
monorepoly/access.py ADDED
@@ -0,0 +1,100 @@
1
+ """Who is asking, and which projects and confidential items they may see."""
2
+
3
+ import dataclasses
4
+ import logging
5
+
6
+ from monorepoly.gitlab import GitLab
7
+ from monorepoly.models import Project
8
+
9
+ _LOGGER = logging.getLogger(__name__)
10
+
11
+ _ENABLED = 'enabled' # Feature access level: readable by anyone who can see the project.
12
+ _NO_PROJECT = 0 # GitLab ids start at 1; Atlas `$vectorSearch.filter` rejects an empty `$in`.
13
+ _OPEN = ('internal', 'public') # Visibilities where non-members can see the project.
14
+ _REPORTER = 20 # GitLab access level.
15
+
16
+
17
+ def _ids(ids: frozenset[int]) -> list[int]:
18
+ return sorted(ids) or [_NO_PROJECT]
19
+
20
+
21
+ def _readable(*, level: str, project: Project, reporter_ids: frozenset[int]) -> bool:
22
+ # Fail closed: a members-only feature counts as Reporter-only, since a non-member who sees
23
+ # an internal project looks the same to us as a Guest member.
24
+ return project.id in reporter_ids or level == _ENABLED
25
+
26
+
27
+ @dataclasses.dataclass(frozen=True, kw_only=True)
28
+ class Access:
29
+ code_project_ids: frozenset[int]
30
+ driver_emails: frozenset[str]
31
+ driver_id: int
32
+ issue_project_ids: frozenset[int]
33
+ merge_request_project_ids: frozenset[int]
34
+ project_ids: frozenset[int]
35
+ reporter_project_ids: frozenset[int]
36
+
37
+ def symbol_filter(self) -> dict:
38
+ """Returns the filter for symbols; code is readable only where GitLab shows it."""
39
+ # An empty set matches nothing: no access means no results.
40
+ return {'project_id': {'$in': _ids(self.code_project_ids)}}
41
+
42
+ def activity_filter(self) -> dict:
43
+ """Returns the filter for activity, kind-aware: each kind needs its GitLab feature."""
44
+ in_issues = {'project_id': {'$in': _ids(self.issue_project_ids)}}
45
+ in_code = self.symbol_filter()
46
+ in_merge_requests = {'project_id': {'$in': _ids(self.merge_request_project_ids)}}
47
+ # Confidential items need Reporter or above.
48
+ visible = {
49
+ '$or': [
50
+ {'confidential': False},
51
+ {'project_id': {'$in': _ids(self.reporter_project_ids)}},
52
+ ]
53
+ }
54
+ return {
55
+ '$or': [
56
+ {'$and': [{'kind': 'issue'}, in_issues, visible]},
57
+ {'$and': [{'kind': 'discussion', 'parent_kind': 'issue'}, in_issues, visible]},
58
+ {'$and': [{'kind': {'$in': ['branch', 'commit']}}, in_code]},
59
+ {'$and': [{'kind': 'merge_request'}, in_merge_requests]},
60
+ {
61
+ '$and': [
62
+ {'kind': 'discussion', 'parent_kind': 'merge_request'},
63
+ in_merge_requests,
64
+ visible,
65
+ ]
66
+ },
67
+ ]
68
+ }
69
+
70
+
71
+ def load_access(*, gitlab: GitLab, group_id: int | str) -> Access:
72
+ """Returns the token user's access; raises if GitLab rejects the token."""
73
+ user = gitlab.current_user()
74
+ projects = gitlab.group_projects(group_id=group_id)
75
+ reporter_ids = frozenset(
76
+ p.id for p in gitlab.group_projects(group_id=group_id, min_access_level=_REPORTER)
77
+ )
78
+
79
+ # Guests on private projects cannot read code, commits or merge requests.
80
+ code_ids = reporter_ids | {
81
+ p.id for p in projects if p.visibility in _OPEN and p.repository_access_level == _ENABLED
82
+ }
83
+ return Access(
84
+ code_project_ids=code_ids,
85
+ driver_emails=frozenset(email.lower() for email in user.emails),
86
+ driver_id=user.id,
87
+ issue_project_ids=frozenset(
88
+ p.id
89
+ for p in projects
90
+ if _readable(level=p.issues_access_level, project=p, reporter_ids=reporter_ids)
91
+ ),
92
+ merge_request_project_ids=frozenset(
93
+ p.id
94
+ for p in projects
95
+ if p.id in code_ids
96
+ and _readable(level=p.merge_requests_access_level, project=p, reporter_ids=reporter_ids)
97
+ ),
98
+ project_ids=frozenset(p.id for p in projects),
99
+ reporter_project_ids=reporter_ids,
100
+ )
monorepoly/chunk.py ADDED
@@ -0,0 +1,136 @@
1
+ """Splits Python source into module, class, function and method symbols."""
2
+
3
+ import ast
4
+ import logging
5
+ import re
6
+ import warnings
7
+ from collections.abc import Iterator, Sequence
8
+
9
+ from monorepoly.models import Symbol, SymbolKind
10
+
11
+ _LOGGER = logging.getLogger(__name__)
12
+
13
+ _FILE_KINDS = frozenset({SymbolKind.CONFIG_FILE, SymbolKind.MODULE})
14
+
15
+
16
+ def chunk_python(*, path: str, project_id: int, ref: str, source: str) -> list[Symbol]:
17
+ """Returns the module symbol followed by its definitions, or nothing if unparsable."""
18
+ try:
19
+ with warnings.catch_warnings():
20
+ # Old code is full of invalid escapes; they are not our problem.
21
+ warnings.simplefilter('ignore', SyntaxWarning)
22
+ tree = ast.parse(source, filename=path)
23
+ except (SyntaxError, ValueError) as err:
24
+ _LOGGER.warning("Skipping unparsable '%s' in project %s: %s", path, project_id, err)
25
+ return []
26
+ lines = re.split(r'\r\n|\r|\n', source)
27
+ # Drop trailing empty element to keep module.end_line == len(lines).
28
+ if lines and lines[-1] == '':
29
+ lines.pop()
30
+ module = _module_name(path)
31
+ symbols = [
32
+ Symbol(
33
+ docstring=ast.get_docstring(tree),
34
+ end_line=max(len(lines), 1),
35
+ kind=SymbolKind.MODULE,
36
+ path=path,
37
+ project_id=project_id,
38
+ qualname=module,
39
+ ref=ref,
40
+ signature=f'module {module}',
41
+ source=source,
42
+ start_line=1,
43
+ )
44
+ ]
45
+ symbols.extend(
46
+ _walk(
47
+ body=tree.body,
48
+ in_class=False,
49
+ lines=lines,
50
+ path=path,
51
+ prefix=module,
52
+ project_id=project_id,
53
+ ref=ref,
54
+ )
55
+ )
56
+ return symbols
57
+
58
+
59
+ def touched_symbols(
60
+ *,
61
+ line_ranges: Sequence[tuple[int, int]],
62
+ symbols: Sequence[Symbol],
63
+ ) -> list[Symbol]:
64
+ """Returns non-file symbols overlapping `line_ranges`, else the file symbol."""
65
+ hits = [
66
+ symbol
67
+ for symbol in symbols
68
+ if symbol.kind not in _FILE_KINDS
69
+ and any(start <= symbol.end_line and symbol.start_line <= end for start, end in line_ranges)
70
+ ]
71
+ if hits or not line_ranges:
72
+ return hits
73
+ return [symbol for symbol in symbols if symbol.kind in _FILE_KINDS]
74
+
75
+
76
+ def _walk(
77
+ *,
78
+ body: list[ast.stmt],
79
+ in_class: bool,
80
+ lines: list[str],
81
+ path: str,
82
+ prefix: str,
83
+ project_id: int,
84
+ ref: str,
85
+ ) -> Iterator[Symbol]:
86
+ for node in body:
87
+ match node:
88
+ case ast.ClassDef():
89
+ kind, signature = SymbolKind.CLASS, _class_signature(node)
90
+ case ast.FunctionDef() | ast.AsyncFunctionDef():
91
+ kind = SymbolKind.METHOD if in_class else SymbolKind.FUNCTION
92
+ signature = _function_signature(node)
93
+ case _:
94
+ continue
95
+ qualname = f'{prefix}.{node.name}'
96
+ start = min([node.lineno, *(decorator.lineno for decorator in node.decorator_list)])
97
+ end = node.end_lineno or node.lineno
98
+ yield Symbol(
99
+ docstring=ast.get_docstring(node),
100
+ end_line=end,
101
+ kind=kind,
102
+ path=path,
103
+ project_id=project_id,
104
+ qualname=qualname,
105
+ ref=ref,
106
+ signature=signature,
107
+ source='\n'.join(lines[start - 1 : end]),
108
+ start_line=start,
109
+ )
110
+ yield from _walk(
111
+ body=node.body,
112
+ in_class=isinstance(node, ast.ClassDef),
113
+ lines=lines,
114
+ path=path,
115
+ prefix=qualname,
116
+ project_id=project_id,
117
+ ref=ref,
118
+ )
119
+
120
+
121
+ def _class_signature(node: ast.ClassDef) -> str:
122
+ bases = ', '.join(ast.unparse(base) for base in [*node.bases, *node.keywords])
123
+ return f'class {node.name}({bases})' if bases else f'class {node.name}'
124
+
125
+
126
+ def _function_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
127
+ keyword = 'async def' if isinstance(node, ast.AsyncFunctionDef) else 'def'
128
+ returns = f' -> {ast.unparse(node.returns)}' if node.returns else ''
129
+ return f'{keyword} {node.name}({ast.unparse(node.args)}){returns}'
130
+
131
+
132
+ def _module_name(path: str) -> str:
133
+ parts = path.removesuffix('.py').split('/')
134
+ if parts[-1] == '__init__' and len(parts) > 1:
135
+ parts.pop()
136
+ return '.'.join(parts)