From 82ba4663baf7fdffdacfb747aee560786bd4acb8 Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 14 Apr 2026 15:28:16 +0000 Subject: [PATCH] Fix case repo sync + auto-create Gitea repos + add sync indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auto-sync-cases.sh: fix broken directory scan (was looking for status subdirs that don't exist), fix env var word-splitting bug, add safe.directory handling and error logging - cases.py: auto-create Gitea repo on case_create, fix documents/original → documents/originals naming mismatch - app.py: add GET /api/cases/{case_number}/git-status endpoint - web-ui: add SyncIndicator component in case header showing sync status (synced/pending/no remote) with last commit time - pyproject.toml: add httpx dependency - CLAUDE.md: update Paperclip wakeup API docs - settings page: switch tag input from Select to free-text with datalist Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 13 ++- mcp-server/pyproject.toml | 1 + mcp-server/src/legal_mcp/tools/cases.py | 101 +++++++++++++++++- scripts/auto-sync-cases.sh | 62 +++++++---- web-ui/src/app/settings/page.tsx | 36 ++++--- web-ui/src/components/cases/case-header.tsx | 5 + .../src/components/cases/sync-indicator.tsx | 62 +++++++++++ web-ui/src/lib/api/cases.ts | 22 ++++ web/app.py | 59 ++++++++++ 9 files changed, 316 insertions(+), 45 deletions(-) create mode 100644 web-ui/src/components/cases/sync-indicator.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 45a80ce..9e14e1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,10 +106,15 @@ ## Paperclip — כללי אינטגרציה קריטיים ### Wakeup API — תמיד דרך API, לעולם לא דרך DB -- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` עם `{"source": "...", "reason": "..."}` -- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק את הרשומה בטבלה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם** -- **הסיבה**: ה-dispatcher של Paperclip מעבד `heartbeat_runs`, לא `agent_wakeup_requests`. רק ה-API יוצר את שניהם אטומית -- **Board API Key**: שמור ב-DB (`board_api_keys`), מאפשר גישה ל-API מ-CLI +- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` (לא `/wake`!) +- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק רשומה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם** +- **⚠️ חובה לשלוח `payload` עם `issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי cwd נכון) +- דוגמה נכונה: + ```json + {"source": "automation", "triggerDetail": "system", "reason": "...", + "payload": {"issueId": "...", "mutation": "comment", "commentId": "..."}} + ``` +- **Board API Key**: שמור ב-DB (`board_api_keys`), auth: `Authorization: Bearer pbk_...` ### ניתוב comments דרך CEO - כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()` diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml index 75c1233..2829d57 100644 --- a/mcp-server/pyproject.toml +++ b/mcp-server/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "google-cloud-vision>=3.7.0", "fastapi>=0.115.0", "uvicorn[standard]>=0.30.0", + "httpx>=0.27.0", ] [build-system] diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index 9d531a5..3b94b93 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -3,14 +3,107 @@ from __future__ import annotations import json +import logging +import os import shutil import subprocess from pathlib import Path from uuid import UUID +import httpx + from legal_mcp import config from legal_mcp.services import audit, db, practice_area as pa +logger = logging.getLogger(__name__) + +GITEA_ORG = "cases" + + +def _gitea_host() -> str: + return os.environ.get("GITEA_HOST", "https://gitea.nautilus.marcusgroup.org") + + +def _gitea_token() -> str: + return os.environ.get("GITEA_ACCESS_TOKEN") or os.environ.get("GITEA_TOKEN", "") + + +async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> bool: + """Create Gitea repo and configure git remote. Best-effort — returns False on failure.""" + token = _gitea_token() + if not token: + logger.info("No GITEA_TOKEN — skipping Gitea repo creation for %s", case_number) + return False + + try: + async with httpx.AsyncClient(verify=False, timeout=30) as client: + resp = await client.post( + f"{_gitea_host()}/api/v1/orgs/{GITEA_ORG}/repos", + headers={"Authorization": f"token {token}"}, + json={ + "name": case_number, + "description": f"ערר {case_number} — {title}"[:255], + "private": True, + "auto_init": False, + }, + ) + if resp.status_code == 409: + resp2 = await client.get( + f"{_gitea_host()}/api/v1/repos/{GITEA_ORG}/{case_number}", + headers={"Authorization": f"token {token}"}, + ) + resp2.raise_for_status() + repo = resp2.json() + else: + resp.raise_for_status() + repo = resp.json() + + clone_url = repo.get("clone_url", "") + if not clone_url: + return False + + auth_url = clone_url.replace("https://", f"https://chaim:{token}@") + + git_env = { + "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"), + } + + # Add or update remote + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=case_dir, capture_output=True, text=True, + ) + if result.returncode == 0: + subprocess.run( + ["git", "remote", "set-url", "origin", auth_url], + cwd=case_dir, capture_output=True, env=git_env, + ) + else: + subprocess.run( + ["git", "remote", "add", "origin", auth_url], + cwd=case_dir, capture_output=True, env=git_env, + ) + + # Push + push = subprocess.run( + ["git", "push", "-u", "origin", "HEAD"], + cwd=case_dir, capture_output=True, text=True, env=git_env, + ) + if push.returncode != 0: + logger.warning("Gitea push failed for %s: %s", case_number, push.stderr) + return False + + logger.info("Gitea repo created and pushed for %s", case_number) + return True + + except Exception as exc: + logger.warning("Gitea setup failed for %s: %s", case_number, exc) + return False + async def case_create( case_number: str, @@ -92,7 +185,7 @@ async def case_create( case_dir.mkdir(parents=True, exist_ok=True) docs_dir = case_dir / "documents" docs_dir.mkdir(exist_ok=True) - (docs_dir / "original").mkdir(exist_ok=True) + (docs_dir / "originals").mkdir(exist_ok=True) (docs_dir / "extracted").mkdir(exist_ok=True) (docs_dir / "proofread").mkdir(exist_ok=True) (docs_dir / "backup").mkdir(exist_ok=True) @@ -121,6 +214,12 @@ async def case_create( except Exception: pass # git not available — non-critical + # Create Gitea repo and configure remote (best-effort) + try: + await _setup_gitea_remote(case_number, title, case_dir) + except Exception: + pass # Gitea not available — non-critical + return json.dumps(case, default=str, ensure_ascii=False, indent=2) diff --git a/scripts/auto-sync-cases.sh b/scripts/auto-sync-cases.sh index fc083cf..c6ca004 100755 --- a/scripts/auto-sync-cases.sh +++ b/scripts/auto-sync-cases.sh @@ -4,34 +4,50 @@ CASES_DIR="/home/chaim/legal-ai/data/cases" LOG="/home/chaim/legal-ai/data/.auto-sync.log" -GIT_ENV="GIT_AUTHOR_NAME=Ezer Mishpati GIT_AUTHOR_EMAIL=legal@local GIT_COMMITTER_NAME=Ezer Mishpati GIT_COMMITTER_EMAIL=legal@local GIT_TERMINAL_PROMPT=0" -for status_dir in "$CASES_DIR"/new "$CASES_DIR"/in-progress "$CASES_DIR"/completed; do - [ -d "$status_dir" ] || continue - for case_dir in "$status_dir"/*/; do - [ -d "$case_dir/.git" ] || continue +export GIT_AUTHOR_NAME="Ezer Mishpati" +export GIT_AUTHOR_EMAIL="legal@local" +export GIT_COMMITTER_NAME="Ezer Mishpati" +export GIT_COMMITTER_EMAIL="legal@local" +export GIT_TERMINAL_PROMPT=0 - cd "$case_dir" || continue +for case_dir in "$CASES_DIR"/*/; do + [ -d "$case_dir/.git" ] || continue - # Check for any changes (modified, new, deleted) - changes=$(git status --porcelain 2>/dev/null) - [ -z "$changes" ] && continue + case_name=$(basename "$case_dir") - # Stage all changes - git add -A 2>/dev/null + # Ensure safe.directory is set for this repo + git config --global --get-all safe.directory | grep -qF "$case_dir" \ + || git config --global --add safe.directory "$case_dir" - # Build commit message from changed files - changed_files=$(git diff --cached --name-only 2>/dev/null | head -5) - count=$(git diff --cached --name-only 2>/dev/null | wc -l) - case_name=$(basename "$case_dir") - msg="סנכרון אוטומטי — ${count} קבצים שונו" + cd "$case_dir" || continue - # Commit - env $GIT_ENV git commit -m "$msg" --quiet 2>/dev/null - if [ $? -eq 0 ]; then - # Push (non-blocking, ignore errors) - git push origin main --quiet 2>/dev/null - echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced" >> "$LOG" + # Check for any changes (modified, new, deleted) + changes=$(git status --porcelain 2>/dev/null) + [ -z "$changes" ] && continue + + # Stage all changes + git add -A 2>/dev/null + + # Count changed files + count=$(git diff --cached --name-only 2>/dev/null | wc -l) + [ "$count" -eq 0 ] && continue + + msg="סנכרון אוטומטי — ${count} קבצים שונו" + + # Commit + if git commit -m "$msg" --quiet 2>/dev/null; then + # Push only if remote exists + if git remote get-url origin >/dev/null 2>&1; then + if git push origin HEAD --quiet 2>/dev/null; then + echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced + pushed" >> "$LOG" + else + echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files committed, push FAILED" >> "$LOG" + fi + else + echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files committed (no remote)" >> "$LOG" fi - done + else + echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | commit FAILED" >> "$LOG" + fi done diff --git a/web-ui/src/app/settings/page.tsx b/web-ui/src/app/settings/page.tsx index 67b32fe..31847de 100644 --- a/web-ui/src/app/settings/page.tsx +++ b/web-ui/src/app/settings/page.tsx @@ -25,6 +25,8 @@ import { import { APPEAL_SUBTYPES } from "@/lib/practice-area"; import { toast } from "sonner"; +const TAG_SUGGESTIONS = APPEAL_SUBTYPES.filter((s) => s.value !== "unknown"); + export default function SettingsPage() { const { data: mappings, isPending: loadingMappings } = useTagMappings(); const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies(); @@ -35,9 +37,9 @@ export default function SettingsPage() { const [tagLabel, setTagLabel] = useState(""); const [companyId, setCompanyId] = useState(""); - function handleTagChange(value: string) { + function handleTagInput(value: string) { setTag(value); - const match = APPEAL_SUBTYPES.find((s) => s.value === value); + const match = TAG_SUGGESTIONS.find((s) => s.value === value); if (match) setTagLabel(match.label); } @@ -137,22 +139,22 @@ export default function SettingsPage() {
- + handleTagInput(e.target.value)} + placeholder="סוג ערר או תגית חופשית" + className="w-[220px]" + /> + + {TAG_SUGGESTIONS.map((s) => ( + + ))} +
diff --git a/web-ui/src/components/cases/case-header.tsx b/web-ui/src/components/cases/case-header.tsx index 2fd4a29..2d3ce56 100644 --- a/web-ui/src/components/cases/case-header.tsx +++ b/web-ui/src/components/cases/case-header.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { StatusBadge } from "@/components/cases/status-badge"; +import { SyncIndicator } from "@/components/cases/sync-indicator"; import { PRACTICE_AREA_LABELS, APPEAL_SUBTYPE_LABELS, @@ -71,6 +72,10 @@ export function CaseHeader({ data }: { data?: CaseDetail }) { עודכן
{formatDate(data?.updated_at)}
+
+ סנכרון +
+
diff --git a/web-ui/src/components/cases/sync-indicator.tsx b/web-ui/src/components/cases/sync-indicator.tsx new file mode 100644 index 0000000..7fc1593 --- /dev/null +++ b/web-ui/src/components/cases/sync-indicator.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useGitStatus } from "@/lib/api/cases"; +import { CheckCircle2, AlertCircle, Clock, CloudOff } from "lucide-react"; + +function formatRelative(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "עכשיו"; + if (mins < 60) return `לפני ${mins} דק׳`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `לפני ${hours} שע׳`; + const days = Math.floor(hours / 24); + return `לפני ${days} ימים`; +} + +export function SyncIndicator({ caseNumber }: { caseNumber?: string }) { + const { data, isLoading } = useGitStatus(caseNumber); + + if (isLoading || !data) return null; + + if (data.error === "no_repo") { + return ( + + + + ); + } + + const synced = data.synced; + const pending = data.dirty_files + data.commits_ahead; + + let Icon = synced ? CheckCircle2 : pending > 0 ? Clock : AlertCircle; + let color = synced + ? "text-success" + : pending > 0 + ? "text-warn" + : "text-ink-muted"; + let label = synced + ? "מסונכרן" + : pending > 0 + ? `${pending} שינויים ממתינים` + : "לא מחובר"; + + if (!data.has_remote) { + Icon = CloudOff; + color = "text-ink-muted/60"; + label = "אין remote"; + } + + const time = data.last_commit_time ? formatRelative(data.last_commit_time) : null; + const tooltip = [label, time ? `commit אחרון: ${time}` : null, data.last_commit_msg] + .filter(Boolean) + .join("\n"); + + return ( + + + {time ?? label} + + ); +} diff --git a/web-ui/src/lib/api/cases.ts b/web-ui/src/lib/api/cases.ts index 88bd5cf..5463ca2 100644 --- a/web-ui/src/lib/api/cases.ts +++ b/web-ui/src/lib/api/cases.ts @@ -145,6 +145,28 @@ export function useUpdateCase(caseNumber: string | undefined) { }); } +export type GitSyncStatus = { + synced: boolean; + has_remote: boolean; + remote_url?: string | null; + dirty_files: number; + commits_ahead: number; + last_commit_time?: string | null; + last_commit_msg?: string | null; + error?: string; +}; + +export function useGitStatus(caseNumber: string | undefined) { + return useQuery({ + queryKey: [...casesKeys.all, "git-status", caseNumber ?? ""] as const, + queryFn: ({ signal }) => + apiRequest(`/api/cases/${caseNumber}/git-status`, { signal }), + enabled: Boolean(caseNumber), + staleTime: 30_000, + refetchInterval: 60_000, + }); +} + export function useWorkflowStatus(caseNumber: string | undefined) { return useQuery({ queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const, diff --git a/web/app.py b/web/app.py index da2dfbb..eea03d0 100644 --- a/web/app.py +++ b/web/app.py @@ -1121,6 +1121,65 @@ async def api_case_create(req: CaseCreateRequest): return parsed +@app.get("/api/cases/{case_number}/git-status") +async def api_case_git_status(case_number: str): + """Git sync status for a case repo.""" + case_dir = config.find_case_dir(case_number) + git_dir = case_dir / ".git" + if not git_dir.exists(): + return {"synced": False, "error": "no_repo"} + + env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "GIT_TERMINAL_PROMPT": "0"} + + # Last commit info + log = subprocess.run( + ["git", "log", "-1", "--format=%H%n%aI%n%s"], + cwd=case_dir, capture_output=True, text=True, env=env, + ) + lines = log.stdout.strip().splitlines() if log.returncode == 0 else [] + last_commit_time = lines[1] if len(lines) > 1 else None + last_commit_msg = lines[2] if len(lines) > 2 else None + + # Dirty files count + status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=case_dir, capture_output=True, text=True, env=env, + ) + dirty = len([l for l in status.stdout.splitlines() if l.strip()]) if status.returncode == 0 else 0 + + # Check if remote exists and if we're ahead + has_remote = False + ahead = 0 + remote_url = None + remote_check = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=case_dir, capture_output=True, text=True, env=env, + ) + if remote_check.returncode == 0: + has_remote = True + # Sanitize token from URL + raw = remote_check.stdout.strip() + remote_url = raw.split("@")[-1] if "@" in raw else raw + + ahead_check = subprocess.run( + ["git", "rev-list", "HEAD", "--not", "--remotes", "--count"], + cwd=case_dir, capture_output=True, text=True, env=env, + ) + if ahead_check.returncode == 0: + ahead = int(ahead_check.stdout.strip() or "0") + + synced = has_remote and dirty == 0 and ahead == 0 + return { + "synced": synced, + "has_remote": has_remote, + "remote_url": remote_url, + "dirty_files": dirty, + "commits_ahead": ahead, + "last_commit_time": last_commit_time, + "last_commit_msg": last_commit_msg, + } + + @app.get("/api/cases/{case_number}/details") async def api_case_get(case_number: str): """Get full case details including documents."""