Case sync: refresh remote URL with current token before each push
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
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:
92
mcp-server/src/legal_mcp/services/git_sync.py
Normal file
92
mcp-server/src/legal_mcp/services/git_sync.py
Normal 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
|
||||||
@@ -13,7 +13,7 @@ from uuid import UUID
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from legal_mcp import config
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -315,21 +315,13 @@ async def case_update(
|
|||||||
|
|
||||||
updated = await db.update_case(UUID(case["id"]), **fields)
|
updated = await db.update_case(UUID(case["id"]), **fields)
|
||||||
|
|
||||||
# Git commit the update (best-effort)
|
# Git commit + push the update (best-effort)
|
||||||
try:
|
try:
|
||||||
case_dir = config.find_case_dir(case_number)
|
case_dir = config.find_case_dir(case_number)
|
||||||
if case_dir.exists():
|
if case_dir.exists():
|
||||||
case_json = case_dir / "case.json"
|
case_json = case_dir / "case.json"
|
||||||
case_json.write_text(json.dumps(updated, default=str, ensure_ascii=False, indent=2))
|
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)
|
git_sync.commit_and_push(case_dir, f"עדכון תיק: {', '.join(fields.keys())}")
|
||||||
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"},
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # git not available — non-critical
|
pass # git not available — non-critical
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp import config
|
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(
|
async def document_upload(
|
||||||
@@ -67,11 +66,10 @@ async def document_upload(
|
|||||||
await db.update_document(UUID(doc["id"]), doc_type=classified_type)
|
await db.update_document(UUID(doc["id"]), doc_type=classified_type)
|
||||||
doc["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:
|
try:
|
||||||
repo_dir = config.find_case_dir(case_number)
|
repo_dir = config.find_case_dir(case_number)
|
||||||
if repo_dir.exists():
|
if repo_dir.exists():
|
||||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
|
||||||
doc_type_hebrew = {
|
doc_type_hebrew = {
|
||||||
"appeal": "כתב ערר",
|
"appeal": "כתב ערר",
|
||||||
"response": "תשובה",
|
"response": "תשובה",
|
||||||
@@ -85,14 +83,7 @@ async def document_upload(
|
|||||||
"exhibit": "נספח",
|
"exhibit": "נספח",
|
||||||
"reference": "מסמך עזר",
|
"reference": "מסמך עזר",
|
||||||
}.get(actual_doc_type, actual_doc_type)
|
}.get(actual_doc_type, actual_doc_type)
|
||||||
subprocess.run(
|
git_sync.commit_and_push(repo_dir, f"הוספת {doc_type_hebrew}: {title}")
|
||||||
["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"},
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # git not available in container — non-critical
|
pass # git not available in container — non-critical
|
||||||
|
|
||||||
|
|||||||
30
web/app.py
30
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)
|
# Import integration clients (same directory)
|
||||||
_web_dir = Path(__file__).resolve().parent
|
_web_dir = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(_web_dir.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 (
|
from web.paperclip_client import (
|
||||||
archive_project as pc_archive_project,
|
archive_project as pc_archive_project,
|
||||||
create_project as pc_create_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"}
|
_progress[task_id] = {"status": "processing", "filename": display_name, "step": "extracting"}
|
||||||
result = await processor.process_document(doc_id, case_id)
|
result = await processor.process_document(doc_id, case_id)
|
||||||
|
|
||||||
# Git commit + push (best-effort — don't fail upload on git errors)
|
|
||||||
try:
|
try:
|
||||||
repo_dir = config.find_case_dir(case_number)
|
repo_dir = config.find_case_dir(case_number)
|
||||||
if repo_dir.exists():
|
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)
|
doc_type_hebrew = DOC_TYPE_NAMES.get(doc_type, doc_type)
|
||||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
commit_and_push(repo_dir, f"הוספת {doc_type_hebrew}: {display_name}")
|
||||||
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",
|
|
||||||
})
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Git commit/push failed for %s (non-critical)", display_name)
|
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:
|
try:
|
||||||
repo_dir = config.find_case_dir(req.case_number)
|
repo_dir = config.find_case_dir(req.case_number)
|
||||||
if repo_dir.exists():
|
if repo_dir.exists():
|
||||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
|
||||||
doc_type_hebrew = {
|
doc_type_hebrew = {
|
||||||
"appeal": "כתב ערר", "response": "תשובה", "decision": "החלטה",
|
"appeal": "כתב ערר", "response": "תשובה", "decision": "החלטה",
|
||||||
"reference": "מסמך עזר", "exhibit": "נספח",
|
"reference": "מסמך עזר", "exhibit": "נספח",
|
||||||
}.get(req.doc_type, req.doc_type)
|
}.get(req.doc_type, req.doc_type)
|
||||||
subprocess.run(
|
commit_and_push(repo_dir, f"הוספת {doc_type_hebrew}: {title}")
|
||||||
["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"},
|
|
||||||
)
|
|
||||||
except Exception:
|
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
|
# Remove from uploads
|
||||||
source.unlink(missing_ok=True)
|
source.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -48,24 +48,96 @@ async def create_repo(case_number: str, title: str, description: str = "") -> di
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
def setup_remote_and_push(case_dir: str | Path, repo_clone_url: str) -> bool:
|
def _git_env() -> dict:
|
||||||
"""Add Gitea as remote 'origin' (or update it) and push."""
|
return {
|
||||||
case_dir = Path(case_dir)
|
|
||||||
if not (case_dir / ".git").exists():
|
|
||||||
return False
|
|
||||||
|
|
||||||
env = {
|
|
||||||
"GIT_AUTHOR_NAME": "Ezer Mishpati",
|
"GIT_AUTHOR_NAME": "Ezer Mishpati",
|
||||||
"GIT_AUTHOR_EMAIL": "legal@local",
|
"GIT_AUTHOR_EMAIL": "legal@local",
|
||||||
"GIT_COMMITTER_NAME": "Ezer Mishpati",
|
"GIT_COMMITTER_NAME": "Ezer Mishpati",
|
||||||
"GIT_COMMITTER_EMAIL": "legal@local",
|
"GIT_COMMITTER_EMAIL": "legal@local",
|
||||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
"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()}@")
|
auth_url = repo_clone_url.replace("https://", f"https://chaim:{_token()}@")
|
||||||
|
|
||||||
# Check if remote exists
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "remote", "get-url", "origin"],
|
["git", "remote", "get-url", "origin"],
|
||||||
cwd=case_dir, capture_output=True, text=True,
|
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,
|
cwd=case_dir, capture_output=True, env=env,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Push
|
|
||||||
push_result = subprocess.run(
|
push_result = subprocess.run(
|
||||||
["git", "push", "-u", "origin", "main"],
|
["git", "push", "-u", "origin", "main"],
|
||||||
cwd=case_dir, capture_output=True, text=True, env=env,
|
cwd=case_dir, capture_output=True, text=True, env=env,
|
||||||
)
|
)
|
||||||
if push_result.returncode != 0:
|
if push_result.returncode != 0:
|
||||||
# Try master branch
|
|
||||||
push_result = subprocess.run(
|
push_result = subprocess.run(
|
||||||
["git", "push", "-u", "origin", "master"],
|
["git", "push", "-u", "origin", "master"],
|
||||||
cwd=case_dir, capture_output=True, text=True, env=env,
|
cwd=case_dir, capture_output=True, text=True, env=env,
|
||||||
|
|||||||
Reference in New Issue
Block a user