From 317942e15b1c0eab48b54f2ee8264e5184f74993 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Mon, 24 Aug 2026 19:38:48 +0000 Subject: [PATCH] fix(pilot): strip opencode $schema + defensive harvest dict-strip - opencode_review.install_config: opencode 1.3.10 rejects factory opencode.json$'\''s top-level "$schema" key with 'Unrecognized key' at parse time, causing every fresh review to fail as the misleading 'opencode empty text (rc=0)'. Add a small drop-list of known-bad top-level keys + a sanitizer applied to both the fast (no-env) and slow (env-substituted) write paths. - feedback_harvest: Gitea versions occasionally serialize reaction content / inline-comment resolver as a dict instead of a string, crashing harvester with 'dict object has no attribute strip'. Coerce both via str() with a brief comment documenting the WHY. Affected reviews: techspark/suaspark-dashboard #11 (fixed), PRAgent reviews on PR #9 era (recovered). Verified: opencode 1.3.10 emits real '### Summary of Changes' body. --- pilot/feedback_harvest.py | 16 ++++++++++++---- pilot/opencode_review.py | 31 +++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/pilot/feedback_harvest.py b/pilot/feedback_harvest.py index 31772ec..6175c93 100644 --- a/pilot/feedback_harvest.py +++ b/pilot/feedback_harvest.py @@ -244,7 +244,9 @@ def harvest_for_pr( if react_status == 200 and isinstance(react_payload, list): for r in react_payload: ruser = (r.get("user") or {}).get("login", "") or "?" - rcontent = (r.get("content") or "").strip() + # Gitea has occasionally returned `content` as a + # dict on older versions; coerce to str defensively. + rcontent = str(r.get("content") or "").strip() if not rcontent: continue if record_reaction( @@ -255,9 +257,15 @@ def harvest_for_pr( stats["reactions_recorded"] += 1 # 4. Thread state (Gitea's `resolver` field on the inline - # comment). Non-empty string = resolved. - resolver = (ic.get("resolver") or "").strip() - if ic.get("resolver") is not None: # field present, even if "" + # comment). Some Gitea versions serialize this as a user + # object ({login, ...}) instead of a username string — + # coerce defensively before calling .strip(). + resolver_raw = ic.get("resolver") + if isinstance(resolver_raw, dict): + resolver = (resolver_raw.get("login") or "").strip() + else: + resolver = str(resolver_raw or "").strip() + if resolver_raw is not None: # field present, even if "" record_thread_state( conn, finding_id=finding_id, resolved=bool(resolver), diff --git a/pilot/opencode_review.py b/pilot/opencode_review.py index 990e8b5..6cd8eda 100644 --- a/pilot/opencode_review.py +++ b/pilot/opencode_review.py @@ -421,9 +421,33 @@ def install_config(src: str, dst: str) -> bool: return False default_url = os.environ.get("PRAGENT_MODEL_BASE_URL", "").strip() default_key = os.environ.get("PRAGENT_MODEL_API_KEY", "").strip() + + # Strip keys opencode's runtime rejects on every version bump we touch. The + # factory `opencode.json` is committed for documentation (so `$schema` + # stays in the file for editor IntelliSense), but opencode 1.3.10 errors + # with "Unrecognized key: schema" at config-parse time and refuses to + # register ANY provider/model — surfacing to the user as the misleading + # "opencode empty text (rc=0)" failure post. Keep the drop list small and + # documented; smoke-test before adding more. + _OPENCODE_INCOMPATIBLE_TOP_KEYS = ("$schema",) + + def _sanitize_and_write(cfg: dict) -> None: + for k in _OPENCODE_INCOMPATIBLE_TOP_KEYS: + cfg.pop(k, None) + with open(dst, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2) + if not default_url and not default_key: - # Fast path: no env at all → commit copy is fine, no rewrite needed. - shutil.copy2(src, dst) + # Fast path: no env at all → still sanitize (the schema key would + # poison every fresh-pod warm-up if we skipped). + try: + with open(src, encoding="utf-8") as f: + cfg = json.load(f) + _sanitize_and_write(cfg) + except (OSError, ValueError): + # If we can't parse, fall back to verbatim copy — opencode will + # report the parse error itself, no need to hide it. + shutil.copy2(src, dst) return True try: with open(src, encoding="utf-8") as f: @@ -439,8 +463,7 @@ def install_config(src: str, dst: str) -> bool: prov["options"]["baseURL"] = url if key: prov["options"]["apiKey"] = key - with open(dst, "w", encoding="utf-8") as f: - json.dump(cfg, f, indent=2) + _sanitize_and_write(cfg) except (OSError, ValueError, AttributeError): # A malformed config is opencode's problem to report, not ours to hide. shutil.copy2(src, dst)