claude-dev-env 1.57.1 → 1.58.0
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.
- package/bin/install.mjs +217 -27
- package/bin/install.test.mjs +344 -1
- package/hooks/blocking/intent_only_ending_blocker.py +155 -0
- package/hooks/blocking/session_handoff_blocker.py +190 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
- package/hooks/blocking/test_session_handoff_blocker.py +312 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/messages.py +4 -0
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
- package/hooks/workflow/auto_formatter.py +26 -1
- package/hooks/workflow/test_auto_formatter.py +134 -0
- package/package.json +1 -1
- package/rules/conservative-action.md +1 -0
- package/rules/long-horizon-autonomy.md +43 -0
- package/skills/autoconverge/SKILL.md +56 -6
- package/skills/autoconverge/reference/closing-report.md +44 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
- package/skills/autoconverge/workflow/converge.mjs +12 -14
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
- package/skills/autoconverge/workflow/render_report.py +903 -0
- package/skills/autoconverge/workflow/test_render_report.py +484 -0
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
"""Render a convergence insights HTML report from an autoconverge workflow journal."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
from autoconverge_report_constants.render_report_constants import (
|
|
14
|
+
BAR_COLOR_ROUND,
|
|
15
|
+
BAR_COLOR_SEVERITY_CRITICAL,
|
|
16
|
+
BAR_COLOR_SEVERITY_MINOR,
|
|
17
|
+
BAR_COLOR_TESTS,
|
|
18
|
+
BAR_COLOR_THEME,
|
|
19
|
+
BAR_FILL_MAX_PERCENT,
|
|
20
|
+
GITHUB_PR_URL_TEMPLATE,
|
|
21
|
+
HTML_DOCTYPE,
|
|
22
|
+
HTML_HEAD_TEMPLATE,
|
|
23
|
+
HTML_STYLE_BLOCK,
|
|
24
|
+
JOURNAL_SIBLING_SUBAGENTS,
|
|
25
|
+
JOURNAL_SIBLING_WORKFLOWS,
|
|
26
|
+
LABEL_COPILOT_GATE,
|
|
27
|
+
LABEL_PREFIX_FIX,
|
|
28
|
+
LABEL_PREFIX_LENS,
|
|
29
|
+
LABEL_RESOLVE_HEAD,
|
|
30
|
+
SEVERITY_BADGE_CLASS_BY_LEVEL,
|
|
31
|
+
SEVERITY_CRITICAL_BUCKET,
|
|
32
|
+
SEVERITY_CRITICAL_LEVELS,
|
|
33
|
+
SEVERITY_MINOR_BUCKET,
|
|
34
|
+
STRUCTURED_OUTPUT_TOOL_NAME,
|
|
35
|
+
TEST_DEFINITION_PATTERN,
|
|
36
|
+
TEST_PATH_GLOBS,
|
|
37
|
+
THEME_FALLBACK,
|
|
38
|
+
THEME_PATH_SEGMENT_COUNT,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class RawFinding:
|
|
44
|
+
"""A single finding from a lens or copilot-gate agent result, tagged with round context."""
|
|
45
|
+
|
|
46
|
+
file: str
|
|
47
|
+
line: int
|
|
48
|
+
severity: str
|
|
49
|
+
title: str
|
|
50
|
+
detail: str
|
|
51
|
+
round_number: int
|
|
52
|
+
sha: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class FixRecord:
|
|
57
|
+
"""The structured result of a fix agent, with round and base-sha context attached."""
|
|
58
|
+
|
|
59
|
+
new_sha: str
|
|
60
|
+
pushed: bool
|
|
61
|
+
resolved_without_commit: bool
|
|
62
|
+
round_number: int
|
|
63
|
+
base_sha: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class PrMetadata:
|
|
68
|
+
"""Owner, repo, number, and pre-built URL for the PR being reported on."""
|
|
69
|
+
|
|
70
|
+
owner: str
|
|
71
|
+
repo: str
|
|
72
|
+
number: int
|
|
73
|
+
url: str
|
|
74
|
+
final_sha: str
|
|
75
|
+
round_count: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class RunData:
|
|
80
|
+
"""Aggregated metrics parsed from a workflow journal and agent transcripts."""
|
|
81
|
+
|
|
82
|
+
generated_date: str
|
|
83
|
+
total_finding_count: int
|
|
84
|
+
critical_finding_count: int
|
|
85
|
+
minor_finding_count: int
|
|
86
|
+
fix_commit_count: int
|
|
87
|
+
tests_added_by_round: dict[int, int]
|
|
88
|
+
finding_count_by_round: dict[int, int]
|
|
89
|
+
finding_count_by_theme: dict[str, int]
|
|
90
|
+
all_critical_findings: list[RawFinding]
|
|
91
|
+
all_minor_findings: list[RawFinding]
|
|
92
|
+
fix_by_round: dict[int, FixRecord]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_agents_dir(journal_path: Path) -> Path:
|
|
96
|
+
"""Return the directory containing per-agent transcript files for this run.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
journal_path: Absolute path to the wf_<runId>.json journal file.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Path to the subagents/workflows/<runId>/ directory.
|
|
103
|
+
"""
|
|
104
|
+
run_id = journal_path.stem
|
|
105
|
+
return (
|
|
106
|
+
journal_path.parent.parent
|
|
107
|
+
/ JOURNAL_SIBLING_SUBAGENTS
|
|
108
|
+
/ JOURNAL_SIBLING_WORKFLOWS
|
|
109
|
+
/ run_id
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _extract_structured_output(transcript_path: Path) -> dict | None:
|
|
114
|
+
"""Return the last StructuredOutput tool input from an agent transcript.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
transcript_path: Path to an agent-<id>.jsonl file.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The input dict of the last StructuredOutput tool_use, or None when absent.
|
|
121
|
+
"""
|
|
122
|
+
last_input: dict | None = None
|
|
123
|
+
try:
|
|
124
|
+
with transcript_path.open(encoding="utf-8") as transcript_file:
|
|
125
|
+
for each_line in transcript_file:
|
|
126
|
+
last_input = _last_structured_input_in_line(each_line, last_input)
|
|
127
|
+
except OSError:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
return last_input
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _last_structured_input_in_line(
|
|
134
|
+
transcript_line: str, prior_input: dict | None
|
|
135
|
+
) -> dict | None:
|
|
136
|
+
"""Return the StructuredOutput input on a transcript line, else the prior input.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
transcript_line: One raw JSON line from an agent transcript.
|
|
140
|
+
prior_input: The last StructuredOutput input seen before this line.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The input dict of the last StructuredOutput tool_use on this line, or
|
|
144
|
+
prior_input when the line carries none.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
parsed = json.loads(transcript_line)
|
|
148
|
+
except (ValueError, json.JSONDecodeError):
|
|
149
|
+
return prior_input
|
|
150
|
+
|
|
151
|
+
message = parsed.get("message") if isinstance(parsed, dict) else None
|
|
152
|
+
content_list = message.get("content") if isinstance(message, dict) else None
|
|
153
|
+
if not isinstance(content_list, list):
|
|
154
|
+
return prior_input
|
|
155
|
+
|
|
156
|
+
latest_input = prior_input
|
|
157
|
+
for each_block in content_list:
|
|
158
|
+
if not isinstance(each_block, dict):
|
|
159
|
+
continue
|
|
160
|
+
if (
|
|
161
|
+
each_block.get("type") == "tool_use"
|
|
162
|
+
and each_block.get("name") == STRUCTURED_OUTPUT_TOOL_NAME
|
|
163
|
+
and isinstance(each_block.get("input"), dict)
|
|
164
|
+
):
|
|
165
|
+
latest_input = each_block["input"]
|
|
166
|
+
|
|
167
|
+
return latest_input
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _derive_theme(file_path: str) -> str:
|
|
171
|
+
"""Return the first two slash-separated segments of a file path.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
file_path: A relative file path string from a finding.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
A theme string like 'src/exports', the whole path when fewer than two
|
|
178
|
+
segments exist, or THEME_FALLBACK when the path is empty.
|
|
179
|
+
"""
|
|
180
|
+
if not file_path:
|
|
181
|
+
return THEME_FALLBACK
|
|
182
|
+
segments = file_path.split("/")
|
|
183
|
+
return "/".join(segments[:THEME_PATH_SEGMENT_COUNT])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _count_tests_added(base_sha: str, new_sha: str, repo_path: Path) -> int:
|
|
187
|
+
"""Count new test definitions introduced between two commits.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
base_sha: The commit sha the round reviewed.
|
|
191
|
+
new_sha: The sha produced by the fix commit.
|
|
192
|
+
repo_path: Path to the git repository root.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Number of newly added test-function definitions; 0 on any git error.
|
|
196
|
+
"""
|
|
197
|
+
diff_command = [
|
|
198
|
+
"git",
|
|
199
|
+
"-C",
|
|
200
|
+
str(repo_path),
|
|
201
|
+
"diff",
|
|
202
|
+
f"{base_sha}..{new_sha}",
|
|
203
|
+
"--",
|
|
204
|
+
*TEST_PATH_GLOBS,
|
|
205
|
+
]
|
|
206
|
+
try:
|
|
207
|
+
completed = subprocess.run(
|
|
208
|
+
diff_command,
|
|
209
|
+
capture_output=True,
|
|
210
|
+
text=True,
|
|
211
|
+
check=True,
|
|
212
|
+
)
|
|
213
|
+
except (subprocess.SubprocessError, OSError):
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
diff_text = completed.stdout
|
|
217
|
+
test_def_pattern = re.compile(TEST_DEFINITION_PATTERN, re.MULTILINE)
|
|
218
|
+
|
|
219
|
+
return len(test_def_pattern.findall(diff_text))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _build_dedup_key(file_path: str, line: int, title: str) -> tuple[str, int, str]:
|
|
223
|
+
"""Return a deduplication key for a finding.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
file_path: The file field from the finding.
|
|
227
|
+
line: The line number from the finding.
|
|
228
|
+
title: The title field from the finding.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
A tuple of (file, line, lowercased_title).
|
|
232
|
+
"""
|
|
233
|
+
return (file_path, line, title.lower())
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parse_finding_from_dict(raw: dict, round_number: int, sha: str) -> RawFinding:
|
|
237
|
+
"""Construct a RawFinding from a raw agent result dict.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
raw: The raw finding dict from the agent result.
|
|
241
|
+
round_number: The round this finding belongs to.
|
|
242
|
+
sha: The commit sha this finding was discovered on.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
A RawFinding dataclass instance.
|
|
246
|
+
"""
|
|
247
|
+
return RawFinding(
|
|
248
|
+
file=raw.get("file", ""),
|
|
249
|
+
line=raw.get("line", 0),
|
|
250
|
+
severity=raw.get("severity", "P2"),
|
|
251
|
+
title=raw.get("title", ""),
|
|
252
|
+
detail=raw.get("detail", ""),
|
|
253
|
+
round_number=round_number,
|
|
254
|
+
sha=sha,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parse_fix_record(
|
|
259
|
+
agent_result: dict, round_number: int, base_sha: str
|
|
260
|
+
) -> FixRecord:
|
|
261
|
+
"""Construct a FixRecord from a fix agent's structured output.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
agent_result: The structured output dict from the fix agent.
|
|
265
|
+
round_number: The round this fix belongs to.
|
|
266
|
+
base_sha: The HEAD sha the round reviewed before fixing.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
A FixRecord dataclass instance.
|
|
270
|
+
"""
|
|
271
|
+
return FixRecord(
|
|
272
|
+
new_sha=agent_result.get("newSha", ""),
|
|
273
|
+
pushed=bool(agent_result.get("pushed", False)),
|
|
274
|
+
resolved_without_commit=bool(agent_result.get("resolvedWithoutCommit", False)),
|
|
275
|
+
round_number=round_number,
|
|
276
|
+
base_sha=base_sha,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _parse_progress_entries(
|
|
281
|
+
progress_entries: list[dict],
|
|
282
|
+
agents_dir: Path,
|
|
283
|
+
) -> tuple[list[RawFinding], dict[int, FixRecord]]:
|
|
284
|
+
"""Walk workflowProgress in order and collect findings and fix results by round.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
progress_entries: The workflowProgress list from the journal.
|
|
288
|
+
agents_dir: Directory containing per-agent .jsonl transcript files.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
A tuple of (all_raw_findings, fix_by_round).
|
|
292
|
+
"""
|
|
293
|
+
all_findings: list[RawFinding] = []
|
|
294
|
+
fix_by_round: dict[int, FixRecord] = {}
|
|
295
|
+
current_round = 0
|
|
296
|
+
current_round_base_sha = ""
|
|
297
|
+
|
|
298
|
+
for each_entry in progress_entries:
|
|
299
|
+
label: str = each_entry.get("label", "")
|
|
300
|
+
agent_id: str | None = each_entry.get("agentId")
|
|
301
|
+
|
|
302
|
+
if label == LABEL_RESOLVE_HEAD:
|
|
303
|
+
current_round += 1
|
|
304
|
+
current_round_base_sha = ""
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
if agent_id is None:
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
transcript_path = agents_dir / f"agent-{agent_id}.jsonl"
|
|
311
|
+
agent_result = _extract_structured_output(transcript_path)
|
|
312
|
+
if agent_result is None:
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
is_lens = label.startswith(LABEL_PREFIX_LENS)
|
|
316
|
+
is_copilot = label == LABEL_COPILOT_GATE
|
|
317
|
+
is_fix = label.startswith(LABEL_PREFIX_FIX)
|
|
318
|
+
|
|
319
|
+
if is_lens or is_copilot:
|
|
320
|
+
sha = agent_result.get("sha", "")
|
|
321
|
+
if not current_round_base_sha and sha:
|
|
322
|
+
current_round_base_sha = sha
|
|
323
|
+
raw_findings: list[dict] = agent_result.get("findings", [])
|
|
324
|
+
for each_raw in raw_findings:
|
|
325
|
+
all_findings.append(
|
|
326
|
+
_parse_finding_from_dict(each_raw, current_round, sha)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if is_fix:
|
|
330
|
+
fix_by_round[current_round] = _parse_fix_record(
|
|
331
|
+
agent_result, current_round, current_round_base_sha
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return all_findings, fix_by_round
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _dedup_findings(all_findings: list[RawFinding]) -> list[RawFinding]:
|
|
338
|
+
"""Deduplicate findings globally by (file, line, lower title), keeping earliest round.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
all_findings: All raw findings in discovery order.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
A list of distinct findings with the earliest occurrence retained.
|
|
345
|
+
"""
|
|
346
|
+
seen_keys: set[tuple[str, int, str]] = set()
|
|
347
|
+
distinct: list[RawFinding] = []
|
|
348
|
+
for each_finding in all_findings:
|
|
349
|
+
dedup_key = _build_dedup_key(
|
|
350
|
+
each_finding.file, each_finding.line, each_finding.title
|
|
351
|
+
)
|
|
352
|
+
if dedup_key not in seen_keys:
|
|
353
|
+
seen_keys.add(dedup_key)
|
|
354
|
+
distinct.append(each_finding)
|
|
355
|
+
return distinct
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def load_run_data(journal_path: Path, repo_path: Path) -> RunData:
|
|
359
|
+
"""Parse a workflow journal and its agent transcripts into aggregated metrics.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
journal_path: Path to the wf_<runId>.json journal file.
|
|
363
|
+
repo_path: Path to the git repository for counting tests added.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
A RunData instance with all counts and finding lists populated.
|
|
367
|
+
"""
|
|
368
|
+
journal = json.loads(journal_path.read_text(encoding="utf-8"))
|
|
369
|
+
timestamp: str = journal.get("timestamp", "")
|
|
370
|
+
generated_date = timestamp[:10] if len(timestamp) >= 10 else ""
|
|
371
|
+
|
|
372
|
+
progress_entries: list[dict] = journal.get("workflowProgress", [])
|
|
373
|
+
agents_dir = _resolve_agents_dir(journal_path)
|
|
374
|
+
|
|
375
|
+
all_raw_findings, fix_by_round = _parse_progress_entries(
|
|
376
|
+
progress_entries, agents_dir
|
|
377
|
+
)
|
|
378
|
+
distinct_findings = _dedup_findings(all_raw_findings)
|
|
379
|
+
|
|
380
|
+
all_critical_findings: list[RawFinding] = []
|
|
381
|
+
all_minor_findings: list[RawFinding] = []
|
|
382
|
+
finding_count_by_round: dict[int, int] = {}
|
|
383
|
+
finding_count_by_theme: dict[str, int] = {}
|
|
384
|
+
|
|
385
|
+
for each_finding in distinct_findings:
|
|
386
|
+
severity = each_finding.severity
|
|
387
|
+
round_number = each_finding.round_number
|
|
388
|
+
theme = _derive_theme(each_finding.file)
|
|
389
|
+
|
|
390
|
+
if severity in SEVERITY_CRITICAL_LEVELS:
|
|
391
|
+
all_critical_findings.append(each_finding)
|
|
392
|
+
else:
|
|
393
|
+
all_minor_findings.append(each_finding)
|
|
394
|
+
|
|
395
|
+
finding_count_by_round[round_number] = (
|
|
396
|
+
finding_count_by_round.get(round_number, 0) + 1
|
|
397
|
+
)
|
|
398
|
+
finding_count_by_theme[theme] = finding_count_by_theme.get(theme, 0) + 1
|
|
399
|
+
|
|
400
|
+
fix_commit_count = sum(1 for each_fix in fix_by_round.values() if each_fix.pushed)
|
|
401
|
+
|
|
402
|
+
tests_added_by_round: dict[int, int] = {}
|
|
403
|
+
for each_round_number, each_fix in fix_by_round.items():
|
|
404
|
+
if not each_fix.pushed:
|
|
405
|
+
tests_added_by_round[each_round_number] = 0
|
|
406
|
+
continue
|
|
407
|
+
tests_added_by_round[each_round_number] = _count_tests_added(
|
|
408
|
+
each_fix.base_sha,
|
|
409
|
+
each_fix.new_sha,
|
|
410
|
+
repo_path,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
return RunData(
|
|
414
|
+
generated_date=generated_date,
|
|
415
|
+
total_finding_count=len(distinct_findings),
|
|
416
|
+
critical_finding_count=len(all_critical_findings),
|
|
417
|
+
minor_finding_count=len(all_minor_findings),
|
|
418
|
+
fix_commit_count=fix_commit_count,
|
|
419
|
+
tests_added_by_round=tests_added_by_round,
|
|
420
|
+
finding_count_by_round=finding_count_by_round,
|
|
421
|
+
finding_count_by_theme=finding_count_by_theme,
|
|
422
|
+
all_critical_findings=all_critical_findings,
|
|
423
|
+
all_minor_findings=all_minor_findings,
|
|
424
|
+
fix_by_round=fix_by_round,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _render_bar_row(label: str, bar_value: int, max_value: int, color: str) -> str:
|
|
429
|
+
"""Return an HTML .bar-row element for a single chart bar.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
label: The text label displayed on the left.
|
|
433
|
+
bar_value: The numeric value for this bar.
|
|
434
|
+
max_value: The maximum value across all bars in this chart.
|
|
435
|
+
color: The CSS hex color for the bar fill.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
An HTML string for one .bar-row.
|
|
439
|
+
"""
|
|
440
|
+
fill_width = round(bar_value / max(max_value, 1) * BAR_FILL_MAX_PERCENT, 1)
|
|
441
|
+
escaped_label = html.escape(label)
|
|
442
|
+
return (
|
|
443
|
+
f'<div class="bar-row">'
|
|
444
|
+
f'<span class="bar-label">{escaped_label}</span>'
|
|
445
|
+
f'<div class="bar-track">'
|
|
446
|
+
f'<div class="bar-fill" style="width:{fill_width}%;background:{color};"></div>'
|
|
447
|
+
f"</div>"
|
|
448
|
+
f'<span class="bar-value">{bar_value}</span>'
|
|
449
|
+
f"</div>"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _render_chart_card(title: str, bar_rows_html: str) -> str:
|
|
454
|
+
"""Return an HTML .chart-card wrapping the given bar rows.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
title: The chart title displayed in uppercase.
|
|
458
|
+
bar_rows_html: Pre-rendered HTML for all bar rows.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
An HTML string for one .chart-card.
|
|
462
|
+
"""
|
|
463
|
+
escaped_title = html.escape(title)
|
|
464
|
+
return (
|
|
465
|
+
f'<div class="chart-card">'
|
|
466
|
+
f'<div class="chart-title">{escaped_title}</div>'
|
|
467
|
+
f"{bar_rows_html}"
|
|
468
|
+
f"</div>"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _render_severity_chart(critical_count: int, minor_count: int) -> str:
|
|
473
|
+
"""Return a severity breakdown chart card.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
critical_count: Total critical (P0/P1) findings.
|
|
477
|
+
minor_count: Total minor (P2) findings.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
An HTML .chart-card string.
|
|
481
|
+
"""
|
|
482
|
+
max_count = max(critical_count, minor_count, 1)
|
|
483
|
+
rows = _render_bar_row(
|
|
484
|
+
SEVERITY_CRITICAL_BUCKET, critical_count, max_count, BAR_COLOR_SEVERITY_CRITICAL
|
|
485
|
+
) + _render_bar_row(
|
|
486
|
+
SEVERITY_MINOR_BUCKET, minor_count, max_count, BAR_COLOR_SEVERITY_MINOR
|
|
487
|
+
)
|
|
488
|
+
return _render_chart_card("Findings by severity", rows)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _render_round_findings_chart(
|
|
492
|
+
round_count: int, finding_count_by_round: dict[int, int]
|
|
493
|
+
) -> str:
|
|
494
|
+
"""Return a per-round finding count chart card.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
round_count: Total number of rounds in the run.
|
|
498
|
+
finding_count_by_round: Mapping of round number to distinct finding count.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
An HTML .chart-card string.
|
|
502
|
+
"""
|
|
503
|
+
all_counts = [finding_count_by_round.get(r, 0) for r in range(1, round_count + 1)]
|
|
504
|
+
max_count = max(all_counts + [1])
|
|
505
|
+
rows = "".join(
|
|
506
|
+
_render_bar_row(
|
|
507
|
+
f"Round {r}", finding_count_by_round.get(r, 0), max_count, BAR_COLOR_ROUND
|
|
508
|
+
)
|
|
509
|
+
for r in range(1, round_count + 1)
|
|
510
|
+
)
|
|
511
|
+
return _render_chart_card("Findings by round", rows)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _render_tests_chart(round_count: int, tests_added_by_round: dict[int, int]) -> str:
|
|
515
|
+
"""Return a per-round tests-added chart card.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
round_count: Total number of rounds in the run.
|
|
519
|
+
tests_added_by_round: Mapping of round number to tests added count.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
An HTML .chart-card string.
|
|
523
|
+
"""
|
|
524
|
+
all_counts = [tests_added_by_round.get(r, 0) for r in range(1, round_count + 1)]
|
|
525
|
+
max_count = max(all_counts + [1])
|
|
526
|
+
rows = "".join(
|
|
527
|
+
_render_bar_row(
|
|
528
|
+
f"Round {r}", tests_added_by_round.get(r, 0), max_count, BAR_COLOR_TESTS
|
|
529
|
+
)
|
|
530
|
+
for r in range(1, round_count + 1)
|
|
531
|
+
)
|
|
532
|
+
return _render_chart_card("Tests added per round", rows)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _render_theme_chart(finding_count_by_theme: dict[str, int]) -> str:
|
|
536
|
+
"""Return a findings-by-theme chart card.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
finding_count_by_theme: Mapping of theme string to distinct finding count.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
An HTML .chart-card string.
|
|
543
|
+
"""
|
|
544
|
+
sorted_themes = sorted(
|
|
545
|
+
finding_count_by_theme.items(), key=lambda pair: pair[1], reverse=True
|
|
546
|
+
)
|
|
547
|
+
max_count = max((each_count for _, each_count in sorted_themes), default=1)
|
|
548
|
+
rows = "".join(
|
|
549
|
+
_render_bar_row(each_theme, each_count, max_count, BAR_COLOR_THEME)
|
|
550
|
+
for each_theme, each_count in sorted_themes
|
|
551
|
+
)
|
|
552
|
+
return _render_chart_card("Findings by theme", rows)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _render_fix_block(finding: RawFinding, fix_by_round: dict[int, FixRecord]) -> str:
|
|
556
|
+
"""Return the green fix resolution sub-block for a finding card.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
finding: The raw finding being described.
|
|
560
|
+
fix_by_round: Mapping of round number to fix record.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
An HTML .bug-fix string describing how the finding was resolved.
|
|
564
|
+
"""
|
|
565
|
+
round_number = finding.round_number
|
|
566
|
+
fix_record = fix_by_round.get(round_number)
|
|
567
|
+
|
|
568
|
+
if fix_record is None:
|
|
569
|
+
return '<div class="bug-fix"><b>Fix:</b> resolved during convergence.</div>'
|
|
570
|
+
|
|
571
|
+
if fix_record.resolved_without_commit:
|
|
572
|
+
return (
|
|
573
|
+
f'<div class="bug-fix"><b>Fix:</b> already resolved at HEAD in round {round_number}; '
|
|
574
|
+
f"threads closed.</div>"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
if not fix_record.new_sha:
|
|
578
|
+
return '<div class="bug-fix"><b>Fix:</b> resolved during convergence.</div>'
|
|
579
|
+
|
|
580
|
+
new_sha_short = fix_record.new_sha[:8]
|
|
581
|
+
return (
|
|
582
|
+
f'<div class="bug-fix"><b>Fix:</b> resolved in the round {round_number} fix commit '
|
|
583
|
+
f"<code>{html.escape(new_sha_short)}</code>.</div>"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _render_bug_card(
|
|
588
|
+
index: int,
|
|
589
|
+
finding: RawFinding,
|
|
590
|
+
fix_by_round: dict[int, FixRecord],
|
|
591
|
+
card_class: str,
|
|
592
|
+
) -> str:
|
|
593
|
+
"""Return an HTML .bug-card element for one finding.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
index: 1-based display index for the card.
|
|
597
|
+
finding: The raw finding to render.
|
|
598
|
+
fix_by_round: Mapping of round number to fix record for the fix sub-block.
|
|
599
|
+
card_class: Either 'crit' or 'minor'.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
An HTML string for one .bug-card.
|
|
603
|
+
"""
|
|
604
|
+
severity = finding.severity
|
|
605
|
+
badge_class = SEVERITY_BADGE_CLASS_BY_LEVEL.get(severity, "b-p2")
|
|
606
|
+
escaped_title = html.escape(finding.title)
|
|
607
|
+
escaped_detail = html.escape(finding.detail)
|
|
608
|
+
escaped_file = html.escape(finding.file)
|
|
609
|
+
line_number = finding.line
|
|
610
|
+
round_number = finding.round_number
|
|
611
|
+
|
|
612
|
+
fix_block = _render_fix_block(finding, fix_by_round)
|
|
613
|
+
|
|
614
|
+
return (
|
|
615
|
+
f'<div class="bug-card {card_class}">'
|
|
616
|
+
f'<div class="bug-head">'
|
|
617
|
+
f'<span class="bug-num">#{index}</span>'
|
|
618
|
+
f'<span class="bug-title">{escaped_title}</span>'
|
|
619
|
+
f'<div class="badges">'
|
|
620
|
+
f'<span class="badge {badge_class}">{html.escape(severity)}</span>'
|
|
621
|
+
f'<span class="badge b-fixed">Fixed</span>'
|
|
622
|
+
f"</div>"
|
|
623
|
+
f"</div>"
|
|
624
|
+
f'<div class="bug-impact">{escaped_detail}</div>'
|
|
625
|
+
f"{fix_block}"
|
|
626
|
+
f'<div class="bug-meta"><code>{escaped_file}</code>:{line_number} · round {round_number}</div>'
|
|
627
|
+
f"</div>"
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _render_stat(label: str, stat_value: int) -> str:
|
|
632
|
+
"""Return an HTML .stat block for the summary stats row.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
label: The label displayed below the number.
|
|
636
|
+
stat_value: The numeric value to display.
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
An HTML string for one .stat element.
|
|
640
|
+
"""
|
|
641
|
+
escaped_label = html.escape(label)
|
|
642
|
+
return (
|
|
643
|
+
f'<div class="stat">'
|
|
644
|
+
f'<div class="stat-value">{stat_value}</div>'
|
|
645
|
+
f'<div class="stat-label">{escaped_label}</div>'
|
|
646
|
+
f"</div>"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _render_finding_cards(
|
|
651
|
+
findings: list[RawFinding],
|
|
652
|
+
fix_by_round: dict[int, FixRecord],
|
|
653
|
+
card_class: str,
|
|
654
|
+
) -> str:
|
|
655
|
+
"""Return an HTML .bugs container with one .bug-card per finding.
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
findings: The list of raw findings to render.
|
|
659
|
+
fix_by_round: Mapping of round number to fix record.
|
|
660
|
+
card_class: Either 'crit' or 'minor'.
|
|
661
|
+
|
|
662
|
+
Returns:
|
|
663
|
+
An HTML string for the .bugs container, or empty string when findings is empty.
|
|
664
|
+
"""
|
|
665
|
+
if not findings:
|
|
666
|
+
return ""
|
|
667
|
+
cards = "".join(
|
|
668
|
+
_render_bug_card(each_index + 1, each_finding, fix_by_round, card_class)
|
|
669
|
+
for each_index, each_finding in enumerate(findings)
|
|
670
|
+
)
|
|
671
|
+
return f'<div class="bugs">{cards}</div>'
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def render_report_html(
|
|
675
|
+
run_data: RunData, pr_metadata: PrMetadata, generated_date: str
|
|
676
|
+
) -> str:
|
|
677
|
+
"""Render the convergence insights report as an HTML string.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
run_data: Aggregated metrics from the workflow journal and transcripts.
|
|
681
|
+
pr_metadata: Owner, repo, number, URL, final sha, and round count for the PR.
|
|
682
|
+
generated_date: ISO date string derived from the journal timestamp.
|
|
683
|
+
|
|
684
|
+
Returns:
|
|
685
|
+
A complete HTML document string.
|
|
686
|
+
"""
|
|
687
|
+
pr_number = pr_metadata.number
|
|
688
|
+
owner = html.escape(pr_metadata.owner)
|
|
689
|
+
repo = html.escape(pr_metadata.repo)
|
|
690
|
+
final_sha_short = pr_metadata.final_sha[:8]
|
|
691
|
+
round_count = pr_metadata.round_count
|
|
692
|
+
|
|
693
|
+
total_findings = run_data.total_finding_count
|
|
694
|
+
critical_count = run_data.critical_finding_count
|
|
695
|
+
minor_count = run_data.minor_finding_count
|
|
696
|
+
fix_commit_count = run_data.fix_commit_count
|
|
697
|
+
tests_added_total = sum(run_data.tests_added_by_round.values())
|
|
698
|
+
|
|
699
|
+
head_html = HTML_HEAD_TEMPLATE.format(
|
|
700
|
+
pr_number=pr_number,
|
|
701
|
+
style_block=HTML_STYLE_BLOCK,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
subtitle = (
|
|
705
|
+
f'<p class="subtitle">{owner}/{repo} · {total_findings} findings '
|
|
706
|
+
f"across {round_count} rounds · {html.escape(generated_date)}</p>"
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
glance_caught = (
|
|
710
|
+
f'<div class="glance-section"><strong>What was caught:</strong> autoconverge ran '
|
|
711
|
+
f"{round_count} rounds and surfaced {total_findings} distinct findings — "
|
|
712
|
+
f"{critical_count} critical, {minor_count} minor.</div>"
|
|
713
|
+
)
|
|
714
|
+
glance_resolution = (
|
|
715
|
+
f'<div class="glance-section"><strong>Resolution:</strong> every finding was fixed '
|
|
716
|
+
f"before the PR was marked ready; {fix_commit_count} fix commits landed.</div>"
|
|
717
|
+
)
|
|
718
|
+
glance_status = (
|
|
719
|
+
f'<div class="glance-section"><strong>Status:</strong> the run converged on commit '
|
|
720
|
+
f"{html.escape(final_sha_short)}.</div>"
|
|
721
|
+
)
|
|
722
|
+
at_a_glance = (
|
|
723
|
+
f'<div class="at-a-glance">'
|
|
724
|
+
f'<div class="glance-title">At a Glance</div>'
|
|
725
|
+
f'<div class="glance-sections">'
|
|
726
|
+
f"{glance_caught}{glance_resolution}{glance_status}"
|
|
727
|
+
f"</div></div>"
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
nav_toc = (
|
|
731
|
+
'<nav class="nav-toc">'
|
|
732
|
+
'<a href="#numbers">The numbers</a>'
|
|
733
|
+
'<a href="#critical">Critical findings</a>'
|
|
734
|
+
'<a href="#minor">Minor findings</a>'
|
|
735
|
+
'<a href="#status">Status</a>'
|
|
736
|
+
"</nav>"
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
stats_row = (
|
|
740
|
+
'<div class="stats-row">'
|
|
741
|
+
+ _render_stat("Findings", total_findings)
|
|
742
|
+
+ _render_stat("Critical", critical_count)
|
|
743
|
+
+ _render_stat("Minor", minor_count)
|
|
744
|
+
+ _render_stat("Rounds", round_count)
|
|
745
|
+
+ _render_stat("Fix commits", fix_commit_count)
|
|
746
|
+
+ _render_stat("Tests added", tests_added_total)
|
|
747
|
+
+ "</div>"
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
severity_chart = _render_severity_chart(critical_count, minor_count)
|
|
751
|
+
round_chart = _render_round_findings_chart(
|
|
752
|
+
round_count, run_data.finding_count_by_round
|
|
753
|
+
)
|
|
754
|
+
tests_chart = _render_tests_chart(round_count, run_data.tests_added_by_round)
|
|
755
|
+
theme_chart = _render_theme_chart(run_data.finding_count_by_theme)
|
|
756
|
+
|
|
757
|
+
charts_row_one = f'<div class="charts-row">{severity_chart}{round_chart}</div>'
|
|
758
|
+
charts_row_two = f'<div class="charts-row">{tests_chart}{theme_chart}</div>'
|
|
759
|
+
|
|
760
|
+
numbers_section = (
|
|
761
|
+
f'<h2 id="numbers">The numbers</h2>{charts_row_one}{charts_row_two}'
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
critical_cards = _render_finding_cards(
|
|
765
|
+
run_data.all_critical_findings, run_data.fix_by_round, "crit"
|
|
766
|
+
)
|
|
767
|
+
critical_intro = '<p class="section-intro">P0 and P1 findings caught and fixed during the run.</p>'
|
|
768
|
+
critical_section = f'<h2 id="critical">Critical findings</h2>' + (
|
|
769
|
+
f"{critical_intro}{critical_cards}"
|
|
770
|
+
if run_data.all_critical_findings
|
|
771
|
+
else '<p class="section-intro">No critical findings.</p>'
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
minor_intro = (
|
|
775
|
+
'<p class="section-intro">P2 findings caught and fixed during the run.</p>'
|
|
776
|
+
)
|
|
777
|
+
minor_cards = _render_finding_cards(
|
|
778
|
+
run_data.all_minor_findings, run_data.fix_by_round, "minor"
|
|
779
|
+
)
|
|
780
|
+
minor_section = f'<h2 id="minor">Minor findings</h2>{minor_intro}{minor_cards}'
|
|
781
|
+
|
|
782
|
+
horizon_tip = (
|
|
783
|
+
f'<div class="horizon-tip">Final commit <code>{html.escape(final_sha_short)}</code> '
|
|
784
|
+
f"· {round_count} rounds · {fix_commit_count} fix commits.</div>"
|
|
785
|
+
)
|
|
786
|
+
status_section = (
|
|
787
|
+
f'<h2 id="status">Status</h2>'
|
|
788
|
+
f'<div class="horizon-card">'
|
|
789
|
+
f'<div class="horizon-title">Converged</div>'
|
|
790
|
+
f'<div class="horizon-possible">The run converged and the PR was marked ready.</div>'
|
|
791
|
+
f"{horizon_tip}"
|
|
792
|
+
f"</div>"
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
footer = (
|
|
796
|
+
f"<footer>{owner}/{repo} · PR #{pr_number} · "
|
|
797
|
+
f"generated {html.escape(generated_date)} from the autoconverge run journal.</footer>"
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
body_content = (
|
|
801
|
+
f"<h1>PR #{pr_number} Convergence Insights</h1>"
|
|
802
|
+
f"{subtitle}"
|
|
803
|
+
f"{at_a_glance}"
|
|
804
|
+
f"{nav_toc}"
|
|
805
|
+
f"{stats_row}"
|
|
806
|
+
f"{numbers_section}"
|
|
807
|
+
f"{critical_section}"
|
|
808
|
+
f"{minor_section}"
|
|
809
|
+
f"{status_section}"
|
|
810
|
+
f"{footer}"
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
return (
|
|
814
|
+
f"{HTML_DOCTYPE}\n"
|
|
815
|
+
f"<html lang='en'>\n"
|
|
816
|
+
f"{head_html}\n"
|
|
817
|
+
f"<body>\n"
|
|
818
|
+
f'<div class="container">\n'
|
|
819
|
+
f"{body_content}\n"
|
|
820
|
+
f"</div>\n"
|
|
821
|
+
f"</body>\n"
|
|
822
|
+
f"</html>"
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _parse_pr_arg(pr_arg: str, err_stream: TextIO) -> tuple[str, str, int] | None:
|
|
827
|
+
"""Parse an 'owner/repo#number' string into its three components.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
pr_arg: A string in the form 'owner/repo#number'.
|
|
831
|
+
err_stream: Stream to write error messages to.
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
A tuple of (owner, repo, pr_number_int), or None on parse failure.
|
|
835
|
+
"""
|
|
836
|
+
match = re.fullmatch(r"([^/]+)/([^#]+)#(\d+)", pr_arg)
|
|
837
|
+
if not match:
|
|
838
|
+
err_stream.write(
|
|
839
|
+
f"Invalid --pr format: {pr_arg!r}. Expected owner/repo#number.\n"
|
|
840
|
+
)
|
|
841
|
+
return None
|
|
842
|
+
return match.group(1), match.group(2), int(match.group(3))
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def main(out_stream: TextIO = sys.stdout, err_stream: TextIO = sys.stderr) -> int:
|
|
846
|
+
"""Parse CLI arguments, load run data, render HTML, write the output file, and emit the path.
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
out_stream: Stream to write the output file path to on success.
|
|
850
|
+
err_stream: Stream to write error messages to.
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
Exit code (0 on success, 1 on argument error).
|
|
854
|
+
"""
|
|
855
|
+
argument_parser = argparse.ArgumentParser(
|
|
856
|
+
description="Render autoconverge convergence insights HTML."
|
|
857
|
+
)
|
|
858
|
+
argument_parser.add_argument(
|
|
859
|
+
"--journal", required=True, help="Path to wf_<runId>.json"
|
|
860
|
+
)
|
|
861
|
+
argument_parser.add_argument("--out", required=True, help="Output HTML file path")
|
|
862
|
+
argument_parser.add_argument("--pr", required=True, help="owner/repo#number")
|
|
863
|
+
argument_parser.add_argument("--final-sha", required=True, help="Final commit SHA")
|
|
864
|
+
argument_parser.add_argument(
|
|
865
|
+
"--rounds", required=True, type=int, help="Total round count"
|
|
866
|
+
)
|
|
867
|
+
argument_parser.add_argument(
|
|
868
|
+
"--repo", default=".", help="Path to the git repository root"
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
parsed_args = argument_parser.parse_args()
|
|
872
|
+
|
|
873
|
+
journal_path = Path(parsed_args.journal).resolve()
|
|
874
|
+
out_path = Path(parsed_args.out)
|
|
875
|
+
repo_path = Path(parsed_args.repo).resolve()
|
|
876
|
+
|
|
877
|
+
parsed_pr = _parse_pr_arg(parsed_args.pr, err_stream)
|
|
878
|
+
if parsed_pr is None:
|
|
879
|
+
return 1
|
|
880
|
+
|
|
881
|
+
owner, repo, pr_number = parsed_pr
|
|
882
|
+
pr_url = GITHUB_PR_URL_TEMPLATE.format(owner=owner, repo=repo, number=pr_number)
|
|
883
|
+
|
|
884
|
+
pr_metadata = PrMetadata(
|
|
885
|
+
owner=owner,
|
|
886
|
+
repo=repo,
|
|
887
|
+
number=pr_number,
|
|
888
|
+
url=pr_url,
|
|
889
|
+
final_sha=parsed_args.final_sha,
|
|
890
|
+
round_count=parsed_args.rounds,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
run_data = load_run_data(journal_path, repo_path)
|
|
894
|
+
html_content = render_report_html(run_data, pr_metadata, run_data.generated_date)
|
|
895
|
+
|
|
896
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
897
|
+
out_path.write_text(html_content, encoding="utf-8")
|
|
898
|
+
out_stream.write(str(out_path) + "\n")
|
|
899
|
+
return 0
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
if __name__ == "__main__":
|
|
903
|
+
sys.exit(main())
|