Compare commits

..

3 Commits

Author SHA1 Message Date
repair-agent
fc6dc52f72 add del project
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m42s
2026-03-13 10:44:29 +08:00
repair-agent
01f87e38aa add bugfix
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m36s
2026-03-02 18:09:53 +08:00
repair-agent
25c9b2d18e fix bug
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m39s
2026-03-02 17:46:33 +08:00
8 changed files with 217 additions and 59 deletions

View File

@ -3,13 +3,9 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import DBAPIError, OperationalError
import os import os
import logging
from dotenv import load_dotenv from dotenv import load_dotenv
logger = logging.getLogger(__name__)
load_dotenv() load_dotenv()
DB_USER = os.getenv("DB_USER") DB_USER = os.getenv("DB_USER")
@ -25,10 +21,7 @@ engine = create_async_engine(
echo=True, echo=True,
future=True, future=True,
pool_pre_ping=True, pool_pre_ping=True,
pool_recycle=180, pool_recycle=300,
pool_size=5,
max_overflow=10,
pool_timeout=30,
) )
async def init_db(): async def init_db():
@ -62,21 +55,9 @@ async def init_db():
except Exception: except Exception:
pass # Already applied pass # Already applied
_async_session_factory = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
_MAX_RETRIES = 2
async def get_session() -> AsyncSession: async def get_session() -> AsyncSession:
for attempt in range(_MAX_RETRIES): async_session = sessionmaker(
try: engine, class_=AsyncSession, expire_on_commit=False
async with _async_session_factory() as session: )
async with async_session() as session:
yield session yield session
return
except (DBAPIError, OperationalError, ConnectionResetError, OSError) as e:
if attempt < _MAX_RETRIES - 1:
logger.warning("Database connection error (attempt %d/%d): %s", attempt + 1, _MAX_RETRIES, e)
await engine.dispose()
continue
raise

View File

@ -1,9 +1,10 @@
""" """
Gitea API 客户端 Gitea API 客户端
""" """
import re
import httpx import httpx
import os import os
from typing import Tuple from typing import Tuple, Optional
class GiteaClient: class GiteaClient:
@ -12,7 +13,7 @@ class GiteaClient:
def __init__(self, gitea_url: str = None, token: str = None): def __init__(self, gitea_url: str = None, token: str = None):
self.gitea_url = (gitea_url or os.getenv("GITEA_URL", "")).rstrip("/") self.gitea_url = (gitea_url or os.getenv("GITEA_URL", "")).rstrip("/")
self.token = token or os.getenv("GITEA_TOKEN", "") self.token = token or os.getenv("GITEA_TOKEN", "")
self.base_api_url = f"{self.gitea_url}/api/v1" self.base_api_url = f"{self.gitea_url}/api/v1" if self.gitea_url else ""
self.client = httpx.Client(timeout=30) self.client = httpx.Client(timeout=30)
def _headers(self) -> dict: def _headers(self) -> dict:
@ -22,21 +23,31 @@ class GiteaClient:
"Content-Type": "application/json", "Content-Type": "application/json",
} }
def _extract_owner_repo_from_pr_url(self, pr_url: str) -> Tuple[str, str, int]: def _parse_pr_url(self, pr_url: str) -> Tuple[str, str, str, int]:
""" """
PR URL 提取 owner, repo, pr_number PR URL 提取 base_url, owner, repo, pr_number
例如: https://gitea.airlabs.art/owner/repo/pulls/45 例如: https://gitea.airlabs.art/owner/repo/pulls/45
返回: ("https://gitea.airlabs.art", "owner", "repo", 45)
""" """
import re match = re.search(r'(https?://[^/]+)/([^/]+)/([^/]+)/pulls/(\d+)', pr_url)
match = re.search(r'([^/]+)/([^/]+)/pulls/(\d+)', pr_url)
if not match: if not match:
raise ValueError(f"无法解析 PR URL: {pr_url}") raise ValueError(f"无法解析 PR URL: {pr_url}")
owner, repo, pr_number = match.groups() base_url, owner, repo, pr_number = match.groups()
return owner, repo, int(pr_number) return base_url, owner, repo, int(pr_number)
def _get_api_url(self, pr_url: Optional[str] = None) -> str:
"""获取 API base URL优先从 pr_url 解析,否则用 self.base_api_url"""
if pr_url:
match = re.search(r'(https?://[^/]+)/', pr_url)
if match:
return f"{match.group(1)}/api/v1"
if self.base_api_url:
return self.base_api_url
raise ValueError("无法确定 Gitea API 地址:未配置 GITEA_URL 且 PR URL 中无法提取")
def merge_pr( def merge_pr(
self, owner: str, repo: str, pr_number: int self, owner: str, repo: str, pr_number: int, api_base_url: str = None
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
""" """
合并 PR 合并 PR
@ -45,11 +56,13 @@ class GiteaClient:
owner: 仓库所有者 owner: 仓库所有者
repo: 仓库名称 repo: 仓库名称
pr_number: PR 编号 pr_number: PR 编号
api_base_url: 可选API base URL pr_url 解析得到
Returns: Returns:
(是否成功, 消息) (是否成功, 消息)
""" """
url = f"{self.base_api_url}/repos/{owner}/{repo}/pulls/{pr_number}/merge" base = api_base_url or self.base_api_url
url = f"{base}/repos/{owner}/{repo}/pulls/{pr_number}/merge"
payload = { payload = {
"Do": "merge", # merge / squash / rebase "Do": "merge", # merge / squash / rebase
"MergeMessageField": f"Merge PR #{pr_number} (approved via Log Center)", "MergeMessageField": f"Merge PR #{pr_number} (approved via Log Center)",
@ -75,7 +88,7 @@ class GiteaClient:
return False, str(e) return False, str(e)
def close_pr( def close_pr(
self, owner: str, repo: str, pr_number: int, reason: str = "" self, owner: str, repo: str, pr_number: int, reason: str = "", api_base_url: str = None
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
""" """
关闭 PR可选添加评论说明原因 关闭 PR可选添加评论说明原因
@ -85,20 +98,24 @@ class GiteaClient:
repo: 仓库名称 repo: 仓库名称
pr_number: PR 编号 pr_number: PR 编号
reason: 关闭原因将作为评论添加 reason: 关闭原因将作为评论添加
api_base_url: 可选API base URL pr_url 解析得到
Returns: Returns:
(是否成功, 消息) (是否成功, 消息)
""" """
base = api_base_url or self.base_api_url
# Step 1: 如果提供了原因,先添加评论 # Step 1: 如果提供了原因,先添加评论
if reason: if reason:
comment_success, comment_msg = self.add_pr_comment( comment_success, comment_msg = self.add_pr_comment(
owner, repo, pr_number, f"## ❌ 修复被拒绝\n\n**原因:**\n{reason}" owner, repo, pr_number, f"## ❌ 修复被拒绝\n\n**原因:**\n{reason}",
api_base_url=base,
) )
if not comment_success: if not comment_success:
return False, f"添加评论失败: {comment_msg}" return False, f"添加评论失败: {comment_msg}"
# Step 2: 关闭 PR # Step 2: 关闭 PR
url = f"{self.base_api_url}/repos/{owner}/{repo}/pulls/{pr_number}" url = f"{base}/repos/{owner}/{repo}/pulls/{pr_number}"
payload = {"state": "closed"} payload = {"state": "closed"}
try: try:
@ -111,7 +128,7 @@ class GiteaClient:
return False, str(e) return False, str(e)
def add_pr_comment( def add_pr_comment(
self, owner: str, repo: str, pr_number: int, comment: str self, owner: str, repo: str, pr_number: int, comment: str, api_base_url: str = None
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
""" """
添加 PR 评论 添加 PR 评论
@ -121,12 +138,14 @@ class GiteaClient:
repo: 仓库名称 repo: 仓库名称
pr_number: PR 编号 pr_number: PR 编号
comment: 评论内容 comment: 评论内容
api_base_url: 可选API base URL
Returns: Returns:
(是否成功, 消息) (是否成功, 消息)
""" """
base = api_base_url or self.base_api_url
# 注意: Gitea 中 PR 和 Issue 共用评论 API # 注意: Gitea 中 PR 和 Issue 共用评论 API
url = f"{self.base_api_url}/repos/{owner}/{repo}/issues/{pr_number}/comments" url = f"{base}/repos/{owner}/{repo}/issues/{pr_number}/comments"
payload = {"body": comment} payload = {"body": comment}
try: try:
@ -137,17 +156,19 @@ class GiteaClient:
return False, str(e) return False, str(e)
def merge_pr_by_url(self, pr_url: str) -> Tuple[bool, str]: def merge_pr_by_url(self, pr_url: str) -> Tuple[bool, str]:
"""通过 PR URL 直接合并""" """通过 PR URL 直接合并(从 URL 自动解析 Gitea 地址)"""
try: try:
owner, repo, pr_number = self._extract_owner_repo_from_pr_url(pr_url) base_url, owner, repo, pr_number = self._parse_pr_url(pr_url)
return self.merge_pr(owner, repo, pr_number) api_base_url = f"{base_url}/api/v1"
return self.merge_pr(owner, repo, pr_number, api_base_url=api_base_url)
except Exception as e: except Exception as e:
return False, str(e) return False, str(e)
def close_pr_by_url(self, pr_url: str, reason: str = "") -> Tuple[bool, str]: def close_pr_by_url(self, pr_url: str, reason: str = "") -> Tuple[bool, str]:
"""通过 PR URL 直接关闭""" """通过 PR URL 直接关闭(从 URL 自动解析 Gitea 地址)"""
try: try:
owner, repo, pr_number = self._extract_owner_repo_from_pr_url(pr_url) base_url, owner, repo, pr_number = self._parse_pr_url(pr_url)
return self.close_pr(owner, repo, pr_number, reason) api_base_url = f"{base_url}/api/v1"
return self.close_pr(owner, repo, pr_number, reason, api_base_url=api_base_url)
except Exception as e: except Exception as e:
return False, str(e) return False, str(e)

View File

@ -85,7 +85,8 @@ def generate_fingerprint(log: ErrorLogCreate) -> str:
if source == "cicd": if source == "cicd":
ctx = log.context or {} ctx = log.context or {}
raw = f"{log.project_id}|cicd|{log.error.get('type')}|{ctx.get('job_name', 'unknown')}|{ctx.get('step_name', 'unknown')}" # 加入 error_message 避免同一 job 不同错误被去重
raw = f"{log.project_id}|cicd|{log.error.get('type')}|{ctx.get('job_name', 'unknown')}|{ctx.get('step_name', 'unknown')}|{log.error.get('message', '')}"
elif source == "deployment": elif source == "deployment":
ctx = log.context or {} ctx = log.context or {}
raw = f"{log.project_id}|deployment|{log.error.get('type')}|{ctx.get('namespace', 'default')}|{ctx.get('deployment_name', 'unknown')}" raw = f"{log.project_id}|deployment|{log.error.get('type')}|{ctx.get('namespace', 'default')}|{ctx.get('deployment_name', 'unknown')}"
@ -105,13 +106,28 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g
existing_log = results.first() existing_log = results.first()
if existing_log: if existing_log:
# If exists and not resolved, just ignore or update count (implied) # If exists and not resolved, update error content but keep status
if existing_log.status not in [LogStatus.DEPLOYED, LogStatus.FIXED, LogStatus.VERIFIED]: if existing_log.status not in [LogStatus.DEPLOYED, LogStatus.FIXED, LogStatus.VERIFIED]:
return {"message": "Log deduplicated", "id": existing_log.id, "status": existing_log.status} # 更新错误内容,确保始终反映最新的错误信息
# If it was resolved but happened again -> Regression! Reset to NEW? existing_log.error_message = log_data.error.get("message", existing_log.error_message)
existing_log.status = LogStatus.NEW existing_log.stack_trace = log_data.error.get("stack_trace", existing_log.stack_trace)
existing_log.context = log_data.context or existing_log.context
existing_log.timestamp = log_data.timestamp or datetime.utcnow() existing_log.timestamp = log_data.timestamp or datetime.utcnow()
existing_log.retry_count = 0 # Reset retries for new occurrence if log_data.commit_hash:
existing_log.commit_hash = log_data.commit_hash
session.add(existing_log)
await session.commit()
await session.refresh(existing_log)
return {"message": "Log deduplicated (content updated)", "id": existing_log.id, "status": existing_log.status}
# If it was resolved but happened again -> Regression! Reset to NEW
existing_log.status = LogStatus.NEW
existing_log.error_message = log_data.error.get("message", existing_log.error_message)
existing_log.stack_trace = log_data.error.get("stack_trace", existing_log.stack_trace)
existing_log.context = log_data.context or existing_log.context
existing_log.timestamp = log_data.timestamp or datetime.utcnow()
existing_log.retry_count = 0
if log_data.commit_hash:
existing_log.commit_hash = log_data.commit_hash
session.add(existing_log) session.add(existing_log)
await session.commit() await session.commit()
await session.refresh(existing_log) await session.refresh(existing_log)
@ -588,6 +604,19 @@ async def update_project(project_id: str, data: ProjectUpdate, session: AsyncSes
await session.refresh(project) await session.refresh(project)
return project return project
@app.delete("/api/v1/projects/{project_id}", tags=["Projects"])
async def delete_project(project_id: str, session: AsyncSession = Depends(get_session)):
"""Delete a project by project_id"""
statement = select(Project).where(Project.project_id == project_id)
results = await session.exec(statement)
project = results.first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
await session.delete(project)
await session.commit()
return {"message": "Project deleted"}
@app.get("/", tags=["Health"]) @app.get("/", tags=["Health"])
async def health_check(): async def health_check():
return {"status": "ok"} return {"status": "ok"}

View File

@ -32,6 +32,9 @@ spec:
value: "log_center" value: "log_center"
- name: DB_PASSWORD - name: DB_PASSWORD
value: "JogNQdtrd3WY8CBCAiYfYEGx" value: "JogNQdtrd3WY8CBCAiYfYEGx"
# Gitea TokenURL 从 PR URL 自动解析)
- name: GITEA_TOKEN
value: "443f7f2f556b4832f90e46df9af3e21ccb06b8a3"
resources: resources:
requests: requests:
memory: "128Mi" memory: "128Mi"

View File

@ -38,6 +38,7 @@ class ClaudeService:
cmd = [ cmd = [
self.cli_path, self.cli_path,
"-p", prompt, "-p", prompt,
"--model", settings.claude_model,
"--dangerously-skip-permissions", "--dangerously-skip-permissions",
] ]

View File

@ -19,6 +19,7 @@ class Settings(BaseSettings):
# Claude CLI # Claude CLI
claude_cli_path: str = Field(default="claude", description="Claude CLI 路径") claude_cli_path: str = Field(default="claude", description="Claude CLI 路径")
claude_timeout: int = Field(default=1000, description="Claude CLI 超时时间(秒)") claude_timeout: int = Field(default=1000, description="Claude CLI 超时时间(秒)")
claude_model: str = Field(default="claude-opus-4-6", description="Claude 模型")
# Git # Git
git_user_name: str = Field(default="repair-agent", description="Git 用户名") git_user_name: str = Field(default="repair-agent", description="Git 用户名")

View File

@ -160,6 +160,9 @@ export const getProjectDetail = (projectId: string) =>
export const updateProject = (projectId: string, data: Partial<Project>) => export const updateProject = (projectId: string, data: Partial<Project>) =>
api.put<Project>(`/api/v1/projects/${projectId}`, data); api.put<Project>(`/api/v1/projects/${projectId}`, data);
export const deleteProject = (projectId: string) =>
api.delete(`/api/v1/projects/${projectId}`);
export const updateTaskStatus = (taskId: number, status: string, message?: string) => export const updateTaskStatus = (taskId: number, status: string, message?: string) =>
api.put(`/api/v1/tasks/${taskId}/status`, { status, message }); api.put(`/api/v1/tasks/${taskId}/status`, { status, message });

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getProjects, updateProject, type Project } from '../api'; import { getProjects, updateProject, deleteProject, type Project } from '../api';
import { Save, X, Pencil } from 'lucide-react'; import { Save, X, Pencil, Trash2 } from 'lucide-react';
export default function ProjectList() { export default function ProjectList() {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
@ -9,6 +9,11 @@ export default function ProjectList() {
const [editForm, setEditForm] = useState({ name: '', repo_url: '', local_path: '', description: '' }); const [editForm, setEditForm] = useState({ name: '', repo_url: '', local_path: '', description: '' });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Delete confirmation state
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const [deleting, setDeleting] = useState(false);
const fetchProjects = async () => { const fetchProjects = async () => {
setLoading(true); setLoading(true);
try { try {
@ -50,6 +55,32 @@ export default function ProjectList() {
} }
}; };
const openDeleteConfirm = (p: Project) => {
setDeleteTarget(p);
setDeleteConfirmName('');
};
const closeDeleteConfirm = () => {
setDeleteTarget(null);
setDeleteConfirmName('');
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await deleteProject(deleteTarget.project_id);
closeDeleteConfirm();
await fetchProjects();
} catch (err) {
console.error('删除失败:', err);
} finally {
setDeleting(false);
}
};
const isDeleteConfirmed = deleteTarget && deleteConfirmName === deleteTarget.project_id;
const ConfigBadge = ({ value }: { value: string | null }) => ( const ConfigBadge = ({ value }: { value: string | null }) => (
value ? ( value ? (
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', wordBreak: 'break-all' }}>{value}</span> <span style={{ fontSize: '12px', color: 'var(--text-secondary)', wordBreak: 'break-all' }}>{value}</span>
@ -164,9 +195,18 @@ export default function ProjectList() {
{new Date(p.updated_at).toLocaleString()} {new Date(p.updated_at).toLocaleString()}
</td> </td>
<td> <td>
<div style={{ display: 'flex', gap: '6px' }}>
<button className="btn-link" onClick={() => startEdit(p)}> <button className="btn-link" onClick={() => startEdit(p)}>
<Pencil size={14} /> <Pencil size={14} />
</button> </button>
<button
className="btn-link"
onClick={() => openDeleteConfirm(p)}
style={{ color: 'var(--error)' }}
>
<Trash2 size={14} />
</button>
</div>
</td> </td>
</> </>
)} )}
@ -233,9 +273,18 @@ export default function ProjectList() {
<> <>
<div className="mobile-card-top"> <div className="mobile-card-top">
<strong>{p.project_id}</strong> <strong>{p.project_id}</strong>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn-link" onClick={() => startEdit(p)}> <button className="btn-link" onClick={() => startEdit(p)}>
<Pencil size={14} /> <Pencil size={14} />
</button> </button>
<button
className="btn-link"
onClick={() => openDeleteConfirm(p)}
style={{ color: 'var(--error)' }}
>
<Trash2 size={14} />
</button>
</div>
</div> </div>
{p.name && <div style={{ fontSize: '13px' }}>{p.name}</div>} {p.name && <div style={{ fontSize: '13px' }}>{p.name}</div>}
<div className="mobile-card-meta" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px', marginTop: '6px' }}> <div className="mobile-card-meta" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px', marginTop: '6px' }}>
@ -253,6 +302,76 @@ export default function ProjectList() {
</div> </div>
</> </>
)} )}
{/* Delete confirmation modal */}
{deleteTarget && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
onClick={closeDeleteConfirm}
>
<div
style={{
background: 'var(--bg-secondary)',
borderRadius: 'var(--radius-md)',
padding: '24px',
maxWidth: '440px',
width: '90%',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
border: '1px solid var(--border)',
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}></h3>
<p style={{ margin: '0 0 16px 0', fontSize: '13px', color: 'var(--text-secondary)' }}>
ID <strong style={{ color: 'var(--error)' }}>{deleteTarget.project_id}</strong>
</p>
<input
type="text"
value={deleteConfirmName}
onChange={(e) => setDeleteConfirmName(e.target.value)}
placeholder={deleteTarget.project_id}
className="edit-input"
style={{ width: '100%', marginBottom: '16px', boxSizing: 'border-box' }}
autoFocus
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
className="btn-link"
onClick={closeDeleteConfirm}
style={{ color: 'var(--text-tertiary)', padding: '6px 12px' }}
>
</button>
<button
onClick={confirmDelete}
disabled={!isDeleteConfirmed || deleting}
style={{
background: isDeleteConfirmed ? 'var(--error)' : 'var(--bg-tertiary)',
color: isDeleteConfirmed ? '#fff' : 'var(--text-tertiary)',
border: 'none',
borderRadius: 'var(--radius-sm, 4px)',
padding: '6px 16px',
fontSize: '13px',
cursor: isDeleteConfirmed ? 'pointer' : 'not-allowed',
}}
>
{deleting ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }