holt-cli 0.1.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.
- holt/__init__.py +0 -0
- holt/agent/__init__.py +0 -0
- holt/agent/entry.py +86 -0
- holt/agent/findings.py +49 -0
- holt/agent/landing.py +154 -0
- holt/agent/pipeline.py +244 -0
- holt/agent/progression.py +408 -0
- holt/agent/signals.py +220 -0
- holt/agent/stages.py +533 -0
- holt/agent/verdict.py +226 -0
- holt/agent/verify.py +140 -0
- holt/baseline.py +89 -0
- holt/baseline_matched.py +116 -0
- holt/cli.py +616 -0
- holt/discover.py +497 -0
- holt/evidence/__init__.py +3 -0
- holt/evidence/fixtures.py +154 -0
- holt/evidence/github_graphql.py +538 -0
- holt/evidence/provider.py +77 -0
- holt/evidence/redact.py +79 -0
- holt/issues.py +41 -0
- holt/model.py +516 -0
- holt/profile.py +126 -0
- holt/report.py +157 -0
- holt/tui/__init__.py +0 -0
- holt/tui/animation.py +84 -0
- holt/tui/app.py +294 -0
- holt/tui/clipboard.py +89 -0
- holt/tui/commands.py +134 -0
- holt/tui/discovery.py +305 -0
- holt/tui/env.py +49 -0
- holt/tui/events.py +245 -0
- holt/tui/mascot.py +121 -0
- holt/tui/models.py +590 -0
- holt/tui/observe.py +297 -0
- holt/tui/screens/__init__.py +35 -0
- holt/tui/screens/assessment.py +337 -0
- holt/tui/screens/confirm.py +62 -0
- holt/tui/screens/discover.py +444 -0
- holt/tui/screens/home.py +519 -0
- holt/tui/screens/inspector.py +106 -0
- holt/tui/screens/live.py +335 -0
- holt/tui/screens/models.py +393 -0
- holt/tui/screens/next_steps.py +425 -0
- holt/tui/screens/profile.py +129 -0
- holt/tui/session.py +711 -0
- holt/tui/store.py +458 -0
- holt/tui/theme.py +479 -0
- holt/tui/visual.py +33 -0
- holt/tui/widgets/__init__.py +0 -0
- holt/tui/widgets/candidates.py +78 -0
- holt/tui/widgets/claims.py +59 -0
- holt/tui/widgets/disclosure.py +121 -0
- holt/tui/widgets/evidence.py +121 -0
- holt/tui/widgets/masthead.py +122 -0
- holt/tui/widgets/recent.py +167 -0
- holt/tui/widgets/scrolling.py +38 -0
- holt/tui/widgets/stages.py +232 -0
- holt/types.py +48 -0
- holt_cli-0.1.0.dist-info/METADATA +198 -0
- holt_cli-0.1.0.dist-info/RECORD +65 -0
- holt_cli-0.1.0.dist-info/WHEEL +4 -0
- holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
- holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- holt_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Where someone who has already landed work here might look next.
|
|
2
|
+
|
|
3
|
+
Reached from a report, because it answers the question that comes *after*
|
|
4
|
+
"is this worth my time". It needs a login, and it needs that login to have merged
|
|
5
|
+
something in the evidence — without a merge there is no set of touched paths and
|
|
6
|
+
the ranking has nothing to work from. That is stated when it happens rather than
|
|
7
|
+
producing an empty list.
|
|
8
|
+
|
|
9
|
+
**It reads what the report read.** This screen used to fetch from the committed
|
|
10
|
+
fixtures and nothing else, whatever mode the run had been in. Assess a repository
|
|
11
|
+
live — which is what the finder now hands you — press `n`, and it answered "no
|
|
12
|
+
committed evidence, so there is nothing to rank from" about a repository GitHub
|
|
13
|
+
would have answered for immediately. So the evidence the report was built from
|
|
14
|
+
is tried first, and a miss is a miss about the repository rather than about
|
|
15
|
+
where we happened to look.
|
|
16
|
+
|
|
17
|
+
**A live report is not answered from fixtures behind your back.** The fix above
|
|
18
|
+
went one step too far: when GitHub had nothing, the ranking silently fell
|
|
19
|
+
through to a recording taken before the holdout and mentioned it in a sentence
|
|
20
|
+
under an order that was already on screen. The order looked live. So the
|
|
21
|
+
fallback in that direction is now something you press `ctrl+e` for, and until
|
|
22
|
+
you do, a live miss reads as a live miss. The fallback the other way — a
|
|
23
|
+
replayed report reaching GitHub — is untouched: you already chose the
|
|
24
|
+
recording, and that direction moves toward the real repository rather than away
|
|
25
|
+
from it.
|
|
26
|
+
|
|
27
|
+
**Reads are reused for ten minutes, and say when they were.** Trying three
|
|
28
|
+
logins against one repository was three round trips to GitHub for the same two
|
|
29
|
+
fetches. The same ten minutes a stored assessment stays reusable for, for the
|
|
30
|
+
same reason: long enough to cover the way this screen is actually used, short
|
|
31
|
+
enough that nobody mistakes it for live data. Every reuse carries its age.
|
|
32
|
+
|
|
33
|
+
**The read happens off the screen's own message pump.** Reading evidence takes
|
|
34
|
+
a thread and, on a live repository, a minute; awaiting it inside the submit
|
|
35
|
+
handler meant the screen answered no keys at all until it came back. It runs as
|
|
36
|
+
a worker, so the notice moves, escape still works, and pressing enter again
|
|
37
|
+
replaces the ranking in flight.
|
|
38
|
+
|
|
39
|
+
The ranking is `progression.path_overlap_rank`: deterministic, no model call.
|
|
40
|
+
Each row shows the tokens that put it there, so the order is inspectable rather
|
|
41
|
+
than asserted — the same standard the rest of the interface holds evidence to.
|
|
42
|
+
And it carries `NEXT_MEASUREMENT` whenever it shows an order, because a ranking
|
|
43
|
+
shown without the number saying how well it works is the overclaim this project
|
|
44
|
+
exists to avoid — `holt next` on the command line has always printed it.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import asyncio
|
|
50
|
+
import time
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
from rich.text import Text
|
|
54
|
+
from textual.app import ComposeResult
|
|
55
|
+
from textual.binding import Binding
|
|
56
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
57
|
+
from textual.screen import Screen
|
|
58
|
+
from textual.widgets import Footer, Input
|
|
59
|
+
|
|
60
|
+
from holt.agent.progression import NEXT_MEASUREMENT
|
|
61
|
+
from holt.tui import animation, discovery, store, theme
|
|
62
|
+
from holt.tui.visual import Line
|
|
63
|
+
from holt.tui.widgets.evidence import cite
|
|
64
|
+
|
|
65
|
+
TOP = 10
|
|
66
|
+
|
|
67
|
+
#: How long a fetch is reused for. Deliberately the same window a stored
|
|
68
|
+
#: assessment is considered fresh for, and imported from there rather than
|
|
69
|
+
#: restated, so the interface has one answer to "how old is too old" instead of
|
|
70
|
+
#: two that can drift apart.
|
|
71
|
+
CACHE_SECONDS = store.DEFAULT_MAX_AGE_SECONDS
|
|
72
|
+
|
|
73
|
+
#: `(repo, kind, live) -> (read_at, records)`. Module level because the screen
|
|
74
|
+
#: is rebuilt every time `n` is pressed, so nothing on the instance survives to
|
|
75
|
+
#: the next visit — which is one of the two cases worth caching for.
|
|
76
|
+
_CACHE: dict[tuple[str, str, bool], tuple[float, Any]] = {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cache_clear() -> None:
|
|
80
|
+
"""Forget every reused read. For tests, and for nothing else."""
|
|
81
|
+
_CACHE.clear()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _NoEvidence(Exception):
|
|
85
|
+
"""Nothing to read, from either source. Carries the sentence to show."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class NextScreen(Screen):
|
|
90
|
+
BINDINGS = [
|
|
91
|
+
("escape", "back", "back"),
|
|
92
|
+
# `ctrl+e`, not a bare letter: the login box holds focus the whole time
|
|
93
|
+
# a reader would want this, and a printable key never reaches the
|
|
94
|
+
# screen from inside an `Input` — it just types itself into the box.
|
|
95
|
+
Binding("ctrl+e", "use_committed", "use committed evidence"),
|
|
96
|
+
("q", "quit", "quit"),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def __init__(self, repo: str, live: bool = False, **kwargs) -> None:
|
|
100
|
+
super().__init__(**kwargs)
|
|
101
|
+
self.repo = repo
|
|
102
|
+
#: How the report this was opened from read its evidence. The ranking
|
|
103
|
+
#: starts from the same place rather than assuming a recording exists.
|
|
104
|
+
self.live = live
|
|
105
|
+
#: Set only by `ctrl+e`. Until it is, a live report is never answered
|
|
106
|
+
#: from the committed fixtures — see `_sources`.
|
|
107
|
+
self.use_committed = False
|
|
108
|
+
#: True while committed evidence is being *offered* rather than used,
|
|
109
|
+
#: which is the only time `ctrl+e` means anything. Drives the footer.
|
|
110
|
+
self._withheld = False
|
|
111
|
+
#: The last login asked about, so `ctrl+e` can re-ask it rather than
|
|
112
|
+
#: making the reader type it again.
|
|
113
|
+
self._login = ""
|
|
114
|
+
|
|
115
|
+
def compose(self) -> ComposeResult:
|
|
116
|
+
with Horizontal(id="chrome"):
|
|
117
|
+
yield Line("holt · what next", id="chrome-left")
|
|
118
|
+
yield Line(Text(self.repo, style=theme.FAINT), id="chrome-right")
|
|
119
|
+
yield Line("─" * 240, classes="rule")
|
|
120
|
+
|
|
121
|
+
with Vertical(id="next-body"):
|
|
122
|
+
yield Line(
|
|
123
|
+
Text(
|
|
124
|
+
"Whose next issues? A GitHub username — yours, or anyone "
|
|
125
|
+
"who has had work merged here.",
|
|
126
|
+
style=theme.DIM,
|
|
127
|
+
),
|
|
128
|
+
classes="section-label",
|
|
129
|
+
)
|
|
130
|
+
yield Input(
|
|
131
|
+
placeholder="GitHub username, e.g. frenck — enter to rank",
|
|
132
|
+
id="login-input",
|
|
133
|
+
)
|
|
134
|
+
yield Line(
|
|
135
|
+
Text(
|
|
136
|
+
"Ranked by overlap with the files they have already had merged "
|
|
137
|
+
"here. Deterministic, and no model is called.",
|
|
138
|
+
style=theme.FAINT,
|
|
139
|
+
),
|
|
140
|
+
id="next-notice",
|
|
141
|
+
)
|
|
142
|
+
# Filled in only when an order is on screen, and never omitted then.
|
|
143
|
+
yield Line("", id="next-measurement")
|
|
144
|
+
with VerticalScroll(id="next-results"):
|
|
145
|
+
yield Line("")
|
|
146
|
+
yield Footer()
|
|
147
|
+
|
|
148
|
+
def on_mount(self) -> None:
|
|
149
|
+
self.query_one("#login-input", Input).focus()
|
|
150
|
+
|
|
151
|
+
def _notice(self, message: str, tone: str = "") -> None:
|
|
152
|
+
self.query_one("#next-notice", Line).update(
|
|
153
|
+
Text(message, style=tone or theme.FAINT)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _measurement(self, message: str = "") -> None:
|
|
157
|
+
self.query_one("#next-measurement", Line).update(
|
|
158
|
+
Text(message, style=theme.FAINT)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _sources(self) -> list[bool]:
|
|
162
|
+
"""Which evidence to try, best first. `True` is GitHub, `False` is disk.
|
|
163
|
+
|
|
164
|
+
The report was built from one of these and the ranking reads the same
|
|
165
|
+
thing first. What happens on a miss is not symmetric, and deliberately
|
|
166
|
+
so:
|
|
167
|
+
|
|
168
|
+
* A **replayed** report falls through to GitHub. You chose the
|
|
169
|
+
recording, and reaching past it goes toward the live repository.
|
|
170
|
+
* A **live** report does *not* fall through to the committed fixtures.
|
|
171
|
+
It used to, silently, and then said so in a sentence underneath a
|
|
172
|
+
ranking already on screen — an order built from evidence recorded
|
|
173
|
+
before the holdout, presented as though GitHub had answered. Reading
|
|
174
|
+
fixtures is a choice, so it takes `ctrl+e`, which sets
|
|
175
|
+
`use_committed` and asks again.
|
|
176
|
+
|
|
177
|
+
Live is dropped from the list without a token rather than attempted and
|
|
178
|
+
reported as a failure, which would name the wrong problem.
|
|
179
|
+
"""
|
|
180
|
+
sources = [self.live]
|
|
181
|
+
other = not self.live
|
|
182
|
+
# `other` is truthy exactly when the fallback is *toward* GitHub, which
|
|
183
|
+
# needs no permission. The fixture direction needs `ctrl+e`.
|
|
184
|
+
if other or self.use_committed:
|
|
185
|
+
sources.append(other)
|
|
186
|
+
return [
|
|
187
|
+
mode for mode in sources if not mode or discovery.missing_token() is None
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
async def _fetch(self, make_provider, kind: str):
|
|
191
|
+
"""Evidence for this repo, from the first source that has it.
|
|
192
|
+
|
|
193
|
+
Returns the records, whether they came from GitHub, and how long ago
|
|
194
|
+
they were read — `None` when they were read just now. The age is
|
|
195
|
+
returned rather than swallowed because a reused read is shown with it;
|
|
196
|
+
a cache nobody can see the age of is a cache that lies.
|
|
197
|
+
|
|
198
|
+
The fetch runs off the event loop: a live read is a network round trip
|
|
199
|
+
and the interface must not freeze for the length of one.
|
|
200
|
+
"""
|
|
201
|
+
from holt.tui import session as session_module
|
|
202
|
+
|
|
203
|
+
failures: list[str] = []
|
|
204
|
+
for live in self._sources():
|
|
205
|
+
hit = _cached(self.repo, kind, live)
|
|
206
|
+
if hit is not None:
|
|
207
|
+
age, records = hit
|
|
208
|
+
return records, live, age
|
|
209
|
+
self._notice("reading GitHub…" if live else "reading committed evidence…")
|
|
210
|
+
try:
|
|
211
|
+
records = await asyncio.to_thread(
|
|
212
|
+
lambda live=live: make_provider(live=live).fetch(self.repo)
|
|
213
|
+
)
|
|
214
|
+
except FileNotFoundError:
|
|
215
|
+
failures.append("nothing recorded")
|
|
216
|
+
continue
|
|
217
|
+
except Exception as exc: # noqa: BLE001 - reported, never a traceback
|
|
218
|
+
failures.append(session_module.readable(exc))
|
|
219
|
+
continue
|
|
220
|
+
_CACHE[(self.repo, kind, live)] = (time.time(), records)
|
|
221
|
+
return records, live, None
|
|
222
|
+
raise _NoEvidence(self._nothing_to_read(kind, failures))
|
|
223
|
+
|
|
224
|
+
def _nothing_to_read(self, missing: str, failures: list[str]) -> str:
|
|
225
|
+
"""Why there is nothing, naming the thing that would fix it.
|
|
226
|
+
|
|
227
|
+
Sets `_withheld` when the committed fixtures are the thing not tried,
|
|
228
|
+
because that is the only state in which `ctrl+e` does anything, and the
|
|
229
|
+
footer should not advertise a key that would be a no-op.
|
|
230
|
+
"""
|
|
231
|
+
token = discovery.missing_token()
|
|
232
|
+
if token:
|
|
233
|
+
return (
|
|
234
|
+
f"No committed {missing} for {self.repo}, and reading GitHub "
|
|
235
|
+
"needs GITHUB_TOKEN — export a token, or put it in .env, and "
|
|
236
|
+
"try again."
|
|
237
|
+
)
|
|
238
|
+
detail = f" ({failures[-1]})" if failures else ""
|
|
239
|
+
if self.live and not self.use_committed:
|
|
240
|
+
# Withheld, not missing. Said as an offer rather than done quietly,
|
|
241
|
+
# which is the whole point of the key existing.
|
|
242
|
+
self._withheld = True
|
|
243
|
+
self.refresh_bindings()
|
|
244
|
+
return (
|
|
245
|
+
f"GitHub has no {missing} for {self.repo}{detail}, so there is "
|
|
246
|
+
"nothing live to rank from. ctrl+e tries the committed evidence "
|
|
247
|
+
"instead — a recording from before the holdout, not this "
|
|
248
|
+
"repository as it is now."
|
|
249
|
+
)
|
|
250
|
+
return (
|
|
251
|
+
f"No {missing} for {self.repo}, from the recording or from "
|
|
252
|
+
f"GitHub{detail}, so there is nothing to rank from."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
256
|
+
"""Start the ranking. Deliberately not `async`, and deliberately not
|
|
257
|
+
awaited.
|
|
258
|
+
|
|
259
|
+
A message handler owns the screen's message pump for as long as it
|
|
260
|
+
runs, and this one runs for as long as the evidence takes to read —
|
|
261
|
+
which on a live repository is a minute of network. Awaiting it here
|
|
262
|
+
meant the screen took no keys at all in the meantime: no escape, no
|
|
263
|
+
typing, nothing. The work moves to a worker so the screen keeps
|
|
264
|
+
answering while it happens, and pressing enter again replaces the
|
|
265
|
+
ranking in flight rather than queueing a second one behind it.
|
|
266
|
+
"""
|
|
267
|
+
login = event.value.strip().lstrip("@")
|
|
268
|
+
if not login:
|
|
269
|
+
return
|
|
270
|
+
self.run_worker(self._rank(login), group="rank", exclusive=True)
|
|
271
|
+
|
|
272
|
+
async def _rank(self, login: str) -> None:
|
|
273
|
+
from holt.agent import progression
|
|
274
|
+
from holt.agent.signals import build_threads
|
|
275
|
+
from holt.issues import open_at_cutoff
|
|
276
|
+
from holt.tui import session as session_module
|
|
277
|
+
|
|
278
|
+
results = self.query_one("#next-results", VerticalScroll)
|
|
279
|
+
await results.remove_children()
|
|
280
|
+
self._measurement()
|
|
281
|
+
self._login = login
|
|
282
|
+
# Cleared before every attempt: whether the fixtures are being withheld
|
|
283
|
+
# is a fact about this attempt, not a mode the screen stays in.
|
|
284
|
+
self._withheld = False
|
|
285
|
+
self.refresh_bindings()
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
records, from_live, evidence_age = await self._fetch(
|
|
289
|
+
session_module._provider, "evidence"
|
|
290
|
+
)
|
|
291
|
+
except _NoEvidence as miss:
|
|
292
|
+
self._notice(str(miss), theme.DROP)
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
# Off the loop for the same reason the fetch is: on a large repository
|
|
296
|
+
# this is a second of work, and a second of dropped frames reads as the
|
|
297
|
+
# interface having stopped.
|
|
298
|
+
contributor = await asyncio.to_thread(
|
|
299
|
+
lambda: progression.history_for(login, build_threads(records))
|
|
300
|
+
)
|
|
301
|
+
if not contributor.merged_count:
|
|
302
|
+
# Said plainly, because an empty list would look like a ranking that
|
|
303
|
+
# found nothing rather than a question that cannot be asked yet.
|
|
304
|
+
self._notice(
|
|
305
|
+
f"{login} has no merged pull request here in this evidence, so "
|
|
306
|
+
"there are no touched paths to rank against. The assessment "
|
|
307
|
+
"answers the question that comes before this one.",
|
|
308
|
+
theme.DROP,
|
|
309
|
+
)
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
issues, issues_live, issue_age = await self._fetch(
|
|
314
|
+
session_module._issue_provider, "issue evidence"
|
|
315
|
+
)
|
|
316
|
+
except _NoEvidence as miss:
|
|
317
|
+
self._notice(str(miss), theme.DROP)
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
candidates = open_at_cutoff(issues)
|
|
321
|
+
if not candidates:
|
|
322
|
+
self._notice("No issue in this evidence was open at the cutoff.",
|
|
323
|
+
theme.DROP)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
ranked = await asyncio.to_thread(
|
|
327
|
+
progression.path_overlap_rank, contributor.files, candidates
|
|
328
|
+
)
|
|
329
|
+
read = "read live from GitHub" if from_live and issues_live else (
|
|
330
|
+
"read from committed evidence" if not (from_live or issues_live)
|
|
331
|
+
else "read from both live and committed evidence"
|
|
332
|
+
)
|
|
333
|
+
read += _reuse(evidence_age, issue_age)
|
|
334
|
+
self._notice(
|
|
335
|
+
f"{login} has {contributor.merged_count} merged "
|
|
336
|
+
f"{'PR' if contributor.merged_count == 1 else 'PRs'} here, touching "
|
|
337
|
+
f"{len(contributor.files)} files. "
|
|
338
|
+
f"{sum(1 for _k, toks in ranked if toks)} of {len(ranked)} open issues "
|
|
339
|
+
f"overlap those paths. {read}."
|
|
340
|
+
)
|
|
341
|
+
# Never an order without the number that says how well it works. The
|
|
342
|
+
# command line has always printed this; this screen is the second path
|
|
343
|
+
# that shows a ranking and it was not printing it.
|
|
344
|
+
self._measurement(NEXT_MEASUREMENT)
|
|
345
|
+
|
|
346
|
+
for index, (key, tokens) in enumerate(ranked[:TOP], 1):
|
|
347
|
+
record = candidates[key]
|
|
348
|
+
await results.mount(_row(index, key, record, tokens))
|
|
349
|
+
animation.reveal(results.children[-1], delay=animation.stagger(index))
|
|
350
|
+
|
|
351
|
+
def check_action(self, action: str, parameters) -> bool | None:
|
|
352
|
+
"""Advertise `ctrl+e` only where it does something.
|
|
353
|
+
|
|
354
|
+
`None` hides a binding rather than disabling it, which is what a key
|
|
355
|
+
that is not currently a choice should be.
|
|
356
|
+
"""
|
|
357
|
+
if action == "use_committed":
|
|
358
|
+
return True if self._withheld else None
|
|
359
|
+
return True
|
|
360
|
+
|
|
361
|
+
async def action_use_committed(self) -> None:
|
|
362
|
+
"""Rank from the committed evidence after all, having been asked.
|
|
363
|
+
|
|
364
|
+
This is the entire gate. Everything about reading fixtures on a live
|
|
365
|
+
report happens because somebody pressed this, and the ranking that
|
|
366
|
+
comes back says which source it came from.
|
|
367
|
+
"""
|
|
368
|
+
if not self._withheld or not self._login:
|
|
369
|
+
return
|
|
370
|
+
self.use_committed = True
|
|
371
|
+
await self._rank(self._login)
|
|
372
|
+
|
|
373
|
+
def action_back(self) -> None:
|
|
374
|
+
self.app.pop_screen()
|
|
375
|
+
|
|
376
|
+
def action_quit(self) -> None:
|
|
377
|
+
self.app.exit()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _cached(repo: str, kind: str, live: bool) -> tuple[float, Any] | None:
|
|
381
|
+
"""A read from within the window, and its age. Expired entries are dropped.
|
|
382
|
+
|
|
383
|
+
Dropped rather than left to rot: this dict lives for the process, and a
|
|
384
|
+
long session against many repositories would otherwise only ever grow.
|
|
385
|
+
"""
|
|
386
|
+
entry = _CACHE.get((repo, kind, live))
|
|
387
|
+
if entry is None:
|
|
388
|
+
return None
|
|
389
|
+
read_at, records = entry
|
|
390
|
+
age = max(0.0, time.time() - read_at)
|
|
391
|
+
if age > CACHE_SECONDS:
|
|
392
|
+
del _CACHE[(repo, kind, live)]
|
|
393
|
+
return None
|
|
394
|
+
return age, records
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _reuse(*ages: float | None) -> str:
|
|
398
|
+
"""How the sentence admits to a reused read, or "" when nothing was reused.
|
|
399
|
+
|
|
400
|
+
The oldest of them, because that is the age of the claim as a whole — the
|
|
401
|
+
freshest part of a mixed answer is not what it should be dated by.
|
|
402
|
+
"""
|
|
403
|
+
known = [age for age in ages if age is not None]
|
|
404
|
+
if not known:
|
|
405
|
+
return ""
|
|
406
|
+
if len(known) < len(ages):
|
|
407
|
+
return f", partly reused from a read {store.describe_age(max(known))}"
|
|
408
|
+
return f", reused from a read {store.describe_age(max(known))}"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _row(index: int, key: str, record, tokens: set[str]) -> Line:
|
|
412
|
+
title = str(record.payload.get("title", "")).strip()
|
|
413
|
+
text = Text()
|
|
414
|
+
text.append(f"{index} ", style=theme.FAINT)
|
|
415
|
+
text.append(title[:76] or key)
|
|
416
|
+
text.append("\n ")
|
|
417
|
+
text.append_text(cite(key, width=44))
|
|
418
|
+
if tokens:
|
|
419
|
+
# Why this row is where it is. An order without a reason is an
|
|
420
|
+
# assertion, and the rest of the interface does not make those.
|
|
421
|
+
text.append(" overlaps ", style=theme.FAINT)
|
|
422
|
+
text.append(", ".join(sorted(tokens)[:4]), style=theme.DIM)
|
|
423
|
+
else:
|
|
424
|
+
text.append(" no path overlap — listed by recency", style=theme.FAINT)
|
|
425
|
+
return Line(text, classes="next-row")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""What you want to work on, said once.
|
|
2
|
+
|
|
3
|
+
`holt discover` reads this, and on the command line it is a set of prompts you
|
|
4
|
+
answer in sequence. In a terminal interface a form is better: every field is
|
|
5
|
+
visible at once, you can see what is already stored, and changing one does not
|
|
6
|
+
mean re-answering the rest.
|
|
7
|
+
|
|
8
|
+
The file is `holt.profile`'s, at `holt.profile.config_path()`, in the same
|
|
9
|
+
format the command-line version writes. Editing here and editing there are the
|
|
10
|
+
same thing, so nobody has to know which one produced the file they have.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
from textual.app import ComposeResult
|
|
17
|
+
from textual.containers import Horizontal, Vertical
|
|
18
|
+
from textual.screen import Screen
|
|
19
|
+
from textual.widgets import Footer, Input
|
|
20
|
+
|
|
21
|
+
from holt.tui import animation, theme
|
|
22
|
+
from holt.tui.visual import Line
|
|
23
|
+
|
|
24
|
+
#: Field name, label, and the sentence that says what it is for. The order is
|
|
25
|
+
#: the order the questions are asked in on the command line.
|
|
26
|
+
FIELDS: tuple[tuple[str, str, str], ...] = (
|
|
27
|
+
("languages", "languages", "comma separated — python, rust"),
|
|
28
|
+
("topics", "topics", "GitHub topics — cli, database"),
|
|
29
|
+
("contributions", "contributions", "docs, tests, ci, code"),
|
|
30
|
+
("days", "days", "how many days you actually have"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProfileScreen(Screen):
|
|
35
|
+
BINDINGS = [
|
|
36
|
+
("ctrl+s", "save", "save"),
|
|
37
|
+
("escape", "home", "home"),
|
|
38
|
+
("q", "quit", "quit"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
def compose(self) -> ComposeResult:
|
|
42
|
+
from holt import profile as profile_mod
|
|
43
|
+
|
|
44
|
+
stored = profile_mod.load()
|
|
45
|
+
|
|
46
|
+
with Horizontal(id="chrome"):
|
|
47
|
+
yield Line("holt · profile", id="chrome-left")
|
|
48
|
+
yield Line(
|
|
49
|
+
Text(str(profile_mod.config_path()), style=theme.FAINT),
|
|
50
|
+
id="chrome-right",
|
|
51
|
+
)
|
|
52
|
+
yield Line("─" * 240, classes="rule")
|
|
53
|
+
|
|
54
|
+
with Vertical(id="profile-body"):
|
|
55
|
+
yield Line(
|
|
56
|
+
Text(
|
|
57
|
+
"Said once, and read by discover. Nothing here is sent "
|
|
58
|
+
"anywhere; it only shapes which repositories are searched for.",
|
|
59
|
+
style=theme.FAINT,
|
|
60
|
+
),
|
|
61
|
+
classes="section-label",
|
|
62
|
+
)
|
|
63
|
+
for name, label, note in FIELDS:
|
|
64
|
+
# The note is the placeholder and nothing else. Printing it as a
|
|
65
|
+
# caption as well said the same thing twice on every field.
|
|
66
|
+
yield Line(Text(label, style=theme.DIM), classes="section-label")
|
|
67
|
+
yield Input(
|
|
68
|
+
value=_value(stored, name),
|
|
69
|
+
placeholder=note,
|
|
70
|
+
id=f"profile-{name}",
|
|
71
|
+
)
|
|
72
|
+
yield Line("", id="profile-notice")
|
|
73
|
+
yield Footer()
|
|
74
|
+
|
|
75
|
+
def on_mount(self) -> None:
|
|
76
|
+
self.query_one("#profile-languages", Input).focus()
|
|
77
|
+
self._notice("ctrl+s saves esc goes back without saving")
|
|
78
|
+
|
|
79
|
+
def _notice(self, message: str, tone: str = "") -> None:
|
|
80
|
+
widget = self.query_one("#profile-notice", Line)
|
|
81
|
+
widget.update(Text(message, style=tone or theme.FAINT))
|
|
82
|
+
animation.reveal(widget)
|
|
83
|
+
|
|
84
|
+
# ─── actions ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def action_save(self) -> None:
|
|
87
|
+
from holt import profile as profile_mod
|
|
88
|
+
|
|
89
|
+
values = {
|
|
90
|
+
name: self.query_one(f"#profile-{name}", Input).value.strip()
|
|
91
|
+
for name, _label, _note in FIELDS
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
days_text = values["days"]
|
|
95
|
+
if days_text and not days_text.isdigit():
|
|
96
|
+
self._notice(f"“{days_text}” is not a number of days.", theme.DROP)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
built = profile_mod.Profile(
|
|
100
|
+
languages=_split(values["languages"]),
|
|
101
|
+
topics=_split(values["topics"]),
|
|
102
|
+
contributions=_split(values["contributions"]),
|
|
103
|
+
days=int(days_text) if days_text else profile_mod.DEFAULT_CONTRIBUTOR_DAYS,
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
path = profile_mod.save(built)
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
self._notice(f"Could not write the profile: {exc}", theme.DROP)
|
|
109
|
+
return
|
|
110
|
+
self._notice(f"Saved to {path}")
|
|
111
|
+
|
|
112
|
+
def action_home(self) -> None:
|
|
113
|
+
self.app.go_home()
|
|
114
|
+
|
|
115
|
+
def action_quit(self) -> None:
|
|
116
|
+
self.app.exit()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _value(stored, name: str) -> str:
|
|
120
|
+
if stored is None:
|
|
121
|
+
return ""
|
|
122
|
+
value = getattr(stored, name, None)
|
|
123
|
+
if isinstance(value, list):
|
|
124
|
+
return ", ".join(value)
|
|
125
|
+
return "" if value is None else str(value)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _split(text: str) -> list[str]:
|
|
129
|
+
return [part.strip() for part in text.split(",") if part.strip()]
|