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 +1 -0
- monorepoly/__main__.py +161 -0
- monorepoly/access.py +100 -0
- monorepoly/chunk.py +136 -0
- monorepoly/chunk_config.py +466 -0
- monorepoly/config.py +54 -0
- monorepoly/embed.py +66 -0
- monorepoly/exceptions.py +29 -0
- monorepoly/git.py +394 -0
- monorepoly/gitlab.py +234 -0
- monorepoly/hosted.py +155 -0
- monorepoly/indexer.py +557 -0
- monorepoly/judge.py +157 -0
- monorepoly/licensing.py +201 -0
- monorepoly/mcp_server.py +165 -0
- monorepoly/models.py +329 -0
- monorepoly/search.py +284 -0
- monorepoly/skill.py +23 -0
- monorepoly/skills/search/SKILL.md +28 -0
- monorepoly/store.py +290 -0
- monorepoly/trusted_keys/kimvais.pub +1 -0
- monorepoly/wizard.py +251 -0
- monorepoly-0.2.0.dist-info/METADATA +230 -0
- monorepoly-0.2.0.dist-info/RECORD +27 -0
- monorepoly-0.2.0.dist-info/WHEEL +4 -0
- monorepoly-0.2.0.dist-info/entry_points.txt +3 -0
- monorepoly-0.2.0.dist-info/licenses/LICENSE +22 -0
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)
|