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/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).