Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/a38.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ agent a38 verify --policy /tmp/a38-run/policy.json \

Only a complete `run` plus successful local `verify` for the signed, clean final SHA may be recorded as `local_check_pass`. That final SHA must be on the open draft with no intervening commit after the verified measurement. Early draft publication may precede this final measurement ([pull request lifecycle](pull-request-lifecycle.md)). Any fix, amend, rebase, or other new SHA requires a new signed clean commit and a complete run and verification from the beginning.

The generated `report.md` is ready to publish: one short sentence under `EN:`, one under `DE:`, and the unchanged machine-readable block inside a closed `<details>` section. Labels appear on their own lines; blank lines separate the languages and follow `</summary>` so GitHub renders the enclosed Markdown. The summary describes the evidence without declaring failed or interrupted runs successful. Results, commands and durations remain in the collapsed details. Adopters and plugins use this central output rather than maintaining another format template.
The generated `report.md` is ready to publish: one short sentence under `EN:`, one under `DE:`, and a closed `<details>` section containing a mandatory table followed by the unchanged machine-readable block in its own nested closed details section. Labels appear on their own lines; blank lines separate the languages and follow `</summary>` so GitHub renders the enclosed Markdown. The summary describes the evidence without declaring failed or interrupted runs successful. The table lists **every recorded A38 job**, in execution order, with its ID/name, duration, final result and exit code, including failed, errored or timed-out jobs. Display durations in seconds, always rounded **up to whole seconds** (84.467 → 85 s; 84 → 84 s; 0 → 0 s). Preserve the exact fractional measurements in the original machine block. Rows represent A38 jobs, not every underlying test case in a suite. Job names are escaped so they cannot alter the table or HTML structure. Commands and exact evidence remain in the nested original report. Adopters and plugins use this central output rather than maintaining another format template.

Post the generated `report.md` unchanged as a PR comment **using the PR author's GitHub account**, preserving its markers and fenced JSON block. For legacy reports, adding this presentation around the original marked block is allowed only if that block remains byte-for-byte unchanged; preserve all measured data and timestamps, then verify and reassess the edited comment. Publishing is separate from running and verification. Do not paste raw logs containing credentials into the comment and do not hand-edit or reconstruct a passing payload.

Expand Down
22 changes: 22 additions & 0 deletions src/agent_cli/a38.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import argparse
import html
import json
import math
import os
Expand Down Expand Up @@ -695,13 +696,34 @@ def _report_from_dict(payload: Mapping[str, Any]) -> LocalCiReport:
return parse_comment(block)


def _report_table_cell(value: str) -> str:
# Keep policy-supplied names inside one literal Markdown/HTML table cell.
escaped = html.escape(" ".join(value.split()))
return "".join(f"&#{ord(char)};" if char in "\\|`*_[]{}" else char for char in escaped)


def _report_table(report: LocalCiReport) -> str:
rows = [
"| Check / Prüfung | Duration / Laufzeit | Result / Ergebnis | Exit code |",
"| --- | ---: | --- | ---: |",
]
for run in report.runs:
label = _report_table_cell(f"{run.id}: {run.name}")
rows.append(f"| {label} | {math.ceil(run.duration_s)} s | {run.result} | {run.exit_code} |")
return "\n".join(rows) + "\n"


def _write_report(output: Path, payload: Mapping[str, Any]) -> None:
report = _report_from_dict(payload)
text = (
"EN:\nThe A38 report below records the checks, results and durations.\n\n"
"DE:\nDer A38-Bericht unten dokumentiert die Prüfungen, Ergebnisse und Laufzeiten.\n\n"
"<details>\n<summary>Details</summary>\n\n"
f"{_report_table(report)}\n"
"Durations rounded up to whole seconds / Laufzeiten auf ganze Sekunden aufgerundet.\n\n"
"<details>\n<summary>Original report / Originalbericht</summary>\n\n"
f"{render_block(report)}\n"
"</details>\n\n"
"</details>\n"
)
_write_bytes_atomic(output, text.encode("utf-8"))
Expand Down
45 changes: 44 additions & 1 deletion tests/test_a38.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,14 +419,57 @@ def test_presentation_preserves_evidence_and_validation_for_all_outcomes(self) -
"DE:\nDer A38-Bericht unten dokumentiert die Prüfungen, Ergebnisse und Laufzeiten.\n\n"
))
# Only the presentation changes; the entire original evidence remains intact.
self.assertEqual(details, render_block(parse_comment(original)) + "\n</details>\n")
table, original_details = details.split("<details>\n<summary>Original report / Originalbericht</summary>\n\n")
self.assertIn(f"| unit: Unit | 1 s | {result} | {code} |", table)
self.assertEqual(original_details, render_block(parse_comment(original)) + "\n</details>\n\n</details>\n")
self.assertNotIn("<details open", comment)
self.assertEqual(parse_comment(comment), parse_comment(original))
self.assertEqual(comment.count(BEGIN_MARK), 1)
self.assertEqual(comment.count(END_MARK), 1)
verdict = verify_report(comment, _policy_dict(), repo="example/app", head=HEAD_A, private=True)
self.assertEqual(verdict["ok"], result == "pass")

def test_table_lists_every_job_in_order_with_ceiling_seconds(self) -> None:
runs = [
_run_payload(ident="zero", name="Zero", duration_s=0),
_run_payload(ident="whole", name="Whole", duration_s=84, result="fail", exit_code=1),
_run_payload(ident="fraction", name="Fraction", duration_s=84.467, result="error", exit_code=127),
_run_payload(ident="tiny", name="Tiny", duration_s=0.001, result="timeout", exit_code=124),
]
original = _report_comment(required=[r["id"] for r in runs], runs=runs)
payload = json.loads(original.split("```json\n", 1)[1].split("```", 1)[0])
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "report.md"
_write_report(output, payload)
comment = output.read_text()
rows = [line for line in comment.splitlines() if line.startswith("| ")][2:]
self.assertEqual(rows, [
"| zero: Zero | 0 s | pass | 0 |",
"| whole: Whole | 84 s | fail | 1 |",
"| fraction: Fraction | 85 s | error | 127 |",
"| tiny: Tiny | 1 s | timeout | 124 |",
])
self.assertEqual(parse_comment(comment), parse_comment(original))
self.assertIn(render_block(parse_comment(original)), comment)

def test_policy_name_cannot_inject_table_rows_or_html(self) -> None:
name = "Checks | `code`\n</details><script>x</script> [link](url) *bold* \\"
original = _report_comment(runs=[_run_payload(name=name)])
payload = json.loads(original.split("```json\n", 1)[1].split("```", 1)[0])
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "report.md"
_write_report(output, payload)
comment = output.read_text()
table = comment.split("<summary>Details</summary>\n\n", 1)[1].split("<details>", 1)[0]
rows = [line for line in table.splitlines() if line.startswith("| ")]
self.assertEqual(len(rows), 3)
self.assertEqual(rows[2].count("|"), 5)
for fragment in ("</details>", "<script>", "`", "[link]", "*bold*", "\\"):
self.assertNotIn(fragment, table)
self.assertIn("&#124;", table)
self.assertIn("&lt;/details&gt;", table)
self.assertEqual(parse_comment(comment).runs[0].name, name)


class OriginTests(unittest.TestCase):
def test_https_and_ssh(self) -> None:
Expand Down
Loading