relore 0.3.17__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.
Files changed (73) hide show
  1. relore/__init__.py +12 -0
  2. relore/api/__init__.py +0 -0
  3. relore/api/schemas.py +363 -0
  4. relore/api/server.py +781 -0
  5. relore/api/tokens.py +219 -0
  6. relore/api/ui.py +812 -0
  7. relore/bench/__init__.py +10 -0
  8. relore/bench/dataset.py +220 -0
  9. relore/bench/judge.py +155 -0
  10. relore/bench/mine.py +550 -0
  11. relore/bench/run.py +433 -0
  12. relore/cli.py +972 -0
  13. relore/code/__init__.py +53 -0
  14. relore/code/api.py +105 -0
  15. relore/code/blame.py +398 -0
  16. relore/code/clone.py +105 -0
  17. relore/code/defs.py +61 -0
  18. relore/code/providers/__init__.py +0 -0
  19. relore/code/providers/ctags.py +104 -0
  20. relore/code/providers/python.py +65 -0
  21. relore/code/providers/treesitter.py +176 -0
  22. relore/code/refresh.py +79 -0
  23. relore/code/refs.py +97 -0
  24. relore/code/registry.py +137 -0
  25. relore/code/repomap.py +123 -0
  26. relore/code/survey.py +297 -0
  27. relore/code/walk.py +96 -0
  28. relore/daemon.py +828 -0
  29. relore/duration.py +25 -0
  30. relore/entrypoints.py +42 -0
  31. relore/freshness.py +14 -0
  32. relore/github/__init__.py +0 -0
  33. relore/github/bulk.py +142 -0
  34. relore/github/client.py +243 -0
  35. relore/github/fetch_thread.py +107 -0
  36. relore/github/graphql.py +235 -0
  37. relore/guidance.py +176 -0
  38. relore/ingest/__init__.py +0 -0
  39. relore/ingest/authority.py +202 -0
  40. relore/ingest/backfill.py +305 -0
  41. relore/ingest/chunk.py +107 -0
  42. relore/ingest/extract.py +534 -0
  43. relore/ingest/index_thread.py +536 -0
  44. relore/ingest/normalize.py +101 -0
  45. relore/ingest/poll.py +236 -0
  46. relore/ingest/relationships.py +92 -0
  47. relore/ingest/sample.py +251 -0
  48. relore/ingest/sweep.py +85 -0
  49. relore/ingest/timestamps.py +31 -0
  50. relore/ingest/versions.py +18 -0
  51. relore/render.py +1044 -0
  52. relore/search/__init__.py +55 -0
  53. relore/search/backends/__init__.py +35 -0
  54. relore/search/backends/base.py +1595 -0
  55. relore/search/backends/postgres.py +162 -0
  56. relore/search/backends/sqlite.py +96 -0
  57. relore/search/expansion.py +335 -0
  58. relore/search/queries.py +640 -0
  59. relore/search/ranking.py +224 -0
  60. relore/security/__init__.py +0 -0
  61. relore/security/untrusted.py +201 -0
  62. relore/store/__init__.py +0 -0
  63. relore/store/dialect.py +189 -0
  64. relore/store/migrations/__init__.py +252 -0
  65. relore/store/repository.py +772 -0
  66. relore/store/schema.py +346 -0
  67. relore/wire.py +95 -0
  68. relore-0.3.17.dist-info/METADATA +125 -0
  69. relore-0.3.17.dist-info/RECORD +73 -0
  70. relore-0.3.17.dist-info/WHEEL +5 -0
  71. relore-0.3.17.dist-info/entry_points.txt +6 -0
  72. relore-0.3.17.dist-info/licenses/LICENSE +201 -0
  73. relore-0.3.17.dist-info/top_level.txt +1 -0
relore/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """relore -- GitHub project memory."""
2
+
3
+ #: The one place the version is written. ``pyproject.toml`` reads it from here, so the
4
+ #: distribution, ``relore --version``, ``relored --version``, the ``/api/v1/status``
5
+ #: payload and the wire handshake (:mod:`relore.wire`) can never disagree.
6
+ #:
7
+ #: The minor is the milestone the build plan considers complete; the patch is every
8
+ #: release inside it. **Bump it in the same commit as any change a client can see** --
9
+ #: a wire payload, a renderer, a CLI flag -- because the client and the daemon must run
10
+ #: the same version to talk at all (:mod:`relore.wire`), which makes this string the
11
+ #: compatibility contract rather than a label.
12
+ __version__ = "0.3.17"
relore/api/__init__.py ADDED
File without changes
relore/api/schemas.py ADDED
@@ -0,0 +1,363 @@
1
+ """Request and response shapes, versioned from the first commit (section 7).
2
+
3
+ The moment a second consumer exists, renaming a field breaks a stranger -- so the fields
4
+ of section 7 are all present now, including the ones whose *effect* is a later milestone.
5
+ ``kind`` is the clearest case: it selects section 6's decay regime and section 6.2's trust
6
+ floor, neither of which exists yet, and it is accepted and echoed anyway because adding it
7
+ later would be a breaking change to a shipped API for no reason.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime as dt
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ from relore.freshness import THREAD_STAMP_NOTE
18
+ from relore.search.queries import (
19
+ HUMAN_TRUST,
20
+ MACHINE_TRUST,
21
+ MAX_HITS,
22
+ QUERY_KINDS,
23
+ SORTS,
24
+ Hit,
25
+ InflightView,
26
+ ThreadView,
27
+ WhyView,
28
+ render_stamp,
29
+ )
30
+
31
+
32
+ class SearchRequest(BaseModel):
33
+ """``POST /api/v1/search``.
34
+
35
+ ``limit`` is clamped, not validated: a client asking for 500 gets 10 (section 6). The
36
+ signal filters read the tables section 5.3's extraction pass fills.
37
+ """
38
+
39
+ query: str = ""
40
+ kind: str | None = Field(default=None, description=f"one of {list(QUERY_KINDS)}")
41
+ trust: str | None = Field(
42
+ default=None,
43
+ description=(
44
+ f"raise the floor to one of {list(HUMAN_TRUST)}, or {MACHINE_TRUST!r} to ask the "
45
+ "separate question of what the deployment's own bots claimed. The server decides "
46
+ "the default and a request can never lower it"
47
+ ),
48
+ )
49
+ repos: list[str] = Field(
50
+ default=[],
51
+ description=(
52
+ "narrow to these repositories. It can only ever *subtract* from the token's "
53
+ "scope (section 11): a name the token cannot see is dropped, not granted, so "
54
+ "asking for one returns nothing rather than an error. Empty means every "
55
+ "repository already in scope"
56
+ ),
57
+ )
58
+ files: list[str] = []
59
+ symbols: list[str] = []
60
+ errors: list[str] = []
61
+ tests: list[str] = []
62
+ labels: list[str] = []
63
+ since: dt.datetime | None = None
64
+ limit: int = MAX_HITS
65
+ sort: str = Field(
66
+ default="relevance",
67
+ description=(
68
+ f"one of {list(SORTS)}. Ordering only -- it reorders the same hits relevance "
69
+ "would have chosen, and never changes which ones they are"
70
+ ),
71
+ )
72
+ compact: bool = Field(
73
+ default=False, description="shorter snippets, no score internals, for a tight budget"
74
+ )
75
+ render: bool = Field(
76
+ default=False,
77
+ description=(
78
+ "also return the exact text the CLI would print, envelope included. What "
79
+ "section 8's 'view as the model sees it' displays; off by default because an "
80
+ "agent reading the JSON would be paying for the same content twice"
81
+ ),
82
+ )
83
+ presentation: bool = Field(
84
+ default=False,
85
+ description=(
86
+ "render for a person at a TTY: the same facts plus the backend tag and the "
87
+ "flags worth trying next. The CLI sends it when stdout is a terminal (#13)"
88
+ ),
89
+ )
90
+ expand: bool = Field(
91
+ default=True,
92
+ description=(
93
+ "section 6's query expansion: fan the call out into error, test, symbol, file "
94
+ "and free-text legs and merge them. On by default because the plain search "
95
+ "ANDs every content term, which is what makes a pasted traceback match "
96
+ "nothing. Turn it off to ask exactly one question"
97
+ ),
98
+ )
99
+
100
+
101
+ class LabelRequest(BaseModel):
102
+ """``POST /api/v1/label`` -- section 8's relevance judgement, section 10's ground truth."""
103
+
104
+ query: str
105
+ repo: str
106
+ number: int
107
+ source_type: str
108
+ verdict: str = Field(description="relevant | not_relevant | decisive")
109
+ kind: str | None = None
110
+ #: The signal filters the judged search actually carried. A label is only
111
+ #: interpretable next to the whole query, and two examples can share the text and
112
+ #: differ only in their `--file` -- folding the labels back into section 10's
113
+ #: evaluation set has no way to tell them apart without this.
114
+ filters: dict[str, list[str]] = {}
115
+ note: str = ""
116
+
117
+
118
+ def hit_json(hit: Hit, *, compact: bool = False) -> dict[str, Any]:
119
+ """One hit, as served.
120
+
121
+ ``trust`` and ``age`` are always present and always rendered: age changes what a model
122
+ concludes, authority changes it more (section 6, section 6.2). ``compact`` drops the
123
+ score and its breakdown, which only a person tuning weights has any use for.
124
+ """
125
+ out: dict[str, Any] = {
126
+ "repo": hit.repo,
127
+ "number": hit.number,
128
+ "type": hit.thread_type,
129
+ "title": hit.title,
130
+ "source_type": hit.source_type,
131
+ "url": hit.url,
132
+ "author": hit.author,
133
+ "trust": hit.trust,
134
+ "age": hit.age,
135
+ # The age is for reading, this is for citing. JSON only, and never instead of the
136
+ # age: a bare timestamp makes a model do arithmetic it will skip
137
+ # (`render_stamp`, huggingface/relore#66).
138
+ "date": render_stamp(hit.created_at),
139
+ "snippet": hit.snippet,
140
+ # Which comment, and which piece of it (huggingface/relore#18). `url` is not an
141
+ # identity: a chunk of a comment carries the comment's URL, so two hits from one
142
+ # 9,827-character comment were indistinguishable from two people agreeing.
143
+ # `document_id` is stable across rebuilds -- it is GitHub's id plus the chunk --
144
+ # where the row's primary key is not.
145
+ "document_id": f"{hit.source_type}:{hit.source_id}:{hit.chunk_index}",
146
+ "source_id": hit.source_id,
147
+ "chunk_index": hit.chunk_index,
148
+ }
149
+ # Absent rather than 1 where nobody counted: corpus-wide search does not pay for the
150
+ # aggregate, and "1 of 1" would be a claim it cannot make.
151
+ if hit.passages:
152
+ out["passages"] = hit.passages
153
+ # Only where it is true, and only where somebody measured it: `search` does not, and a
154
+ # `false` it did not check would be a claim. The page uses it to decide whether it owes
155
+ # the caller the address of the rest (huggingface/relore#71).
156
+ if hit.truncated:
157
+ out["truncated"] = True
158
+ if not compact:
159
+ out["score"] = round(hit.score, 6)
160
+ out["breakdown"] = {k: round(v, 6) for k, v in hit.breakdown.items()}
161
+ return out
162
+
163
+
164
+ def inflight_json(view: InflightView) -> dict[str, Any]:
165
+ """``GET /api/v1/inflight/{n}`` -- what claims to close this thread.
166
+
167
+ ``links_indexed`` is here for the reason every count in this module is: an index with
168
+ no relationship rows answers every question with an empty list, and an agent cannot
169
+ tell that from "nobody is working on this" -- which are opposite instructions.
170
+ """
171
+ return {
172
+ "repo": view.repo,
173
+ "number": view.number,
174
+ "claims": [
175
+ {
176
+ "repo": claim.repo,
177
+ "number": claim.number,
178
+ "type": claim.thread_type,
179
+ "title": claim.title,
180
+ "url": claim.url,
181
+ "author": claim.author,
182
+ "state": claim.state,
183
+ "draft": claim.draft,
184
+ "merged": claim.merged,
185
+ "state_reason": claim.state_reason,
186
+ "closed_by": claim.closed_by,
187
+ "review_decision": claim.review_decision,
188
+ "age": claim.age,
189
+ "date": render_stamp(claim.created_at),
190
+ "relationship": claim.relationship,
191
+ }
192
+ for claim in view.claims
193
+ ],
194
+ "claims_returned": len(view.claims),
195
+ "claims_total": view.total,
196
+ "links_indexed": view.links_indexed,
197
+ }
198
+
199
+
200
+ def _outline_json(hit: Hit) -> dict[str, Any]:
201
+ """One outline row. Deliberately not :func:`hit_json`: an outline line carries what is
202
+ needed to *choose* a comment and nothing that would let it be quoted -- no score, no
203
+ breakdown, no repeated repo and number, and a snippet cut at
204
+ :data:`~relore.search.queries.OUTLINE_CHARS`. The whole value of the view is that it
205
+ is cheap enough to serve complete."""
206
+ return {
207
+ "id": hit.source_id,
208
+ "source_type": hit.source_type,
209
+ "author": hit.author,
210
+ "trust": hit.trust,
211
+ "age": hit.age,
212
+ "date": render_stamp(hit.created_at),
213
+ "url": hit.url,
214
+ "passages": hit.passages,
215
+ "snippet": hit.snippet,
216
+ }
217
+
218
+
219
+ def why_json(view: WhyView, *, blame: Any) -> dict[str, Any]:
220
+ """``GET /api/v1/why`` -- the commit, the pull request, and what was said on the line.
221
+
222
+ ``number: null`` and an empty ``anchored`` are different answers: no pull request could
223
+ be resolved for the commit, versus one was and nobody reviewed this line.
224
+ """
225
+ return {
226
+ "repo": view.repo,
227
+ "path": view.path,
228
+ "line": view.line,
229
+ "blame": {
230
+ "sha": blame.sha,
231
+ "author": blame.author,
232
+ "summary": blame.summary,
233
+ "text": blame.text,
234
+ },
235
+ "number": view.number,
236
+ "resolved_by": view.resolved_by,
237
+ "thread": thread_json(view.thread) if view.thread else None,
238
+ "anchored": [dict(comment) for comment in view.anchored],
239
+ # The widening, and what it reached (relore#63, #64). Separate keys rather than one
240
+ # merged list: a comment forty lines away and an approval on the pull request are
241
+ # both evidence and are not the same claim, and a caller merging them cannot say
242
+ # which it read.
243
+ "on_file": [dict(comment) for comment in view.on_file],
244
+ "reviews": [dict(review) for review in view.reviews],
245
+ "level": view.level,
246
+ "history": [dict(commit) for commit in view.history],
247
+ # Separate from `history` and never merged into it: one follows the line, the other
248
+ # follows a word, and a caller that cannot tell them apart cannot tell "this line
249
+ # was reformatted eight times" from "this behaviour was argued here".
250
+ "origin": [dict(commit) for commit in view.origin],
251
+ "origin_term": view.origin_term,
252
+ "origin_considered": [
253
+ {"term": term, "commits": count} for term, count in view.origin_considered
254
+ ],
255
+ # Why the chain is empty, when it is. `comment` | `no-candidate` | `unreadable`,
256
+ # and they are three different answers (huggingface/relore#71).
257
+ "origin_declined": view.origin_declined,
258
+ }
259
+
260
+
261
+ def _stamp(moment: dt.datetime | None) -> str | None:
262
+ """A freshness mark a reader can compare with a GitHub timestamp, to the second.
263
+
264
+ One function for every timestamp this API serves, so a caller reading ``indexed_at``
265
+ next to a comment's ``date`` never has to parse two formats
266
+ (:func:`~relore.search.queries.render_stamp`).
267
+ """
268
+ return render_stamp(moment)
269
+
270
+
271
+ def thread_json(view: ThreadView, *, compact: bool = False) -> dict[str, Any]:
272
+ return {
273
+ "repo": view.repo,
274
+ "number": view.number,
275
+ "type": view.thread_type,
276
+ "title": view.title,
277
+ "url": view.url,
278
+ "author": view.author,
279
+ "state": view.state,
280
+ "age": view.age,
281
+ "date": render_stamp(view.created_at),
282
+ "labels": list(view.labels),
283
+ # Events, not prose (issue #22).
284
+ "state_reason": view.state_reason,
285
+ "closed_by": view.closed_by,
286
+ "merged": view.merged,
287
+ "review_decision": view.review_decision,
288
+ "review_decision_by": list(view.review_decision_by),
289
+ "requested_reviewers": list(view.requested_reviewers),
290
+ "body": view.body,
291
+ # A cap a caller cannot see is a cap a caller reads as the whole document. `full`
292
+ # serves the rest; these two say whether there is a rest.
293
+ "body_chars": view.body_chars,
294
+ "body_truncated": view.body_truncated,
295
+ # Two keys, never one. Presence in `files_changed` says the thread changed the
296
+ # path; presence in `files_mentioned` says somebody typed it, which is not the
297
+ # same claim and was indistinguishable while both shared an array
298
+ # (huggingface/relore#17).
299
+ "files_changed": list(view.files_changed),
300
+ # Also the diff, and also not part of the collected page: an inline review comment
301
+ # can only hang on a changed file, so this recovers paths past the 100-row cap.
302
+ "files_anchored": list(view.files_anchored),
303
+ "files_mentioned": list(view.files_mentioned),
304
+ # The denominator of the changed-file list, and `len(files_changed)` is its
305
+ # numerator -- they are the same quantity, which the old bare count was not. A
306
+ # truncated list must never be able to answer a membership question:
307
+ # `files_collected < files_total` means a path that is absent may still have been
308
+ # touched.
309
+ "files_total": view.files_total,
310
+ "files_collected": view.files_collected,
311
+ "links": [dict(link) for link in view.links],
312
+ # Named so the cap is visible in the response rather than inferred from a short
313
+ # list: a caller that cannot tell truncation from a quiet thread will read ten
314
+ # comments as the whole argument.
315
+ "comments": [hit_json(hit, compact=compact) for hit in view.comments],
316
+ "comments_returned": len(view.comments),
317
+ # Comments, not documents: a long comment is several documents and counting rows
318
+ # called one comment two (huggingface/relore#18). Every comment the thread has,
319
+ # including the ones the trust floor withheld -- `comments_total` minus
320
+ # `comments_machine_suppressed` is what the cap and the sampling compose with, and
321
+ # a total computed post-filter said `0 of 0` about a thread we hold a comment for
322
+ # (huggingface/relore#28).
323
+ "comments_total": view.total_documents + view.machine_suppressed,
324
+ # The gap, named rather than left to be inferred from a short list. Machine
325
+ # authors are excluded and not down-weighted (section 6.2), so this is a filter a
326
+ # reader has to know fired before concluding a thread is quiet.
327
+ "comments_machine_suppressed": view.machine_suppressed,
328
+ # When this response's thread was last rebuilt from GitHub. Per document, because
329
+ # `status`'s per-source high-water rows cannot be composed into an answer about
330
+ # one thread and two field runs composed them wrongly in opposite directions
331
+ # (huggingface/relore#32). A string, not a datetime: `render` takes plain JSON
332
+ # shapes and must read the same on both sides of the wire.
333
+ "indexed_at": _stamp(view.indexed_at),
334
+ "indexed_at_note": THREAD_STAMP_NOTE,
335
+ # HOW those comments were chosen. A positional sample and a ranked top ten look
336
+ # identical on the page, and the first five and last five of a 97-comment thread
337
+ # were read as its ten best (huggingface/relore#16).
338
+ "selection": view.selection,
339
+ # A focus orders the comments and never selects them, so the count that matters is
340
+ # how many carried every term: zero next to ten returned comments says "your
341
+ # question matched nothing, this is the thread in order" rather than "nothing here".
342
+ "focus": view.focus,
343
+ "focus_matched": view.focus_matched,
344
+ # One line per comment for the whole thread (relore#70). Present and empty unless
345
+ # `--outline` asked, so a caller can tell "not requested" from "nothing to show".
346
+ "outline": [_outline_json(c) for c in view.outline],
347
+ "outline_returned": len(view.outline),
348
+ # An outline narrowed by a focus is complete *of the comments carrying a word*,
349
+ # which is a different completeness from the unnarrowed one and has to be
350
+ # distinguishable without reading the rows (huggingface/relore#71). `widened` is
351
+ # the case where the focus carried nothing and the whole outline came back
352
+ # instead, so a caller never has to infer that from a count that did not shrink.
353
+ "outline_matched": view.outline_matched,
354
+ "outline_widened": view.outline_widened,
355
+ # What `--comment` asked for and what became of it. `absent` and `suppressed` are
356
+ # both "no comment came back" and are opposite next actions, so the page says which
357
+ # rather than leaving it to be inferred from an empty list (huggingface/relore#71).
358
+ "comment": view.comment,
359
+ "comment_status": view.comment_status,
360
+ # Where a sweep resumed. A page that silently begins in the middle is worse than
361
+ # one that stops, so the cursor travels with the page that honoured it.
362
+ "after": view.after,
363
+ }