Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
548 lines
31 KiB
Python
Executable File
548 lines
31 KiB
Python
Executable File
"""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 = "<!-- govoplan-devkit:evidence:v1:"
|
||
STATUSES = {"planned", "pending", "running", "passed", "failed", "timed_out", "skipped", "cancelled", "interrupted", "blocked", "partial", "stale"}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class NoteTarget:
|
||
root: Path
|
||
base_url: str
|
||
owner: str
|
||
repository: str
|
||
issue: int
|
||
issue_id: int | None = None
|
||
|
||
@property
|
||
def url(self) -> str:
|
||
return f"{self.base_url}/{self.owner}/{self.repository}/issues/{self.issue}"
|
||
|
||
@property
|
||
def path(self) -> str:
|
||
return repo_path(self.owner, self.repository, f"/issues/{self.issue}")
|
||
|
||
def record(self) -> dict:
|
||
return {"root": str(self.root), "base_url": self.base_url, "owner": self.owner,
|
||
"repository": self.repository, "issue": self.issue, "url": self.url,
|
||
**({"issue_id": self.issue_id} if self.issue_id is not None else {})}
|
||
|
||
|
||
def register(subparsers) -> None:
|
||
parser = subparsers.add_parser("issues", help="Preview or append evidence to exact Gitea issues")
|
||
commands = parser.add_subparsers(dest="issues_command", required=True)
|
||
note = commands.add_parser("note", help="Append a deduplicated note; offline dry run by default")
|
||
note.add_argument("--root", type=Path, help="Target repository root or child directory")
|
||
note.add_argument("--issue", type=int)
|
||
note.add_argument("--target-plan", type=Path, help="JSON with schema_version=1 and exact root/issue/url targets")
|
||
note.add_argument("--remote", default="origin")
|
||
note.add_argument("--env-file", type=Path, help="Private GITEA_TOKEN dotenv file; target overrides are ignored")
|
||
note.add_argument("--evidence", help="Local run ID or explicit receipt JSON path")
|
||
note.add_argument("--key", default="verification", help="Stable note purpose within an evidence run")
|
||
note.add_argument("--summary", dest="note_summary", action="append", default=[])
|
||
note.add_argument("--next", dest="next_steps", action="append", default=[])
|
||
note.add_argument("--body-file", type=Path, help="Additional Markdown, never executed")
|
||
note.add_argument("--note-file", type=Path, help="Structured JSON: summary[], next[], body")
|
||
note.add_argument("--apply", action="store_true", help="Explicitly authorize serial comment creation")
|
||
note.add_argument("--retry-uncertain", action="store_true", help="After reconciliation, explicitly retry a still-unconfirmed earlier POST")
|
||
note.set_defaults(handler=handle_note)
|
||
|
||
|
||
def _text(value: Any, *, maximum: int = 16384) -> str:
|
||
if not isinstance(value, str) or len(value.encode("utf-8")) > maximum:
|
||
raise ValueError("Note/evidence text must be a bounded string")
|
||
if any(ord(character) < 32 and character not in "\n\t\r" for character in value):
|
||
raise ValueError("Note/evidence text contains control characters")
|
||
if MARKER_PREFIX in value:
|
||
raise ValueError("Evidence markers are reserved for the publisher")
|
||
return redact(value)
|
||
|
||
|
||
def _positive(value: Any) -> int:
|
||
if type(value) is not int or value <= 0:
|
||
raise ValueError("Issue and comment identities must be positive integers")
|
||
return value
|
||
|
||
|
||
def _base_url(value: str) -> str:
|
||
parsed = urlsplit(value)
|
||
if (parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username
|
||
or parsed.password or parsed.query or parsed.fragment or "\\" in value
|
||
or any(ord(char) < 33 for char in value)):
|
||
raise ValueError("Gitea target must be a credential-free HTTP(S) URL")
|
||
if any(part in {".", ".."} or "%" in part for part in parsed.path.split("/")):
|
||
raise ValueError("Gitea base URL contains an ambiguous path")
|
||
try:
|
||
parsed.port
|
||
except ValueError as exc:
|
||
raise ValueError("Invalid Gitea port") from exc
|
||
return value.rstrip("/")
|
||
|
||
|
||
def _name(value: str) -> str:
|
||
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", value) or value in {".", ".."}:
|
||
raise ValueError("Invalid Gitea owner or repository name")
|
||
return value
|
||
|
||
|
||
def resolve_target(root: Path, issue: int, workspace_root: Path, *, remote: str = "origin",
|
||
expected_url: str | None = None, issue_id: int | None = None) -> NoteTarget:
|
||
requested = root if root.is_absolute() else workspace_root / root
|
||
resolved = requested.resolve()
|
||
if not resolved.is_relative_to(workspace_root.resolve()) or not resolved.is_dir():
|
||
raise ValueError("Issue target must be an existing repository inside the selected workspace")
|
||
if not re.fullmatch(r"[A-Za-z0-9_.-]+", remote):
|
||
raise ValueError("Invalid Git remote name")
|
||
command = subprocess.run(["git", "-C", str(resolved), "rev-parse", "--show-toplevel"],
|
||
capture_output=True, text=True, timeout=15)
|
||
if command.returncode:
|
||
raise ValueError("Issue target is not a Git checkout")
|
||
actual = Path(command.stdout.strip()).resolve()
|
||
if not actual.is_relative_to(workspace_root.resolve()):
|
||
raise ValueError("Resolved issue repository escapes the workspace")
|
||
result = subprocess.run(["git", "-C", str(actual), "remote", "get-url", remote],
|
||
capture_output=True, text=True, timeout=15)
|
||
if result.returncode or len(result.stdout) > 8192:
|
||
raise ValueError("Target repository has no usable Git remote")
|
||
remote_url = result.stdout.strip()
|
||
parsed = urlsplit(remote_url)
|
||
if (any(ord(char) < 33 for char in remote_url) or "\\" in remote_url
|
||
or parsed.query or parsed.fragment):
|
||
raise ValueError("Ambiguous Git remote URL cannot bind an issue target")
|
||
if parsed.scheme in {"http", "https"} and (parsed.username or parsed.password):
|
||
raise ValueError("Credential-bearing Git remotes cannot be used for issue publishing")
|
||
base, owner, repository = _parse_remote(remote_url)
|
||
target = NoteTarget(actual, _base_url(base), _name(owner), _name(repository), _positive(issue),
|
||
_positive(issue_id) if issue_id is not None else None)
|
||
if expected_url is not None and expected_url != target.url:
|
||
raise ValueError("Target plan issue URL does not match the exact repository remote and issue number")
|
||
return target
|
||
|
||
|
||
def _targets(args) -> list[NoteTarget]:
|
||
workspace_root = Path(args.workspace_root).resolve()
|
||
if args.target_plan:
|
||
if args.root is not None or args.issue is not None:
|
||
raise ValueError("Use either --root/--issue or --target-plan")
|
||
payload = read_json(args.target_plan, max_bytes=1024 * 1024)
|
||
if not isinstance(payload, dict) or type(payload.get("schema_version")) is not int or payload["schema_version"] != 1:
|
||
raise ValueError("Target plan requires schema_version 1")
|
||
records = payload.get("targets")
|
||
if not isinstance(records, list) or not 1 <= len(records) <= MAX_TARGETS:
|
||
raise ValueError("Target plan must contain 1–256 exact targets")
|
||
targets = []
|
||
for record in records:
|
||
if (not isinstance(record, dict) or not isinstance(record.get("root"), str)
|
||
or not isinstance(record.get("url"), str)):
|
||
raise ValueError("Each target plan entry requires root, issue and URL")
|
||
target = resolve_target(Path(record["root"]), record.get("issue"), workspace_root,
|
||
remote=args.remote, expected_url=record["url"], issue_id=record.get("issue_id"))
|
||
for key in ("base_url", "owner", "repository"):
|
||
if key in record and record[key] != getattr(target, key):
|
||
raise ValueError("Target plan repository identity is inconsistent")
|
||
targets.append(target)
|
||
else:
|
||
if args.root is None or args.issue is None:
|
||
raise ValueError("An exact --root and --issue are required")
|
||
targets = [resolve_target(args.root, args.issue, workspace_root, remote=args.remote)]
|
||
if len({target.url for target in targets}) != len(targets):
|
||
raise ValueError("Duplicate issue targets are not accepted")
|
||
if len({target.base_url for target in targets}) != 1:
|
||
raise ValueError("A credentialed target plan must be confined to one exact Gitea base URL")
|
||
return targets
|
||
|
||
|
||
def validate_receipt(payload: Any, workspace_root: Path) -> dict:
|
||
if not isinstance(payload, dict) or type(payload.get("schema_version")) is not int or payload["schema_version"] != 1:
|
||
raise ValueError("Evidence requires receipt schema_version 1")
|
||
identifier(payload.get("run_id"))
|
||
recorded_workspace = payload.get("workspace_root")
|
||
if not isinstance(recorded_workspace, str) or Path(recorded_workspace).resolve() != workspace_root.resolve():
|
||
raise ValueError("Evidence belongs to another workspace")
|
||
fingerprint = payload.get("source_fingerprint")
|
||
if not isinstance(fingerprint, str) or not re.fullmatch(r"[a-f0-9]{64}", fingerprint):
|
||
raise ValueError("Evidence requires a recorded source fingerprint")
|
||
if not isinstance(payload.get("status"), str) or payload["status"] not in STATUSES:
|
||
raise ValueError("Unknown evidence status")
|
||
_text(payload.get("generated_at"), maximum=128)
|
||
if payload.get("finished_at") is not None:
|
||
_text(payload["finished_at"], maximum=128)
|
||
stages = payload.get("stages")
|
||
if not isinstance(stages, list) or len(stages) > 1000:
|
||
raise ValueError("Evidence requires a bounded stage list")
|
||
seen = set()
|
||
for stage in stages:
|
||
if not isinstance(stage, dict):
|
||
raise ValueError("Invalid evidence stage")
|
||
identity = _text(stage.get("id"), maximum=256)
|
||
if not identity or identity in seen or "\n" in identity:
|
||
raise ValueError("Evidence stage IDs must be unique")
|
||
seen.add(identity)
|
||
if not isinstance(stage.get("status"), str) or stage["status"] not in STATUSES:
|
||
raise ValueError("Unknown evidence stage status")
|
||
code = stage.get("exit_code")
|
||
if code is not None and type(code) is not int:
|
||
raise ValueError("Evidence exit code must be an integer or null")
|
||
duration = stage.get("duration_seconds")
|
||
if duration is not None and (type(duration) not in {int, float} or not 0 <= duration < 31536000):
|
||
raise ValueError("Invalid evidence stage duration")
|
||
if stage.get("log_path") is not None:
|
||
_text(stage["log_path"], maximum=4096)
|
||
if stage["status"] == "passed" and code != 0:
|
||
raise ValueError("Passed evidence stage must have exit code zero")
|
||
if payload["status"] == "passed" and (not stages or any(stage["status"] != "passed" for stage in stages)):
|
||
raise ValueError("Passed evidence is inconsistent with its stages")
|
||
if payload["status"] == "passed" and payload.get("snapshot_verified") is not True:
|
||
raise ValueError("Passed evidence requires a verified source snapshot")
|
||
coverage_notes(stages)
|
||
from .checkpoints import validate_checkpoint_receipt
|
||
validate_checkpoint_receipt(payload)
|
||
return payload
|
||
|
||
|
||
def coverage_notes(stages: list[dict]) -> list[str]:
|
||
"""Keep declared coverage limits visible without interpreting them as commands."""
|
||
notes, total, size = [], 0, 0
|
||
for stage in stages:
|
||
values = stage.get("coverage_notes", [])
|
||
if not isinstance(values, list):
|
||
raise ValueError("Evidence coverage_notes must be a bounded list of strings")
|
||
total += len(values)
|
||
if total > MAX_COVERAGE_NOTES:
|
||
raise ValueError("Evidence coverage_notes exceed their count bound")
|
||
for value in values:
|
||
text = _text(value, maximum=4096).strip()
|
||
if not text:
|
||
raise ValueError("Evidence coverage notes cannot be empty")
|
||
size += len(text.encode())
|
||
if size > MAX_NOTE_BYTES:
|
||
raise ValueError("Evidence coverage_notes exceed their size bound")
|
||
notes.append(text)
|
||
return list(dict.fromkeys(notes))
|
||
|
||
|
||
def evidence_record(value: str | None, args) -> dict | None:
|
||
if not value:
|
||
return None
|
||
workspace_root = Path(args.workspace_root).resolve()
|
||
external = value.endswith(".json") or "/" in value or "\\" in value
|
||
if external:
|
||
path = Path(value).expanduser().absolute()
|
||
payload = read_json(path)
|
||
origin = "external-unverified"
|
||
else:
|
||
from .runner import read_receipt
|
||
identity = identifier(value)
|
||
payload = read_receipt(workspace_root, args.state_dir, identity)
|
||
path = state_root(workspace_root, args.state_dir) / "runs" / identity / "receipt.json"
|
||
origin = "local-integrity-checked"
|
||
receipt = validate_receipt(payload, workspace_root)
|
||
from .workspace import load_project, source_fingerprint
|
||
expected_project = str(args.project.resolve()) if getattr(args, "project", None) else None
|
||
source_state = "not-compared"
|
||
current = None
|
||
if receipt.get("project_file") == expected_project:
|
||
try:
|
||
project = load_project(workspace_root, getattr(args, "project", None))
|
||
if receipt.get("fingerprint_version") == FINGERPRINT_VERSION:
|
||
from .inputs import InputSnapshotter
|
||
scope = receipt.get("source_scope", {})
|
||
if not isinstance(scope, dict) or not isinstance(scope.get("repos"), list):
|
||
raise ValueError("Scoped evidence requires an explicit recorded repository scope")
|
||
current = InputSnapshotter(project, workspace_root=workspace_root).source_snapshot(scope["repos"])["observed_source_fingerprint"]
|
||
else:
|
||
current = source_fingerprint(project)
|
||
source_state = "matches-current" if current == receipt["source_fingerprint"] else "historical-source-differs"
|
||
except (OSError, ValueError, subprocess.SubprocessError):
|
||
source_state = "current-source-unavailable"
|
||
else:
|
||
source_state = "different-project-not-compared"
|
||
return {"run_id": redact(receipt["run_id"]), "status": receipt["status"], "origin": origin,
|
||
"receipt_path": redact(str(path)), "source_fingerprint": receipt["source_fingerprint"],
|
||
"current_source_fingerprint": current, "source_state": source_state,
|
||
"snapshot_verified": receipt.get("snapshot_verified") is True,
|
||
"coverage_notes": coverage_notes(receipt["stages"]) + ([
|
||
"Source comparison is limited to the recorded repository input scope; this is not a whole-workspace or artifact verification."
|
||
] if receipt.get("fingerprint_version") == FINGERPRINT_VERSION and not receipt.get("source_scope", {}).get("complete_workspace") else []),
|
||
"fingerprint_version": receipt.get("fingerprint_version"),
|
||
"source_scope": receipt.get("source_scope"),
|
||
"generated_at": redact(receipt["generated_at"]), "finished_at": redact(receipt["finished_at"]) if receipt.get("finished_at") else None,
|
||
"stages": [{**{key: redact(stage[key]) if isinstance(stage.get(key), str) else stage.get(key)
|
||
for key in ("id", "status", "exit_code", "duration_seconds", "log_path")},
|
||
"coverage_notes": coverage_notes([stage])} for stage in receipt["stages"]],
|
||
"attestation": "Receipt metadata is not an independent attestation, a live verification, or a completed module review."}
|
||
|
||
|
||
def _structured_note(args) -> dict:
|
||
payload = read_json(args.note_file, max_bytes=MAX_NOTE_BYTES) if args.note_file else {}
|
||
if not isinstance(payload, dict) or set(payload) - {"summary", "next", "body"}:
|
||
raise ValueError("Structured note accepts only summary[], next[] and body")
|
||
result = {}
|
||
for key, values in (("summary", args.note_summary), ("next", args.next_steps)):
|
||
supplied = payload.get(key, [])
|
||
if not isinstance(supplied, list) or len(supplied) + len(values) > 100:
|
||
raise ValueError("Summary and next steps must be bounded lists")
|
||
result[key] = [_text(item).strip() for item in [*supplied, *values]]
|
||
body = _text(payload.get("body", ""), maximum=MAX_NOTE_BYTES)
|
||
if args.body_file:
|
||
# Reuse the bounded, no-symlink file reader by wrapping no content in code.
|
||
from .common import reject_symlinks
|
||
import stat
|
||
reject_symlinks(args.body_file)
|
||
descriptor = os.open(args.body_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 > MAX_NOTE_BYTES:
|
||
raise ValueError("Additional body must be a bounded regular file")
|
||
text = handle.read(MAX_NOTE_BYTES + 1).decode("utf-8")
|
||
body += "\n\n" + _text(text, maximum=MAX_NOTE_BYTES)
|
||
result["body"] = body.strip()
|
||
return result
|
||
|
||
|
||
def _cell(value: Any) -> str:
|
||
return html.escape(str(value if value is not None else "—")).replace("|", "|").replace("`", "`").replace("\n", " ")
|
||
|
||
|
||
def render_note(note: dict, evidence: dict | None, target: NoteTarget, key: str) -> tuple[str, str]:
|
||
identity = {"target": target.url, "key": identifier(key),
|
||
"evidence": evidence["run_id"] if evidence else digest(note)}
|
||
marker = MARKER_PREFIX + digest(identity) + " -->"
|
||
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
|