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/models.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Documents stored in MongoDB; each `__bson__` returns pymongo-native types only."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import datetime
|
|
5
|
+
import enum
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from monorepoly.exceptions import MonorepolyValueError
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
_NAMESPACE = uuid.UUID('6f1c9a52-3b0e-4d8a-9a51-2f6c1e7d4b10')
|
|
14
|
+
|
|
15
|
+
# ':' cannot appear in git branch names, so this never collides with a branch.
|
|
16
|
+
DEFAULT_REF = ':default'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_id(*parts: str | int) -> str:
|
|
20
|
+
"""Returns a deterministic id for the natural key `parts`."""
|
|
21
|
+
return str(uuid.uuid5(_NAMESPACE, '\x1f'.join(str(part) for part in parts)))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def commit_id(*, project_id: int, sha: str) -> str:
|
|
25
|
+
"""Returns the id of the commit document, without building the commit."""
|
|
26
|
+
return make_id(ActivityKind.COMMIT, project_id, sha)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def utcnow() -> datetime.datetime:
|
|
30
|
+
"""Returns the current time as an aware UTC datetime."""
|
|
31
|
+
return datetime.datetime.now(datetime.UTC)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SymbolKind(enum.StrEnum):
|
|
35
|
+
MODULE = 'module'
|
|
36
|
+
CLASS = 'class'
|
|
37
|
+
FUNCTION = 'function'
|
|
38
|
+
METHOD = 'method'
|
|
39
|
+
CONFIG_FILE = 'config_file'
|
|
40
|
+
CONFIG_DOCUMENT = 'config_document'
|
|
41
|
+
CONFIG_KEY = 'config_key'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ActivityKind(enum.StrEnum):
|
|
45
|
+
BRANCH = 'branch'
|
|
46
|
+
COMMIT = 'commit'
|
|
47
|
+
DISCUSSION = 'discussion'
|
|
48
|
+
ISSUE = 'issue'
|
|
49
|
+
MERGE_REQUEST = 'merge_request'
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CommitCategory(enum.StrEnum):
|
|
53
|
+
FEATURE = 'feature'
|
|
54
|
+
FIX = 'fix'
|
|
55
|
+
CLEANUP = 'cleanup'
|
|
56
|
+
CONFIGURATION = 'configuration'
|
|
57
|
+
DEPENDENCIES = 'dependencies'
|
|
58
|
+
TESTS = 'tests'
|
|
59
|
+
DOCS = 'docs'
|
|
60
|
+
OTHER = 'other'
|
|
61
|
+
MERGE = 'merge'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class State(enum.StrEnum):
|
|
65
|
+
"""GitLab issue and merge request states."""
|
|
66
|
+
|
|
67
|
+
OPENED = 'opened'
|
|
68
|
+
CLOSED = 'closed'
|
|
69
|
+
MERGED = 'merged'
|
|
70
|
+
LOCKED = 'locked'
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _to_bson(value: object) -> object:
|
|
74
|
+
match value:
|
|
75
|
+
case enum.Enum():
|
|
76
|
+
return value.value
|
|
77
|
+
case str() | int() | float() | datetime.datetime() | None:
|
|
78
|
+
return value
|
|
79
|
+
case tuple() | list():
|
|
80
|
+
return [_to_bson(item) for item in value]
|
|
81
|
+
case _ if hasattr(value, '__bson__'):
|
|
82
|
+
return value.__bson__()
|
|
83
|
+
case _:
|
|
84
|
+
raise MonorepolyValueError(f'Unsupported BSON value {value!r}!')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fields(obj: object) -> dict[str, object]:
|
|
88
|
+
return {field.name: _to_bson(getattr(obj, field.name)) for field in dataclasses.fields(obj)}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
92
|
+
class Project:
|
|
93
|
+
default_branch: str | None
|
|
94
|
+
empty_repo: bool
|
|
95
|
+
http_url_to_repo: str
|
|
96
|
+
id: int
|
|
97
|
+
# Feature access levels: 'enabled' (anyone who can see the project), 'private' (members
|
|
98
|
+
# only) or 'disabled'.
|
|
99
|
+
issues_access_level: str
|
|
100
|
+
merge_requests_access_level: str
|
|
101
|
+
path_with_namespace: str
|
|
102
|
+
repository_access_level: str
|
|
103
|
+
visibility: str
|
|
104
|
+
web_url: str
|
|
105
|
+
|
|
106
|
+
def __bson__(self) -> dict:
|
|
107
|
+
return {'_id': self.id, **_fields(self)}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
111
|
+
class Symbol:
|
|
112
|
+
"""A module, class, function, method or config part at one ref."""
|
|
113
|
+
|
|
114
|
+
docstring: str | None
|
|
115
|
+
end_line: int
|
|
116
|
+
kind: SymbolKind
|
|
117
|
+
path: str
|
|
118
|
+
project_id: int
|
|
119
|
+
qualname: str
|
|
120
|
+
ref: str
|
|
121
|
+
signature: str
|
|
122
|
+
source: str
|
|
123
|
+
start_line: int
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def key(self) -> str:
|
|
127
|
+
"""Returns the ref-independent identity that links commits to symbols."""
|
|
128
|
+
return make_id('symbol', self.project_id, self.path, self.qualname)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def id(self) -> str:
|
|
132
|
+
return make_id(self.key, self.ref)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def content_hash(self) -> str:
|
|
136
|
+
content = '\x1f'.join((self.path, self.qualname, self.source))
|
|
137
|
+
return hashlib.sha256(content.encode()).hexdigest()
|
|
138
|
+
|
|
139
|
+
def embedding_input(self) -> tuple[str, str]:
|
|
140
|
+
"""Returns the (title, text) pair to embed."""
|
|
141
|
+
text = '\n'.join(part for part in (self.signature, self.docstring, self.source) if part)
|
|
142
|
+
return f'{self.path} {self.qualname}', text
|
|
143
|
+
|
|
144
|
+
def __bson__(self) -> dict:
|
|
145
|
+
return {
|
|
146
|
+
'_id': self.id,
|
|
147
|
+
**_fields(self),
|
|
148
|
+
'content_hash': self.content_hash,
|
|
149
|
+
'key': self.key,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
154
|
+
class Change:
|
|
155
|
+
"""One file touched by a commit; line ranges are on the new side."""
|
|
156
|
+
|
|
157
|
+
line_ranges: tuple[tuple[int, int], ...]
|
|
158
|
+
path: str
|
|
159
|
+
qualnames: tuple[str, ...]
|
|
160
|
+
symbol_keys: tuple[str, ...]
|
|
161
|
+
|
|
162
|
+
def __bson__(self) -> dict:
|
|
163
|
+
return _fields(self)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
167
|
+
class Commit:
|
|
168
|
+
author_email: str
|
|
169
|
+
author_name: str
|
|
170
|
+
authored_at: datetime.datetime
|
|
171
|
+
category: CommitCategory
|
|
172
|
+
changes: tuple[Change, ...]
|
|
173
|
+
committed_at: datetime.datetime
|
|
174
|
+
diff: str
|
|
175
|
+
message: str
|
|
176
|
+
parents: tuple[str, ...]
|
|
177
|
+
project_id: int
|
|
178
|
+
sha: str
|
|
179
|
+
web_url: str
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def id(self) -> str:
|
|
183
|
+
return commit_id(project_id=self.project_id, sha=self.sha)
|
|
184
|
+
|
|
185
|
+
def embedding_input(self) -> tuple[str, str]:
|
|
186
|
+
"""Returns the (title, text) pair to embed: message plus touched paths and qualnames."""
|
|
187
|
+
touched = sorted(
|
|
188
|
+
{*(c.path for c in self.changes), *(q for c in self.changes for q in c.qualnames)}
|
|
189
|
+
)
|
|
190
|
+
return self.message.partition('\n')[0], '\n'.join([self.message, *touched])
|
|
191
|
+
|
|
192
|
+
def __bson__(self) -> dict:
|
|
193
|
+
return {
|
|
194
|
+
'_id': self.id,
|
|
195
|
+
**_fields(self),
|
|
196
|
+
'confidential': False,
|
|
197
|
+
'kind': str(ActivityKind.COMMIT),
|
|
198
|
+
'state': None,
|
|
199
|
+
'symbol_keys': sorted({key for c in self.changes for key in c.symbol_keys}),
|
|
200
|
+
# When it landed: a rebased or cherry-picked commit may have been authored long ago.
|
|
201
|
+
'updated_at': self.committed_at,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
206
|
+
class Branch:
|
|
207
|
+
author_email: str
|
|
208
|
+
author_name: str
|
|
209
|
+
head_sha: str
|
|
210
|
+
messages: tuple[str, ...]
|
|
211
|
+
name: str
|
|
212
|
+
project_id: int
|
|
213
|
+
state: State # Opened, or merged once fully merged into the default branch.
|
|
214
|
+
symbol_keys: tuple[str, ...]
|
|
215
|
+
updated_at: datetime.datetime
|
|
216
|
+
web_url: str
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def id(self) -> str:
|
|
220
|
+
return make_id(ActivityKind.BRANCH, self.project_id, self.name)
|
|
221
|
+
|
|
222
|
+
def embedding_input(self) -> tuple[str, str]:
|
|
223
|
+
"""Returns the (title, text) pair to embed."""
|
|
224
|
+
return self.name, '\n'.join(self.messages)
|
|
225
|
+
|
|
226
|
+
def __bson__(self) -> dict:
|
|
227
|
+
return {
|
|
228
|
+
'_id': self.id,
|
|
229
|
+
**_fields(self),
|
|
230
|
+
'confidential': False,
|
|
231
|
+
'kind': str(ActivityKind.BRANCH),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
236
|
+
class Issue:
|
|
237
|
+
assignee_ids: tuple[int, ...]
|
|
238
|
+
author_id: int | None
|
|
239
|
+
closed_at: datetime.datetime | None
|
|
240
|
+
confidential: bool
|
|
241
|
+
created_at: datetime.datetime | None
|
|
242
|
+
description: str
|
|
243
|
+
iid: int
|
|
244
|
+
labels: tuple[str, ...]
|
|
245
|
+
project_id: int
|
|
246
|
+
state: State
|
|
247
|
+
title: str
|
|
248
|
+
updated_at: datetime.datetime | None
|
|
249
|
+
web_url: str
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def id(self) -> str:
|
|
253
|
+
return make_id(ActivityKind.ISSUE, self.project_id, self.iid)
|
|
254
|
+
|
|
255
|
+
def embedding_input(self) -> tuple[str, str]:
|
|
256
|
+
"""Returns the (title, text) pair to embed."""
|
|
257
|
+
return self.title, self.description
|
|
258
|
+
|
|
259
|
+
def __bson__(self) -> dict:
|
|
260
|
+
return {'_id': self.id, **_fields(self), 'kind': str(ActivityKind.ISSUE)}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
264
|
+
class MergeRequest:
|
|
265
|
+
assignee_ids: tuple[int, ...]
|
|
266
|
+
author_id: int | None
|
|
267
|
+
created_at: datetime.datetime | None
|
|
268
|
+
description: str
|
|
269
|
+
iid: int
|
|
270
|
+
merged_at: datetime.datetime | None
|
|
271
|
+
project_id: int
|
|
272
|
+
reviewer_ids: tuple[int, ...]
|
|
273
|
+
source_branch: str
|
|
274
|
+
state: State
|
|
275
|
+
symbol_keys: tuple[str, ...]
|
|
276
|
+
target_branch: str
|
|
277
|
+
title: str
|
|
278
|
+
updated_at: datetime.datetime | None
|
|
279
|
+
web_url: str
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def id(self) -> str:
|
|
283
|
+
return make_id(ActivityKind.MERGE_REQUEST, self.project_id, self.iid)
|
|
284
|
+
|
|
285
|
+
def embedding_input(self) -> tuple[str, str]:
|
|
286
|
+
"""Returns the (title, text) pair to embed."""
|
|
287
|
+
return self.title, self.description
|
|
288
|
+
|
|
289
|
+
def __bson__(self) -> dict:
|
|
290
|
+
return {
|
|
291
|
+
'_id': self.id,
|
|
292
|
+
**_fields(self),
|
|
293
|
+
'confidential': False,
|
|
294
|
+
'kind': str(ActivityKind.MERGE_REQUEST),
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
299
|
+
class Discussion:
|
|
300
|
+
"""One thread of human notes on an issue or merge request."""
|
|
301
|
+
|
|
302
|
+
authors: tuple[str, ...]
|
|
303
|
+
confidential: bool
|
|
304
|
+
discussion_id: str
|
|
305
|
+
notes: tuple[str, ...]
|
|
306
|
+
parent_iid: int
|
|
307
|
+
parent_kind: ActivityKind
|
|
308
|
+
parent_title: str
|
|
309
|
+
project_id: int
|
|
310
|
+
state: State
|
|
311
|
+
updated_at: datetime.datetime | None
|
|
312
|
+
web_url: str
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def id(self) -> str:
|
|
316
|
+
return make_id(
|
|
317
|
+
ActivityKind.DISCUSSION,
|
|
318
|
+
self.project_id,
|
|
319
|
+
self.parent_kind,
|
|
320
|
+
self.parent_iid,
|
|
321
|
+
self.discussion_id,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def embedding_input(self) -> tuple[str, str]:
|
|
325
|
+
"""Returns the (title, text) pair to embed."""
|
|
326
|
+
return self.parent_title, '\n\n'.join(self.notes)
|
|
327
|
+
|
|
328
|
+
def __bson__(self) -> dict:
|
|
329
|
+
return {'_id': self.id, **_fields(self), 'kind': str(ActivityKind.DISCUSSION)}
|
monorepoly/search.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Query functions behind the MCP tools; every query is filtered by the caller's access."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import datetime
|
|
5
|
+
import enum
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
|
|
9
|
+
from monorepoly.access import Access
|
|
10
|
+
from monorepoly.embed import Embedder, QueryTask
|
|
11
|
+
from monorepoly.exceptions import MonorepolyValueError
|
|
12
|
+
from monorepoly.judge import RELEVANCE_THRESHOLD, Judge
|
|
13
|
+
from monorepoly.models import DEFAULT_REF, ActivityKind, CommitCategory, State
|
|
14
|
+
from monorepoly.store import Store
|
|
15
|
+
|
|
16
|
+
_LOGGER = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_CANDIDATES = 30
|
|
19
|
+
_HIDDEN = frozenset({'_id', 'changes', 'diff', 'embedding', 'score', 'symbol_keys'})
|
|
20
|
+
_HISTORY_LIMIT = 50
|
|
21
|
+
_JUDGE_TEXT_CHARS = 2_000
|
|
22
|
+
_LIMIT = 10
|
|
23
|
+
_RECENT = datetime.timedelta(hours=48)
|
|
24
|
+
_RELATED_KINDS = (
|
|
25
|
+
ActivityKind.BRANCH,
|
|
26
|
+
ActivityKind.COMMIT,
|
|
27
|
+
ActivityKind.ISSUE,
|
|
28
|
+
ActivityKind.MERGE_REQUEST,
|
|
29
|
+
)
|
|
30
|
+
_SNIPPET_LINES = 40
|
|
31
|
+
_TRIM_CHARS = 2_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
35
|
+
class QueryContext:
|
|
36
|
+
access: Access
|
|
37
|
+
embedder: Embedder
|
|
38
|
+
judge: Judge
|
|
39
|
+
store: Store
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def search_code(*, ctx: QueryContext, project: str | None, query: str) -> list[dict]:
|
|
43
|
+
"""Returns the default-branch symbols most relevant to `query`, best first."""
|
|
44
|
+
clauses = [ctx.access.symbol_filter(), {'ref': DEFAULT_REF}]
|
|
45
|
+
if project is not None:
|
|
46
|
+
project_id = ctx.store.project_id_by_path(project)
|
|
47
|
+
if project_id is None:
|
|
48
|
+
return []
|
|
49
|
+
clauses.append({'project_id': project_id})
|
|
50
|
+
conditions = {'$and': clauses}
|
|
51
|
+
docs = ctx.store.vector_search(
|
|
52
|
+
collection='symbols',
|
|
53
|
+
conditions=conditions,
|
|
54
|
+
limit=_CANDIDATES,
|
|
55
|
+
vector=ctx.embedder.query(task=QueryTask.CODE, text=query),
|
|
56
|
+
)
|
|
57
|
+
ranked = _rerank(ctx=ctx, docs=docs, query=query, text=_symbol_text)[:_LIMIT]
|
|
58
|
+
projects = ctx.store.projects_by_id({doc['project_id'] for doc in ranked})
|
|
59
|
+
return [_present_symbol(doc=doc, project=projects[doc['project_id']]) for doc in ranked]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_related_work(
|
|
63
|
+
*, ctx: QueryContext, now: datetime.datetime, topic: str
|
|
64
|
+
) -> dict[str, list[dict]]:
|
|
65
|
+
"""Returns open or recent work by others that Jev judges to be about `topic`, by kind."""
|
|
66
|
+
conditions = {
|
|
67
|
+
'$and': [
|
|
68
|
+
ctx.access.activity_filter(),
|
|
69
|
+
{'kind': {'$in': [str(kind) for kind in _RELATED_KINDS]}},
|
|
70
|
+
{'$or': [{'state': str(State.OPENED)}, {'updated_at': {'$gte': now - _RECENT}}]},
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
docs = ctx.store.vector_search(
|
|
74
|
+
collection='activity',
|
|
75
|
+
conditions=conditions,
|
|
76
|
+
limit=_CANDIDATES,
|
|
77
|
+
vector=ctx.embedder.query(task=QueryTask.SEARCH, text=topic),
|
|
78
|
+
)
|
|
79
|
+
others = [doc for doc in docs if _by_someone_else(access=ctx.access, doc=doc)]
|
|
80
|
+
grouped: dict[str, list[dict]] = {}
|
|
81
|
+
for doc in _rerank(ctx=ctx, docs=others, query=topic, text=_activity_text):
|
|
82
|
+
if doc['relevance'] >= RELEVANCE_THRESHOLD:
|
|
83
|
+
grouped.setdefault(doc['kind'], []).append(_present_activity(doc))
|
|
84
|
+
return grouped
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def search_history(
|
|
88
|
+
*,
|
|
89
|
+
categories: Sequence[str] | None,
|
|
90
|
+
ctx: QueryContext,
|
|
91
|
+
kinds: Sequence[str] | None,
|
|
92
|
+
query: str,
|
|
93
|
+
state: str | None,
|
|
94
|
+
) -> list[dict]:
|
|
95
|
+
"""Returns issues, merge requests, discussions, branches and commits relevant to `query`."""
|
|
96
|
+
clauses = [ctx.access.activity_filter()]
|
|
97
|
+
if kinds:
|
|
98
|
+
clauses.append({'kind': {'$in': [_parse(ActivityKind, kind) for kind in kinds]}})
|
|
99
|
+
if categories:
|
|
100
|
+
clauses.append({'category': {'$in': [_parse(CommitCategory, c) for c in categories]}})
|
|
101
|
+
if state:
|
|
102
|
+
clauses.append({'state': _parse(State, state)})
|
|
103
|
+
docs = ctx.store.vector_search(
|
|
104
|
+
collection='activity',
|
|
105
|
+
conditions={'$and': clauses},
|
|
106
|
+
limit=_CANDIDATES,
|
|
107
|
+
vector=ctx.embedder.query(task=QueryTask.SEARCH, text=query),
|
|
108
|
+
)
|
|
109
|
+
ranked = _rerank(ctx=ctx, docs=docs, query=query, text=_activity_text)
|
|
110
|
+
return [_present_activity(doc) for doc in ranked[:_LIMIT]]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def symbol_history(*, ctx: QueryContext, symbol_id: str) -> list[dict]:
|
|
114
|
+
"""Returns commits, branches and merge requests touching the symbol, newest first."""
|
|
115
|
+
symbols = ctx.store.find(
|
|
116
|
+
collection='symbols',
|
|
117
|
+
limit=1,
|
|
118
|
+
query={'$and': [ctx.access.symbol_filter(), {'_id': symbol_id}]},
|
|
119
|
+
)
|
|
120
|
+
if not symbols:
|
|
121
|
+
return []
|
|
122
|
+
docs = ctx.store.find(
|
|
123
|
+
collection='activity',
|
|
124
|
+
limit=_HISTORY_LIMIT,
|
|
125
|
+
query={'$and': [ctx.access.activity_filter(), {'symbol_keys': symbols[0]['key']}]},
|
|
126
|
+
sort=[('updated_at', -1)],
|
|
127
|
+
)
|
|
128
|
+
return [_present_activity(doc) for doc in docs]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_item(*, ctx: QueryContext, item_id: str) -> dict | None:
|
|
132
|
+
"""Returns the full symbol or activity item, with discussions for issues and MRs."""
|
|
133
|
+
# Not found and not accessible look the same, so ids of hidden items cannot be probed.
|
|
134
|
+
symbols = ctx.store.find(
|
|
135
|
+
collection='symbols',
|
|
136
|
+
limit=1,
|
|
137
|
+
query={'$and': [ctx.access.symbol_filter(), {'_id': item_id}]},
|
|
138
|
+
)
|
|
139
|
+
if symbols:
|
|
140
|
+
return symbols[0]
|
|
141
|
+
filter_ = ctx.access.activity_filter()
|
|
142
|
+
items = ctx.store.find(
|
|
143
|
+
collection='activity', limit=1, query={'$and': [filter_, {'_id': item_id}]}
|
|
144
|
+
)
|
|
145
|
+
if not items:
|
|
146
|
+
return None
|
|
147
|
+
item = items[0]
|
|
148
|
+
if item['kind'] in (ActivityKind.ISSUE, ActivityKind.MERGE_REQUEST):
|
|
149
|
+
item['discussions'] = ctx.store.find(
|
|
150
|
+
collection='activity',
|
|
151
|
+
query={
|
|
152
|
+
'$and': [
|
|
153
|
+
filter_,
|
|
154
|
+
{
|
|
155
|
+
'kind': str(ActivityKind.DISCUSSION),
|
|
156
|
+
'parent_iid': item['iid'],
|
|
157
|
+
'parent_kind': item['kind'],
|
|
158
|
+
'project_id': item['project_id'],
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
sort=[('updated_at', 1)],
|
|
163
|
+
)
|
|
164
|
+
return item
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _rerank(
|
|
168
|
+
*,
|
|
169
|
+
ctx: QueryContext,
|
|
170
|
+
docs: list[dict],
|
|
171
|
+
query: str,
|
|
172
|
+
text: Callable[[dict], str],
|
|
173
|
+
) -> list[dict]:
|
|
174
|
+
scores = ctx.judge.relevance(
|
|
175
|
+
candidates=[text(doc)[:_JUDGE_TEXT_CHARS] for doc in docs], query=query
|
|
176
|
+
)
|
|
177
|
+
for doc, score in zip(docs, scores, strict=True):
|
|
178
|
+
doc['relevance'] = score
|
|
179
|
+
return sorted(docs, key=lambda doc: doc['relevance'], reverse=True)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _by_someone_else(*, access: Access, doc: dict) -> bool:
|
|
183
|
+
match doc['kind']:
|
|
184
|
+
case ActivityKind.ISSUE:
|
|
185
|
+
return bool(set(doc['assignee_ids']) - {access.driver_id})
|
|
186
|
+
case ActivityKind.MERGE_REQUEST:
|
|
187
|
+
return doc['author_id'] != access.driver_id or bool(
|
|
188
|
+
set(doc['assignee_ids']) - {access.driver_id}
|
|
189
|
+
)
|
|
190
|
+
case ActivityKind.BRANCH | ActivityKind.COMMIT:
|
|
191
|
+
return doc['author_email'].lower() not in access.driver_emails
|
|
192
|
+
case kind:
|
|
193
|
+
raise MonorepolyValueError(f"Unexpected activity kind '{kind}'!")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse(enum_type: type[enum.StrEnum], value: str) -> str:
|
|
197
|
+
try:
|
|
198
|
+
return str(enum_type(value))
|
|
199
|
+
except ValueError as err:
|
|
200
|
+
allowed = ', '.join(member.value for member in enum_type)
|
|
201
|
+
raise MonorepolyValueError(f"Unknown value '{value}'; expected one of {allowed}!") from err
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _symbol_text(doc: dict) -> str:
|
|
205
|
+
return '\n'.join(
|
|
206
|
+
filter(None, (doc['qualname'], doc['signature'], doc.get('docstring'), doc['source']))
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _activity_text(doc: dict) -> str:
|
|
211
|
+
parts = [
|
|
212
|
+
doc.get(field) for field in ('name', 'title', 'parent_title', 'message', 'description')
|
|
213
|
+
]
|
|
214
|
+
return '\n'.join(filter(None, [*parts, *doc.get('messages', []), *doc.get('notes', [])]))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _present_symbol(*, doc: dict, project: dict) -> dict:
|
|
218
|
+
lines = f'{doc["start_line"]}-{doc["end_line"]}'
|
|
219
|
+
return {
|
|
220
|
+
'docstring': doc.get('docstring'),
|
|
221
|
+
'id': doc['_id'],
|
|
222
|
+
'kind': doc['kind'],
|
|
223
|
+
'lines': lines,
|
|
224
|
+
'path': doc['path'],
|
|
225
|
+
'project_id': doc['project_id'],
|
|
226
|
+
'qualname': doc['qualname'],
|
|
227
|
+
'relevance': doc['relevance'],
|
|
228
|
+
'signature': doc['signature'],
|
|
229
|
+
'snippet': '\n'.join(doc['source'].splitlines()[:_SNIPPET_LINES]),
|
|
230
|
+
'web_url': (
|
|
231
|
+
f'{project["web_url"]}/-/blob/{project["default_branch"]}/{doc["path"]}#L{lines}'
|
|
232
|
+
),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _present_activity(doc: dict) -> dict:
|
|
237
|
+
# Status first: agents otherwise read closed or abandoned work as shipped.
|
|
238
|
+
return {
|
|
239
|
+
'id': doc['_id'],
|
|
240
|
+
'status': _status(doc),
|
|
241
|
+
**{k: _trim(v) for k, v in doc.items() if k not in _HIDDEN},
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _status(doc: dict) -> str:
|
|
246
|
+
"""Returns whether the item shipped, in words an agent cannot misread."""
|
|
247
|
+
match doc['kind'], doc.get('state'):
|
|
248
|
+
case ActivityKind.MERGE_REQUEST, State.MERGED:
|
|
249
|
+
return 'merged'
|
|
250
|
+
case ActivityKind.MERGE_REQUEST, State.CLOSED:
|
|
251
|
+
return 'closed without merging'
|
|
252
|
+
case ActivityKind.MERGE_REQUEST, State.OPENED:
|
|
253
|
+
return 'open, not merged yet'
|
|
254
|
+
case ActivityKind.MERGE_REQUEST, State.LOCKED:
|
|
255
|
+
return 'locked, not merged'
|
|
256
|
+
case ActivityKind.ISSUE, State.OPENED:
|
|
257
|
+
return 'open issue'
|
|
258
|
+
case ActivityKind.ISSUE, State.CLOSED:
|
|
259
|
+
return 'closed issue'
|
|
260
|
+
case ActivityKind.DISCUSSION, _:
|
|
261
|
+
parent = _status({'kind': doc['parent_kind'], 'state': doc.get('state')})
|
|
262
|
+
article = 'an' if doc['parent_kind'] == ActivityKind.ISSUE else 'a'
|
|
263
|
+
noun = 'issue' if doc['parent_kind'] == ActivityKind.ISSUE else 'merge request'
|
|
264
|
+
return f'thread on {article} {noun}: {parent}'
|
|
265
|
+
case ActivityKind.BRANCH, State.MERGED:
|
|
266
|
+
return 'branch merged into the default branch'
|
|
267
|
+
case ActivityKind.BRANCH, _:
|
|
268
|
+
return 'unmerged branch'
|
|
269
|
+
case ActivityKind.COMMIT, _:
|
|
270
|
+
if doc.get('on_default_branch'):
|
|
271
|
+
return 'on the default branch'
|
|
272
|
+
return 'not on the default branch: unmerged or abandoned'
|
|
273
|
+
case kind, state:
|
|
274
|
+
raise MonorepolyValueError(f"Unexpected activity '{kind}' in state '{state}'!")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _trim(value: object) -> object:
|
|
278
|
+
match value:
|
|
279
|
+
case str() if len(value) > _TRIM_CHARS:
|
|
280
|
+
return value[:_TRIM_CHARS] + '…'
|
|
281
|
+
case list():
|
|
282
|
+
return [_trim(item) for item in value]
|
|
283
|
+
case _:
|
|
284
|
+
return value
|
monorepoly/skill.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Installs the bundled Claude Code skill."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import shutil
|
|
5
|
+
from importlib.resources import as_file, files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_LOGGER = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
SKILLS_DIR = Path.home() / '.claude' / 'skills'
|
|
11
|
+
|
|
12
|
+
_SKILLS = files('monorepoly') / 'skills'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def install_skill(*, dry_run: bool, skills_dir: Path) -> None:
|
|
16
|
+
"""Copies the bundled skills into `skills_dir`, replacing older copies."""
|
|
17
|
+
with as_file(_SKILLS) as source:
|
|
18
|
+
names = ', '.join(sorted(f"'{path.name}'" for path in source.iterdir()))
|
|
19
|
+
if dry_run:
|
|
20
|
+
_LOGGER.info("Would install skills %s into '%s'.", names, skills_dir)
|
|
21
|
+
return
|
|
22
|
+
shutil.copytree(source, skills_dir, dirs_exist_ok=True)
|
|
23
|
+
_LOGGER.info("Installed skills %s into '%s'.", names, skills_dir)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: search
|
|
3
|
+
description: Use before writing new code, starting a task, filing an issue, or extending existing code in this organisation. Checks for reusable code, in-flight work by others, existing issues, and lessons from earlier attempts through the monorepoly MCP tools.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Look before you build
|
|
7
|
+
|
|
8
|
+
The monorepoly MCP server indexes every project in the organisation. Grepping the current
|
|
9
|
+
repository is not enough: the code, issue or lesson you need is usually somewhere else.
|
|
10
|
+
|
|
11
|
+
1. Before writing new code, call `search_code` with what the code should do. If something close
|
|
12
|
+
exists, call `symbol_history` on it and prefer extending it over building from scratch.
|
|
13
|
+
`search_code` covers code and config: CI jobs, Kubernetes objects, other YAML keys, TOML tables and Dockerfile stages too.
|
|
14
|
+
2. Before starting any task, call `find_related_work` with the task's topic. If someone else has
|
|
15
|
+
an open merge request, branch or assigned issue on it, tell the user before you continue.
|
|
16
|
+
3. Before filing an issue, call `search_history` with `kinds=["issue"]`.
|
|
17
|
+
4. Before trying an approach, call `search_history` with `kinds=["merge_request", "discussion"]`
|
|
18
|
+
and `state="closed"` to find earlier attempts and why they stopped.
|
|
19
|
+
5. When the user names something vaguely ("the upload thing"), call `search_code` to find out
|
|
20
|
+
which code they mean before asking them.
|
|
21
|
+
|
|
22
|
+
Use `get_item` to read a full result. Cite results by their `web_url`.
|
|
23
|
+
|
|
24
|
+
Every history result starts with a `status`. Read it before you say something shipped: only
|
|
25
|
+
"merged", "on the default branch" and "branch merged into the default branch" mean the change
|
|
26
|
+
is live. "closed without merging", "unmerged branch" and "not on the default branch" are
|
|
27
|
+
abandoned or unfinished work. Say so, and check the current code with `search_code` before
|
|
28
|
+
relying on them. A "closed issue" may be done or rejected (check its labels and thread).
|