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/__init__.py +8 -0
- nightshift/__main__.py +4 -0
- nightshift/adapters/__init__.py +61 -0
- nightshift/adapters/_process.py +219 -0
- nightshift/adapters/base.py +171 -0
- nightshift/adapters/claude_code.py +372 -0
- nightshift/adapters/codex.py +360 -0
- nightshift/adapters/copilot.py +51 -0
- nightshift/budget.py +150 -0
- nightshift/cli.py +602 -0
- nightshift/config.py +447 -0
- nightshift/cron.py +96 -0
- nightshift/events.py +293 -0
- nightshift/lock.py +197 -0
- nightshift/prompts/code_review.md +24 -0
- nightshift/prompts/dead_links.md +21 -0
- nightshift/prompts/deps_audit.md +23 -0
- nightshift/prompts/docs_drift.md +21 -0
- nightshift/prompts/security_audit.md +23 -0
- nightshift/prompts.py +66 -0
- nightshift/queue.py +92 -0
- nightshift/report.py +394 -0
- nightshift/scheduler.py +425 -0
- nightshift/sessions.py +62 -0
- nightshift/store.py +48 -0
- nightshift_cli-0.1.0.dist-info/METADATA +430 -0
- nightshift_cli-0.1.0.dist-info/RECORD +30 -0
- nightshift_cli-0.1.0.dist-info/WHEEL +4 -0
- nightshift_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nightshift_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|