nightshift-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.
nightshift/queue.py ADDED
@@ -0,0 +1,92 @@
1
+ """Persistent round-robin over ``(project, task)`` pairs.
2
+
3
+ Position is stored as the *last pair served* rather than an index, so that
4
+ editing the config — adding a project, reordering tasks — degrades gracefully
5
+ instead of silently skipping work.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from nightshift.config import state_dir
13
+ from nightshift.store import read_json, write_json
14
+
15
+ Pair = tuple[str, str]
16
+
17
+
18
+ def queue_path() -> Path:
19
+ return state_dir() / "queue.json"
20
+
21
+
22
+ class Queue:
23
+ def __init__(self, path: Path | None = None):
24
+ self.path = path or queue_path()
25
+ self._last: Pair | None = self._load()
26
+
27
+ def _load(self) -> Pair | None:
28
+ raw = read_json(self.path, default={})
29
+ if not isinstance(raw, dict):
30
+ return None
31
+ last = raw.get("last")
32
+ if (
33
+ isinstance(last, list)
34
+ and len(last) == 2
35
+ and all(isinstance(x, str) for x in last)
36
+ ):
37
+ return (last[0], last[1])
38
+ return None
39
+
40
+ @property
41
+ def last(self) -> Pair | None:
42
+ return self._last
43
+
44
+ def peek(self, pairs: list[Pair]) -> Pair | None:
45
+ """The pair that :meth:`pop` would return, without advancing."""
46
+ if not pairs:
47
+ return None
48
+ if self._last is None or self._last not in pairs:
49
+ # Unknown position (first run, or the last pair was removed from
50
+ # config) — restart from the top rather than guessing.
51
+ return pairs[0]
52
+ return pairs[(pairs.index(self._last) + 1) % len(pairs)]
53
+
54
+ def take(self, pair: Pair) -> None:
55
+ """Record ``pair`` as served, wherever it sits in the rotation.
56
+
57
+ For when the pair the rotation offered could not run — a project pinned
58
+ to a provider that is out of budget — and a later one was served in its
59
+ place. The passed-over pair is not held back for next time; it simply
60
+ comes round again. Holding it would let one unavailable provider wedge
61
+ the rotation, which is the same starvation :meth:`pop` refuses.
62
+ """
63
+ self._last = pair
64
+ write_json(self.path, {"last": list(pair)})
65
+
66
+ def pop(self, pairs: list[Pair]) -> Pair | None:
67
+ """Take the next pair and persist the new position.
68
+
69
+ The position advances even when the run later fails: a project that
70
+ always errors must not wedge the rotation and starve the others.
71
+ """
72
+ pair = self.peek(pairs)
73
+ if pair is None:
74
+ return None
75
+ self.take(pair)
76
+ return pair
77
+
78
+ def rotation(self, pairs: list[Pair]) -> list[Pair]:
79
+ """``pairs`` reordered to start where :meth:`peek` points.
80
+
81
+ Every pair, exactly once, in the order the rotation would offer them —
82
+ so a caller can walk forward looking for one it is able to run.
83
+ """
84
+ start = self.peek(pairs)
85
+ if start is None:
86
+ return []
87
+ i = pairs.index(start)
88
+ return pairs[i:] + pairs[:i]
89
+
90
+ def reset(self) -> None:
91
+ self._last = None
92
+ write_json(self.path, {})
nightshift/report.py ADDED
@@ -0,0 +1,394 @@
1
+ """Persisting run results and rendering the morning digest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import date, datetime
9
+ from pathlib import Path
10
+
11
+ from nightshift.adapters.base import RunResult
12
+ from nightshift.budget import Ledger
13
+ from nightshift.config import Config
14
+ from nightshift.store import read_json, write_json
15
+
16
+ NO_FINDINGS = "No findings."
17
+
18
+ #: Stands in for project/task on a run that never picked any work — currently
19
+ #: only budget skips. Not a real project, so it must not be counted as one.
20
+ PLACEHOLDER = "—"
21
+
22
+ SEVERITY_RANK = {"HIGH": 0, "MED": 1, "LOW": 2}
23
+ SEVERITY_EMOJI = {"HIGH": "🔴", "MED": "🟠", "LOW": "🟡"}
24
+
25
+ #: Normalises whatever the model actually wrote into our three levels.
26
+ _SEVERITY_ALIASES = {
27
+ "CRITICAL": "HIGH",
28
+ "CRIT": "HIGH",
29
+ "SEV1": "HIGH",
30
+ "HIGH": "HIGH",
31
+ "MEDIUM": "MED",
32
+ "MED": "MED",
33
+ "MODERATE": "MED",
34
+ "WARN": "MED",
35
+ "WARNING": "MED",
36
+ "LOW": "LOW",
37
+ "MINOR": "LOW",
38
+ "INFO": "LOW",
39
+ "NIT": "LOW",
40
+ }
41
+
42
+ _LIST_ITEM = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(?P<body>.+?)\s*$")
43
+ _SEVERITY_PREFIX = re.compile(
44
+ r"^(?:[\[(]\s*)?(?:\*\*|__|`)?\s*"
45
+ r"(?P<sev>[A-Za-z]+)"
46
+ r"\s*(?:\*\*|__|`)?(?:\s*[\])])?"
47
+ r"\s*(?:[:\-–—]\s*|\s+)",
48
+ )
49
+ _FILE_REF = re.compile(r"(?P<ref>(?:[\w.@+-]+/)*[\w.@+-]+\.[A-Za-z0-9]+(?::\d+)?)")
50
+ _LEADING_EMOJI = re.compile(r"^[\U0001F300-\U0001FAFF☀-➿️\s]+")
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Finding:
55
+ severity: str
56
+ text: str
57
+ project: str
58
+ task: str
59
+ provider: str
60
+ ref: str = ""
61
+
62
+ @property
63
+ def rank(self) -> int:
64
+ return SEVERITY_RANK.get(self.severity, 2)
65
+
66
+ @property
67
+ def emoji(self) -> str:
68
+ return SEVERITY_EMOJI.get(self.severity, "🟡")
69
+
70
+
71
+ def normalise_severity(token: str) -> str | None:
72
+ return _SEVERITY_ALIASES.get(token.strip().upper().strip(":-—–"))
73
+
74
+
75
+ def parse_finding_line(
76
+ line: str,
77
+ project: str = "",
78
+ task: str = "",
79
+ provider: str = "",
80
+ ) -> Finding | None:
81
+ """One markdown list item as a :class:`Finding`, or ``None``.
82
+
83
+ Split out of :func:`parse_findings` so a live view can classify a line the
84
+ instant it arrives and still agree with the digest — one severity model,
85
+ not two that drift.
86
+ """
87
+ item = _LIST_ITEM.match(line)
88
+ if not item:
89
+ return None
90
+ body = _LEADING_EMOJI.sub("", item.group("body")).strip()
91
+ if not body:
92
+ return None
93
+
94
+ severity = "LOW"
95
+ match = _SEVERITY_PREFIX.match(body)
96
+ if match:
97
+ candidate = normalise_severity(match.group("sev"))
98
+ if candidate:
99
+ severity = candidate
100
+ body = body[match.end():].strip()
101
+
102
+ ref_match = _FILE_REF.search(body)
103
+ ref = ref_match.group("ref") if ref_match else ""
104
+ body = _LEADING_EMOJI.sub("", body).strip(" -–—:")
105
+ # The documented format leads with the ref, and we render it separately
106
+ # — keeping it inline too would print the path twice on every line. The
107
+ # model almost always writes it as `code`, so match through the markup
108
+ # rather than only against a bare path.
109
+ if ref:
110
+ lead = re.match(
111
+ r"^[`*_]*" + re.escape(ref) + r"[`*_]*\s*[-–—:]*\s*",
112
+ body,
113
+ )
114
+ if lead:
115
+ body = body[lead.end() :].strip(" -–—:")
116
+ if not body:
117
+ # A finding that was *only* a file ref still deserves to be seen.
118
+ body = ref
119
+ if not body:
120
+ return None
121
+ return Finding(
122
+ severity=severity,
123
+ text=body,
124
+ project=project,
125
+ task=task,
126
+ provider=provider,
127
+ ref=ref,
128
+ )
129
+
130
+
131
+ def parse_findings(result: RunResult) -> list[Finding]:
132
+ """Pull structured findings out of an adapter's markdown.
133
+
134
+ Deliberately forgiving: models drift from the requested format, and a
135
+ finding we can't classify is still worth showing, so anything unlabelled
136
+ lands at LOW rather than being dropped.
137
+ """
138
+ text = (result.findings_md or "").strip()
139
+ if not text or text.lower().startswith(NO_FINDINGS.lower()):
140
+ return []
141
+
142
+ findings: list[Finding] = []
143
+ for line in text.splitlines():
144
+ finding = parse_finding_line(line, result.project, result.task, result.provider)
145
+ if finding is not None:
146
+ findings.append(finding)
147
+ return findings
148
+
149
+
150
+ def day_dir(cfg: Config, on: date) -> Path:
151
+ return cfg.digest_dir / on.isoformat()
152
+
153
+
154
+ def digest_path(cfg: Config, on: date) -> Path:
155
+ return cfg.digest_dir / f"DIGEST-{on.isoformat()}.md"
156
+
157
+
158
+ def _slug(value: str) -> str:
159
+ return re.sub(r"[^A-Za-z0-9_-]+", "-", value).strip("-") or "unknown"
160
+
161
+
162
+ def _unique_stem(directory: Path, stem: str) -> str:
163
+ """Disambiguate a stem that already exists.
164
+
165
+ Filenames are second-granular, so two runs of the same (project, task)
166
+ inside one second would collide and the later one would overwrite the
167
+ earlier. Hourly cron makes that unlikely, but `run --now` in a loop hits it
168
+ immediately — and a silently discarded run is precisely what the digest
169
+ promises never happens.
170
+ """
171
+ if not (directory / f"{stem}.json").exists():
172
+ return stem
173
+ for n in range(2, 1000):
174
+ candidate = f"{stem}-{n}"
175
+ if not (directory / f"{candidate}.json").exists():
176
+ return candidate
177
+ return f"{stem}-{os.getpid()}"
178
+
179
+
180
+ def store_result(cfg: Config, result: RunResult) -> Path:
181
+ """Write the full RunResult as JSON, plus its findings as markdown."""
182
+ directory = day_dir(cfg, result.started_at.date())
183
+ directory.mkdir(parents=True, exist_ok=True)
184
+ when = result.started_at.strftime("%H%M%S")
185
+ if result.project == PLACEHOLDER:
186
+ # No work was picked, so "unknown-unknown-030000" would be the filename.
187
+ stem = f"skipped-{_slug(result.provider)}-{when}"
188
+ else:
189
+ stem = f"{_slug(result.project)}-{_slug(result.task)}-{when}"
190
+ if result.attempt > 1:
191
+ stem += f"-retry{result.attempt - 1}"
192
+ stem = _unique_stem(directory, stem)
193
+
194
+ json_path = directory / f"{stem}.json"
195
+ write_json(json_path, result.to_dict())
196
+
197
+ md_path = directory / f"{stem}.md"
198
+ header = (
199
+ f"# {result.project} · {result.task}\n\n"
200
+ f"- provider: `{result.provider}`\n"
201
+ f"- status: `{result.status}`\n"
202
+ f"- started: {result.started_at.isoformat(timespec='seconds')}\n"
203
+ f"- duration: {format_duration(result)}\n"
204
+ )
205
+ if result.detail:
206
+ header += f"- detail: {result.detail}\n"
207
+ body = (result.findings_md or "").strip() or NO_FINDINGS
208
+ md_path.write_text(f"{header}\n{body}\n", encoding="utf-8")
209
+ return json_path
210
+
211
+
212
+ def load_results(cfg: Config, on: date) -> list[RunResult]:
213
+ """Every recorded result for a day, oldest first."""
214
+ directory = day_dir(cfg, on)
215
+ if not directory.is_dir():
216
+ return []
217
+ results: list[RunResult] = []
218
+ for path in sorted(directory.glob("*.json")):
219
+ raw = read_json(path, default=None)
220
+ if not isinstance(raw, dict):
221
+ continue
222
+ try:
223
+ results.append(RunResult.from_dict(raw))
224
+ except (KeyError, ValueError, TypeError):
225
+ # A single unreadable result must not sink the whole digest.
226
+ continue
227
+ results.sort(key=lambda r: r.started_at)
228
+ return results
229
+
230
+
231
+ def format_duration(result: RunResult) -> str:
232
+ if result.status == "skipped":
233
+ return "—"
234
+ total = int(round(result.duration_s))
235
+ if total < 60:
236
+ return f"{total}s"
237
+ return f"{total // 60}m{total % 60:02d}s"
238
+
239
+
240
+ def budget_bar(used: int, cap: int, width: int = 12) -> str:
241
+ """``▓▓▓░░░`` — one cell per run when the cap is small, scaled when it isn't."""
242
+ if cap <= 0:
243
+ return ""
244
+ cells = min(cap, width)
245
+ filled = min(cells, round(used / cap * cells)) if cap else 0
246
+ if used > 0 and filled == 0:
247
+ filled = 1 # never render spent budget as an empty bar
248
+ return "▓" * filled + "░" * (cells - filled)
249
+
250
+
251
+ def _budget_lines(cfg: Config, ledger: Ledger, on: date) -> list[str]:
252
+ when = datetime.combine(on, datetime.min.time())
253
+ lines = []
254
+ for provider in cfg.enabled_providers():
255
+ usage = ledger.usage(provider.name, provider.budget, when)
256
+ bar = budget_bar(usage.day, usage.max_day)
257
+ lines.append(
258
+ f"- `{provider.name}` {bar} "
259
+ f"{usage.day}/{usage.max_day} today · {usage.week}/{usage.max_week} week"
260
+ )
261
+ return lines
262
+
263
+
264
+ def dedupe(findings: list[Finding]) -> list[Finding]:
265
+ """Collapse findings that are literally the same finding.
266
+
267
+ Two runs of the same task on the same day surface the same problems, and
268
+ three copies of one issue would push real findings out of the top five.
269
+ The task stays in the key: the same file flagged by ``security_audit`` and
270
+ by ``code_review`` is two different observations and both are worth seeing.
271
+ """
272
+ seen: set[tuple[str, str, str, str, str]] = set()
273
+ unique: list[Finding] = []
274
+ for f in findings:
275
+ key = (f.severity, f.project, f.task, f.ref, f.text)
276
+ if key in seen:
277
+ continue
278
+ seen.add(key)
279
+ unique.append(f)
280
+ return unique
281
+
282
+
283
+ def _highlights(findings: list[Finding], limit: int = 5) -> list[Finding]:
284
+ ordered = sorted(findings, key=lambda f: (f.rank, f.project, f.task))
285
+ return ordered[:limit]
286
+
287
+
288
+ def _fmt_finding(f: Finding, *, with_project: bool) -> str:
289
+ context = f"{f.project} · {f.task}" if with_project else f.task
290
+ ref = f" · `{f.ref}`" if f.ref else ""
291
+ return f"- {f.emoji} {f.text} — _{context}_{ref}"
292
+
293
+
294
+ def render_digest(
295
+ cfg: Config,
296
+ on: date,
297
+ ledger: Ledger | None = None,
298
+ results: list[RunResult] | None = None,
299
+ generated_at: datetime | None = None,
300
+ ) -> str:
301
+ """Render ``DIGEST-YYYY-MM-DD.md`` for a day."""
302
+ ledger = ledger if ledger is not None else Ledger()
303
+ results = load_results(cfg, on) if results is None else results
304
+ generated_at = generated_at or datetime.now()
305
+
306
+ findings: list[Finding] = []
307
+ for r in results:
308
+ if r.status == "ok":
309
+ findings.extend(parse_findings(r))
310
+ findings = dedupe(findings)
311
+
312
+ projects_seen = sorted({r.project for r in results if r.project != PLACEHOLDER})
313
+ out: list[str] = []
314
+ out.append("# Nightshift · morning digest")
315
+ out.append("")
316
+ out.append(
317
+ f"{on.strftime('%a %b')} {on.day}, {on.year} · generated "
318
+ f"{generated_at.strftime('%H:%M')} local · "
319
+ f"{len(projects_seen)} project{'s' if len(projects_seen) != 1 else ''} · "
320
+ f"{len(results)} run{'s' if len(results) != 1 else ''}"
321
+ )
322
+ out.append("")
323
+
324
+ out.append("## Budget remaining")
325
+ out.append("")
326
+ out.extend(_budget_lines(cfg, ledger, on) or ["- _no providers enabled_"])
327
+ out.append("")
328
+
329
+ if not results:
330
+ out.append("## Highlights")
331
+ out.append("")
332
+ out.append("Nothing ran today — no runs were recorded.")
333
+ out.append("")
334
+ return "\n".join(out).rstrip() + "\n"
335
+
336
+ out.append("## Highlights")
337
+ out.append("")
338
+ highlights = _highlights(findings)
339
+ if highlights:
340
+ out.extend(_fmt_finding(f, with_project=True) for f in highlights)
341
+ elif any(r.status == "ok" for r in results):
342
+ out.append("No findings — every run came back clean.")
343
+ else:
344
+ out.append("No findings — no run completed successfully today.")
345
+ out.append("")
346
+
347
+ by_project: dict[str, list[Finding]] = {}
348
+ for f in findings:
349
+ by_project.setdefault(f.project, []).append(f)
350
+
351
+ if by_project:
352
+ out.append("## By project")
353
+ out.append("")
354
+ for project in sorted(by_project):
355
+ items = sorted(by_project[project], key=lambda f: (f.rank, f.task))
356
+ out.append(f"### {project}")
357
+ out.append("")
358
+ out.append(f"{len(items)} finding{'s' if len(items) != 1 else ''}")
359
+ out.append("")
360
+ out.extend(_fmt_finding(f, with_project=False) for f in items)
361
+ out.append("")
362
+
363
+ out.append("## Run log")
364
+ out.append("")
365
+ out.append("| project | task | provider | status | dur | time |")
366
+ out.append("| --- | --- | --- | --- | --- | --- |")
367
+ for r in sorted(results, key=lambda r: (r.started_at, r.project)):
368
+ status = r.status
369
+ if r.detail and r.status != "ok":
370
+ status = f"{r.status} · {r.detail}"
371
+ out.append(
372
+ f"| {r.project} | {r.task} | {r.provider} | {status} | "
373
+ f"{format_duration(r)} | {r.started_at.strftime('%H:%M')} |"
374
+ )
375
+ out.append("")
376
+ out.append(
377
+ "_Skipped and failed runs stay in the log so nothing silently "
378
+ "disappears._"
379
+ )
380
+ out.append("")
381
+ return "\n".join(out).rstrip() + "\n"
382
+
383
+
384
+ def write_digest(
385
+ cfg: Config,
386
+ on: date,
387
+ ledger: Ledger | None = None,
388
+ generated_at: datetime | None = None,
389
+ ) -> Path:
390
+ text = render_digest(cfg, on, ledger=ledger, generated_at=generated_at)
391
+ path = digest_path(cfg, on)
392
+ path.parent.mkdir(parents=True, exist_ok=True)
393
+ path.write_text(text, encoding="utf-8")
394
+ return path