From 5e4c03d0cdbe8b23f31d9fb29c70f9d84aa624f7 Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 28 Apr 2026 17:14:57 +0000 Subject: [PATCH] Case sync: refresh remote URL with current token before each push Cases failed to push silently after the Gitea token in Infisical was rotated: the embedded credential in each case repo's origin URL was the old token, the rotation never propagated, and capture_output=True hid the auth failure as a logger.warning. Three cases (1033-25, 1130-25, 1194-25) accumulated unpushed commits over weeks before this was noticed. Fixes the root cause in two places: web/gitea_client.py for uploads through the FastAPI endpoint, and mcp-server/services/git_sync.py for case_update / document_upload through MCP tools (which previously committed but never pushed at all). The new commit_and_push helper: - re-injects the current GITEA_ACCESS_TOKEN into the existing origin URL on every call, so pushes survive token rotation - logs push failures at WARNING with the actual stderr (the previous code suppressed errors entirely) - continues to push even when the commit was a no-op, in case earlier commits are still unpushed Co-Authored-By: Claude Opus 4.7 (1M context) --- mcp-server/src/legal_mcp/services/git_sync.py | 92 +++++++++++++++++++ mcp-server/src/legal_mcp/tools/cases.py | 14 +-- mcp-server/src/legal_mcp/tools/documents.py | 15 +-- web/app.py | 30 +----- web/gitea_client.py | 92 ++++++++++++++++--- 5 files changed, 183 insertions(+), 60 deletions(-) create mode 100644 mcp-server/src/legal_mcp/services/git_sync.py diff --git a/mcp-server/src/legal_mcp/services/git_sync.py b/mcp-server/src/legal_mcp/services/git_sync.py new file mode 100644 index 0000000..4afaeef --- /dev/null +++ b/mcp-server/src/legal_mcp/services/git_sync.py @@ -0,0 +1,92 @@ +"""Git sync helpers for case repos. + +Each case lives in its own git repo with a Gitea remote. The remote URL +embeds an auth token (https://chaim:TOKEN@host/...). When the token is +rotated in Infisical, repos created with the old token will fail to +push silently — only logged at WARNING level. ``commit_and_push`` +re-injects the *current* token into the existing origin URL on every +call, so push survives token rotation. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def _gitea_token() -> str: + return os.environ.get("GITEA_ACCESS_TOKEN") or os.environ.get("GITEA_TOKEN", "") + + +def _git_env() -> dict: + return { + "GIT_AUTHOR_NAME": "Ezer Mishpati", + "GIT_AUTHOR_EMAIL": "legal@local", + "GIT_COMMITTER_NAME": "Ezer Mishpati", + "GIT_COMMITTER_EMAIL": "legal@local", + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "GIT_TERMINAL_PROMPT": "0", + } + + +def _refresh_remote_url(case_dir: Path, env: dict) -> bool: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=case_dir, capture_output=True, text=True, + ) + if result.returncode != 0: + return False + current_url = result.stdout.strip() + if "@" in current_url and current_url.startswith("https://"): + bare_url = "https://" + current_url.split("@", 1)[1] + else: + bare_url = current_url + token = _gitea_token() + if not token: + return True # Push without auth — will fail, but caller decides what to do + auth_url = bare_url.replace("https://", f"https://chaim:{token}@") + if auth_url != current_url: + subprocess.run( + ["git", "remote", "set-url", "origin", auth_url], + cwd=case_dir, capture_output=True, env=env, + ) + return True + + +def commit_and_push(case_dir: str | Path, message: str) -> bool: + """Stage, commit, refresh origin URL with current token, and push. + + Best-effort: on failure logs at WARNING and returns False, but never + raises. Continues to push even if the commit was a no-op (in case + earlier commits are unpushed). + """ + case_dir = Path(case_dir) + if not (case_dir / ".git").exists(): + return False + + env = _git_env() + + subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True, env=env) + commit = subprocess.run( + ["git", "commit", "-m", message], + cwd=case_dir, capture_output=True, text=True, env=env, + ) + if commit.returncode != 0 and "nothing to commit" not in commit.stdout: + logger.warning("Git commit failed in %s: %s", case_dir, commit.stderr or commit.stdout) + + if not _refresh_remote_url(case_dir, env): + logger.warning("No origin remote configured in %s — skipping push", case_dir) + return False + + push = subprocess.run( + ["git", "push"], + cwd=case_dir, capture_output=True, text=True, env=env, + ) + if push.returncode != 0: + logger.warning("Git push failed in %s: %s", case_dir, push.stderr) + return False + return True diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index f23131d..4ce03db 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -13,7 +13,7 @@ from uuid import UUID import httpx from legal_mcp import config -from legal_mcp.services import audit, db, practice_area as pa +from legal_mcp.services import audit, db, git_sync, practice_area as pa logger = logging.getLogger(__name__) @@ -315,21 +315,13 @@ async def case_update( updated = await db.update_case(UUID(case["id"]), **fields) - # Git commit the update (best-effort) + # Git commit + push the update (best-effort) try: case_dir = config.find_case_dir(case_number) if case_dir.exists(): case_json = case_dir / "case.json" case_json.write_text(json.dumps(updated, default=str, ensure_ascii=False, indent=2)) - subprocess.run(["git", "add", "case.json"], cwd=case_dir, capture_output=True) - subprocess.run( - ["git", "commit", "-m", f"עדכון תיק: {', '.join(fields.keys())}"], - cwd=case_dir, - capture_output=True, - env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local", - "GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local", - "PATH": "/usr/bin:/bin"}, - ) + git_sync.commit_and_push(case_dir, f"עדכון תיק: {', '.join(fields.keys())}") except Exception: pass # git not available — non-critical diff --git a/mcp-server/src/legal_mcp/tools/documents.py b/mcp-server/src/legal_mcp/tools/documents.py index 8faad6b..67e9dbc 100644 --- a/mcp-server/src/legal_mcp/tools/documents.py +++ b/mcp-server/src/legal_mcp/tools/documents.py @@ -4,12 +4,11 @@ from __future__ import annotations import json import shutil -import subprocess from pathlib import Path from uuid import UUID from legal_mcp import config -from legal_mcp.services import db, processor +from legal_mcp.services import db, git_sync, processor async def document_upload( @@ -67,11 +66,10 @@ async def document_upload( await db.update_document(UUID(doc["id"]), doc_type=classified_type) doc["doc_type"] = classified_type - # Git commit (best-effort — don't fail upload on git errors) + # Git commit + push (best-effort — don't fail upload on git errors) try: repo_dir = config.find_case_dir(case_number) if repo_dir.exists(): - subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True) doc_type_hebrew = { "appeal": "כתב ערר", "response": "תשובה", @@ -85,14 +83,7 @@ async def document_upload( "exhibit": "נספח", "reference": "מסמך עזר", }.get(actual_doc_type, actual_doc_type) - subprocess.run( - ["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"], - cwd=repo_dir, - capture_output=True, - env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local", - "GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local", - "PATH": "/usr/bin:/bin"}, - ) + git_sync.commit_and_push(repo_dir, f"הוספת {doc_type_hebrew}: {title}") except Exception: pass # git not available in container — non-critical diff --git a/web/app.py b/web/app.py index 1ec980c..a072b27 100644 --- a/web/app.py +++ b/web/app.py @@ -34,7 +34,7 @@ from legal_mcp.tools import cases as cases_tools, search as search_tools, workfl # Import integration clients (same directory) _web_dir = Path(__file__).resolve().parent sys.path.insert(0, str(_web_dir.parent)) -from web.gitea_client import create_repo, setup_remote_and_push +from web.gitea_client import commit_and_push, create_repo, setup_remote_and_push from web.paperclip_client import ( archive_project as pc_archive_project, create_project as pc_create_project, @@ -3005,26 +3005,11 @@ async def _process_tagged_document(task_id: str, dest: Path, case_number: str, c _progress[task_id] = {"status": "processing", "filename": display_name, "step": "extracting"} result = await processor.process_document(doc_id, case_id) - # Git commit + push (best-effort — don't fail upload on git errors) try: repo_dir = config.find_case_dir(case_number) if repo_dir.exists(): - env = { - "GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local", - "GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local", - "PATH": "/usr/bin:/bin", - } doc_type_hebrew = DOC_TYPE_NAMES.get(doc_type, doc_type) - subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True) - subprocess.run( - ["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {display_name}"], - cwd=repo_dir, capture_output=True, env=env, - ) - # Try to push to Gitea (non-blocking) - subprocess.run(["git", "push"], cwd=repo_dir, capture_output=True, env={ - **env, - "GIT_TERMINAL_PROMPT": "0", - }) + commit_and_push(repo_dir, f"הוספת {doc_type_hebrew}: {display_name}") except Exception: logger.warning("Git commit/push failed for %s (non-critical)", display_name) @@ -3360,20 +3345,13 @@ async def _process_case_document(task_id: str, source: Path, req: ClassifyReques try: repo_dir = config.find_case_dir(req.case_number) if repo_dir.exists(): - subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True) doc_type_hebrew = { "appeal": "כתב ערר", "response": "תשובה", "decision": "החלטה", "reference": "מסמך עזר", "exhibit": "נספח", }.get(req.doc_type, req.doc_type) - subprocess.run( - ["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"], - cwd=repo_dir, capture_output=True, - env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local", - "GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local", - "PATH": "/usr/bin:/bin"}, - ) + commit_and_push(repo_dir, f"הוספת {doc_type_hebrew}: {title}") except Exception: - logger.warning("Git commit failed for %s (non-critical)", req.filename) + logger.warning("Git commit/push failed for %s (non-critical)", req.filename) # Remove from uploads source.unlink(missing_ok=True) diff --git a/web/gitea_client.py b/web/gitea_client.py index ade7c65..04ae688 100644 --- a/web/gitea_client.py +++ b/web/gitea_client.py @@ -48,24 +48,96 @@ async def create_repo(case_number: str, title: str, description: str = "") -> di return resp.json() -def setup_remote_and_push(case_dir: str | Path, repo_clone_url: str) -> bool: - """Add Gitea as remote 'origin' (or update it) and push.""" - case_dir = Path(case_dir) - if not (case_dir / ".git").exists(): - return False - - env = { +def _git_env() -> dict: + return { "GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local", "GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local", "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "GIT_TERMINAL_PROMPT": "0", } - # Inject token into clone URL for auth + +def _refresh_remote_url(case_dir: Path, env: dict) -> bool: + """Re-inject current token into existing origin URL. + + Handles the case where the token in Infisical was rotated but the + case repo's origin URL still has the old token embedded. + Returns True if origin exists (and was refreshed), False otherwise. + """ + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=case_dir, capture_output=True, text=True, + ) + if result.returncode != 0: + return False + current_url = result.stdout.strip() + # Strip any embedded credentials (https://user:token@host -> https://host) + if "@" in current_url and current_url.startswith("https://"): + bare_url = "https://" + current_url.split("@", 1)[1] + else: + bare_url = current_url + auth_url = bare_url.replace("https://", f"https://chaim:{_token()}@") + if auth_url != current_url: + subprocess.run( + ["git", "remote", "set-url", "origin", auth_url], + cwd=case_dir, capture_output=True, env=env, + ) + return True + + +def commit_and_push(case_dir: str | Path, message: str) -> bool: + """Stage, commit, refresh remote URL with current token, and push. + + Best-effort: returns False on any failure, but logs the reason at + WARNING level so issues are visible (the previous code swallowed + push failures silently). + """ + case_dir = Path(case_dir) + if not (case_dir / ".git").exists(): + return False + + env = _git_env() + + subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True, env=env) + commit = subprocess.run( + ["git", "commit", "-m", message], + cwd=case_dir, capture_output=True, text=True, env=env, + ) + if commit.returncode != 0 and "nothing to commit" not in commit.stdout: + logger.warning("Git commit failed in %s: %s", case_dir, commit.stderr or commit.stdout) + # Continue anyway — maybe there were no new changes but we still + # want to push earlier unpushed commits. + + if not _refresh_remote_url(case_dir, env): + logger.warning("No origin remote configured in %s — skipping push", case_dir) + return False + + push = subprocess.run( + ["git", "push"], + cwd=case_dir, capture_output=True, text=True, env=env, + ) + if push.returncode != 0: + logger.warning("Git push failed in %s: %s", case_dir, push.stderr) + return False + return True + + +def setup_remote_and_push(case_dir: str | Path, repo_clone_url: str) -> bool: + """Add Gitea as remote 'origin' (or update it) and push. + + Used on initial case creation. For routine commits + push on + existing repos, prefer ``commit_and_push``. + """ + case_dir = Path(case_dir) + if not (case_dir / ".git").exists(): + return False + + env = _git_env() + auth_url = repo_clone_url.replace("https://", f"https://chaim:{_token()}@") - # Check if remote exists result = subprocess.run( ["git", "remote", "get-url", "origin"], cwd=case_dir, capture_output=True, text=True, @@ -81,13 +153,11 @@ def setup_remote_and_push(case_dir: str | Path, repo_clone_url: str) -> bool: cwd=case_dir, capture_output=True, env=env, ) - # Push push_result = subprocess.run( ["git", "push", "-u", "origin", "main"], cwd=case_dir, capture_output=True, text=True, env=env, ) if push_result.returncode != 0: - # Try master branch push_result = subprocess.run( ["git", "push", "-u", "origin", "master"], cwd=case_dir, capture_output=True, text=True, env=env,