From f534696fb80cff01859f700ca815c9653ee63927 Mon Sep 17 00:00:00 2001 From: Mathis Date: Mon, 14 Sep 2026 22:31:55 +0000 Subject: [PATCH 1/2] fix(ferndesk): skip unchanged fps + lookup-before-create Avoid burning FernDesk write quota on re-runs that PATCH every page before creating the remainder. Skip when fp: matches keywords; resolve missing cache entries via slug lookup before POST. --- scripts/ferndesk_sync.py | 53 ++++++++++++++++++++++- scripts/tests/ferndesk-sync-retry.test.py | 4 +- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/scripts/ferndesk_sync.py b/scripts/ferndesk_sync.py index 73c0f1b..90bb5b1 100755 --- a/scripts/ferndesk_sync.py +++ b/scripts/ferndesk_sync.py @@ -506,7 +506,11 @@ def main() -> int: if a.get("sectionId") == section["id"] ] by_slug = { - a.get("slug"): {"id": a["id"], "status": a.get("status")} + a.get("slug"): { + "id": a["id"], + "status": a.get("status"), + "keywords": a.get("keywords") or "", + } for a in articles if a.get("slug") } @@ -531,10 +535,16 @@ def main() -> int: if existing: eid = existing["id"] if isinstance(existing, dict) else existing estatus = existing.get("status") if isinstance(existing, dict) else None + ekw = (existing.get("keywords") or "") if isinstance(existing, dict) else "" if dry: log(f"DRY update {page['slug']} -> {eid}") skipped += 1 continue + # Skip PATCH when Mintlify fingerprint already present (saves write quota). + if page["fp"] and f"fp:{page['fp']}" in ekw: + skipped += 1 + log(f"skip unchanged {page['slug']}") + continue try: api(key, f"/articles/{eid}", "PATCH", body_common, label=page["slug"]) if publish and estatus != "published": @@ -547,6 +557,12 @@ def main() -> int: failures.append({"slug": page["slug"], "op": "update", "error": str(e)}) log(f"ERROR update {page['slug']} failed after retries: {e}") continue + by_slug[page["slug"]] = { + "id": eid, + "status": "published" if publish else (estatus or "draft"), + "keywords": page["keywords"], + } + cache_path.write_text(json.dumps(by_slug, indent=2) + "\n") updated += 1 log(f"updated {page['slug']}") time.sleep(1.2) @@ -556,6 +572,41 @@ def main() -> int: log(f"DRY create {page['slug']} in {page['collection']}") created += 1 continue + # Lookup-before-create: avoid POST when slug already exists but missed cache. + looked = None + try: + for a in list_all( + key, + f"/articles?sectionId={urllib.parse.quote(str(section['id']))}&slug={urllib.parse.quote(page['slug'])}", + max_pages=3, + ): + if a.get("slug") == page["slug"]: + looked = a + break + except RuntimeError as e: + log(f"slug lookup failed for {page['slug']}: {e}") + if looked: + try: + api(key, f"/articles/{looked['id']}", "PATCH", body_common, label=page["slug"]) + if publish and looked.get("status") != "published": + time.sleep(0.3) + api(key, f"/articles/{looked['id']}/publish", "POST", {}, label=page["slug"]) + except RuntimeError as e: + failed += 1 + failures.append({"slug": page["slug"], "op": "lookup-update", "error": str(e)}) + log(f"ERROR lookup-update {page['slug']} failed after retries: {e}") + continue + by_slug[page["slug"]] = { + "id": looked["id"], + "status": looked.get("status") or "published", + "keywords": page["keywords"], + } + cache_path.write_text(json.dumps(by_slug, indent=2) + "\n") + updated += 1 + log(f"lookup-update {page['slug']}") + time.sleep(1.5) + continue + try: art = api( key, diff --git a/scripts/tests/ferndesk-sync-retry.test.py b/scripts/tests/ferndesk-sync-retry.test.py index d3539a9..d041ff0 100644 --- a/scripts/tests/ferndesk-sync-retry.test.py +++ b/scripts/tests/ferndesk-sync-retry.test.py @@ -262,7 +262,7 @@ def flaky_then_ok(method, url, n): SECTIONS = [{"id": "sec-1", "name": "Staging"}] COLLECTIONS = [{"id": "col-1", "sectionId": "sec-1", "title": "Getting Started"}] -# --- create conflict recovery (main's slug lookup + PATCH) still works ------- +# --- slug lookup / create conflict recovery (lookup-before-create + PATCH) ------- def conflict_routes(method, url, n): if "/sections" in url: return FakeResponse(200, SECTIONS) @@ -300,7 +300,7 @@ def conflict_routes(method, url, n): check("conflict recovery reports no failures", summary.get("failed") == 0, summary) check("conflict recovery caches the found id", json.loads(cache_path.read_text()).get( "getting-started-quickstart", {}).get("id") == "art-9", cache_path.read_text()) - check("conflict recovery is logged", any("recovered-update" in line for line in logs), logs) + check("conflict recovery is logged", any(("recovered-update" in line or "lookup-update" in line) for line in logs), logs) finally: restore(saved) From d7698f5910b6161af13f99708835acab0d8b5aa5 Mon Sep 17 00:00:00 2001 From: Mathis Date: Mon, 14 Sep 2026 22:37:52 +0000 Subject: [PATCH 2/2] fix(ferndesk): humanize snake_case problem titles; skip unchanged FernDesk POST /articles returns 400 for bare titles like bad_request. Prefer description-based titles for snake_case codes. Also skip unchanged fps and lookup-before-create (COR-444). --- scripts/ferndesk_sync.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/ferndesk_sync.py b/scripts/ferndesk_sync.py index 90bb5b1..1a6487b 100755 --- a/scripts/ferndesk_sync.py +++ b/scripts/ferndesk_sync.py @@ -406,7 +406,14 @@ def discover_pages(docs_root: Path) -> list[dict]: continue meta, body = strip_frontmatter(path.read_text(encoding="utf-8", errors="replace")) md = mdx_to_markdown(body) - title = meta.get("title") or path.stem.replace("-", " ").title() + raw_title = meta.get("title") or path.stem.replace("-", " ").replace("_", " ").title() + # FernDesk 400s on bare snake_case problem codes as titles (COR-444). + if "_" in raw_title and " " not in raw_title: + desc = (meta.get("description") or "").split(".")[0].strip() + nice = raw_title.replace("_", " ").title() + title = f"{desc} ({raw_title})" if desc else nice + else: + title = raw_title.replace("_", " ") if "_" in raw_title else raw_title slug = rel.rsplit(".", 1)[0] if slug.endswith("/index"): slug = slug[: -len("/index")] or "index"