Fix case repo sync + auto-create Gitea repos + add sync indicator
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
- 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) <noreply@anthropic.com>
This commit is contained in:
13
CLAUDE.md
13
CLAUDE.md
@@ -106,10 +106,15 @@
|
|||||||
## Paperclip — כללי אינטגרציה קריטיים
|
## Paperclip — כללי אינטגרציה קריטיים
|
||||||
|
|
||||||
### Wakeup API — תמיד דרך API, לעולם לא דרך DB
|
### Wakeup API — תמיד דרך API, לעולם לא דרך DB
|
||||||
- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` עם `{"source": "...", "reason": "..."}`
|
- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` (לא `/wake`!)
|
||||||
- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק את הרשומה בטבלה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם**
|
- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק רשומה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם**
|
||||||
- **הסיבה**: ה-dispatcher של Paperclip מעבד `heartbeat_runs`, לא `agent_wakeup_requests`. רק ה-API יוצר את שניהם אטומית
|
- **⚠️ חובה לשלוח `payload` עם `issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי cwd נכון)
|
||||||
- **Board API Key**: שמור ב-DB (`board_api_keys`), מאפשר גישה ל-API מ-CLI
|
- דוגמה נכונה:
|
||||||
|
```json
|
||||||
|
{"source": "automation", "triggerDetail": "system", "reason": "...",
|
||||||
|
"payload": {"issueId": "...", "mutation": "comment", "commentId": "..."}}
|
||||||
|
```
|
||||||
|
- **Board API Key**: שמור ב-DB (`board_api_keys`), auth: `Authorization: Bearer pbk_...`
|
||||||
|
|
||||||
### ניתוב comments דרך CEO
|
### ניתוב comments דרך CEO
|
||||||
- כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()`
|
- כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()`
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies = [
|
|||||||
"google-cloud-vision>=3.7.0",
|
"google-cloud-vision>=3.7.0",
|
||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"uvicorn[standard]>=0.30.0",
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -3,14 +3,107 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
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, 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(
|
async def case_create(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
@@ -92,7 +185,7 @@ async def case_create(
|
|||||||
case_dir.mkdir(parents=True, exist_ok=True)
|
case_dir.mkdir(parents=True, exist_ok=True)
|
||||||
docs_dir = case_dir / "documents"
|
docs_dir = case_dir / "documents"
|
||||||
docs_dir.mkdir(exist_ok=True)
|
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 / "extracted").mkdir(exist_ok=True)
|
||||||
(docs_dir / "proofread").mkdir(exist_ok=True)
|
(docs_dir / "proofread").mkdir(exist_ok=True)
|
||||||
(docs_dir / "backup").mkdir(exist_ok=True)
|
(docs_dir / "backup").mkdir(exist_ok=True)
|
||||||
@@ -121,6 +214,12 @@ async def case_create(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # git not available — non-critical
|
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)
|
return json.dumps(case, default=str, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,34 +4,50 @@
|
|||||||
|
|
||||||
CASES_DIR="/home/chaim/legal-ai/data/cases"
|
CASES_DIR="/home/chaim/legal-ai/data/cases"
|
||||||
LOG="/home/chaim/legal-ai/data/.auto-sync.log"
|
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
|
export GIT_AUTHOR_NAME="Ezer Mishpati"
|
||||||
[ -d "$status_dir" ] || continue
|
export GIT_AUTHOR_EMAIL="legal@local"
|
||||||
for case_dir in "$status_dir"/*/; do
|
export GIT_COMMITTER_NAME="Ezer Mishpati"
|
||||||
[ -d "$case_dir/.git" ] || continue
|
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)
|
case_name=$(basename "$case_dir")
|
||||||
changes=$(git status --porcelain 2>/dev/null)
|
|
||||||
[ -z "$changes" ] && continue
|
|
||||||
|
|
||||||
# Stage all changes
|
# Ensure safe.directory is set for this repo
|
||||||
git add -A 2>/dev/null
|
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
|
cd "$case_dir" || continue
|
||||||
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} קבצים שונו"
|
|
||||||
|
|
||||||
# Commit
|
# Check for any changes (modified, new, deleted)
|
||||||
env $GIT_ENV git commit -m "$msg" --quiet 2>/dev/null
|
changes=$(git status --porcelain 2>/dev/null)
|
||||||
if [ $? -eq 0 ]; then
|
[ -z "$changes" ] && continue
|
||||||
# Push (non-blocking, ignore errors)
|
|
||||||
git push origin main --quiet 2>/dev/null
|
# Stage all changes
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced" >> "$LOG"
|
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
|
fi
|
||||||
done
|
else
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | commit FAILED" >> "$LOG"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
|
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const TAG_SUGGESTIONS = APPEAL_SUBTYPES.filter((s) => s.value !== "unknown");
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { data: mappings, isPending: loadingMappings } = useTagMappings();
|
const { data: mappings, isPending: loadingMappings } = useTagMappings();
|
||||||
const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies();
|
const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies();
|
||||||
@@ -35,9 +37,9 @@ export default function SettingsPage() {
|
|||||||
const [tagLabel, setTagLabel] = useState("");
|
const [tagLabel, setTagLabel] = useState("");
|
||||||
const [companyId, setCompanyId] = useState("");
|
const [companyId, setCompanyId] = useState("");
|
||||||
|
|
||||||
function handleTagChange(value: string) {
|
function handleTagInput(value: string) {
|
||||||
setTag(value);
|
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);
|
if (match) setTagLabel(match.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,22 +139,22 @@ export default function SettingsPage() {
|
|||||||
<div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule">
|
<div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule">
|
||||||
<div className="flex flex-col gap-1.5 min-w-[180px]">
|
<div className="flex flex-col gap-1.5 min-w-[180px]">
|
||||||
<label className="text-[0.72rem] text-ink-muted">
|
<label className="text-[0.72rem] text-ink-muted">
|
||||||
תגית (appeal subtype)
|
תגית
|
||||||
</label>
|
</label>
|
||||||
<Select value={tag} onValueChange={handleTagChange}>
|
<Input
|
||||||
<SelectTrigger className="w-[200px]">
|
list="tag-suggestions"
|
||||||
<SelectValue placeholder="בחר סוג ערר" />
|
value={tag}
|
||||||
</SelectTrigger>
|
onChange={(e) => handleTagInput(e.target.value)}
|
||||||
<SelectContent>
|
placeholder="סוג ערר או תגית חופשית"
|
||||||
{APPEAL_SUBTYPES.filter((s) => s.value !== "unknown").map(
|
className="w-[220px]"
|
||||||
(s) => (
|
/>
|
||||||
<SelectItem key={s.value} value={s.value}>
|
<datalist id="tag-suggestions">
|
||||||
{s.label}
|
{TAG_SUGGESTIONS.map((s) => (
|
||||||
</SelectItem>
|
<option key={s.value} value={s.value}>
|
||||||
),
|
{s.label}
|
||||||
)}
|
</option>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { StatusBadge } from "@/components/cases/status-badge";
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
|
import { SyncIndicator } from "@/components/cases/sync-indicator";
|
||||||
import {
|
import {
|
||||||
PRACTICE_AREA_LABELS,
|
PRACTICE_AREA_LABELS,
|
||||||
APPEAL_SUBTYPE_LABELS,
|
APPEAL_SUBTYPE_LABELS,
|
||||||
@@ -71,6 +72,10 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
|||||||
עודכן
|
עודכן
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
|
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
|
||||||
|
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||||
|
סנכרון
|
||||||
|
</dt>
|
||||||
|
<dd><SyncIndicator caseNumber={data?.case_number} /></dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
62
web-ui/src/components/cases/sync-indicator.tsx
Normal file
62
web-ui/src/components/cases/sync-indicator.tsx
Normal file
@@ -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 (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[0.7rem] text-ink-muted/60" title="אין ריפו מקומי">
|
||||||
|
<CloudOff className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[0.7rem] ${color}`} title={tooltip}>
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
<span className="hidden sm:inline">{time ?? label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<GitSyncStatus>(`/api/cases/${caseNumber}/git-status`, { signal }),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useWorkflowStatus(caseNumber: string | undefined) {
|
export function useWorkflowStatus(caseNumber: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
|
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
|
||||||
|
|||||||
59
web/app.py
59
web/app.py
@@ -1121,6 +1121,65 @@ async def api_case_create(req: CaseCreateRequest):
|
|||||||
return parsed
|
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")
|
@app.get("/api/cases/{case_number}/details")
|
||||||
async def api_case_get(case_number: str):
|
async def api_case_get(case_number: str):
|
||||||
"""Get full case details including documents."""
|
"""Get full case details including documents."""
|
||||||
|
|||||||
Reference in New Issue
Block a user