#!/usr/bin/env python3 """publish_source_ledger.py — idempotent Gitea publication for the Source Ledger (PROV-1, #282). Pure projection publisher: renders the ledger from the accepted OUTWARD artifact and posts/updates a single OUTWARD SOURCE LEDGER comment on the issue. Idempotency: the comment is identified by a fixed marker containing the artifact hash — re-running with the same hash UPDATES the existing comment instead of creating duplicates. Publication failure raises visibly (per #282 constraint: provenance publication fails visibly, must not silently falsify the ledger). It does NOT alter research cognition or stage outcomes — callers invoke it after gate acceptance, and its failure does not invalidate the accepted artifact. """ from __future__ import annotations import json import os import urllib.request from source_ledger import build_records, render_markdown import sys as _sys _SCRIPT_DIR = str(__import__('pathlib').Path(__file__).resolve().parent) if _SCRIPT_DIR not in _sys.path: _sys.path.insert(0, _SCRIPT_DIR) import gitea_auth MARKER_PREFIX = "" def publish( issue: int, artifact_dir: str, artifact_hash: str, runner: dict | None = None, model: str | None = None ) -> dict: """Render + post/update the ledger comment. Returns publication result.""" path = os.path.join(artifact_dir, "outward.json") with open(path) as f: outward = json.load(f) # runner/model: persisted factual fields from the artifact; UNKNOWN if absent runner = runner or outward.get("runner") model = model or outward.get("model", "UNKNOWN") records = build_records(outward) legacy = not any( c.get("contradicts_source_ids") for c in outward.get("contradictory_evidence", outward.get("contradictions", [])) if isinstance(c, dict) ) body = ( _marker(artifact_hash) + "\n" + render_markdown(records, outward, runner, model, artifact_hash, legacy_claims=legacy) ) # Find existing ledger comment for this artifact hash (idempotency) status, comments = _api("GET", f"/repos/conductor/rune/issues/{issue}/comments?limit=50") if status != 200 or not isinstance(comments, list): raise RuntimeError(f"SourceLedger publish: cannot list comments (HTTP {status})") existing = None for c in comments: if isinstance(c, dict) and c.get("body", "").startswith(_marker(artifact_hash)): existing = c break if existing: status, _ = _api("PATCH", f"/repos/conductor/rune/issues/comments/{existing['id']}", {"body": body}) if status not in (200, 201): raise RuntimeError(f"SourceLedger publish: update failed (HTTP {status})") return {"action": "updated", "comment_id": existing["id"], "artifact_hash": artifact_hash} status, created = _api("POST", f"/repos/conductor/rune/issues/{issue}/comments", {"body": body}) if status not in (200, 201): raise RuntimeError(f"SourceLedger publish: create failed (HTTP {status}): {created}") return {"action": "created", "comment_id": created.get("id"), "artifact_hash": artifact_hash} def main_with(argv: list[str]) -> int: import argparse parser = argparse.ArgumentParser(description="Publish the OUTWARD Source Ledger") parser.add_argument("--issue", type=int, required=True) parser.add_argument("--artifact-dir", required=True) parser.add_argument("--artifact-hash", required=True) args = parser.parse_args(argv[1:]) result = publish(args.issue, args.artifact_dir, args.artifact_hash) print(json.dumps(result)) return 0 if __name__ == "__main__": import sys raise SystemExit(main_with(sys.argv))