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.
This commit is contained in:
Executable
+547
@@ -0,0 +1,547 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user