Case sync: refresh remote URL with current token before each push
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 17:14:57 +00:00
parent 6b5d6586dc
commit 5e4c03d0cd
5 changed files with 183 additions and 60 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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,