"""Append-only, idempotent Gitea evidence notes; preview is entirely offline.""" from __future__ import annotations from contextlib import ExitStack from dataclasses import dataclass import hashlib import html import os from pathlib import Path import re import subprocess import sys from typing import Any from urllib.parse import urlsplit from .common import META_ROOT, atomic_json, digest, identifier, read_json, redact, resource_lock, state_root from .inputs import FINGERPRINT_VERSION _GITEA_PATH = str(META_ROOT / "tools/gitea") if _GITEA_PATH not in sys.path: sys.path.insert(0, _GITEA_PATH) from gitea_common import GiteaClient, RepoTarget, _parse_remote, repo_path # noqa: E402 MAX_NOTE_BYTES = 128 * 1024 MAX_TARGETS = 256 MAX_COVERAGE_NOTES = 2048 MARKER_PREFIX = "" lines = [marker, "## Development evidence", "", f"Issue: {target.url}", ""] for field, title in (("summary", "Summary"), ("next", "Next / remaining")): if note[field]: lines += [f"### {title}", "", *["- " + item for item in note[field]], ""] if note["body"]: lines += [note["body"], ""] if evidence: lines += ["### Recorded check evidence", "", f"Run: `{evidence['run_id']}`; reported result: **{evidence['status']}**.", f"Receipt origin: `{evidence['origin']}`; source comparison: `{evidence['source_state']}`.", f"Recorded source fingerprint: `{evidence['source_fingerprint']}`.", f"Finished: {_cell(evidence['finished_at'])}; receipt: `{_cell(evidence['receipt_path'])}`.", "", "| Stage | Reported status | Exit | Seconds | Local log reference |", "| --- | --- | --- | --- | --- |"] for stage in evidence["stages"]: lines.append("| " + " | ".join(_cell(stage.get(field)) for field in ("id", "status", "exit_code", "duration_seconds", "log_path")) + " |") if evidence.get("coverage_notes"): lines += ["", "### Coverage limitations / checks not included", "", "A passing recorded stage does not mean these omitted checks ran.", "", *["- " + _cell(value) for value in evidence["coverage_notes"]]] lines += ["", evidence["attestation"], "Local logs are referenced only; their content has not been read or uploaded.", ""] lines += ["This comment does not close the issue, complete its review, or change its checklist."] body = redact("\n".join(lines).rstrip() + "\n") if len(body.encode()) > MAX_NOTE_BYTES: raise ValueError("Rendered note exceeds its size bound") return marker, body def _token(env_file: Path | None) -> str: values = {} if env_file is not None: from .common import reject_symlinks reject_symlinks(env_file) import stat descriptor = os.open(env_file, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) with os.fdopen(descriptor, "rb") as handle: metadata = os.fstat(handle.fileno()) if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 65536: raise ValueError("Credential file must be a bounded regular file") content = handle.read(65537) if len(content) > 65536: raise ValueError("Credential file exceeds its size limit") for line in content.decode("utf-8").splitlines(): line = line.strip() if not line or line.startswith("#"): continue if line.startswith("export "): line = line[7:].strip() key, separator, value = line.partition("=") if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key.strip()): raise ValueError("Invalid credential file format") value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: value = value[1:-1] if key.strip() in values: raise ValueError("Duplicate credential file keys") values[key.strip()] = value token = os.environ.get("GITEA_TOKEN") or values.get("GITEA_TOKEN") if not token or len(token) > 8192 or any(char.isspace() for char in token): raise ValueError("GITEA_TOKEN is required for --apply; use the environment or --env-file") return token def make_client(target: NoteTarget, token: str): return GiteaClient(RepoTarget(target.base_url, target.owner, target.repository), token) def _issue(client, target: NoteTarget) -> dict: issue = client.request_json("GET", target.path) if (not isinstance(issue, dict) or issue.get("number") != target.issue or issue.get("html_url") != target.url or issue.get("pull_request") is not None): raise ValueError("Remote issue identity does not match the exact target") _positive(issue.get("id")) if target.issue_id is not None and issue["id"] != target.issue_id: raise ValueError("Remote issue ID changed from the target plan") return issue def _comments(client, target: NoteTarget) -> list[dict]: comments, seen = [], set() for page in range(1, 10001): values = client.request_json("GET", target.path + "/comments", query={"page": page, "limit": 50}) if not isinstance(values, list): raise ValueError("Remote comment pagination did not return a list") if not values: return comments for comment in values: if not isinstance(comment, dict) or not isinstance(comment.get("body"), str): raise ValueError("Remote comment has an invalid shape") identity = _positive(comment.get("id")) if identity in seen: raise ValueError("Repeated comment pagination; cannot establish a complete duplicate check") seen.add(identity) comments.append(comment) if len(comments) > 100000: raise ValueError("Remote comments exceed the bounded duplicate-check limit") raise ValueError("Remote comment pagination did not terminate") def _existing(comments: list[dict], marker: str, body: str) -> dict | None: matches = [comment for comment in comments if marker in comment["body"]] if len(matches) > 1 or (matches and matches[0]["body"] != body): raise ValueError("Evidence marker collision; existing comments are preserved") return matches[0] if matches else None def _readback(client, target: NoteTarget, comment: dict, body: str) -> dict: identity = _positive(comment.get("id")) fresh = client.request_json("GET", repo_path(target.owner, target.repository, f"/issues/comments/{identity}")) issue_api_url = target.base_url + "/api/v1" + target.path if not isinstance(fresh, dict) or fresh.get("id") != identity or fresh.get("body") != body: raise ValueError("Posted comment read-back did not match") if not fresh.get("html_url") and not fresh.get("issue_url"): raise ValueError("Comment read-back has no issue binding") if fresh.get("issue_url") and fresh["issue_url"] != issue_api_url: raise ValueError("Comment read-back belongs to another issue") if fresh.get("html_url") and fresh["html_url"].split("#", 1)[0] != target.url: raise ValueError("Comment read-back URL belongs to another issue") return fresh def handle_note(args) -> dict: targets = _targets(args) note = _structured_note(args) evidence = evidence_record(args.evidence, args) if not evidence and not any((note["summary"], note["next"], note["body"])): raise ValueError("Provide evidence or a nonempty structured note") if args.retry_uncertain and not args.apply: raise ValueError("--retry-uncertain requires --apply") prepared = [(target, *render_note(note, evidence, target, args.key)) for target in targets] result = {"schema_version": 1, "operation": "issues.note", "apply": bool(args.apply), "evidence": evidence, "targets": [{**target.record(), "marker": marker, "body": body, "status": "would-post"} for target, marker, body in prepared], "summary": [f"{'Apply' if args.apply else 'Offline dry run'}: {len(targets)} exact issue target(s); issue bodies and states are preserved."]} if evidence and evidence["coverage_notes"]: result["summary"].append(f"Evidence has {len(evidence['coverage_notes'])} coverage limitation(s), retained in the note; omitted checks are not claimed as passed.") if not args.apply: return result token = _token(args.env_file) # Tokens loaded from an explicit file are not inserted into the process environment. for record in result["targets"]: if token in record["body"]: raise ValueError("A credential occurs in note content; refusing publication") state = state_root(Path(args.workspace_root), args.state_dir) with ExitStack() as stack: for target, marker, _body in sorted(prepared, key=lambda item: item[0].url): stack.enter_context(resource_lock(state / "locks", "issues.note:" + marker)) clients, bindings, journals = {}, {}, {} try: # Validate every target and every existing marker before the first write. for target, marker, body in prepared: client = make_client(target, token) stack.callback(client.close) clients[target.url] = client bindings[target.url] = _issue(client, target)["id"] _existing(_comments(client, target), marker, body) journal_path = state / "issue-notes" / (hashlib.sha256(marker.encode()).hexdigest() + ".json") prior = read_json(journal_path) if journal_path.exists() else None if prior is not None and (not isinstance(prior, dict) or prior.get("schema_version") != 1 or prior.get("target") != target.url or prior.get("body_digest") != digest(body) or prior.get("issue_id") != bindings[target.url] or prior.get("status") not in {"posting", "uncertain", "verified"}): raise ValueError("Local evidence journal identity collision; inspect before retrying") journals[target.url] = (journal_path, prior) for index, (target, marker, body) in enumerate(prepared): client = clients[target.url] output = result["targets"][index] journal_path, prior = journals[target.url] issue = _issue(client, target) if issue["id"] != bindings[target.url]: raise ValueError("Issue identity changed after preflight") existing = _existing(_comments(client, target), marker, body) if existing: verified = _readback(client, target, existing, body) output.update(status="existing-verified", comment_id=verified["id"]) atomic_json(journal_path, {"schema_version": 1, "target": target.url, "issue_id": issue["id"], "body_digest": digest(body), "status": "verified", "comment_id": verified["id"]}) continue if prior and prior.get("status") in {"uncertain", "posting", "verified"} and not args.retry_uncertain: output["status"] = "uncertain-retry-required" result["_exit_code"] = 2 result["summary"].append("An earlier POST is not visible after complete reconciliation; explicit --retry-uncertain is required. No further posts attempted.") break journal = {"schema_version": 1, "target": target.url, "issue_id": issue["id"], "body_digest": digest(body), "status": "posting"} atomic_json(journal_path, journal) try: posted = client.request_json("POST", target.path + "/comments", body={"body": body}) verified = _readback(client, target, posted, body) unique = _existing(_comments(client, target), marker, body) if unique is None or unique["id"] != verified["id"]: raise ValueError("New comment is not uniquely visible in its issue") output.update(status="posted-verified", comment_id=verified["id"]) except Exception: # POST is never replayed automatically, even after a timeout or bad response. atomic_json(journal_path, {**journal, "status": "uncertain"}) try: observed = _existing(_comments(client, target), marker, body) verified = _readback(client, target, observed, body) if observed else None except Exception: verified = None if verified is None: output["status"] = "uncertain" result["_exit_code"] = 2 result["summary"].append("POST/read-back could not be confirmed. No automatic retry and no further posts; reconcile this target before continuing.") break output.update(status="reconciled-verified", comment_id=verified["id"]) atomic_json(journal_path, {**journal, "status": "verified", "comment_id": output["comment_id"]}) except Exception: # HTTP response/error bodies may contain secrets; never return them to a report. result["_exit_code"] = 2 result["summary"].append("Gitea validation or local journal checks failed; existing issues/comments were preserved. No further posts attempted.") finally: token = "" for record in result["targets"]: if record["status"] == "would-post": record["status"] = "not-attempted" result["summary"].append("; ".join(f"{record['url']}: {record['status']}" for record in result["targets"])) return result