feat: Add CI/CD integration with Gitea webhooks and pull request review functionality.

This commit is contained in:
vinland100 2025-12-31 16:40:33 +08:00
parent 647373345f
commit b401a26b10
25 changed files with 2314 additions and 365 deletions

View File

@ -0,0 +1,212 @@
"""add_ci_fields
Revision ID: ecc7c0ff0957
Revises: 008_add_files_with_findings
Create Date: 2025-12-31 15:26:51.588929
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'ecc7c0ff0957'
down_revision = '008_add_files_with_findings'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('pr_reviews',
sa.Column('id', sa.String(), nullable=False),
sa.Column('project_id', sa.String(), nullable=False),
sa.Column('pr_number', sa.Integer(), nullable=False),
sa.Column('commit_sha', sa.String(), nullable=True),
sa.Column('event_type', sa.String(), nullable=False),
sa.Column('summary', sa.Text(), nullable=True),
sa.Column('full_report', sa.Text(), nullable=True),
sa.Column('context_used', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.drop_index(op.f('ix_agent_tasks_created_at'), table_name='agent_tasks')
op.drop_index(op.f('ix_agent_tasks_created_by'), table_name='agent_tasks')
op.drop_index(op.f('ix_agent_tasks_project_id'), table_name='agent_tasks')
op.drop_index(op.f('ix_agent_tasks_status'), table_name='agent_tasks')
op.alter_column('audit_issues', 'status',
existing_type=sa.VARCHAR(),
nullable=True,
existing_server_default=sa.text("'open'::character varying"))
op.drop_index(op.f('ix_audit_rule_sets_language'), table_name='audit_rule_sets')
op.drop_index(op.f('ix_audit_rule_sets_rule_type'), table_name='audit_rule_sets')
op.drop_index(op.f('ix_audit_rules_category'), table_name='audit_rules')
op.drop_index(op.f('ix_audit_rules_rule_code'), table_name='audit_rules')
op.alter_column('audit_tasks', 'status',
existing_type=sa.VARCHAR(),
nullable=True,
existing_server_default=sa.text("'pending'::character varying"))
op.alter_column('audit_tasks', 'total_files',
existing_type=sa.INTEGER(),
nullable=True,
existing_server_default=sa.text('0'))
op.alter_column('audit_tasks', 'scanned_files',
existing_type=sa.INTEGER(),
nullable=True,
existing_server_default=sa.text('0'))
op.alter_column('audit_tasks', 'total_lines',
existing_type=sa.INTEGER(),
nullable=True,
existing_server_default=sa.text('0'))
op.alter_column('audit_tasks', 'issues_count',
existing_type=sa.INTEGER(),
nullable=True,
existing_server_default=sa.text('0'))
op.alter_column('audit_tasks', 'quality_score',
existing_type=sa.DOUBLE_PRECISION(precision=53),
nullable=True,
existing_server_default=sa.text("'0'::double precision"))
op.alter_column('instant_analyses', 'issues_count',
existing_type=sa.INTEGER(),
nullable=True,
existing_server_default=sa.text('0'))
op.alter_column('instant_analyses', 'quality_score',
existing_type=sa.DOUBLE_PRECISION(precision=53),
nullable=True,
existing_server_default=sa.text("'0'::double precision"))
op.alter_column('instant_analyses', 'analysis_time',
existing_type=sa.DOUBLE_PRECISION(precision=53),
nullable=True,
existing_server_default=sa.text("'0'::double precision"))
op.alter_column('project_members', 'role',
existing_type=sa.VARCHAR(),
nullable=True,
existing_server_default=sa.text("'member'::character varying"))
op.add_column('projects', sa.Column('is_ci_managed', sa.Boolean(), nullable=False, server_default=sa.text('false')))
op.add_column('projects', sa.Column('latest_pr_activity', sa.DateTime(timezone=True), nullable=True))
op.alter_column('projects', 'source_type',
existing_type=sa.VARCHAR(length=20),
nullable=False,
existing_server_default=sa.text("'repository'::character varying"))
op.alter_column('projects', 'is_active',
existing_type=sa.BOOLEAN(),
nullable=True,
existing_server_default=sa.text('true'))
op.drop_index(op.f('ix_projects_owner_id'), table_name='projects')
op.create_index(op.f('ix_projects_name'), 'projects', ['name'], unique=False)
op.drop_column('projects', 'is_deleted')
op.drop_column('projects', 'status')
op.drop_column('projects', 'deleted_at')
op.drop_index(op.f('ix_prompt_templates_is_system'), table_name='prompt_templates')
op.drop_index(op.f('ix_prompt_templates_template_type'), table_name='prompt_templates')
op.alter_column('users', 'is_active',
existing_type=sa.BOOLEAN(),
nullable=True,
existing_server_default=sa.text('true'))
op.alter_column('users', 'is_superuser',
existing_type=sa.BOOLEAN(),
nullable=True,
existing_server_default=sa.text('false'))
op.alter_column('users', 'role',
existing_type=sa.VARCHAR(),
nullable=True,
existing_server_default=sa.text("'user'::character varying"))
op.drop_constraint(op.f('users_email_key'), 'users', type_='unique')
op.create_index(op.f('ix_users_full_name'), 'users', ['full_name'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_full_name'), table_name='users')
op.create_unique_constraint(op.f('users_email_key'), 'users', ['email'], postgresql_nulls_not_distinct=False)
op.alter_column('users', 'role',
existing_type=sa.VARCHAR(),
nullable=False,
existing_server_default=sa.text("'user'::character varying"))
op.alter_column('users', 'is_superuser',
existing_type=sa.BOOLEAN(),
nullable=False,
existing_server_default=sa.text('false'))
op.alter_column('users', 'is_active',
existing_type=sa.BOOLEAN(),
nullable=False,
existing_server_default=sa.text('true'))
op.create_index(op.f('ix_prompt_templates_template_type'), 'prompt_templates', ['template_type'], unique=False)
op.create_index(op.f('ix_prompt_templates_is_system'), 'prompt_templates', ['is_system'], unique=False)
op.add_column('projects', sa.Column('deleted_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True))
op.add_column('projects', sa.Column('status', sa.VARCHAR(), server_default=sa.text("'active'::character varying"), autoincrement=False, nullable=False))
op.add_column('projects', sa.Column('is_deleted', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False))
op.drop_index(op.f('ix_projects_name'), table_name='projects')
op.create_index(op.f('ix_projects_owner_id'), 'projects', ['owner_id'], unique=False)
op.alter_column('projects', 'is_active',
existing_type=sa.BOOLEAN(),
nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('projects', 'source_type',
existing_type=sa.VARCHAR(length=20),
nullable=True,
existing_server_default=sa.text("'repository'::character varying"))
op.drop_column('projects', 'latest_pr_activity')
op.drop_column('projects', 'is_ci_managed')
op.alter_column('project_members', 'role',
existing_type=sa.VARCHAR(),
nullable=False,
existing_server_default=sa.text("'member'::character varying"))
op.alter_column('instant_analyses', 'analysis_time',
existing_type=sa.DOUBLE_PRECISION(precision=53),
nullable=False,
existing_server_default=sa.text("'0'::double precision"))
op.alter_column('instant_analyses', 'quality_score',
existing_type=sa.DOUBLE_PRECISION(precision=53),
nullable=False,
existing_server_default=sa.text("'0'::double precision"))
op.alter_column('instant_analyses', 'issues_count',
existing_type=sa.INTEGER(),
nullable=False,
existing_server_default=sa.text('0'))
op.alter_column('audit_tasks', 'quality_score',
existing_type=sa.DOUBLE_PRECISION(precision=53),
nullable=False,
existing_server_default=sa.text("'0'::double precision"))
op.alter_column('audit_tasks', 'issues_count',
existing_type=sa.INTEGER(),
nullable=False,
existing_server_default=sa.text('0'))
op.alter_column('audit_tasks', 'total_lines',
existing_type=sa.INTEGER(),
nullable=False,
existing_server_default=sa.text('0'))
op.alter_column('audit_tasks', 'scanned_files',
existing_type=sa.INTEGER(),
nullable=False,
existing_server_default=sa.text('0'))
op.alter_column('audit_tasks', 'total_files',
existing_type=sa.INTEGER(),
nullable=False,
existing_server_default=sa.text('0'))
op.alter_column('audit_tasks', 'status',
existing_type=sa.VARCHAR(),
nullable=False,
existing_server_default=sa.text("'pending'::character varying"))
op.create_index(op.f('ix_audit_rules_rule_code'), 'audit_rules', ['rule_code'], unique=False)
op.create_index(op.f('ix_audit_rules_category'), 'audit_rules', ['category'], unique=False)
op.create_index(op.f('ix_audit_rule_sets_rule_type'), 'audit_rule_sets', ['rule_type'], unique=False)
op.create_index(op.f('ix_audit_rule_sets_language'), 'audit_rule_sets', ['language'], unique=False)
op.alter_column('audit_issues', 'status',
existing_type=sa.VARCHAR(),
nullable=False,
existing_server_default=sa.text("'open'::character varying"))
op.create_index(op.f('ix_agent_tasks_status'), 'agent_tasks', ['status'], unique=False)
op.create_index(op.f('ix_agent_tasks_project_id'), 'agent_tasks', ['project_id'], unique=False)
op.create_index(op.f('ix_agent_tasks_created_by'), 'agent_tasks', ['created_by'], unique=False)
op.create_index(op.f('ix_agent_tasks_created_at'), 'agent_tasks', ['created_at'], unique=False)
op.drop_table('pr_reviews')
# ### end Alembic commands ###

View File

@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, projects, tasks, scan, members, config, database, prompts, rules, agent_tasks, embedding_config, ssh_keys from app.api.v1.endpoints import auth, users, projects, tasks, scan, members, config, database, prompts, rules, agent_tasks, embedding_config, ssh_keys, webhooks, ci_routes
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@ -15,3 +15,5 @@ api_router.include_router(rules.router, prefix="/rules", tags=["rules"])
api_router.include_router(agent_tasks.router, prefix="/agent-tasks", tags=["agent-tasks"]) api_router.include_router(agent_tasks.router, prefix="/agent-tasks", tags=["agent-tasks"])
api_router.include_router(embedding_config.router, prefix="/embedding", tags=["embedding"]) api_router.include_router(embedding_config.router, prefix="/embedding", tags=["embedding"])
api_router.include_router(ssh_keys.router, prefix="/ssh-keys", tags=["ssh-keys"]) api_router.include_router(ssh_keys.router, prefix="/ssh-keys", tags=["ssh-keys"])
api_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"])
api_router.include_router(ci_routes.router, prefix="/ci", tags=["ci"])

View File

@ -3051,7 +3051,7 @@ async def get_checkpoint_detail(
@router.get("/{task_id}/report") @router.get("/{task_id}/report")
async def generate_audit_report( async def generate_audit_report(
task_id: str, task_id: str,
format: str = Query("markdown", regex="^(markdown|json)$"), format: str = Query("markdown", pattern="^(markdown|json)$"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user), current_user: User = Depends(deps.get_current_user),
): ):

View File

@ -0,0 +1,61 @@
from typing import List, Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.api import deps
from app.db.session import get_db
from app.models.project import Project
from app.models.ci import PRReview
from app.schemas.ci import CIProjectRead, PRReviewRead
router = APIRouter()
@router.get("/projects", response_model=List[CIProjectRead])
async def read_ci_projects(
db: AsyncSession = Depends(get_db),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve all CI-managed projects.
"""
query = select(Project).where(Project.is_ci_managed == True)
query = query.order_by(desc(Project.latest_pr_activity)).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.get("/projects/{project_id}/reviews", response_model=List[PRReviewRead])
async def read_project_reviews(
project_id: str,
db: AsyncSession = Depends(get_db),
skip: int = 0,
limit: int = 50,
) -> Any:
"""
Retrieve PR reviews for a specific project.
"""
query = select(PRReview).where(PRReview.project_id == project_id)
query = query.order_by(desc(PRReview.created_at)).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.delete("/projects/{project_id}")
async def delete_ci_project(
project_id: str,
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Delete a CI project and its reviews.
"""
project = await db.get(Project, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if not project.is_ci_managed:
raise HTTPException(status_code=400, detail="Cannot delete non-CI managed project via this API")
await db.delete(project)
await db.commit()
return {"status": "success"}

View File

@ -0,0 +1,80 @@
from fastapi import APIRouter, Header, Request, HTTPException, Depends, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
import hmac
import hashlib
import logging
import json
from app.api.deps import get_db
from app.db.session import AsyncSessionLocal
from app.core.config import settings
from app.services.ci_service import CIService
router = APIRouter()
logger = logging.getLogger(__name__)
async def process_gitea_event(event_type: str, payload: dict):
"""
Background task to process Gitea events without blocking the webhook response.
"""
async with AsyncSessionLocal() as db:
try:
ci_service = CIService(db)
if event_type == "pull_request":
action = payload.get("action")
if action in ["opened", "synchronize", "reopened"]:
logger.info(f"Starting background PR processing for action: {action}")
await ci_service.handle_pr_event(payload)
else:
logger.info(f"Ignoring PR Action in background: {action}")
elif event_type == "issue_comment":
logger.info(f"Starting background comment processing")
await ci_service.handle_comment_event(payload)
except Exception as e:
logger.error(f"Background Webhook Processing Error: {e}", exc_info=True)
@router.get("/health")
async def webhook_health():
return {"status": "ok", "module": "webhooks"}
@router.post("/gitea")
async def handle_gitea_webhook(
request: Request,
background_tasks: BackgroundTasks,
x_gitea_signature: str = Header(None),
x_gitea_event: str = Header(None),
):
"""
Handle incoming Gitea webhook events with background processing to prevent timeouts.
"""
# 1. Capture Raw Body for Signature Verification
payload_bytes = await request.body()
# 2. Verify Signature
if settings.GITEA_WEBHOOK_SECRET:
if not x_gitea_signature:
logger.warning("Missing Gitea Signature")
raise HTTPException(status_code=401, detail="Missing Signature")
computed_signature = hmac.new(
key=settings.GITEA_WEBHOOK_SECRET.encode(),
msg=payload_bytes,
digestmod=hashlib.sha256
).hexdigest()
if not hmac.compare_digest(computed_signature, x_gitea_signature):
logger.warning(f"Invalid Gitea Signature. Expected: {computed_signature}, Got: {x_gitea_signature}")
raise HTTPException(status_code=401, detail="Invalid Signature")
# 3. Parse Payload
try:
payload = json.loads(payload_bytes.decode())
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON")
# 4. Enqueue Background Task
logger.info(f"Enqueuing Gitea Event for background processing: {x_gitea_event}")
background_tasks.add_task(process_gitea_event, x_gitea_event, payload)
# 5. Return Success Immediately
return {"status": "accepted", "event": x_gitea_event, "message": "Task enqueued"}

View File

@ -0,0 +1,130 @@
"""
DeepAudit CI/CD Prompts
Contains structured prompts for automated PR reviews and interactive chat.
"""
from typing import Optional
# -----------------------------------------------------------------------------
# Base Template
# -----------------------------------------------------------------------------
# strict structure to ensure the LLM has all necessary context without hallucinations.
PROMPT_TEMPLATE = """
### ROLE
{system_prompt}
### CONTEXT FROM REPOSITORY
The following code snippets were retrieved from the existing repository to provide context:
{repo_context}
### PR DIFF / CHANGES
The following are the actual changes in this Pull Request (or specific commit):
{diff_content}
### CONVERSATION HISTORY
{conversation_history}
### TASK
{task_description}
### OUTPUT FORMAT
{output_format}
"""
# -----------------------------------------------------------------------------
# 1. PR Review Prompts
# -----------------------------------------------------------------------------
REVIEW_SYSTEM_PROMPT = """
You are DeepAudit Bot, an expert Senior Security Engineer and Code Reviewer.
Your goal is to identify security vulnerabilities, potential bugs, and code quality issues in the provided Pull Request changes.
You must ground your analysis in the provided Repository Context to understand how the changes impact the broader system.
"""
PR_REVIEW_TASK = """
Analyze the "PR DIFF / CHANGES" above, considering the "CONTEXT FROM REPOSITORY".
1. **Security Analysis**: Identify any security risks (e.g., Injection, Auth bypass, Hardcoded secrets, etc.).
2. **Logic & Bugs**: Find edge cases or logic errors introduced in this change.
3. **Quality & Performance**: Point out maintainability issues or performance bottlenecks.
4. **Context check**: Use the repo context to verify if function calls or contract changes are valid.
Ignore minor formatting/linting issues unless they severely impact readability.
"""
PR_REVIEW_OUTPUT_FORMAT = """
Output ONLY a Markdown response in the following format:
## 🔍 DeepAudit Review Summary
<Short summary of the changes and overall risk level>
## 🛡️ Key Issues Found
### [Severity: High/Medium/Low] <Title of Issue>
- **File**: `<filepath>`
- **Problem**: <Description>
- **Context**: <Why this is an issue based on repo context>
- **Suggestion**:
```<language>
<code fix>
```
... (Repeat for other issues)
## 💡 Improvements
- <Bullet points for minor improvements>
"""
# -----------------------------------------------------------------------------
# 2. Incremental (Sync) Review Prompts
# -----------------------------------------------------------------------------
PR_SYNC_TASK = """
The user has pushed new commits to the existing Pull Request.
Focus ONLY on the changes in "PR DIFF / CHANGES" (which are the new commits).
Check if these new changes introduce any new issues or fail to address previous concerns (visible in history).
"""
# -----------------------------------------------------------------------------
# 3. Chat / Q&A Prompts
# -----------------------------------------------------------------------------
CHAT_SYSTEM_PROMPT = """
You are DeepAudit Bot, a helpful AI assistant integrated into the CI/CD workflow.
You are chatting with a developer in a Pull Request comment thread.
The user has mentioned you (@ai-bot) to ask a question or request clarification.
You have access to the relevant snippets of the codebase via RAG (Retrieval Augmented Generation).
"""
BOT_CHAT_TASK = """
Answer the user's question or respond to their comment found in "CONVERSATION HISTORY".
Use the "CONTEXT FROM REPOSITORY" to provide accurate, specific answers about the code.
If the context doesn't contain the answer, admit it or provide a best-effort answer based on general knowledge.
Do NOT repeat the user's question. Go straight to the answer.
"""
BOT_CHAT_OUTPUT_FORMAT = """
Markdown text. Be concise but technical.
"""
def build_pr_review_prompt(diff: str, context: str, history: str = "None") -> str:
return PROMPT_TEMPLATE.format(
system_prompt=REVIEW_SYSTEM_PROMPT,
repo_context=context if context else "No additional context retrieved.",
diff_content=diff,
conversation_history=history,
task_description=PR_REVIEW_TASK,
output_format=PR_REVIEW_OUTPUT_FORMAT
)
def build_chat_prompt(user_query: str, context: str, history: str) -> str:
# Note: user_query is conceptually part of the history/task
return PROMPT_TEMPLATE.format(
system_prompt=CHAT_SYSTEM_PROMPT,
repo_context=context if context else "No additional context retrieved.",
diff_content="[Not applicable for general chat, unless user refers to recent changes]",
conversation_history=history,
task_description=BOT_CHAT_TASK + f"\n\nUSER QUESTION: {user_query}",
output_format=BOT_CHAT_OUTPUT_FORMAT
)

View File

@ -67,6 +67,9 @@ class Settings(BaseSettings):
# Gitea配置 # Gitea配置
GITEA_TOKEN: Optional[str] = None GITEA_TOKEN: Optional[str] = None
GITEA_HOST_URL: Optional[str] = "http://localhost:3000"
GITEA_BOT_TOKEN: Optional[str] = None
GITEA_WEBHOOK_SECRET: Optional[str] = None
# 扫描配置 # 扫描配置
MAX_ANALYZE_FILES: int = 0 # 最大分析文件数0表示无限制 MAX_ANALYZE_FILES: int = 0 # 最大分析文件数0表示无限制

View File

@ -1,6 +1,13 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Union from typing import Any, Union
from jose import jwt from jose import jwt
import bcrypt # Import first
# MonkeyPatch passlib/bcrypt compatibility (passlib expects __about__)
if not hasattr(bcrypt, "__about__"):
from types import SimpleNamespace
bcrypt.__about__ = SimpleNamespace(__version__=bcrypt.__version__)
from passlib.context import CryptContext from passlib.context import CryptContext
from app.core.config import settings from app.core.config import settings

View File

@ -10,6 +10,7 @@ from .agent_task import (
AgentTaskStatus, AgentTaskPhase, AgentEventType, AgentTaskStatus, AgentTaskPhase, AgentEventType,
VulnerabilitySeverity, VulnerabilityType, FindingStatus VulnerabilitySeverity, VulnerabilityType, FindingStatus
) )
from .ci import PRReview

39
backend/app/models/ci.py Normal file
View File

@ -0,0 +1,39 @@
import uuid
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Text
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.db.base import Base
class PRReview(Base):
"""
Stores the history of PR reviews and Bot interactions.
This serves as the 'memory' for the CI agent.
"""
__tablename__ = "pr_reviews"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
project_id = Column(String, ForeignKey("projects.id"), nullable=False)
# PR Identification
pr_number = Column(Integer, nullable=False)
commit_sha = Column(String, nullable=True) # The specific commit being reviewed
# Interaction Type
event_type = Column(String, nullable=False) # 'opened', 'synchronize', 'comment'
# Content
summary = Column(Text, nullable=True) # Short summary (Markdown)
full_report = Column(Text, nullable=True) # Detailed report or JSON data
# RAG Context (Debug/Transparency)
# Stores a JSON string list of file paths/chunk IDs used to generate this response
context_used = Column(Text, default="[]")
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
project = relationship("Project", backref="pr_reviews")
def __repr__(self):
return f"<PRReview pr={self.pr_number} event={self.event_type}>"

View File

@ -24,6 +24,10 @@ class Project(Base):
owner_id = Column(String, ForeignKey("users.id"), nullable=False) owner_id = Column(String, ForeignKey("users.id"), nullable=False)
is_active = Column(Boolean(), default=True) is_active = Column(Boolean(), default=True)
# CI/CD Integration Fields
is_ci_managed = Column(Boolean(), default=False, nullable=False)
latest_pr_activity = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())

30
backend/app/schemas/ci.py Normal file
View File

@ -0,0 +1,30 @@
from typing import List, Optional, Any
from datetime import datetime
from pydantic import BaseModel
from app.models.ci import PRReview
class PRReviewRead(BaseModel):
id: str
project_id: str
pr_number: int
commit_sha: Optional[str]
event_type: str
summary: Optional[str]
full_report: Optional[str]
context_used: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class CIProjectRead(BaseModel):
id: str
name: str
description: Optional[str]
repository_url: Optional[str]
latest_pr_activity: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,370 @@
"""
CI Service
Handles Gitea webhook events, manages RAG indexing for CI projects, and performs automated code reviews.
"""
import os
import shutil
import logging
import subprocess
import json
from typing import Dict, Any, List, Optional
from pathlib import Path
from datetime import datetime
import asyncio
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.models.project import Project
from app.models.ci import PRReview
from app.core.ci_prompts import (
build_pr_review_prompt,
build_chat_prompt,
PR_SYNC_TASK
)
from app.services.rag.indexer import CodeIndexer, IndexUpdateMode
from app.services.rag.retriever import CodeRetriever
from app.services.llm.service import LLMService
logger = logging.getLogger(__name__)
# Base directory for storing CI clones
CI_WORKSPACE_DIR = Path("data/ci_workspace")
CI_VECTOR_DB_DIR = Path("data/ci_vectordb")
class CIService:
def __init__(self, db: AsyncSession):
self.db = db
# Ensure workspaces exist
CI_WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
CI_VECTOR_DB_DIR.mkdir(parents=True, exist_ok=True)
self.llm_service = LLMService() # Use default config
async def handle_pr_event(self, payload: Dict[str, Any]):
"""
Handle Pull Request events (opened, synchronized)
"""
action = payload.get("action")
pr = payload.get("pull_request")
repo = payload.get("repository")
if not pr or not repo:
return
repo_url = repo.get("clone_url")
pr_number = pr.get("number")
branch = pr.get("head", {}).get("ref")
commit_sha = pr.get("head", {}).get("sha")
base_branch = pr.get("base", {}).get("ref")
logger.info(f"🚀 Handling PR Event: {repo.get('full_name')} #{pr_number} ({action})")
# 1. Get or Create Project
try:
project = await self._get_or_create_project(repo, pr)
except Exception as e:
logger.error(f"Error creating project: {e}")
return
# 2. Clone/Update Repo & Indexing (RAG)
try:
repo_path = await self._prepare_repository(project, repo_url, branch, settings.GITEA_BOT_TOKEN)
except Exception as e:
logger.error(f"Git operation failed: {e}")
# If clone fails, we can't proceed with RAG, but we shouldn't crash
return
try:
# 3. Incremental Indexing
indexer = CodeIndexer(
collection_name=f"ci_{project.id}",
persist_directory=str(CI_VECTOR_DB_DIR / project.id)
)
# Iterate over the generator to execute indexing
async for progress in indexer.smart_index_directory(
directory=repo_path,
update_mode=IndexUpdateMode.INCREMENTAL
):
if progress.processed_files % 10 == 0:
logger.info(f"Indexing progress: {progress.processed_files}/{progress.total_files}")
# 4. Analyze Diff & Retrieve Context
diff_text = await self._get_pr_diff(repo, pr_number)
if not diff_text:
logger.warning("Empty diff or failed to fetch diff. Skipping review.")
return
# Retrieve context relevant to the diff
retriever = CodeRetriever(
collection_name=f"ci_{project.id}",
persist_directory=str(CI_VECTOR_DB_DIR / project.id)
)
context_results = await retriever.retrieve(diff_text[:1000], top_k=5)
repo_context = "\n".join([r.to_context_string() for r in context_results])
# 5. Generate Review
history = ""
if action == "synchronize":
prompt = build_pr_review_prompt(diff_text, repo_context, history)
prompt += f"\n\nNOTE: {PR_SYNC_TASK}"
else:
prompt = build_pr_review_prompt(diff_text, repo_context, history)
# Call LLM
response = await self.llm_service.chat_completion_raw(
messages=[{"role": "user", "content": prompt}],
temperature=0.2
)
review_body = response["content"]
# 6. Post Comment
await self._post_gitea_comment(repo, pr_number, review_body)
# 7. Save Record
review_record = PRReview(
project_id=project.id,
pr_number=pr_number,
commit_sha=commit_sha,
event_type=action,
summary=review_body[:200] + "...",
full_report=review_body,
context_used=json.dumps([r.file_path for r in context_results])
)
self.db.add(review_record)
# Update project activity
project.latest_pr_activity = datetime.utcnow()
await self.db.commit()
except Exception as e:
logger.error(f"Error processing PR event: {e}")
import traceback
logger.error(traceback.format_exc())
# Don't raise, just log, so webhook returns 200
return
async def handle_comment_event(self, payload: Dict[str, Any]):
"""
Handle Issue Comment events (chat)
"""
action = payload.get("action")
issue = payload.get("issue")
comment = payload.get("comment")
repo = payload.get("repository")
if action != "created" or not issue or not comment:
return
# Check if it's a PR
if "pull_request" not in issue:
return
body = comment.get("body", "")
if "@ai-bot" not in body:
return
logger.info(f"💬 Handling Chat Event: {repo.get('full_name')} #{issue.get('number')}")
# 1. Get Project (or Create if discovered via Chat first)
# We need a dummy PR object if we are creating project from chat, or we just fetch by repo
# Since _get_or_create_project needs PR info to determine branch/owner, we might need a distinct method
# or simplified flow.
project = await self._get_project_by_repo(repo.get("clone_url"))
if not project:
# If project doesn't exist, we try to create it using available repo info
# We construct a minimal "pseudo-PR" dict if needed, or better:
# We assume if we are chatting on a PR, we can get PR details via API later
# For now, let's just Try to Find Project. If not found, we CANNOT proceed easily without syncing.
# But user wants "Auto Discovery".
# Let's try to create it.
try:
# Mock a PR object for creation purposes (minimal fields)
mock_pr = {
"number": issue.get("number"),
"head": {"ref": repo.get("default_branch", "main"), "sha": "HEAD"}, # Fallback
"base": {"ref": repo.get("default_branch", "main")}
}
project = await self._get_or_create_project(repo, mock_pr)
except Exception as e:
logger.error(f"Failed to auto-create project from chat: {e}")
return
if not project:
logger.warning("Project could not be determined for chat event")
return
# 2. Retrieve Context (RAG)
retriever = CodeRetriever(
collection_name=f"ci_{project.id}",
persist_directory=str(CI_VECTOR_DB_DIR / project.id)
)
# Use the user comment as query
query = body.replace("@ai-bot", "").strip()
context_results = await retriever.retrieve(query, top_k=5)
repo_context = "\n".join([r.to_context_string() for r in context_results])
# 3. Build Prompt
# Fetch conversation history (simplified: just current comment)
history = f"User: {query}"
prompt = build_chat_prompt(query, repo_context, history)
# 4. Generate Answer
response = await self.llm_service.chat_completion_raw(
messages=[{"role": "user", "content": prompt}],
temperature=0.4
)
answer = response["content"]
# 5. Reply
# Append context info footer
footer = "\n\n---\n*Context used: " + ", ".join([f"`{r.file_path}`" for r in context_results]) + "*"
await self._post_gitea_comment(repo, issue.get("number"), answer + footer)
# 6. Record (Optional, maybe just log)
review_record = PRReview(
project_id=project.id,
pr_number=issue.get("number"),
event_type="comment",
summary=f"Q: {query[:50]}...",
full_report=answer,
context_used=json.dumps([r.file_path for r in context_results])
)
self.db.add(review_record)
await self.db.commit()
async def _get_or_create_project(self, repo: Dict, pr: Dict) -> Project:
repo_url = repo.get("clone_url")
# Check if exists
stmt = select(Project).where(Project.repository_url == repo_url)
result = await self.db.execute(stmt)
project = result.scalars().first()
if not project:
# Create new
# Find a valid user to assign as owner (required field)
from app.models.user import User
user_stmt = select(User).limit(1)
user_res = await self.db.execute(user_stmt)
default_user = user_res.scalars().first()
owner_id = default_user.id if default_user else "system_fallback_user"
project = Project(
name=repo.get("name"),
description=repo.get("description"),
source_type="repository",
repository_url=repo_url,
repository_type="gitea",
default_branch=repo.get("default_branch", "main"),
owner_id=owner_id,
is_ci_managed=True
)
try:
self.db.add(project)
await self.db.commit()
await self.db.refresh(project)
logger.info(f"🆕 Created CI Project: {project.name}")
except Exception as e:
logger.error(f"Failed to create project: {e}")
# Try rollback possibly?
await self.db.rollback()
raise e
return project
async def _get_project_by_repo(self, repo_url: str) -> Optional[Project]:
stmt = select(Project).where(Project.repository_url == repo_url)
result = await self.db.execute(stmt)
return result.scalars().first()
async def _prepare_repository(self, project: Project, repo_url: str, branch: str, token: str) -> str:
"""
Clones or Updates the repository locally.
"""
target_dir = CI_WORKSPACE_DIR / project.id
# Inject Token into URL for auth
# Format: http://token@host/repo.git
if "://" in repo_url:
protocol, rest = repo_url.split("://", 1)
auth_url = f"{protocol}://{token}@{rest}"
else:
auth_url = repo_url # Fallback
if target_dir.exists():
# Update
logger.info(f"🔄 Updating repo at {target_dir}")
try:
# git fetch --all
subprocess.run(["git", "fetch", "--all"], cwd=target_dir, check=True)
# git checkout branch
subprocess.run(["git", "checkout", branch], cwd=target_dir, check=True)
# git reset --hard origin/branch
subprocess.run(["git", "reset", "--hard", f"origin/{branch}"], cwd=target_dir, check=True)
except Exception as e:
logger.error(f"Git update failed: {e}. Re-cloning...")
shutil.rmtree(target_dir) # Nuke and retry
return await self._prepare_repository(project, repo_url, branch, token)
else:
# Clone
logger.info(f"📥 Cloning repo to {target_dir}")
try:
subprocess.run(["git", "clone", "-b", branch, auth_url, str(target_dir)], check=True)
except Exception as e:
logger.error(f"Git clone failed: {e}")
raise e
return str(target_dir)
async def _get_pr_diff(self, repo: Dict, pr_number: int) -> str:
"""
Fetch the PR diff from Gitea API
"""
api_url = f"{settings.GITEA_HOST_URL}/api/v1/repos/{repo['owner']['login']}/{repo['name']}/pulls/{pr_number}.diff"
headers = {"Authorization": f"token {settings.GITEA_BOT_TOKEN}"}
try:
async with httpx.AsyncClient() as client:
resp = await client.get(api_url, headers=headers)
if resp.status_code == 200:
return resp.text
else:
logger.error(f"Failed to fetch diff: {resp.status_code} - {resp.text[:200]}")
return ""
except Exception as e:
logger.error(f"Failed to fetch PR diff: {e}")
return ""
async def _post_gitea_comment(self, repo: Dict, issue_number: int, body: str):
if not settings.GITEA_HOST_URL or not settings.GITEA_BOT_TOKEN:
logger.error("GITEA_HOST_URL or GITEA_BOT_TOKEN not configured")
return
api_url = f"{settings.GITEA_HOST_URL}/api/v1/repos/{repo['owner']['login']}/{repo['name']}/issues/{issue_number}/comments"
headers = {
"Authorization": f"token {settings.GITEA_BOT_TOKEN}",
"Content-Type": "application/json"
}
try:
async with httpx.AsyncClient() as client:
resp = await client.post(api_url, headers=headers, json={"body": body})
if resp.status_code >= 400:
logger.error(f"Gitea API Error: {resp.status_code} - {resp.text}")
except Exception as e:
logger.error(f"Failed to post Gitea comment: {e}")

View File

@ -188,6 +188,9 @@ GITLAB_TOKEN=
# 权限要求: read_repository # 权限要求: read_repository
GITEA_TOKEN= GITEA_TOKEN=
# Gitea Webhook Secret
GITEA_WEBHOOK_SECRET=
# ============================================= # =============================================
# 扫描配置 # 扫描配置
# ============================================= # =============================================
@ -216,3 +219,5 @@ ZIP_STORAGE_PATH=./uploads/zip_files
# zh-CN: 中文 # zh-CN: 中文
# en-US: 英文 # en-US: 英文
OUTPUT_LANGUAGE=zh-CN OUTPUT_LANGUAGE=zh-CN
GITEA_HOST_URL=
GITEA_BOT_TOKEN=

View File

@ -32,7 +32,7 @@ dependencies = [
"passlib[bcrypt]>=1.7.4", "passlib[bcrypt]>=1.7.4",
"python-jose[cryptography]>=3.3.0", "python-jose[cryptography]>=3.3.0",
"python-multipart>=0.0.6", "python-multipart>=0.0.6",
"bcrypt>=4.0.0,<5.0.0", "bcrypt>=4.0.1",
# ============ HTTP Client ============ # ============ HTTP Client ============
"httpx>=0.25.0", "httpx>=0.25.0",

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,8 @@ import AdminDashboard from "@/pages/AdminDashboard";
import Account from "@/pages/Account"; import Account from "@/pages/Account";
import AuditRules from "@/pages/AuditRules"; import AuditRules from "@/pages/AuditRules";
import PromptManager from "@/pages/PromptManager"; import PromptManager from "@/pages/PromptManager";
import CIProjects from "@/pages/ci/CIProjects";
import CIDetails from "@/pages/ci/CIDetails";
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
export interface RouteConfig { export interface RouteConfig {
@ -44,6 +46,18 @@ const routes: RouteConfig[] = [
element: <Projects />, element: <Projects />,
visible: true, visible: true,
}, },
{
name: "CI集成",
path: "/ci-integration",
element: <CIProjects />,
visible: true,
},
{
name: "CI项目详情",
path: "/ci-integration/:id",
element: <CIDetails />,
visible: false,
},
{ {
name: "项目详情", name: "项目详情",
path: "/projects/:id", path: "/projects/:id",

View File

@ -24,6 +24,7 @@ import {
MessageSquare, MessageSquare,
Bot, Bot,
ExternalLink, ExternalLink,
GitGraph,
} from "lucide-react"; } from "lucide-react";
import routes from "@/app/routes"; import routes from "@/app/routes";
import { version } from "../../../package.json"; import { version } from "../../../package.json";
@ -39,6 +40,7 @@ const routeIcons: Record<string, React.ReactNode> = {
"/prompts": <MessageSquare className="w-5 h-5" />, "/prompts": <MessageSquare className="w-5 h-5" />,
"/admin": <Settings className="w-5 h-5" />, "/admin": <Settings className="w-5 h-5" />,
"/recycle-bin": <Trash2 className="w-5 h-5" />, "/recycle-bin": <Trash2 className="w-5 h-5" />,
"/ci-integration": <GitGraph className="w-5 h-5" />,
}; };
interface SidebarProps { interface SidebarProps {

View File

@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Bot, GitPullRequest, MessageSquare, ChevronLeft, Calendar, FileText } from "lucide-react";
import { marked } from 'marked';
import { format } from 'date-fns';
interface PRReview {
id: string;
pr_number: number;
event_type: string;
summary: string;
full_report: string;
context_used: string;
created_at: string;
}
const CIDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [reviews, setReviews] = useState<PRReview[]>([]);
const [loading, setLoading] = useState(true);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
useEffect(() => {
const fetchReviews = async () => {
try {
const res = await fetch(`/api/v1/ci/projects/${id}/reviews`);
if (res.ok) {
const data = await res.json();
setReviews(data);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
fetchReviews();
}, [id]);
const toggleExpand = (reviewId: string) => {
const newSet = new Set(expandedIds);
if (newSet.has(reviewId)) {
newSet.delete(reviewId);
} else {
newSet.add(reviewId);
}
setExpandedIds(newSet);
};
const getEventIcon = (type: string) => {
switch (type) {
case 'opened': return <GitPullRequest className="w-5 h-5 text-blue-500" />;
case 'synchronize': return <GitPullRequest className="w-5 h-5 text-yellow-500" />;
case 'comment': return <MessageSquare className="w-5 h-5 text-green-500" />;
default: return <Bot className="w-5 h-5" />;
}
};
const renderMarkdown = (content: string) => {
return { __html: marked.parse(content) as string };
};
if (loading) return <div className="p-8 text-center text-muted-foreground">Loading timeline...</div>;
return (
<div className="p-6 space-y-6 max-w-5xl mx-auto">
<div className="flex items-center gap-4 mb-6">
<Link to="/ci-integration">
<Button variant="ghost" size="icon">
<ChevronLeft className="w-5 h-5" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Review History</h1>
<p className="text-muted-foreground text-sm">Project ID: {id}</p>
</div>
</div>
<div className="space-y-6 relative border-l-2 border-muted ml-4 pl-8 pb-8">
{reviews.map((review) => (
<div key={review.id} className="relative">
{/* Dot indicator */}
<div className="absolute -left-[41px] top-4 w-5 h-5 rounded-full bg-background border-2 border-primary ring-4 ring-background flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-primary" />
</div>
<Card className={`transition-all duration-300 ${expandedIds.has(review.id) ? 'ring-2 ring-primary/20' : ''}`}>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1 px-2 py-1">
{getEventIcon(review.event_type)}
<span className="capitalize">{review.event_type}</span>
</Badge>
<span className="font-mono text-sm text-muted-foreground">
#{review.pr_number}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
{format(new Date(review.created_at), 'yyyy-MM-dd HH:mm:ss')}
</div>
</div>
<CardTitle className="text-lg mt-2 font-medium">
<div
className="prose prose-invert prose-sm max-w-none line-clamp-2"
dangerouslySetInnerHTML={renderMarkdown(review.summary.split('\n')[0])}
/>
</CardTitle>
</CardHeader>
<CardContent>
<div className={`relative ${expandedIds.has(review.id) ? '' : 'max-h-24 overflow-hidden mask-gradient-b'}`}>
<div className="bg-muted/30 p-4 rounded-md text-sm font-mono overflow-auto">
<div
className="prose prose-invert max-w-none"
dangerouslySetInnerHTML={renderMarkdown(review.full_report)}
/>
</div>
{/* Context Debug Info */}
{expandedIds.has(review.id) && review.context_used && review.context_used !== "[]" && (
<div className="mt-4 pt-4 border-t border-border">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground mb-2">
<FileText className="w-3 h-3" />
RAG Context Used:
</div>
<div className="flex flex-wrap gap-2">
{JSON.parse(review.context_used).map((file: string, idx: number) => (
<Badge key={idx} variant="outline" className="text-xs font-normal">
{file}
</Badge>
))}
</div>
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-primary hover:text-primary/80"
onClick={() => toggleExpand(review.id)}
>
{expandedIds.has(review.id) ? "Collapse" : "Read Full Report"}
</Button>
</CardContent>
</Card>
</div>
))}
{reviews.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
No reviews found for this project yet.
</div>
)}
</div>
</div>
);
};
export default CIDetails;

View File

@ -0,0 +1,134 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { GitGraph, ArrowRight, Trash2, Clock } from "lucide-react";
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
interface CIProject {
id: string;
name: string;
description: string;
repository_url: string;
latest_pr_activity: string;
created_at: string;
}
const CIProjects: React.FC = () => {
const [projects, setProjects] = useState<CIProject[]>([]);
const [loading, setLoading] = useState(true);
const fetchProjects = async () => {
try {
const res = await fetch('/api/v1/ci/projects');
if (res.ok) {
const data = await res.json();
setProjects(data);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchProjects();
}, []);
const handleDelete = async (id: string) => {
if (!confirm("Are you sure? This will delete all review history.")) return;
try {
const res = await fetch(`/api/v1/ci/projects/${id}`, { method: 'DELETE' });
if (res.ok) {
fetchProjects();
}
} catch (error) {
console.error(error);
}
};
if (loading) return <div className="p-8 text-center text-muted-foreground">Loading projects...</div>;
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<GitGraph className="w-8 h-8 text-primary" />
CI Integration
</h1>
<p className="text-muted-foreground mt-2">
Automatically discovered projects from Gitea Webhooks.
</p>
</div>
</div>
{projects.length === 0 ? (
<Card className="bg-muted/30 border-dashed">
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
<GitGraph className="w-12 h-12 text-muted-foreground mb-4 opacity-50" />
<h3 className="text-xl font-semibold mb-2">No CI Projects Yet</h3>
<p className="text-muted-foreground max-w-md mx-auto mb-6">
Configure a Webhook in your Gitea repository to start auto-discovering projects and generating reviews.
</p>
<div className="bg-muted p-4 rounded-md text-xs font-mono text-left w-full max-w-lg">
<div className="text-muted-foreground mb-2">Webhook URL:</div>
<div className="text-primary select-all">http://YOUR_SERVER_IP:8000/api/v1/webhooks/gitea</div>
</div>
</CardContent>
</Card>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{projects.map(project => (
<Card key={project.id} className="hover:shadow-lg transition-all group border-l-4 border-l-transparent hover:border-l-primary">
<CardHeader>
<div className="flex justify-between items-start">
<div className="space-y-1">
<CardTitle className="text-xl break-all line-clamp-1" title={project.name}>
{project.name}
</CardTitle>
<CardDescription className="line-clamp-2 min-h-[2.5em]">
{project.description || "No description provided"}
</CardDescription>
</div>
<Badge variant="outline" className="font-mono text-xs">Public</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>
Latest: {project.latest_pr_activity ? formatDistanceToNow(new Date(project.latest_pr_activity), { addSuffix: true, locale: zhCN }) : 'Never'}
</span>
</div>
<div className="flex items-center gap-3 pt-2">
<Link to={`/ci-integration/${project.id}`} className="flex-1">
<Button className="w-full gap-2" variant="default">
View Reviews <ArrowRight className="w-4 h-4" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(project.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
};
export default CIProjects;

View File

@ -0,0 +1,90 @@
import hmac
import hashlib
import json
import urllib.request
import urllib.error
# Configuration
SECRET = "zheke@703"
URL = "http://127.0.0.1:8000/api/v1/webhooks/gitea"
def send_webhook(event_type, payload):
payload_json = json.dumps(payload).encode('utf-8')
signature = hmac.new(
key=SECRET.encode(),
msg=payload_json,
digestmod=hashlib.sha256
).hexdigest()
headers = {
"Content-Type": "application/json",
"X-Gitea-Event": event_type,
"X-Gitea-Signature": signature
}
req = urllib.request.Request(URL, data=payload_json, headers=headers, method='POST')
try:
print(f"Sending {event_type} to {URL}...")
with urllib.request.urlopen(req) as response:
print(f"Status: {response.status}")
print(f"Response: {response.read().decode()}")
except urllib.error.HTTPError as e:
print(f"HTTP Error: {e.code}")
print(f"Response: {e.read().decode()}")
except Exception as e:
print(f"Error: {e}")
# Payload from User
# Replaced localhost with 127.0.0.1 for safety if needed, but strings don't matter much for logic unless parsed.
payload = {
"action": "created",
"issue": {
"id": 7,
"number": 6,
"title": "ai-bot-pr-test-4",
"body": "ai-bot-pr-test-4",
"state": "open",
"pull_request": {
"merged": False,
"merged_at": None
},
"repository": {
"id": 1,
"name": "SpaceSim",
"owner": "vinland100",
"full_name": "vinland100/SpaceSim"
},
},
"comment": {
"id": 62,
"body": "@ai-bot",
"user": {
"id": 1,
"login": "vinland100"
}
},
"repository": {
"id": 1,
"owner": {
"id": 1,
"login": "vinland100",
"email": "wuyanbo5210@qq.com"
},
"name": "SpaceSim",
"full_name": "vinland100/SpaceSim",
"html_url": "http://localhost:3333/vinland100/SpaceSim",
"clone_url": "http://localhost:3333/vinland100/SpaceSim.git",
"default_branch": "main",
"description": "SpaceSim"
},
"sender": {
"id": 1,
"login": "vinland100"
},
"is_pull": True
}
if __name__ == "__main__":
send_webhook("issue_comment", payload)

View File

@ -0,0 +1,80 @@
import hmac
import hashlib
import json
import urllib.request
import urllib.error
import time
# Configuration
SECRET = "zheke@703"
URL = "http://127.0.0.1:8000/api/v1/webhooks/gitea"
def send_webhook(event_type, payload):
payload_json = json.dumps(payload).encode('utf-8')
signature = hmac.new(
key=SECRET.encode(),
msg=payload_json,
digestmod=hashlib.sha256
).hexdigest()
headers = {
"Content-Type": "application/json",
"X-Gitea-Event": event_type,
"X-Gitea-Signature": signature
}
req = urllib.request.Request(URL, data=payload_json, headers=headers, method='POST')
try:
print(f"Sending {event_type} to {URL}...")
with urllib.request.urlopen(req) as response:
print(f"Status: {response.status}")
print(f"Response: {response.read().decode()}")
except urllib.error.HTTPError as e:
print(f"HTTP Error: {e.code}")
print(f"Response: {e.read().decode()}")
except Exception as e:
print(f"Error: {e}")
# Payload: PR Opened
pr_payload = {
"action": "opened",
"number": 1,
"pull_request": {
"number": 1,
"title": "Fix memory leak in scanner",
"body": "This PR fixes a critical memory leak issue.",
"html_url": "http://182.96.17.140:82/owner/deep-audit-test/pulls/1",
"head": {
"ref": "feature/memory-fix",
"sha": "a1b2c3d4e5f6g7h8i9j0"
},
"base": {
"ref": "main"
},
"user": {
"id": 999,
"login": "developer"
}
},
"repository": {
"id": 123,
"name": "deep-audit-test",
"full_name": "owner/deep-audit-test",
"owner": {
"login": "owner",
"email": "owner@example.com"
},
"html_url": "http://182.96.17.140:82/owner/deep-audit-test",
"clone_url": "http://182.96.17.140:82/owner/deep-audit-test.git",
"default_branch": "main",
"description": "A test repository for DeepAudit CI"
},
"sender": {
"login": "developer"
}
}
if __name__ == "__main__":
send_webhook("pull_request", pr_payload)

View File

@ -0,0 +1,47 @@
import requests
import json
import sys
# Configuration
GITEA_HOST = "http://182.96.17.140:82"
TOKEN = "8b0687d58aa70a09f5493737565b00f0b87eb868"
OWNER = "vinland100"
REPO = "SpaceSim"
PR_INDEX = "4"
# API Endpoint
# Note: Gitea PRs are issues, so we use the issues endpoint to comment
api_url = f"{GITEA_HOST}/api/v1/repos/{OWNER}/{REPO}/issues/{PR_INDEX}/comments"
headers = {
"Authorization": f"token {TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json"
}
data = {
"body": "🤖 **DeepAudit Bot Test**: verifying access token permissions.\n\nRunning from test script."
}
def test_comment():
print(f"Testing access to: {api_url}")
print(f"Using Token: {TOKEN[:5]}...{TOKEN[-5:]}")
try:
response = requests.post(api_url, headers=headers, json=data, timeout=10)
print(f"Status Code: {response.status_code}")
if response.status_code == 201:
print("✅ Success! Comment posted.")
print("Response:", json.dumps(response.json(), indent=2))
else:
print("❌ Failed.")
print("Response:", response.text)
except Exception as e:
print(f"❌ Error occurred: {e}")
if __name__ == "__main__":
test_comment()

461
scripts/verify_signature.py Normal file
View File

@ -0,0 +1,461 @@
import hmac
import hashlib
import json
# Secret from .env
SECRET = "zheke@703"
# Signature from User's Log
EXPECTED_SIGNATURE = "ff8b3217cdfd039866410441b0513e6e87f298d7995d56c4ed899d0214afa08a"
# Payload from User's Log
payload_dict = {
"action": "synchronized",
"number": 7,
"pull_request": {
"id": 6,
"url": "http://localhost:3333/vinland100/SpaceSim/pulls/7",
"number": 7,
"user": {
"id": 3,
"login": "ai-bot",
"login_name": "",
"full_name": "",
"email": "ai-bot@noreply.localhost",
"avatar_url": "http://localhost:3333/avatar/6049cd56dee263bd6fb35f9c27d7a0ee",
"language": "",
"is_admin": False,
"last_login": "0001-01-01T00:00:00Z",
"created": "2025-12-31T09:57:36+08:00",
"restricted": False,
"active": False,
"prohibit_login": False,
"location": "",
"website": "",
"description": "",
"visibility": "public",
"followers_count": 0,
"following_count": 0,
"starred_repos_count": 0,
"username": "ai-bot"
},
"title": "ai-bot PR test 5",
"body": "ai-bot PR test 5",
"labels": [],
"milestone": None,
"assignee": None,
"assignees": None,
"requested_reviewers": None,
"state": "open",
"is_locked": False,
"comments": 0,
"html_url": "http://localhost:3333/vinland100/SpaceSim/pulls/7",
"diff_url": "http://localhost:3333/vinland100/SpaceSim/pulls/7.diff",
"patch_url": "http://localhost:3333/vinland100/SpaceSim/pulls/7.patch",
"mergeable": False,
"merged": False,
"merged_at": None,
"merge_commit_sha": None,
"merged_by": None,
"allow_maintainer_edit": False,
"base": {
"label": "main",
"ref": "main",
"sha": "e601c85c4d762772c9ff49a781964013bb4c6377",
"repo_id": 1,
"repo": {
"id": 1,
"owner": {
"id": 1,
"login": "vinland100",
"login_name": "",
"full_name": "",
"email": "wuyanbo5210@qq.com",
"avatar_url": "http://localhost:3333/avatar/9807be06d1f8d640f4c605f5edbb8cdb",
"language": "",
"is_admin": False,
"last_login": "0001-01-01T00:00:00Z",
"created": "2025-12-02T09:45:59+08:00",
"restricted": False,
"active": False,
"prohibit_login": False,
"location": "",
"website": "",
"description": "",
"visibility": "public",
"followers_count": 0,
"following_count": 0,
"starred_repos_count": 0,
"username": "vinland100"
},
"name": "SpaceSim",
"full_name": "vinland100/SpaceSim",
"description": "",
"empty": False,
"private": False,
"fork": False,
"template": False,
"parent": None,
"mirror": False,
"size": 86,
"language": "",
"languages_url": "http://localhost:3333/api/v1/repos/vinland100/SpaceSim/languages",
"html_url": "http://localhost:3333/vinland100/SpaceSim",
"url": "http://localhost:3333/api/v1/repos/vinland100/SpaceSim",
"link": "",
"ssh_url": "git@localhost:vinland100/SpaceSim.git",
"clone_url": "http://localhost:3333/vinland100/SpaceSim.git",
"original_url": "",
"website": "",
"stars_count": 0,
"forks_count": 2,
"watchers_count": 1,
"open_issues_count": 2,
"open_pr_counter": 2,
"release_counter": 0,
"default_branch": "main",
"archived": False,
"created_at": "2025-12-02T09:50:22+08:00",
"updated_at": "2025-12-31T16:05:18+08:00",
"archived_at": "1970-01-01T08:00:00+08:00",
"permissions": {
"admin": False,
"push": False,
"pull": True
},
"has_issues": True,
"internal_tracker": {
"enable_time_tracker": True,
"allow_only_contributors_to_track_time": True,
"enable_issue_dependencies": True
},
"has_wiki": True,
"has_pull_requests": True,
"has_projects": True,
"has_releases": True,
"has_packages": True,
"has_actions": False,
"ignore_whitespace_conflicts": False,
"allow_merge_commits": True,
"allow_rebase": True,
"allow_rebase_explicit": True,
"allow_squash_merge": True,
"allow_rebase_update": True,
"default_delete_branch_after_merge": False,
"default_merge_style": "merge",
"default_allow_maintainer_edit": False,
"avatar_url": "",
"internal": False,
"mirror_interval": "",
"mirror_updated": "0001-01-01T00:00:00Z",
"repo_transfer": None
}
},
"head": {
"label": "ai-bot-PR-test-5",
"ref": "ai-bot-PR-test-5",
"sha": "95ecf0e8a1262406c6c3bd5a36519f5e6813b00e",
"repo_id": 8,
"repo": {
"id": 8,
"owner": {
"id": 3,
"login": "ai-bot",
"login_name": "",
"full_name": "",
"email": "1942664940@qq.com",
"avatar_url": "http://localhost:3333/avatar/6049cd56dee263bd6fb35f9c27d7a0ee",
"language": "",
"is_admin": False,
"last_login": "0001-01-01T00:00:00Z",
"created": "2025-12-31T09:57:36+08:00",
"restricted": False,
"active": False,
"prohibit_login": False,
"location": "",
"website": "",
"description": "",
"visibility": "public",
"followers_count": 0,
"following_count": 0,
"starred_repos_count": 0,
"username": "ai-bot"
},
"name": "SpaceSim",
"full_name": "ai-bot/SpaceSim",
"description": "",
"empty": False,
"private": False,
"fork": True,
"template": False,
"parent": {
"id": 1,
"owner": {
"id": 1,
"login": "vinland100",
"login_name": "",
"full_name": "",
"email": "wuyanbo5210@qq.com",
"avatar_url": "http://localhost:3333/avatar/9807be06d1f8d640f4c605f5edbb8cdb",
"language": "",
"is_admin": False,
"last_login": "0001-01-01T00:00:00Z",
"created": "2025-12-02T09:45:59+08:00",
"restricted": False,
"active": False,
"prohibit_login": False,
"location": "",
"website": "",
"description": "",
"visibility": "public",
"followers_count": 0,
"following_count": 0,
"starred_repos_count": 0,
"username": "vinland100"
},
"name": "SpaceSim",
"full_name": "vinland100/SpaceSim",
"description": "",
"empty": False,
"private": False,
"fork": False,
"template": False,
"parent": None,
"mirror": False,
"size": 86,
"language": "",
"languages_url": "http://localhost:3333/api/v1/repos/vinland100/SpaceSim/languages",
"html_url": "http://localhost:3333/vinland100/SpaceSim",
"url": "http://localhost:3333/api/v1/repos/vinland100/SpaceSim",
"link": "",
"ssh_url": "git@localhost:vinland100/SpaceSim.git",
"clone_url": "http://localhost:3333/vinland100/SpaceSim.git",
"original_url": "",
"website": "",
"stars_count": 0,
"forks_count": 2,
"watchers_count": 1,
"open_issues_count": 2,
"open_pr_counter": 2,
"release_counter": 0,
"default_branch": "main",
"archived": False,
"created_at": "2025-12-02T09:50:22+08:00",
"updated_at": "2025-12-31T16:05:18+08:00",
"archived_at": "1970-01-01T08:00:00+08:00",
"permissions": {
"admin": False,
"push": False,
"pull": True
},
"has_issues": True,
"internal_tracker": {
"enable_time_tracker": True,
"allow_only_contributors_to_track_time": True,
"enable_issue_dependencies": True
},
"has_wiki": True,
"has_pull_requests": True,
"has_projects": True,
"has_releases": True,
"has_packages": True,
"has_actions": False,
"ignore_whitespace_conflicts": False,
"allow_merge_commits": True,
"allow_rebase": True,
"allow_rebase_explicit": True,
"allow_squash_merge": True,
"allow_rebase_update": True,
"default_delete_branch_after_merge": False,
"default_merge_style": "merge",
"default_allow_maintainer_edit": False,
"avatar_url": "",
"internal": False,
"mirror_interval": "",
"mirror_updated": "0001-01-01T00:00:00Z",
"repo_transfer": None
},
"mirror": False,
"size": 88,
"language": "",
"languages_url": "http://localhost:3333/api/v1/repos/ai-bot/SpaceSim/languages",
"html_url": "http://localhost:3333/ai-bot/SpaceSim",
"url": "http://localhost:3333/api/v1/repos/ai-bot/SpaceSim",
"link": "",
"ssh_url": "git@localhost:ai-bot/SpaceSim.git",
"clone_url": "http://localhost:3333/ai-bot/SpaceSim.git",
"original_url": "",
"website": "",
"stars_count": 0,
"forks_count": 0,
"watchers_count": 1,
"open_issues_count": 0,
"open_pr_counter": 0,
"release_counter": 0,
"default_branch": "main",
"archived": False,
"created_at": "2025-12-31T10:02:40+08:00",
"updated_at": "2025-12-31T16:07:21+08:00",
"archived_at": "1970-01-01T08:00:00+08:00",
"permissions": {
"admin": False,
"push": False,
"pull": True
},
"has_issues": False,
"has_wiki": False,
"has_pull_requests": True,
"has_projects": False,
"has_releases": False,
"has_packages": False,
"has_actions": False,
"ignore_whitespace_conflicts": False,
"allow_merge_commits": True,
"allow_rebase": True,
"allow_rebase_explicit": True,
"allow_squash_merge": True,
"allow_rebase_update": True,
"default_delete_branch_after_merge": False,
"default_merge_style": "merge",
"default_allow_maintainer_edit": False,
"avatar_url": "",
"internal": False,
"mirror_interval": "",
"mirror_updated": "0001-01-01T00:00:00Z",
"repo_transfer": None
}
},
"merge_base": "cc82211696bdf4e5d6e32809403f388fd708fcae",
"due_date": None,
"created_at": "2025-12-31T16:08:27+08:00",
"updated_at": "2025-12-31T16:10:05+08:00",
"closed_at": None,
"pin_order": 0
},
"requested_reviewer": None,
"repository": {
"id": 1,
"owner": {
"id": 1,
"login": "vinland100",
"login_name": "",
"full_name": "",
"email": "wuyanbo5210@qq.com",
"avatar_url": "http://localhost:3333/avatar/9807be06d1f8d640f4c605f5edbb8cdb",
"language": "",
"is_admin": False,
"last_login": "0001-01-01T00:00:00Z",
"created": "2025-12-02T09:45:59+08:00",
"restricted": False,
"active": False,
"prohibit_login": False,
"location": "",
"website": "",
"description": "",
"visibility": "public",
"followers_count": 0,
"following_count": 0,
"starred_repos_count": 0,
"username": "vinland100"
},
"name": "SpaceSim",
"full_name": "vinland100/SpaceSim",
"description": "",
"empty": False,
"private": False,
"fork": False,
"template": False,
"parent": None,
"mirror": False,
"size": 86,
"language": "",
"languages_url": "http://localhost:3333/api/v1/repos/vinland100/SpaceSim/languages",
"html_url": "http://localhost:3333/vinland100/SpaceSim",
"url": "http://localhost:3333/api/v1/repos/vinland100/SpaceSim",
"link": "",
"ssh_url": "git@localhost:vinland100/SpaceSim.git",
"clone_url": "http://localhost:3333/vinland100/SpaceSim.git",
"original_url": "",
"website": "",
"stars_count": 0,
"forks_count": 2,
"watchers_count": 1,
"open_issues_count": 2,
"open_pr_counter": 2,
"release_counter": 0,
"default_branch": "main",
"archived": False,
"created_at": "2025-12-02T09:50:22+08:00",
"updated_at": "2025-12-31T16:05:18+08:00",
"archived_at": "1970-01-01T08:00:00+08:00",
"permissions": {
"admin": True,
"push": True,
"pull": True
},
"has_issues": True,
"internal_tracker": {
"enable_time_tracker": True,
"allow_only_contributors_to_track_time": True,
"enable_issue_dependencies": True
},
"has_wiki": True,
"has_pull_requests": True,
"has_projects": True,
"has_releases": True,
"has_packages": True,
"has_actions": False,
"ignore_whitespace_conflicts": False,
"allow_merge_commits": True,
"allow_rebase": True,
"allow_rebase_explicit": True,
"allow_squash_merge": True,
"allow_rebase_update": True,
"default_delete_branch_after_merge": False,
"default_merge_style": "merge",
"default_allow_maintainer_edit": False,
"avatar_url": "",
"internal": False,
"mirror_interval": "",
"mirror_updated": "0001-01-01T00:00:00Z",
"repo_transfer": None
},
"sender": {
"id": 3,
"login": "ai-bot",
"login_name": "",
"full_name": "",
"email": "ai-bot@noreply.localhost",
"avatar_url": "http://localhost:3333/avatar/6049cd56dee263bd6fb35f9c27d7a0ee",
"language": "",
"is_admin": False,
"last_login": "0001-01-01T00:00:00Z",
"created": "2025-12-31T09:57:36+08:00",
"restricted": False,
"active": False,
"prohibit_login": False,
"location": "",
"website": "",
"description": "",
"visibility": "public",
"followers_count": 0,
"following_count": 0,
"starred_repos_count": 0,
"username": "ai-bot"
},
"commit_id": "",
"review": None
}
# Serialize and sign
json_payload = json.dumps(payload_dict).encode('utf-8')
calculated_signature = hmac.new(
key=SECRET.encode(),
msg=json_payload,
digestmod=hashlib.sha256
).hexdigest()
print(f"Expected: {EXPECTED_SIGNATURE}")
print(f"Calculated: {calculated_signature}")
print(f"Match: {EXPECTED_SIGNATURE == calculated_signature}")