Files
govoplan/tools/devkit/govoplan_devkit/issues.py
T
zemion 2ffdb23f69
Dependency Audit / dependency-audit (push) Successful in 1m45s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m30s
feat(devkit): add resumable workspace automation and UI review tooling
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.
2026-09-09 02:03:17 +02:00

548 lines
31 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 1256 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("|", "&#124;").replace("`", "&#96;").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