Commit dbe0dcba authored by Mahmoud Aglan's avatar Mahmoud Aglan

gitlab try 1

parent 10eb3289
......@@ -15,10 +15,10 @@
PROJECT CODEBASE — FULL SOURCE DUMP
==============================================================================
Generated: 2026-03-29 14:58:58
Generated: 2026-03-29 15:16:11
Source Dir: /Users/mahmoudaglan/son-of-anton
Total Files: 57
Total Lines: 13183
Total Lines: 13201
Total Size: 475KB
THIS FILE CONTAINS THE COMPLETE CODEBASE INCLUDING:
......@@ -136,7 +136,7 @@ Collected file paths:
[016] 156 4KB backend/routes/attachments.py
[017] 90 2KB backend/routes/auth_routes.py
[018] 193 6KB backend/routes/chat_routes.py
[019] 48 1KB backend/routes/files_routes.py
[019] 62 1KB backend/routes/files_routes.py
[020] 309 10KB backend/routes/knowledge_routes.py
[021] 127 3KB backend/routes/messages_patch.py
[022] 28 855B backend/seed.py
......@@ -155,9 +155,9 @@ Collected file paths:
[035] 27 646B frontend/package.json
[036] 5 80B frontend/postcss.config.js
[037] 65 1KB frontend/src/App.jsx
[038] 215 8KB frontend/src/api.js
[038] 221 8KB frontend/src/api.js
[039] 158 4KB frontend/src/components/AttachmentPreview.jsx
[040] 420 17KB frontend/src/components/ChatView.jsx
[040] 418 17KB frontend/src/components/ChatView.jsx
[041] 95 3KB frontend/src/components/CodeBlock.jsx
[042] 81 1KB frontend/src/components/FileUploadButton.jsx
[043] 188 8KB frontend/src/components/MessageBubble.jsx
......@@ -3779,7 +3779,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [019/57]: backend/routes/files_routes.py
│ LANGUAGE: python | LINES: 48 | SIZE: 1238 bytes
│ LANGUAGE: python | LINES: 62 | SIZE: 1664 bytes
├──────────────────────────────────────────────────────────────────────────────
1 """
......@@ -3787,50 +3787,64 @@ Collected file paths:
3 """
4
5 import io
6 import zipfile
7 from pydantic import BaseModel
8
9 from fastapi import APIRouter
10 from fastapi.responses import StreamingResponse
11
12 from backend.services.code_extractor import extract_code_blocks
6 import re
7 import zipfile
8 from typing import Optional
9 from pydantic import BaseModel
10
11 from fastapi import APIRouter
12 from fastapi.responses import StreamingResponse
13
14 router = APIRouter()
14 from backend.services.code_extractor import extract_code_blocks
15
16
17 class ExtractBody(BaseModel):
18 markdown: str
19
20
21 @router.post("/extract")
22 def extract_files(body: ExtractBody):
23 blocks = extract_code_blocks(body.markdown)
24 return {"files": blocks}
25
26
27 @router.post("/download-zip")
28 def download_zip(body: ExtractBody):
29 blocks = extract_code_blocks(body.markdown)
30 if not blocks:
31 return {"error": "No code blocks found"}
32
33 buf = io.BytesIO()
34 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
35 seen = set()
36 for b in blocks:
37 name = b["filename"]
38 if name in seen:
39 base, ext = name.rsplit(".", 1) if "." in name else (name, "txt")
40 name = f"{base}_{len(seen)}.{ext}"
41 seen.add(name)
42 zf.writestr(name, b["code"])
16 router = APIRouter()
17
18
19 class ExtractBody(BaseModel):
20 markdown: str
21 title: Optional[str] = None
22
23
24 @router.post("/extract")
25 def extract_files(body: ExtractBody):
26 blocks = extract_code_blocks(body.markdown)
27 return {"files": blocks}
28
29
30 @router.post("/download-zip")
31 def download_zip(body: ExtractBody):
32 blocks = extract_code_blocks(body.markdown)
33 if not blocks:
34 return {"error": "No code blocks found"}
35
36 # Keep LAST occurrence of each filename (latest version wins)
37 file_map: dict[str, str] = {}
38 for b in blocks:
39 file_map[b["filename"]] = b["code"]
40
41 if not file_map:
42 return {"error": "No code blocks found"}
43
44 buf.seek(0)
45 return StreamingResponse(
46 buf,
47 media_type="application/zip",
48 headers={"Content-Disposition": "attachment; filename=son-of-anton-code.zip"},
49 )│
44 # Build a safe zip filename from chat title
45 raw_title = (body.title or "").strip()
46 if not raw_title or raw_title == "New Chat":
47 safe_title = "code"
48 else:
49 safe_title = re.sub(r'[^\w\s-]', '', raw_title).strip()
50 safe_title = re.sub(r'[\s]+', '-', safe_title)[:60] or "code"
51 zip_filename = f"{safe_title}.zip"
52
53 buf = io.BytesIO()
54 with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
55 for name, code in file_map.items():
56 zf.writestr(name, code)
57
58 buf.seek(0)
59 return StreamingResponse(
60 buf,
61 media_type="application/zip",
62 headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
63 )│
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [019]: backend/routes/files_routes.py
......@@ -10929,7 +10943,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [038/57]: frontend/src/api.js
│ LANGUAGE: javascript | LINES: 215 | SIZE: 8526 bytes
│ LANGUAGE: javascript | LINES: 221 | SIZE: 8820 bytes
├──────────────────────────────────────────────────────────────────────────────
1 const BASE = "/api";
......@@ -11127,11 +11141,11 @@ Collected file paths:
193 // Code Download
194 // ═══════════════════════════════════════════════════
195
196 export async function downloadZip(token, markdown) {
196 export async function downloadZip(token, markdown, chatTitle) {
197 const res = await fetch(`${BASE}/files/download-zip`, {
198 method: "POST",
199 headers: headers(token),
200 body: JSON.stringify({ markdown }),
200 body: JSON.stringify({ markdown, title: chatTitle || null }),
201 });
202 if (!res.ok) throw new Error("Download failed");
203 const ct = res.headers.get("content-type") || "";
......@@ -11140,14 +11154,20 @@ Collected file paths:
206 const url = URL.createObjectURL(blob);
207 const a = document.createElement("a");
208 a.href = url;
209 a.download = "son-of-anton-code.zip";
210 a.click();
211 URL.revokeObjectURL(url);
212 } else {
213 const data = await res.json();
214 if (data.error) throw new Error(data.error);
215 }
216 }│
209 // Derive filename from chat title, fallback to generic
210 const raw = (chatTitle || "").trim();
211 const safeName =
212 raw && raw !== "New Chat"
213 ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code"
214 : "code";
215 a.download = `${safeName}.zip`;
216 a.click();
217 URL.revokeObjectURL(url);
218 } else {
219 const data = await res.json();
220 if (data.error) throw new Error(data.error);
221 }
222 }│
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [038]: frontend/src/api.js
......@@ -11322,7 +11342,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [040/57]: frontend/src/components/ChatView.jsx
│ LANGUAGE: jsx | LINES: 420 | SIZE: 17903 bytes
│ LANGUAGE: jsx | LINES: 418 | SIZE: 17897 bytes
├──────────────────────────────────────────────────────────────────────────────
1 import React, { useState, useEffect, useRef, useCallback } from "react";
......@@ -11660,92 +11680,90 @@ Collected file paths:
333 <div className="flex items-end gap-1.5">
334 <button
335 onClick={toggleSettings}
336 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
337 showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
338 }`}
339 >
340 <Settings2 size={18} />
341 </button>
342
343 <button
344 onClick={() => fileRef.current?.click()}
345 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${
346 pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
347 }`}
348 title="Attach files"
349 >
350 <Paperclip size={18} />
351 </button>
352
353 <input
354 ref={fileRef}
355 type="file"
356 multiple
357 className="hidden"
358 accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log"
359 onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }}
360 />
361
362 <div className="flex-1 min-w-0">
363 <textarea
364 ref={inputRef}
365 value={input}
366 onChange={(e) => setInput(e.target.value)}
367 onKeyDown={handleKeyDown}
368 onPaste={handlePaste}
369 placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"}
370 rows={1}
371 style={{ maxHeight: "120px" }}
372 className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
373 onInput={(e) => {
374 e.target.style.height = "auto";
375 e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
376 }}
377 />
378 </div>
379
380 {streaming ? (
381 <button
382 onClick={() => streamManager.abortStream(chatId)}
383 className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
384 >
385 <Square size={18} />
386 </button>
387 ) : (
388 <button
389 onClick={handleSend}
390 disabled={(!input.trim() && !pendingFiles.length) || uploading}
391 className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
392 >
393 {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
394 </button>
395 )}
396 </div>
397
398 {/* Status bar */}
399 <div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
400 <span>{MODELS.find((m) => m.id === model)?.label}</span>
401 <span>•</span>
402 <span>{maxTokens.toLocaleString()} tok</span>
403 {reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
404 {selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>}
405 {pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
406 {messages.some((m) => m.role === "assistant") && (
407 <button
408 onClick={async () => {
409 const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
410 if (all) try { await downloadZip(state.token, all); } catch { /* */ }
411 }}
412 className="ml-auto hover:text-anton-accent transition"
413 >
414 ⬇ Code
415 </button>
416 )}
417 </div>
418 </div>
419 </div>
420 );
421 }│
336 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
337 }`}
338 >
339 <Settings2 size={18} />
340 </button>
341
342 <button
343 onClick={() => fileRef.current?.click()}
344 className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card active:bg-anton-card"
345 }`}
346 title="Attach files"
347 >
348 <Paperclip size={18} />
349 </button>
350
351 <input
352 ref={fileRef}
353 type="file"
354 multiple
355 className="hidden"
356 accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log"
357 onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }}
358 />
359
360 <div className="flex-1 min-w-0">
361 <textarea
362 ref={inputRef}
363 value={input}
364 onChange={(e) => setInput(e.target.value)}
365 onKeyDown={handleKeyDown}
366 onPaste={handlePaste}
367 placeholder={pendingFiles.length ? "Add a message…" : "Ask anything…"}
368 rows={1}
369 style={{ maxHeight: "120px" }}
370 className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug"
371 onInput={(e) => {
372 e.target.style.height = "auto";
373 e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
374 }}
375 />
376 </div>
377
378 {streaming ? (
379 <button
380 onClick={() => streamManager.abortStream(chatId)}
381 className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center active:scale-95"
382 >
383 <Square size={18} />
384 </button>
385 ) : (
386 <button
387 onClick={handleSend}
388 disabled={(!input.trim() && !pendingFiles.length) || uploading}
389 className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30 active:scale-95"
390 >
391 {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
392 </button>
393 )}
394 </div>
395
396 {/* Status bar */}
397 <div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
398 <span>{MODELS.find((m) => m.id === model)?.label}</span>
399 <span>•</span>
400 <span>{maxTokens.toLocaleString()} tok</span>
401 {reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
402 {selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>}
403 {pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
404 {messages.some((m) => m.role === "assistant") && (
405 <button
406 onClick={async () => {
407 const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
408 if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { /* */ }
409 }}
410 className="ml-auto hover:text-anton-accent transition"
411 >
412 ⬇ Code
413 </button>
414 )}
415 </div>
416 </div>
417 </div>
418 );
419 }│
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [040]: frontend/src/components/ChatView.jsx
......@@ -13979,9 +13997,9 @@ Collected file paths:
# ✅ END OF COMPLETE CODEBASE DUMP #
# #
# Total Files: 57 #
# Total Lines: 13183 #
# Total Lines: 13201 #
# Total Size: 475KB #
# Generated: 2026-03-29 14:58:58 #
# Generated: 2026-03-29 15:16:11 #
# #
# This file contains EVERYTHING: source code, configs, env vars, Docker, #
# CI/CD, build tools, package manifests, docs — the complete picture. #
......
......@@ -20,9 +20,11 @@ from backend.routes.admin_routes import router as admin_router
from backend.routes.knowledge_routes import router as knowledge_router
from backend.routes.files_routes import router as files_router
from backend.routes.attachment_routes import router as attachment_router
from backend.routes.gitlab_routes import router as gitlab_router
from backend.services.bedrock_service import close_http_client
from backend.services.gitlab_service import close_gitlab_client
APP_VERSION = "2.1.0"
APP_VERSION = "3.0.0"
APP_BUILD_TIME = str(int(time.time()))
......@@ -49,6 +51,18 @@ def _run_migrations():
ChatAttachment.__table__.create(bind=engine, checkfirst=True)
print(" Created chat_attachments table")
# GitLab tables
for table_name in ("gitlab_configs", "gitlab_operations", "gitlab_audit_log"):
if table_name not in existing_tables:
from backend.models import GitLabConfig, GitLabOperation, GitLabAuditLog
table_map = {
"gitlab_configs": GitLabConfig,
"gitlab_operations": GitLabOperation,
"gitlab_audit_log": GitLabAuditLog,
}
table_map[table_name].__table__.create(bind=engine, checkfirst=True)
print(f" Created {table_name} table")
except Exception as e:
print(f" Migration note: {e}")
......@@ -61,6 +75,7 @@ async def lifespan(app: FastAPI):
print(f"Son of Anton v{APP_VERSION} (build {APP_BUILD_TIME}) is online.")
yield
await close_http_client()
await close_gitlab_client()
print("Son of Anton shutting down.")
......@@ -84,28 +99,21 @@ app.add_middleware(
async def add_cache_headers(request: Request, call_next):
response: Response = await call_next(request)
path = request.url.path
# API responses: never cache
if path.startswith("/api"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# Hashed assets (contain hash in filename): cache aggressively
elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
# HTML and everything else: never cache
elif path.endswith(".html") or not path.startswith("/assets"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# Always add version header for debugging
response.headers["X-App-Version"] = APP_VERSION
response.headers["X-Build-Time"] = APP_BUILD_TIME
return response
# Version endpoint for frontend to check
@app.get("/api/version")
def get_version():
return {"version": APP_VERSION, "build": APP_BUILD_TIME}
......@@ -117,6 +125,7 @@ app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"])
app.include_router(files_router, prefix="/api/files", tags=["Files"])
app.include_router(attachment_router, prefix="/api", tags=["Attachments"])
app.include_router(gitlab_router, prefix="/api/gitlab", tags=["GitLab"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
......@@ -135,7 +144,6 @@ async def serve_frontend(full_path: str):
file_path = FRONTEND_DIR / full_path
if full_path and file_path.is_file():
resp = FileResponse(str(file_path))
# Don't cache non-hashed static files
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
return resp
index = FRONTEND_DIR / "index.html"
......
......@@ -124,3 +124,49 @@ class KnowledgeDocument(Base):
file_size = Column(Integer, default=0)
chunk_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
# ══════════════════════════════════════════════════════
# GitLab CE Integration Models
# ══════════════════════════════════════════════════════
class GitLabConfig(Base):
__tablename__ = "gitlab_configs"
id = Column(String(36), primary_key=True, default=new_id)
gitlab_url = Column(String(500), nullable=False)
access_token_enc = Column(String(500), nullable=False)
default_namespace = Column(String(200), nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class GitLabOperation(Base):
__tablename__ = "gitlab_operations"
id = Column(String(36), primary_key=True, default=new_id)
operation_type = Column(String(50), nullable=False)
status = Column(String(20), default="pending")
project_id = Column(Integer, nullable=True)
project_name = Column(String(200), nullable=True)
branch = Column(String(200), nullable=True)
payload = Column(Text, nullable=False)
result = Column(Text, nullable=True)
chat_id = Column(String(36), nullable=True)
message_id = Column(String(36), nullable=True)
created_by = Column(String(36), ForeignKey("users.id"), nullable=False)
approved_by = Column(String(36), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
executed_at = Column(DateTime, nullable=True)
class GitLabAuditLog(Base):
__tablename__ = "gitlab_audit_log"
id = Column(String(36), primary_key=True, default=new_id)
operation_id = Column(String(36), nullable=True)
action = Column(String(100), nullable=False)
details = Column(Text, nullable=True)
user_id = Column(String(36), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
\ No newline at end of file
"""
GitLab CE integration routes — superadmin only.
Provides repo management, surgical code operations, approval queue, and audit log.
"""
import json
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, GitLabConfig, GitLabOperation, GitLabAuditLog, new_id
from backend.auth import require_superadmin
from backend.services import gitlab_service
router = APIRouter()
# ══════════════════════════════════════════════════════
# Pydantic Bodies
# ══════════════════════════════════════════════════════
class SaveConfigBody(BaseModel):
gitlab_url: str
access_token: str
default_namespace: Optional[str] = None
class CreateProjectBody(BaseModel):
name: str
description: str = ""
visibility: str = "private"
namespace_id: Optional[int] = None
initialize_with_readme: bool = True
class FileOperationBody(BaseModel):
file_path: str
content: str
commit_message: str
branch: str = "main"
class BatchCommitBody(BaseModel):
project_id: int
branch: str = "main"
commit_message: str
files: list[dict] # [{"file_path": "...", "content": "..."}]
class CreateBranchBody(BaseModel):
branch_name: str
ref: str = "main"
class CreateMRBody(BaseModel):
source_branch: str
target_branch: str = "main"
title: str
description: str = ""
class QueueOperationBody(BaseModel):
operation_type: str
project_id: Optional[int] = None
project_name: Optional[str] = None
branch: Optional[str] = None
payload: dict
chat_id: Optional[str] = None
message_id: Optional[str] = None
def _get_config(db: Session) -> GitLabConfig:
cfg = db.query(GitLabConfig).filter(GitLabConfig.is_active == True).first()
if not cfg:
raise HTTPException(404, "GitLab not configured. Go to GitLab settings first.")
return cfg
def _log_audit(db: Session, action: str, user_id: str, details: str = None, op_id: str = None):
entry = GitLabAuditLog(
operation_id=op_id,
action=action,
details=details,
user_id=user_id,
)
db.add(entry)
db.commit()
# ══════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════
@router.get("/config")
def get_config(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
cfg = db.query(GitLabConfig).filter(GitLabConfig.is_active == True).first()
if not cfg:
return {"configured": False}
return {
"configured": True,
"id": cfg.id,
"gitlab_url": cfg.gitlab_url,
"token_masked": gitlab_service.mask_token(cfg.access_token_enc),
"default_namespace": cfg.default_namespace,
"updated_at": str(cfg.updated_at),
}
@router.post("/config")
async def save_config(
body: SaveConfigBody,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
result = await gitlab_service.test_connection(body.gitlab_url, body.access_token)
if not result.get("ok"):
raise HTTPException(400, f"Connection failed: {result.get('error', 'Unknown error')}")
existing = db.query(GitLabConfig).filter(GitLabConfig.is_active == True).first()
if existing:
existing.gitlab_url = body.gitlab_url.rstrip("/")
existing.access_token_enc = gitlab_service.encode_token(body.access_token)
existing.default_namespace = body.default_namespace
existing.updated_at = datetime.utcnow()
else:
cfg = GitLabConfig(
gitlab_url=body.gitlab_url.rstrip("/"),
access_token_enc=gitlab_service.encode_token(body.access_token),
default_namespace=body.default_namespace,
)
db.add(cfg)
db.commit()
_log_audit(db, "config_saved", admin.id, f"Connected to {body.gitlab_url} as {result.get('user')}")
return {"ok": True, "user": result.get("user"), "name": result.get("name")}
@router.post("/test-connection")
async def test_connection(
body: SaveConfigBody,
admin: User = Depends(require_superadmin),
):
result = await gitlab_service.test_connection(body.gitlab_url, body.access_token)
return result
# ══════════════════════════════════════════════════════
# Projects
# ══════════════════════════════════════════════════════
@router.get("/projects")
async def list_projects(
search: str = "",
page: int = 1,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
return await gitlab_service.list_projects(cfg.gitlab_url, token, search=search, page=page)
@router.post("/projects")
async def create_project(
body: CreateProjectBody,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
result = await gitlab_service.create_project(
cfg.gitlab_url, token, body.name, body.description,
body.visibility, body.namespace_id, body.initialize_with_readme,
)
_log_audit(db, "project_created", admin.id, f"Created project: {body.name} (id={result.get('id')})")
return result
@router.delete("/projects/{project_id}")
async def delete_project(
project_id: int,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
ok = await gitlab_service.delete_project(cfg.gitlab_url, token, project_id)
if not ok:
raise HTTPException(500, "Failed to delete project")
_log_audit(db, "project_deleted", admin.id, f"Deleted project id={project_id}")
return {"ok": True}
@router.get("/projects/{project_id}")
async def get_project(
project_id: int,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
return await gitlab_service.get_project(cfg.gitlab_url, token, project_id)
# ══════════════════════════════════════════════════════
# Branches
# ══════════════════════════════════════════════════════
@router.get("/projects/{project_id}/branches")
async def list_branches(
project_id: int,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
return await gitlab_service.list_branches(cfg.gitlab_url, token, project_id)
@router.post("/projects/{project_id}/branches")
async def create_branch(
project_id: int,
body: CreateBranchBody,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
result = await gitlab_service.create_branch(cfg.gitlab_url, token, project_id, body.branch_name, body.ref)
_log_audit(db, "branch_created", admin.id, f"Project {project_id}: created branch '{body.branch_name}' from '{body.ref}'")
return result
# ══════════════════════════════════════════════════════
# File Tree & Files
# ══════════════════════════════════════════════════════
@router.get("/projects/{project_id}/tree")
async def get_tree(
project_id: int,
path: str = "",
ref: str = "main",
recursive: bool = False,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
return await gitlab_service.get_tree(cfg.gitlab_url, token, project_id, path, ref, recursive)
@router.get("/projects/{project_id}/files")
async def get_file(
project_id: int,
file_path: str,
ref: str = "main",
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
return await gitlab_service.get_file(cfg.gitlab_url, token, project_id, file_path, ref)
# ══════════════════════════════════════════════════════
# Merge Requests
# ══════════════════════════════════════════════════════
@router.get("/projects/{project_id}/merge-requests")
async def list_merge_requests(
project_id: int,
state: str = "opened",
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
return await gitlab_service.list_merge_requests(cfg.gitlab_url, token, project_id, state)
@router.post("/projects/{project_id}/merge-requests")
async def create_merge_request(
project_id: int,
body: CreateMRBody,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
result = await gitlab_service.create_merge_request(
cfg.gitlab_url, token, project_id,
body.source_branch, body.target_branch, body.title, body.description,
)
_log_audit(db, "mr_created", admin.id, f"Project {project_id}: MR '{body.title}' ({body.source_branch} → {body.target_branch})")
return result
# ══════════════════════════════════════════════════════
# Pipelines
# ══════════════════════════════════════════════════════
@router.get("/projects/{project_id}/pipelines")
async def list_pipelines(
project_id: int,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
return await gitlab_service.list_pipelines(cfg.gitlab_url, token, project_id)
@router.get("/projects/{project_id}/pipelines/{pipeline_id}")
async def get_pipeline(
project_id: int, pipeline_id: int,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
pipeline = await gitlab_service.get_pipeline(cfg.gitlab_url, token, project_id, pipeline_id)
jobs = await gitlab_service.get_pipeline_jobs(cfg.gitlab_url, token, project_id, pipeline_id)
return {"pipeline": pipeline, "jobs": jobs}
@router.post("/projects/{project_id}/pipelines/trigger")
async def trigger_pipeline(
project_id: int,
ref: str = "main",
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
result = await gitlab_service.trigger_pipeline(cfg.gitlab_url, token, project_id, ref)
_log_audit(db, "pipeline_triggered", admin.id, f"Project {project_id}: triggered pipeline on '{ref}'")
return result
# ══════════════════════════════════════════════════════
# Operation Queue (THE APPROVAL SYSTEM)
# ══════════════════════════════════════════════════════
@router.post("/operations")
def queue_operation(
body: QueueOperationBody,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
op = GitLabOperation(
operation_type=body.operation_type,
project_id=body.project_id,
project_name=body.project_name,
branch=body.branch,
payload=json.dumps(body.payload),
chat_id=body.chat_id,
message_id=body.message_id,
created_by=admin.id,
)
db.add(op)
db.commit()
db.refresh(op)
_log_audit(db, "operation_queued", admin.id, f"{body.operation_type} queued (id={op.id})", op.id)
return _op_dict(op)
@router.get("/operations")
def list_operations(
status: str = "pending",
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
q = db.query(GitLabOperation)
if status != "all":
q = q.filter(GitLabOperation.status == status)
ops = q.order_by(GitLabOperation.created_at.desc()).limit(100).all()
return [_op_dict(o) for o in ops]
@router.post("/operations/{op_id}/approve")
async def approve_operation(
op_id: str,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
op = db.query(GitLabOperation).filter(GitLabOperation.id == op_id).first()
if not op:
raise HTTPException(404, "Operation not found")
if op.status != "pending":
raise HTTPException(400, f"Operation is {op.status}, not pending")
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
payload = json.loads(op.payload)
try:
result = await _execute_operation(cfg.gitlab_url, token, op.operation_type, op.project_id, op.branch, payload)
op.status = "executed"
op.result = json.dumps(result) if isinstance(result, (dict, list)) else str(result)
op.executed_at = datetime.utcnow()
op.approved_by = admin.id
db.commit()
_log_audit(db, "operation_approved_executed", admin.id, f"{op.operation_type} executed successfully", op.id)
return {"ok": True, "result": result}
except Exception as e:
op.status = "failed"
op.result = str(e)
op.executed_at = datetime.utcnow()
db.commit()
_log_audit(db, "operation_failed", admin.id, f"{op.operation_type} failed: {str(e)}", op.id)
raise HTTPException(500, f"Execution failed: {str(e)}")
@router.post("/operations/{op_id}/reject")
def reject_operation(
op_id: str,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
op = db.query(GitLabOperation).filter(GitLabOperation.id == op_id).first()
if not op:
raise HTTPException(404)
if op.status != "pending":
raise HTTPException(400, f"Operation is {op.status}")
op.status = "rejected"
op.approved_by = admin.id
op.executed_at = datetime.utcnow()
db.commit()
_log_audit(db, "operation_rejected", admin.id, f"{op.operation_type} rejected", op.id)
return {"ok": True}
@router.delete("/operations/{op_id}")
def delete_operation(
op_id: str,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
op = db.query(GitLabOperation).filter(GitLabOperation.id == op_id).first()
if not op:
raise HTTPException(404)
db.delete(op)
db.commit()
return {"ok": True}
# ══════════════════════════════════════════════════════
# Direct Execute (bypass queue — for quick actions)
# ══════════════════════════════════════════════════════
@router.post("/execute/commit")
async def direct_commit(
body: BatchCommitBody,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
"""Direct batch commit without queuing — for when you want instant execution."""
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
result = await gitlab_service.smart_batch_commit(
cfg.gitlab_url, token, body.project_id,
body.branch, body.commit_message, body.files,
)
_log_audit(
db, "direct_commit", admin.id,
f"Project {body.project_id}, branch '{body.branch}': {len(body.files)} files committed",
)
return result
@router.post("/execute/file")
async def direct_file_op(
body: dict,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
"""Direct single file create/update."""
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
project_id = body["project_id"]
file_path = body["file_path"]
content = body["content"]
commit_message = body.get("commit_message", f"Update {file_path}")
branch = body.get("branch", "main")
create = body.get("create", False)
result = await gitlab_service.create_or_update_file(
cfg.gitlab_url, token, project_id,
file_path, content, commit_message, branch, create,
)
action = "created" if create else "updated"
_log_audit(db, f"file_{action}", admin.id, f"Project {project_id}: {action} {file_path} on {branch}")
return result
# ══════════════════════════════════════════════════════
# Audit Log
# ══════════════════════════════════════════════════════
@router.get("/audit-log")
def get_audit_log(
page: int = 1,
per_page: int = 50,
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
total = db.query(GitLabAuditLog).count()
entries = (
db.query(GitLabAuditLog)
.order_by(GitLabAuditLog.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
return {
"total": total,
"page": page,
"entries": [
{
"id": e.id,
"operation_id": e.operation_id,
"action": e.action,
"details": e.details,
"user_id": e.user_id,
"created_at": str(e.created_at),
}
for e in entries
],
}
# ══════════════════════════════════════════════════════
# Namespaces
# ══════════════════════════════════════════════════════
@router.get("/namespaces")
async def list_namespaces(
admin: User = Depends(require_superadmin),
db: Session = Depends(get_db),
):
cfg = _get_config(db)
token = gitlab_service.decode_token(cfg.access_token_enc)
return await gitlab_service.list_namespaces(cfg.gitlab_url, token)
# ══════════════════════════════════════════════════════
# Helpers
# ══════════════════════════════════════════════════════
async def _execute_operation(gitlab_url, token, op_type, project_id, branch, payload):
if op_type == "batch_commit":
return await gitlab_service.smart_batch_commit(
gitlab_url, token, project_id,
branch or "main", payload.get("commit_message", "Son of Anton commit"),
payload.get("files", []),
)
elif op_type == "create_file":
return await gitlab_service.create_or_update_file(
gitlab_url, token, project_id,
payload["file_path"], payload["content"],
payload.get("commit_message", f"Create {payload['file_path']}"),
branch or "main", create=True,
)
elif op_type == "update_file":
return await gitlab_service.create_or_update_file(
gitlab_url, token, project_id,
payload["file_path"], payload["content"],
payload.get("commit_message", f"Update {payload['file_path']}"),
branch or "main", create=False,
)
elif op_type == "create_project":
return await gitlab_service.create_project(
gitlab_url, token,
payload["name"], payload.get("description", ""),
payload.get("visibility", "private"),
)
elif op_type == "create_branch":
return await gitlab_service.create_branch(
gitlab_url, token, project_id,
payload["branch_name"], payload.get("ref", "main"),
)
elif op_type == "create_mr":
return await gitlab_service.create_merge_request(
gitlab_url, token, project_id,
payload["source_branch"], payload.get("target_branch", "main"),
payload["title"], payload.get("description", ""),
)
elif op_type == "trigger_pipeline":
return await gitlab_service.trigger_pipeline(
gitlab_url, token, project_id, payload.get("ref", branch or "main"),
)
else:
raise ValueError(f"Unknown operation type: {op_type}")
def _op_dict(op: GitLabOperation) -> dict:
return {
"id": op.id,
"operation_type": op.operation_type,
"status": op.status,
"project_id": op.project_id,
"project_name": op.project_name,
"branch": op.branch,
"payload": json.loads(op.payload) if op.payload else {},
"result": json.loads(op.result) if op.result and op.result.startswith("{") else op.result,
"chat_id": op.chat_id,
"created_by": op.created_by,
"approved_by": op.approved_by,
"created_at": str(op.created_at),
"executed_at": str(op.executed_at) if op.executed_at else None,
}
\ No newline at end of file
"""
GitLab CE API Service.
Handles all communication with a self-hosted GitLab CE instance.
Superadmin-only. Every operation is auditable.
"""
import base64
import json
from typing import Optional
from urllib.parse import quote
import httpx
_client: Optional[httpx.AsyncClient] = None
def _get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=15.0, read=60.0, write=30.0, pool=30.0),
)
return _client
async def close_gitlab_client():
global _client
if _client and not _client.is_closed:
await _client.aclose()
_client = None
def _headers(token: str) -> dict:
return {"PRIVATE-TOKEN": token, "Content-Type": "application/json"}
def _api_url(gitlab_url: str, path: str) -> str:
base = gitlab_url.rstrip("/")
return f"{base}/api/v4{path}"
def encode_token(plain: str) -> str:
return base64.b64encode(plain.encode()).decode()
def decode_token(encoded: str) -> str:
return base64.b64decode(encoded.encode()).decode()
def mask_token(encoded: str) -> str:
try:
plain = decode_token(encoded)
if len(plain) <= 8:
return "****"
return plain[:4] + "…" + plain[-4:]
except Exception:
return "****"
# ══════════════════════════════════════════════════════
# Connection
# ══════════════════════════════════════════════════════
async def test_connection(gitlab_url: str, token: str) -> dict:
client = _get_client()
try:
resp = await client.get(
_api_url(gitlab_url, "/user"),
headers=_headers(token),
)
if resp.status_code == 200:
user = resp.json()
return {
"ok": True,
"user": user.get("username"),
"name": user.get("name"),
"email": user.get("email"),
"is_admin": user.get("is_admin", False),
"gitlab_version": resp.headers.get("x-gitlab-meta", ""),
}
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
except Exception as e:
return {"ok": False, "error": str(e)}
# ══════════════════════════════════════════════════════
# Projects / Repositories
# ══════════════════════════════════════════════════════
async def list_projects(
gitlab_url: str, token: str,
search: str = "", page: int = 1, per_page: int = 20,
owned: bool = True, order_by: str = "updated_at",
) -> dict:
client = _get_client()
params = {
"page": page, "per_page": per_page,
"order_by": order_by, "sort": "desc",
"owned": str(owned).lower(),
}
if search:
params["search"] = search
resp = await client.get(
_api_url(gitlab_url, "/projects"),
headers=_headers(token), params=params,
)
resp.raise_for_status()
total = int(resp.headers.get("x-total", "0"))
total_pages = int(resp.headers.get("x-total-pages", "1"))
return {
"projects": resp.json(),
"total": total,
"page": page,
"total_pages": total_pages,
}
async def get_project(gitlab_url: str, token: str, project_id: int) -> dict:
client = _get_client()
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}"),
headers=_headers(token),
)
resp.raise_for_status()
return resp.json()
async def create_project(
gitlab_url: str, token: str,
name: str, description: str = "",
visibility: str = "private",
namespace_id: Optional[int] = None,
initialize_with_readme: bool = True,
) -> dict:
client = _get_client()
body = {
"name": name,
"description": description,
"visibility": visibility,
"initialize_with_readme": initialize_with_readme,
}
if namespace_id:
body["namespace_id"] = namespace_id
resp = await client.post(
_api_url(gitlab_url, "/projects"),
headers=_headers(token), json=body,
)
resp.raise_for_status()
return resp.json()
async def delete_project(gitlab_url: str, token: str, project_id: int) -> bool:
client = _get_client()
resp = await client.delete(
_api_url(gitlab_url, f"/projects/{project_id}"),
headers=_headers(token),
)
return resp.status_code in (200, 202, 204)
async def fork_project(
gitlab_url: str, token: str, project_id: int,
name: Optional[str] = None, namespace_id: Optional[int] = None,
) -> dict:
client = _get_client()
body = {}
if name:
body["name"] = name
if namespace_id:
body["namespace_id"] = namespace_id
resp = await client.post(
_api_url(gitlab_url, f"/projects/{project_id}/fork"),
headers=_headers(token), json=body,
)
resp.raise_for_status()
return resp.json()
# ══════════════════════════════════════════════════════
# Namespaces / Groups
# ══════════════════════════════════════════════════════
async def list_namespaces(gitlab_url: str, token: str) -> list:
client = _get_client()
resp = await client.get(
_api_url(gitlab_url, "/namespaces"),
headers=_headers(token), params={"per_page": 100},
)
resp.raise_for_status()
return resp.json()
# ══════════════════════════════════════════════════════
# Branches
# ══════════════════════════════════════════════════════
async def list_branches(gitlab_url: str, token: str, project_id: int) -> list:
client = _get_client()
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/repository/branches"),
headers=_headers(token), params={"per_page": 100},
)
resp.raise_for_status()
return resp.json()
async def create_branch(
gitlab_url: str, token: str, project_id: int,
branch_name: str, ref: str = "main",
) -> dict:
client = _get_client()
resp = await client.post(
_api_url(gitlab_url, f"/projects/{project_id}/repository/branches"),
headers=_headers(token),
json={"branch": branch_name, "ref": ref},
)
resp.raise_for_status()
return resp.json()
async def delete_branch(
gitlab_url: str, token: str, project_id: int, branch_name: str,
) -> bool:
client = _get_client()
encoded = quote(branch_name, safe="")
resp = await client.delete(
_api_url(gitlab_url, f"/projects/{project_id}/repository/branches/{encoded}"),
headers=_headers(token),
)
return resp.status_code in (200, 204)
# ══════════════════════════════════════════════════════
# File Tree / Repository Browser
# ══════════════════════════════════════════════════════
async def get_tree(
gitlab_url: str, token: str, project_id: int,
path: str = "", ref: str = "main", recursive: bool = False,
) -> list:
client = _get_client()
params = {"ref": ref, "per_page": 100, "recursive": str(recursive).lower()}
if path:
params["path"] = path
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/repository/tree"),
headers=_headers(token), params=params,
)
if resp.status_code == 404:
return []
resp.raise_for_status()
return resp.json()
async def get_file(
gitlab_url: str, token: str, project_id: int,
file_path: str, ref: str = "main",
) -> dict:
client = _get_client()
encoded_path = quote(file_path, safe="")
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/repository/files/{encoded_path}"),
headers=_headers(token), params={"ref": ref},
)
resp.raise_for_status()
data = resp.json()
if data.get("encoding") == "base64" and data.get("content"):
try:
data["decoded_content"] = base64.b64decode(data["content"]).decode("utf-8")
except Exception:
data["decoded_content"] = "[Binary file — cannot decode]"
return data
async def create_or_update_file(
gitlab_url: str, token: str, project_id: int,
file_path: str, content: str,
commit_message: str, branch: str = "main",
create: bool = False,
) -> dict:
client = _get_client()
encoded_path = quote(file_path, safe="")
url = _api_url(gitlab_url, f"/projects/{project_id}/repository/files/{encoded_path}")
body = {
"branch": branch,
"content": content,
"commit_message": commit_message,
}
if create:
resp = await client.post(url, headers=_headers(token), json=body)
else:
resp = await client.put(url, headers=_headers(token), json=body)
resp.raise_for_status()
return resp.json()
async def delete_file(
gitlab_url: str, token: str, project_id: int,
file_path: str, commit_message: str, branch: str = "main",
) -> dict:
client = _get_client()
encoded_path = quote(file_path, safe="")
resp = await client.delete(
_api_url(gitlab_url, f"/projects/{project_id}/repository/files/{encoded_path}"),
headers=_headers(token),
json={"branch": branch, "commit_message": commit_message},
)
resp.raise_for_status()
return resp.json()
# ══════════════════════════════════════════════════════
# Batch Commits (THE SURGICAL WEAPON)
# ══════════════════════════════════════════════════════
async def batch_commit(
gitlab_url: str, token: str, project_id: int,
branch: str, commit_message: str,
actions: list[dict],
) -> dict:
"""
Perform a batch commit with multiple file actions.
Each action: {"action": "create"|"update"|"delete", "file_path": "...", "content": "..."}
"""
client = _get_client()
body = {
"branch": branch,
"commit_message": commit_message,
"actions": actions,
}
resp = await client.post(
_api_url(gitlab_url, f"/projects/{project_id}/repository/commits"),
headers=_headers(token), json=body,
)
resp.raise_for_status()
return resp.json()
async def smart_batch_commit(
gitlab_url: str, token: str, project_id: int,
branch: str, commit_message: str,
files: list[dict],
) -> dict:
"""
Intelligently create or update files. Checks if each file exists first.
files: [{"file_path": "...", "content": "..."}]
"""
actions = []
for f in files:
exists = False
try:
await get_file(gitlab_url, token, project_id, f["file_path"], ref=branch)
exists = True
except Exception:
pass
actions.append({
"action": "update" if exists else "create",
"file_path": f["file_path"],
"content": f["content"],
})
return await batch_commit(gitlab_url, token, project_id, branch, commit_message, actions)
# ══════════════════════════════════════════════════════
# Merge Requests
# ══════════════════════════════════════════════════════
async def list_merge_requests(
gitlab_url: str, token: str, project_id: int,
state: str = "opened",
) -> list:
client = _get_client()
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/merge_requests"),
headers=_headers(token),
params={"state": state, "per_page": 50, "order_by": "updated_at", "sort": "desc"},
)
resp.raise_for_status()
return resp.json()
async def create_merge_request(
gitlab_url: str, token: str, project_id: int,
source_branch: str, target_branch: str,
title: str, description: str = "",
) -> dict:
client = _get_client()
body = {
"source_branch": source_branch,
"target_branch": target_branch,
"title": title,
"description": description,
"remove_source_branch": True,
}
resp = await client.post(
_api_url(gitlab_url, f"/projects/{project_id}/merge_requests"),
headers=_headers(token), json=body,
)
resp.raise_for_status()
return resp.json()
async def get_merge_request_changes(
gitlab_url: str, token: str, project_id: int, mr_iid: int,
) -> dict:
client = _get_client()
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/merge_requests/{mr_iid}/changes"),
headers=_headers(token),
)
resp.raise_for_status()
return resp.json()
# ══════════════════════════════════════════════════════
# Pipelines
# ══════════════════════════════════════════════════════
async def list_pipelines(
gitlab_url: str, token: str, project_id: int,
) -> list:
client = _get_client()
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/pipelines"),
headers=_headers(token), params={"per_page": 30},
)
resp.raise_for_status()
return resp.json()
async def get_pipeline(
gitlab_url: str, token: str, project_id: int, pipeline_id: int,
) -> dict:
client = _get_client()
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/pipelines/{pipeline_id}"),
headers=_headers(token),
)
resp.raise_for_status()
return resp.json()
async def get_pipeline_jobs(
gitlab_url: str, token: str, project_id: int, pipeline_id: int,
) -> list:
client = _get_client()
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/pipelines/{pipeline_id}/jobs"),
headers=_headers(token), params={"per_page": 100},
)
resp.raise_for_status()
return resp.json()
async def trigger_pipeline(
gitlab_url: str, token: str, project_id: int, ref: str = "main",
) -> dict:
client = _get_client()
resp = await client.post(
_api_url(gitlab_url, f"/projects/{project_id}/pipeline"),
headers=_headers(token), json={"ref": ref},
)
resp.raise_for_status()
return resp.json()
async def retry_pipeline(
gitlab_url: str, token: str, project_id: int, pipeline_id: int,
) -> dict:
client = _get_client()
resp = await client.post(
_api_url(gitlab_url, f"/projects/{project_id}/pipelines/{pipeline_id}/retry"),
headers=_headers(token),
)
resp.raise_for_status()
return resp.json()
# ══════════════════════════════════════════════════════
# Compare / Diff
# ══════════════════════════════════════════════════════
async def compare(
gitlab_url: str, token: str, project_id: int,
from_ref: str, to_ref: str,
) -> dict:
client = _get_client()
resp = await client.get(
_api_url(gitlab_url, f"/projects/{project_id}/repository/compare"),
headers=_headers(token),
params={"from": from_ref, "to": to_ref},
)
resp.raise_for_status()
return resp.json()
\ No newline at end of file
......@@ -7,26 +7,20 @@ import LoginPage from "./pages/LoginPage";
import ChatPage from "./pages/ChatPage";
import AdminPage from "./pages/AdminPage";
import KnowledgePage from "./pages/KnowledgePage";
import GitLabPage from "./pages/GitLabPage";
import { Flame } from "lucide-react";
export default function App() {
const { state, dispatch } = useApp();
const [authChecked, setAuthChecked] = useState(!state.token);
// Connect streamManager to store dispatch
useEffect(() => {
streamManager.setDispatch(dispatch);
}, [dispatch]);
useEffect(() => {
if (!state.token) {
setAuthChecked(true);
return;
}
if (state.user) {
setAuthChecked(true);
return;
}
if (!state.token) { setAuthChecked(true); return; }
if (state.user) { setAuthChecked(true); return; }
(async () => {
try {
const user = await getMe(state.token);
......@@ -52,14 +46,13 @@ export default function App() {
);
}
if (!state.token) {
return <LoginPage />;
}
if (!state.token) return <LoginPage />;
return (
<Routes>
<Route path="/admin" element={<AdminPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/gitlab" element={<GitLabPage />} />
<Route path="/*" element={<ChatPage />} />
</Routes>
);
......
......@@ -24,48 +24,29 @@ async function request(method, path, token, body) {
// ═══════════════════════════════════════════════════
// Auth
// ═══════════════════════════════════════════════════
export const login = (username, password) =>
request("POST", "/auth/login", null, { username, password });
export const register = (username, email, password) =>
request("POST", "/auth/register", null, { username, email, password });
export const getMe = (token) => request("GET", "/auth/me", token);
// ═══════════════════════════════════════════════════
// Chats
// ═══════════════════════════════════════════════════
export const listChats = (token) => request("GET", "/chats", token);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) =>
request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) =>
updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) =>
request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token);
export const checkGenerating = (token, chatId) =>
request("GET", `/chats/${chatId}/generating`, token);
export const updateChat = (token, chatId, data) => request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) => updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) => request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) => request("GET", `/chats/${chatId}/messages`, token);
export const checkGenerating = (token, chatId) => request("GET", `/chats/${chatId}/generating`, token);
// ═══════════════════════════════════════════════════
// Streaming
// ═══════════════════════════════════════════════════
export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST",
headers: headers(token),
body: JSON.stringify(body),
signal,
method: "POST", headers: headers(token), body: JSON.stringify(body), signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
......@@ -82,122 +63,60 @@ export async function* streamMessage(token, chatId, body, signal) {
buffer = parts.pop() || "";
for (const part of parts) {
const line = part.trim();
if (line.startsWith("data: ")) {
try {
yield JSON.parse(line.slice(6));
} catch {
/* skip */
}
}
}
}
if (buffer.trim().startsWith("data: ")) {
try {
yield JSON.parse(buffer.trim().slice(6));
} catch {
/* skip */
if (line.startsWith("data: ")) { try { yield JSON.parse(line.slice(6)); } catch { } }
}
}
if (buffer.trim().startsWith("data: ")) { try { yield JSON.parse(buffer.trim().slice(6)); } catch { } }
}
// ═══════════════════════════════════════════════════
// Chat Attachments
// ═══════════════════════════════════════════════════
export async function uploadAttachments(token, chatId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST",
headers: authHeader(token),
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, { method: "POST", headers: authHeader(token), body: form });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || "Upload failed"); }
return res.json();
}
export function getAttachmentUrl(attachmentId) {
return `${BASE}/attachments/${attachmentId}/file`;
}
export const deleteAttachment = (token, attachmentId) =>
request("DELETE", `/attachments/${attachmentId}`, token);
export function getAttachmentUrl(attachmentId) { return `${BASE}/attachments/${attachmentId}/file`; }
export const deleteAttachment = (token, attachmentId) => request("DELETE", `/attachments/${attachmentId}`, token);
// ═══════════════════════════════════════════════════
// Knowledge Bases
// ═══════════════════════════════════════════════════
export const listKnowledgeBases = (token) =>
request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") =>
request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) =>
request("GET", `/knowledge/${kbId}`, token);
export const updateKnowledgeBase = (token, kbId, data) =>
request("PUT", `/knowledge/${kbId}`, token, data);
export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
// ═══════════════════════════════════════════════════
// Knowledge Base Documents
// ═══════════════════════════════════════════════════
export const listKnowledgeDocuments = (token, kbId) =>
request("GET", `/knowledge/${kbId}/documents`, token);
export const deleteKnowledgeDocument = (token, kbId, docId) =>
request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") => request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) => request("GET", `/knowledge/${kbId}`, token);
export const updateKnowledgeBase = (token, kbId, data) => request("PUT", `/knowledge/${kbId}`, token, data);
export const deleteKnowledgeBase = (token, kbId) => request("DELETE", `/knowledge/${kbId}`, token);
export const listKnowledgeDocuments = (token, kbId) => request("GET", `/knowledge/${kbId}/documents`, token);
export const deleteKnowledgeDocument = (token, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
export async function uploadDocuments(token, kbId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
method: "POST",
headers: authHeader(token),
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { method: "POST", headers: authHeader(token), body: form });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || "Upload failed"); }
return res.json();
}
export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
export const uploadDocument = (token, kbId, file) => uploadDocuments(token, kbId, [file]);
// ═══════════════════════════════════════════════════
// Admin
// ═══════════════════════════════════════════════════
export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) =>
request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) =>
request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) =>
request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) =>
request("GET", "/admin/chats", token);
export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) => request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) => request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) => request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) => request("GET", "/admin/chats", token);
// ═══════════════════════════════════════════════════
// Code Download
// ═══════════════════════════════════════════════════
export async function downloadZip(token, markdown, chatTitle) {
const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST",
headers: headers(token),
body: JSON.stringify({ markdown, title: chatTitle || null }),
method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title: chatTitle || null }),
});
if (!res.ok) throw new Error("Download failed");
const ct = res.headers.get("content-type") || "";
......@@ -206,12 +125,8 @@ export async function downloadZip(token, markdown, chatTitle) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// Derive filename from chat title, fallback to generic
const raw = (chatTitle || "").trim();
const safeName =
raw && raw !== "New Chat"
? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code"
: "code";
const safeName = raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code";
a.download = `${safeName}.zip`;
a.click();
URL.revokeObjectURL(url);
......@@ -219,4 +134,62 @@ export async function downloadZip(token, markdown, chatTitle) {
const data = await res.json();
if (data.error) throw new Error(data.error);
}
}
\ No newline at end of file
}
// ═══════════════════════════════════════════════════
// 🔥 GitLab CE Integration (Superadmin Only)
// ═══════════════════════════════════════════════════
// Config
export const gitlabGetConfig = (token) => request("GET", "/gitlab/config", token);
export const gitlabSaveConfig = (token, data) => request("POST", "/gitlab/config", token, data);
export const gitlabTestConnection = (token, data) => request("POST", "/gitlab/test-connection", token, data);
// Projects
export const gitlabListProjects = (token, search = "", page = 1) =>
request("GET", `/gitlab/projects?search=${encodeURIComponent(search)}&page=${page}`, token);
export const gitlabGetProject = (token, projectId) => request("GET", `/gitlab/projects/${projectId}`, token);
export const gitlabCreateProject = (token, data) => request("POST", "/gitlab/projects", token, data);
export const gitlabDeleteProject = (token, projectId) => request("DELETE", `/gitlab/projects/${projectId}`, token);
// Branches
export const gitlabListBranches = (token, projectId) => request("GET", `/gitlab/projects/${projectId}/branches`, token);
export const gitlabCreateBranch = (token, projectId, data) => request("POST", `/gitlab/projects/${projectId}/branches`, token, data);
// Files
export const gitlabGetTree = (token, projectId, path = "", ref = "main", recursive = false) =>
request("GET", `/gitlab/projects/${projectId}/tree?path=${encodeURIComponent(path)}&ref=${ref}&recursive=${recursive}`, token);
export const gitlabGetFile = (token, projectId, filePath, ref = "main") =>
request("GET", `/gitlab/projects/${projectId}/files?file_path=${encodeURIComponent(filePath)}&ref=${ref}`, token);
// Merge Requests
export const gitlabListMRs = (token, projectId, state = "opened") =>
request("GET", `/gitlab/projects/${projectId}/merge-requests?state=${state}`, token);
export const gitlabCreateMR = (token, projectId, data) =>
request("POST", `/gitlab/projects/${projectId}/merge-requests`, token, data);
// Pipelines
export const gitlabListPipelines = (token, projectId) =>
request("GET", `/gitlab/projects/${projectId}/pipelines`, token);
export const gitlabGetPipeline = (token, projectId, pipelineId) =>
request("GET", `/gitlab/projects/${projectId}/pipelines/${pipelineId}`, token);
export const gitlabTriggerPipeline = (token, projectId, ref = "main") =>
request("POST", `/gitlab/projects/${projectId}/pipelines/trigger?ref=${ref}`, token);
// Operations Queue
export const gitlabQueueOperation = (token, data) => request("POST", "/gitlab/operations", token, data);
export const gitlabListOperations = (token, status = "pending") =>
request("GET", `/gitlab/operations?status=${status}`, token);
export const gitlabApproveOperation = (token, opId) => request("POST", `/gitlab/operations/${opId}/approve`, token);
export const gitlabRejectOperation = (token, opId) => request("POST", `/gitlab/operations/${opId}/reject`, token);
export const gitlabDeleteOperation = (token, opId) => request("DELETE", `/gitlab/operations/${opId}`, token);
// Direct Execute (bypass queue)
export const gitlabDirectCommit = (token, data) => request("POST", "/gitlab/execute/commit", token, data);
export const gitlabDirectFileOp = (token, data) => request("POST", "/gitlab/execute/file", token, data);
// Audit
export const gitlabAuditLog = (token, page = 1) => request("GET", `/gitlab/audit-log?page=${page}`, token);
// Namespaces
export const gitlabListNamespaces = (token) => request("GET", "/gitlab/namespaces", token);
\ No newline at end of file
import React, { useState } from "react";
import React, { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import { createChat, deleteChat, renameChat } from "../api";
import { listChats, createChat, deleteChat, renameChat } from "../api";
import {
Flame, Plus, MessageSquare, Trash2, Edit3, Check, X,
LogOut, Shield, BookOpen, ChevronRight,
Plus, Trash2, MessageSquare, LogOut, Shield, BookOpen,
MoreHorizontal, Pencil, Check, X, Flame, Menu, FolderGit2,
} from "lucide-react";
export default function Sidebar({ mobile, onClose }) {
export default function Sidebar({ activeChatId, onSelectChat, isOpen, onToggle }) {
const { state, dispatch } = useApp();
const navigate = useNavigate();
const [editId, setEditId] = useState(null);
const nav = useNavigate();
const [editingId, setEditingId] = useState(null);
const [editTitle, setEditTitle] = useState("");
const [menuId, setMenuId] = useState(null);
const menuRef = useRef(null);
useEffect(() => {
(async () => {
try {
const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats });
} catch { /* ignore */ }
})();
}, [state.token, dispatch]);
useEffect(() => {
function handleClick(e) { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuId(null); }
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
async function handleNew() {
try {
const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat });
onSelectChat(chat.id);
} catch { /* ignore */ }
}
async function handleDelete(e, chatId) {
e.stopPropagation();
if (!confirm("Delete this chat?")) return;
async function handleDelete(id) {
try {
await deleteChat(state.token, chatId);
dispatch({ type: "REMOVE_CHAT", chatId });
await deleteChat(state.token, id);
dispatch({ type: "DELETE_CHAT", chatId: id });
if (activeChatId === id) {
const remaining = state.chats.filter((c) => c.id !== id);
onSelectChat(remaining.length ? remaining[0].id : null);
}
} catch { /* ignore */ }
setMenuId(null);
}
function startEdit(e, chat) {
e.stopPropagation();
setEditId(chat.id);
setEditTitle(chat.title);
}
async function confirmEdit(e) {
e.stopPropagation();
if (editTitle.trim() && editId) {
try {
await renameChat(state.token, editId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: editId, title: editTitle.trim() } });
} catch { /* ignore */ }
}
setEditId(null);
}
function cancelEdit(e) {
e.stopPropagation();
setEditId(null);
}
function selectChat(chatId) {
dispatch({ type: "SET_ACTIVE_CHAT", chatId });
async function handleRename(id) {
if (!editTitle.trim()) { setEditingId(null); return; }
try {
await renameChat(state.token, id, editTitle);
dispatch({ type: "UPDATE_CHAT", chat: { id, title: editTitle } });
} catch { /* ignore */ }
setEditingId(null);
}
function handleLogout() {
dispatch({ type: "LOGOUT" });
function startRename(chat) {
setEditingId(chat.id);
setEditTitle(chat.title);
setMenuId(null);
}
const isSuperadmin = state.user?.role === "superadmin";
return (
<div className={`flex flex-col bg-anton-surface border-r border-anton-border h-full ${mobile ? "w-full" : "w-64"}`}>
{/* Header */}
<div className="p-3 border-b border-anton-border">
<div className="flex items-center gap-2.5 mb-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20 shrink-0">
<Flame size={18} className="text-white" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-bold text-white truncate">Son of Anton</h1>
<p className="text-[10px] text-anton-muted truncate">{state.user?.username || ""}</p>
<>
{isOpen && <div className="fixed inset-0 bg-black/50 z-30 md:hidden" onClick={onToggle} />}
<aside className={`fixed md:static inset-y-0 left-0 z-40 w-72 bg-anton-surface border-r border-anton-border flex flex-col transition-transform duration-200 ${isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"}`}>
{/* Header */}
<div className="p-3 border-b border-anton-border">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
<Flame size={16} className="text-white" />
</div>
<div>
<h1 className="text-sm font-bold text-white">Son of Anton</h1>
<p className="text-[10px] text-anton-muted">v3.0 • {state.user?.username}</p>
</div>
</div>
{mobile && (
<button onClick={onClose} className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition">
<X size={18} />
</button>
)}
<button onClick={handleNew} className="w-full flex items-center justify-center gap-1.5 bg-anton-accent text-white rounded-lg py-2 text-sm font-medium hover:opacity-90 transition active:scale-[0.98]">
<Plus size={16} /> New Chat
</button>
</div>
<button
onClick={handleNew}
className="w-full flex items-center justify-center gap-2 py-2.5 px-3 bg-anton-accent text-white rounded-xl text-sm font-medium hover:opacity-90 transition active:scale-[0.98]"
>
<Plus size={16} /> New Chat
</button>
</div>
{/* Chat list */}
<div className="flex-1 overflow-y-auto py-1.5 px-1.5 space-y-0.5">
{state.chats.length === 0 && (
<p className="text-center text-anton-muted text-xs py-8 px-4">
No chats yet. Start a new conversation!
</p>
)}
{state.chats.map((chat) => {
const active = chat.id === state.activeChatId;
const editing = editId === chat.id;
return (
<div
key={chat.id}
onClick={() => !editing && selectChat(chat.id)}
className={`group flex items-center gap-2 px-2.5 py-2.5 rounded-lg cursor-pointer transition min-h-[44px] ${
active
? "bg-anton-accent/15 text-white border border-anton-accent/30"
: "text-anton-muted hover:bg-anton-card hover:text-white border border-transparent"
}`}
>
<MessageSquare size={14} className="shrink-0 opacity-50" />
{editing ? (
<div className="flex-1 flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") confirmEdit(e); if (e.key === "Escape") cancelEdit(e); }}
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent min-w-0"
autoFocus
/>
<button onClick={confirmEdit} className="p-1 text-anton-success"><Check size={14} /></button>
<button onClick={cancelEdit} className="p-1 text-anton-muted"><X size={14} /></button>
{/* Chat list */}
<div className="flex-1 overflow-y-auto py-1">
{state.chats.map((chat) => (
<div key={chat.id} className={`group relative mx-1.5 my-0.5 rounded-lg transition ${activeChatId === chat.id ? "bg-anton-card border border-anton-border" : "hover:bg-anton-card/50"}`}>
{editingId === chat.id ? (
<div className="flex items-center gap-1 px-2 py-2">
<input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleRename(chat.id)} className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none" autoFocus />
<button onClick={() => handleRename(chat.id)} className="p-1 text-green-400 hover:bg-green-500/20 rounded"><Check size={12} /></button>
<button onClick={() => setEditingId(null)} className="p-1 text-anton-muted hover:bg-anton-card rounded"><X size={12} /></button>
</div>
) : (
<>
<span className="flex-1 text-sm truncate">{chat.title}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={(e) => startEdit(e, chat)} className="p-1 rounded hover:bg-anton-bg transition"><Edit3 size={12} /></button>
<button onClick={(e) => handleDelete(e, chat.id)} className="p-1 rounded hover:bg-anton-danger/20 text-anton-danger transition"><Trash2 size={12} /></button>
</div>
</>
<button onClick={() => { onSelectChat(chat.id); if (window.innerWidth < 768) onToggle?.(); }} className="w-full text-left px-3 py-2.5 flex items-center gap-2">
<MessageSquare size={14} className="text-anton-muted shrink-0" />
<span className="text-sm truncate flex-1">{chat.title}</span>
<button onClick={(e) => { e.stopPropagation(); setMenuId(menuId === chat.id ? null : chat.id); }} className="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-anton-bg text-anton-muted">
<MoreHorizontal size={12} />
</button>
</button>
)}
{menuId === chat.id && (
<div ref={menuRef} className="absolute right-2 top-9 z-50 bg-anton-card border border-anton-border rounded-lg shadow-xl py-1 w-36 animate-fade-in">
<button onClick={() => startRename(chat)} className="w-full text-left px-3 py-1.5 text-xs hover:bg-anton-bg flex items-center gap-2"><Pencil size={10} /> Rename</button>
<button onClick={() => handleDelete(chat.id)} className="w-full text-left px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 flex items-center gap-2"><Trash2 size={10} /> Delete</button>
</div>
)}
</div>
);
})}
</div>
))}
</div>
{/* Footer nav */}
<div className="p-2 border-t border-anton-border space-y-0.5">
<button
onClick={() => { navigate("/knowledge"); onClose?.(); }}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]"
>
<BookOpen size={16} /> Knowledge Bases <ChevronRight size={14} className="ml-auto opacity-40" />
</button>
{isSuperadmin && (
<button
onClick={() => { navigate("/admin"); onClose?.(); }}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-white hover:bg-anton-card transition min-h-[44px]"
>
<Shield size={16} /> Admin Panel <ChevronRight size={14} className="ml-auto opacity-40" />
{/* Bottom nav */}
<div className="border-t border-anton-border p-2 space-y-0.5">
{isSuperadmin && (
<>
<button onClick={() => nav("/gitlab")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-orange-400 hover:bg-orange-500/10 transition">
<FolderGit2 size={14} /> GitLab Command Center
</button>
<button onClick={() => nav("/admin")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card transition">
<Shield size={14} /> Admin Dashboard
</button>
</>
)}
<button onClick={() => nav("/knowledge")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card transition">
<BookOpen size={14} /> Knowledge Bases
</button>
)}
<button
onClick={handleLogout}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition min-h-[44px]"
>
<LogOut size={16} /> Sign Out
</button>
{/* Quota display */}
{state.user && (
<div className="px-3 py-2 text-[10px] text-anton-muted">
<div className="flex justify-between mb-1">
<span>Tokens used</span>
<span>{((state.user.tokens_used_this_month || 0) / 1000).toFixed(0)}K / {((state.user.quota_tokens_monthly || 0) / 1000).toFixed(0)}K</span>
</div>
<div className="h-1 bg-anton-border rounded-full overflow-hidden">
<div
className="h-full bg-anton-accent rounded-full transition-all"
style={{ width: `${Math.min(100, ((state.user.tokens_used_this_month || 0) / (state.user.quota_tokens_monthly || 1)) * 100)}%` }}
/>
</div>
</div>
)}
</div>
</div>
<button onClick={() => dispatch({ type: "LOGOUT" })} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-red-400 hover:bg-red-500/10 transition">
<LogOut size={14} /> Logout
</button>
</div>
</aside>
</>
);
}
\ No newline at end of file
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import {
gitlabGetConfig, gitlabSaveConfig, gitlabTestConnection,
gitlabListProjects, gitlabCreateProject, gitlabDeleteProject,
gitlabGetTree, gitlabGetFile, gitlabListBranches, gitlabCreateBranch,
gitlabListOperations, gitlabApproveOperation, gitlabRejectOperation,
gitlabDeleteOperation, gitlabDirectCommit, gitlabAuditLog,
gitlabListPipelines, gitlabTriggerPipeline, gitlabListMRs,
} from "../api";
import {
ArrowLeft, Settings, FolderGit2, ListChecks, ScrollText,
Plus, Trash2, RefreshCw, Check, X, ExternalLink, GitBranch,
File, Folder, ChevronRight, ChevronDown, Loader2, Shield,
Zap, Clock, CheckCircle, XCircle, AlertCircle, Play, Eye,
Copy, TerminalSquare, GitMerge, Rocket,
} from "lucide-react";
const TABS = [
{ id: "settings", label: "Settings", icon: Settings },
{ id: "repos", label: "Repositories", icon: FolderGit2 },
{ id: "operations", label: "Operations", icon: ListChecks },
{ id: "audit", label: "Audit Log", icon: ScrollText },
];
const STATUS_STYLES = {
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
executed: "bg-green-500/20 text-green-400 border-green-500/30",
failed: "bg-red-500/20 text-red-400 border-red-500/30",
rejected: "bg-gray-500/20 text-gray-400 border-gray-500/30",
};
export default function GitLabPage() {
const { state } = useApp();
const nav = useNavigate();
const [tab, setTab] = useState("settings");
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const c = await gitlabGetConfig(state.token);
setConfig(c);
} catch { /* ignore */ }
setLoading(false);
})();
}, [state.token]);
if (state.user?.role !== "superadmin") {
return (
<div className="h-dvh flex items-center justify-center bg-anton-bg">
<div className="text-center">
<Shield size={48} className="text-red-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-white mb-2">Access Denied</h1>
<p className="text-anton-muted">Superadmin access required.</p>
<button onClick={() => nav("/")} className="mt-4 px-4 py-2 bg-anton-accent rounded-lg text-white text-sm">Back to Chat</button>
</div>
</div>
);
}
return (
<div className="h-dvh flex flex-col bg-anton-bg text-white">
{/* Header */}
<div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3 shrink-0">
<button onClick={() => nav("/")} className="p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white">
<ArrowLeft size={18} />
</button>
<FolderGit2 size={20} className="text-orange-400" />
<h1 className="text-lg font-bold">GitLab Command Center</h1>
<span className="text-xs text-anton-muted ml-auto">Superadmin Only</span>
</div>
{/* Tabs */}
<div className="border-b border-anton-border bg-anton-surface px-4 flex gap-1 shrink-0 overflow-x-auto">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition whitespace-nowrap ${tab === t.id ? "border-orange-400 text-orange-400" : "border-transparent text-anton-muted hover:text-white"
}`}
>
<t.icon size={14} />
{t.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin text-anton-accent" size={24} />
</div>
) : (
<>
{tab === "settings" && <SettingsTab config={config} setConfig={setConfig} token={state.token} />}
{tab === "repos" && <ReposTab config={config} token={state.token} />}
{tab === "operations" && <OperationsTab token={state.token} />}
{tab === "audit" && <AuditTab token={state.token} />}
</>
)}
</div>
</div>
);
}
// ══════════════════════════════════════════════════════
// Settings Tab
// ══════════════════════════════════════════════════════
function SettingsTab({ config, setConfig, token }) {
const [url, setUrl] = useState(config?.gitlab_url || "");
const [pat, setPat] = useState("");
const [ns, setNs] = useState(config?.default_namespace || "");
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [msg, setMsg] = useState(null);
async function handleTest() {
if (!url || !pat) return;
setTesting(true); setMsg(null);
try {
const r = await gitlabTestConnection(token, { gitlab_url: url, access_token: pat });
setMsg(r.ok ? { type: "success", text: `Connected as ${r.name} (@${r.user})${r.is_admin ? " [Admin]" : ""}` } : { type: "error", text: r.error });
} catch (e) { setMsg({ type: "error", text: e.message }); }
setTesting(false);
}
async function handleSave() {
if (!url || !pat) return;
setSaving(true); setMsg(null);
try {
const r = await gitlabSaveConfig(token, { gitlab_url: url, access_token: pat, default_namespace: ns || null });
setMsg({ type: "success", text: `Saved! Connected as ${r.name} (@${r.user})` });
setConfig({ configured: true, gitlab_url: url });
} catch (e) { setMsg({ type: "error", text: e.message }); }
setSaving(false);
}
return (
<div className="max-w-xl mx-auto space-y-6">
<div className="bg-anton-card border border-anton-border rounded-xl p-5 space-y-4">
<h2 className="text-lg font-bold flex items-center gap-2"><Settings size={18} className="text-orange-400" /> GitLab Connection</h2>
{config?.configured && <p className="text-xs text-green-400">✓ Currently connected to {config.gitlab_url} (token: {config.token_masked})</p>}
<div>
<label className="text-xs text-anton-muted block mb-1">GitLab URL</label>
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://gitlab.yourdomain.com" className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400" />
</div>
<div>
<label className="text-xs text-anton-muted block mb-1">Personal Access Token</label>
<input type="password" value={pat} onChange={(e) => setPat(e.target.value)} placeholder="glpat-xxxxxxxxxxxxxxxxxxxx" className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400" />
<p className="text-[10px] text-anton-muted mt-1">Needs scopes: api, read_repository, write_repository</p>
</div>
<div>
<label className="text-xs text-anton-muted block mb-1">Default Namespace (optional)</label>
<input value={ns} onChange={(e) => setNs(e.target.value)} placeholder="your-group" className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400" />
</div>
{msg && (
<div className={`text-sm p-3 rounded-lg ${msg.type === "success" ? "bg-green-500/10 text-green-400 border border-green-500/20" : "bg-red-500/10 text-red-400 border border-red-500/20"}`}>
{msg.text}
</div>
)}
<div className="flex gap-2">
<button onClick={handleTest} disabled={testing || !url || !pat} className="px-4 py-2 bg-anton-card border border-anton-border rounded-lg text-sm hover:border-orange-400 disabled:opacity-30 flex items-center gap-1.5">
{testing ? <Loader2 size={14} className="animate-spin" /> : <Zap size={14} />} Test
</button>
<button onClick={handleSave} disabled={saving || !url || !pat} className="px-4 py-2 bg-orange-500 rounded-lg text-sm text-white hover:bg-orange-600 disabled:opacity-30 flex items-center gap-1.5">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} Save & Connect
</button>
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════
// Repos Tab
// ══════════════════════════════════════════════════════
function ReposTab({ config, token }) {
const [projects, setProjects] = useState([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [creating, setCreating] = useState(false);
const [selectedProject, setSelectedProject] = useState(null);
const loadProjects = useCallback(async () => {
setLoading(true);
try {
const r = await gitlabListProjects(token, search);
setProjects(r.projects || []);
} catch { /* ignore */ }
setLoading(false);
}, [token, search]);
useEffect(() => { if (config?.configured) loadProjects(); }, [config, loadProjects]);
async function handleCreate() {
if (!newName.trim()) return;
setCreating(true);
try {
await gitlabCreateProject(token, { name: newName, description: newDesc });
setNewName(""); setNewDesc(""); setShowCreate(false);
loadProjects();
} catch { /* ignore */ }
setCreating(false);
}
if (!config?.configured) return <p className="text-anton-muted text-center py-10">Configure GitLab connection in Settings first.</p>;
if (selectedProject) {
return <ProjectDetail project={selectedProject} token={token} onBack={() => { setSelectedProject(null); loadProjects(); }} />;
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 flex-wrap">
<input value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => e.key === "Enter" && loadProjects()} placeholder="Search repos…" className="flex-1 min-w-[200px] bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-400" />
<button onClick={loadProjects} className="p-2 bg-anton-card border border-anton-border rounded-lg hover:border-orange-400"><RefreshCw size={16} /></button>
<button onClick={() => setShowCreate(!showCreate)} className="px-3 py-2 bg-orange-500 rounded-lg text-sm text-white flex items-center gap-1.5 hover:bg-orange-600"><Plus size={14} /> New Repo</button>
</div>
{showCreate && (
<div className="bg-anton-card border border-orange-500/30 rounded-xl p-4 space-y-3 animate-fade-in">
<input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Repository name" className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-400" />
<input value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Description (optional)" className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-400" />
<div className="flex gap-2">
<button onClick={handleCreate} disabled={creating || !newName.trim()} className="px-4 py-2 bg-orange-500 rounded-lg text-sm text-white disabled:opacity-30 flex items-center gap-1.5">
{creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} Create
</button>
<button onClick={() => setShowCreate(false)} className="px-4 py-2 bg-anton-card border border-anton-border rounded-lg text-sm">Cancel</button>
</div>
</div>
)}
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-anton-accent" /></div>
) : (
<div className="grid gap-2">
{projects.map((p) => (
<button key={p.id} onClick={() => setSelectedProject(p)} className="w-full text-left bg-anton-card border border-anton-border rounded-xl p-4 hover:border-orange-400/50 transition group">
<div className="flex items-start justify-between">
<div className="min-w-0">
<div className="font-semibold text-sm truncate group-hover:text-orange-400 transition">{p.path_with_namespace || p.name}</div>
{p.description && <p className="text-xs text-anton-muted mt-0.5 truncate">{p.description}</p>}
</div>
<ChevronRight size={16} className="text-anton-muted shrink-0 mt-0.5" />
</div>
<div className="flex gap-3 mt-2 text-[10px] text-anton-muted">
<span>{p.visibility}</span>
<span>{p.default_branch || "main"}</span>
{p.last_activity_at && <span>{new Date(p.last_activity_at).toLocaleDateString()}</span>}
</div>
</button>
))}
{!projects.length && <p className="text-anton-muted text-center py-6 text-sm">No repositories found.</p>}
</div>
)}
</div>
);
}
// ══════════════════════════════════════════════════════
// Project Detail (File Browser + Actions)
// ══════════════════════════════════════════════════════
function ProjectDetail({ project, token, onBack }) {
const [tree, setTree] = useState([]);
const [path, setPath] = useState("");
const [branches, setBranches] = useState([]);
const [branch, setBranch] = useState(project.default_branch || "main");
const [loading, setLoading] = useState(false);
const [fileContent, setFileContent] = useState(null);
const [pipelines, setPipelines] = useState([]);
const [subTab, setSubTab] = useState("files");
const loadTree = useCallback(async () => {
setLoading(true);
try {
const t = await gitlabGetTree(token, project.id, path, branch);
setTree(t.sort((a, b) => (a.type === "tree" ? -1 : 1) - (b.type === "tree" ? -1 : 1) || a.name.localeCompare(b.name)));
} catch { setTree([]); }
setLoading(false);
}, [token, project.id, path, branch]);
useEffect(() => {
loadTree();
gitlabListBranches(token, project.id).then(setBranches).catch(() => { });
}, [loadTree, token, project.id]);
async function openFile(filePath) {
try {
const f = await gitlabGetFile(token, project.id, filePath, branch);
setFileContent({ ...f, path: filePath });
} catch { /* ignore */ }
}
async function loadPipelines() {
try {
const p = await gitlabListPipelines(token, project.id);
setPipelines(p);
} catch { setPipelines([]); }
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<button onClick={onBack} className="p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white"><ArrowLeft size={16} /></button>
<h2 className="font-bold text-sm">{project.path_with_namespace}</h2>
<select value={branch} onChange={(e) => { setBranch(e.target.value); setPath(""); setFileContent(null); }} className="ml-auto bg-anton-card border border-anton-border rounded-lg px-2 py-1 text-xs text-white">
{branches.map((b) => <option key={b.name} value={b.name}>{b.name}</option>)}
</select>
</div>
{/* Sub-tabs */}
<div className="flex gap-1 border-b border-anton-border pb-1">
{[{ id: "files", label: "Files", icon: Folder }, { id: "pipelines", label: "Pipelines", icon: Rocket }].map((t) => (
<button key={t.id} onClick={() => { setSubTab(t.id); if (t.id === "pipelines") loadPipelines(); }}
className={`flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-lg transition ${subTab === t.id ? "bg-orange-500/20 text-orange-400" : "text-anton-muted hover:text-white"}`}>
<t.icon size={12} />{t.label}
</button>
))}
</div>
{subTab === "files" && (
<>
{/* Breadcrumb */}
{path && (
<div className="flex items-center gap-1 text-xs text-anton-muted flex-wrap">
<button onClick={() => { setPath(""); setFileContent(null); }} className="hover:text-orange-400">{project.name}</button>
{path.split("/").map((seg, i, arr) => (
<React.Fragment key={i}>
<ChevronRight size={10} />
<button onClick={() => { setPath(arr.slice(0, i + 1).join("/")); setFileContent(null); }} className="hover:text-orange-400">{seg}</button>
</React.Fragment>
))}
</div>
)}
{fileContent ? (
<div className="bg-anton-card border border-anton-border rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-anton-border bg-anton-surface">
<span className="text-xs font-mono text-anton-muted">{fileContent.path}</span>
<div className="flex gap-1.5">
<button onClick={() => { navigator.clipboard.writeText(fileContent.decoded_content || ""); }} className="text-[10px] text-anton-muted hover:text-white flex items-center gap-1"><Copy size={10} /> Copy</button>
<button onClick={() => setFileContent(null)} className="text-[10px] text-anton-muted hover:text-white"><X size={12} /></button>
</div>
</div>
<pre className="p-3 text-xs text-green-300 font-mono overflow-auto max-h-[60vh] whitespace-pre-wrap">{fileContent.decoded_content || "[Binary]"}</pre>
</div>
) : loading ? (
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-anton-accent" /></div>
) : (
<div className="space-y-0.5">
{tree.map((item) => (
<button
key={item.id || item.name}
onClick={() => {
if (item.type === "tree") {
setPath(item.path);
} else {
openFile(item.path);
}
}}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-anton-card transition text-left"
>
{item.type === "tree" ? <Folder size={14} className="text-blue-400 shrink-0" /> : <File size={14} className="text-anton-muted shrink-0" />}
<span className="text-sm truncate">{item.name}</span>
</button>
))}
{!tree.length && <p className="text-anton-muted text-center py-6 text-sm">Empty directory.</p>}
</div>
)}
</>
)}
{subTab === "pipelines" && (
<div className="space-y-2">
<button onClick={() => gitlabTriggerPipeline(token, project.id, branch).then(loadPipelines)} className="px-3 py-1.5 bg-green-600 rounded-lg text-xs text-white flex items-center gap-1.5 hover:bg-green-700">
<Play size={12} /> Run Pipeline
</button>
{pipelines.map((p) => (
<div key={p.id} className="bg-anton-card border border-anton-border rounded-lg px-3 py-2 flex items-center gap-3">
<span className={`w-2 h-2 rounded-full shrink-0 ${p.status === "success" ? "bg-green-500" : p.status === "failed" ? "bg-red-500" : p.status === "running" ? "bg-blue-500 animate-pulse" : "bg-yellow-500"}`} />
<div className="min-w-0 flex-1">
<span className="text-xs font-mono">#{p.id}</span>
<span className="text-xs text-anton-muted ml-2">{p.ref}</span>
</div>
<span className="text-[10px] text-anton-muted">{p.status}</span>
</div>
))}
{!pipelines.length && <p className="text-anton-muted text-center py-6 text-sm">No pipelines yet.</p>}
</div>
)}
</div>
);
}
// ══════════════════════════════════════════════════════
// Operations Tab (THE APPROVAL QUEUE)
// ══════════════════════════════════════════════════════
function OperationsTab({ token }) {
const [ops, setOps] = useState([]);
const [filter, setFilter] = useState("pending");
const [loading, setLoading] = useState(false);
const [expandedOp, setExpandedOp] = useState(null);
const load = useCallback(async () => {
setLoading(true);
try { setOps(await gitlabListOperations(token, filter)); } catch { setOps([]); }
setLoading(false);
}, [token, filter]);
useEffect(() => { load(); }, [load]);
async function handleApprove(id) {
try { await gitlabApproveOperation(token, id); load(); } catch { /* ignore */ }
}
async function handleReject(id) {
try { await gitlabRejectOperation(token, id); load(); } catch { /* ignore */ }
}
return (
<div className="space-y-4">
<div className="flex gap-1.5">
{["pending", "executed", "failed", "rejected", "all"].map((f) => (
<button key={f} onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${filter === f ? "bg-orange-500/20 text-orange-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
<button onClick={load} className="ml-auto p-1.5 hover:bg-anton-card rounded-lg text-anton-muted"><RefreshCw size={14} /></button>
</div>
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-anton-accent" /></div>
) : (
<div className="space-y-2">
{ops.map((op) => (
<div key={op.id} className={`bg-anton-card border rounded-xl overflow-hidden ${STATUS_STYLES[op.status] || "border-anton-border"}`}>
<button onClick={() => setExpandedOp(expandedOp === op.id ? null : op.id)} className="w-full px-4 py-3 flex items-center gap-3 text-left">
<span className={`text-xs font-bold uppercase px-2 py-0.5 rounded ${STATUS_STYLES[op.status]}`}>{op.status}</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{op.operation_type.replace(/_/g, " ")}</div>
<div className="text-[10px] text-anton-muted">{op.project_name || `Project #${op.project_id}`}{op.branch || "main"}{new Date(op.created_at).toLocaleString()}</div>
</div>
{expandedOp === op.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{expandedOp === op.id && (
<div className="px-4 pb-3 border-t border-anton-border/50 pt-3 space-y-3 animate-fade-in">
<div className="bg-anton-bg rounded-lg p-3">
<p className="text-[10px] text-anton-muted mb-1 font-bold">PAYLOAD</p>
<pre className="text-xs font-mono text-green-300 whitespace-pre-wrap max-h-60 overflow-y-auto">{JSON.stringify(op.payload, null, 2)}</pre>
</div>
{op.result && (
<div className="bg-anton-bg rounded-lg p-3">
<p className="text-[10px] text-anton-muted mb-1 font-bold">RESULT</p>
<pre className="text-xs font-mono text-blue-300 whitespace-pre-wrap max-h-40 overflow-y-auto">{typeof op.result === "string" ? op.result : JSON.stringify(op.result, null, 2)}</pre>
</div>
)}
{op.status === "pending" && (
<div className="flex gap-2">
<button onClick={() => handleApprove(op.id)} className="px-4 py-2 bg-green-600 rounded-lg text-sm text-white flex items-center gap-1.5 hover:bg-green-700">
<CheckCircle size={14} /> Approve & Execute
</button>
<button onClick={() => handleReject(op.id)} className="px-4 py-2 bg-red-600 rounded-lg text-sm text-white flex items-center gap-1.5 hover:bg-red-700">
<XCircle size={14} /> Reject
</button>
</div>
)}
</div>
)}
</div>
))}
{!ops.length && <p className="text-anton-muted text-center py-10 text-sm">No operations found.</p>}
</div>
)}
</div>
);
}
// ══════════════════════════════════════════════════════
// Audit Tab
// ══════════════════════════════════════════════════════
function AuditTab({ token }) {
const [entries, setEntries] = useState([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
(async () => {
setLoading(true);
try {
const r = await gitlabAuditLog(token, page);
setEntries(r.entries || []);
setTotal(r.total || 0);
} catch { setEntries([]); }
setLoading(false);
})();
}, [token, page]);
return (
<div className="space-y-4">
<h2 className="text-sm font-bold text-anton-muted">{total} total audit entries</h2>
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-anton-accent" /></div>
) : (
<div className="space-y-1">
{entries.map((e) => (
<div key={e.id} className="bg-anton-card border border-anton-border rounded-lg px-3 py-2 flex items-start gap-3">
<Clock size={12} className="text-anton-muted mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-orange-400">{e.action}</div>
{e.details && <p className="text-[10px] text-anton-muted mt-0.5">{e.details}</p>}
</div>
<span className="text-[10px] text-anton-muted whitespace-nowrap">{new Date(e.created_at).toLocaleString()}</span>
</div>
))}
{entries.length === 50 && (
<div className="flex justify-center pt-2">
<button onClick={() => setPage((p) => p + 1)} className="text-xs text-orange-400 hover:underline">Load more</button>
</div>
)}
</div>
)}
</div>
);
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment