feat: Add CI/CD integration with Gitea webhooks and pull request review functionality.
This commit is contained in:
parent
647373345f
commit
b401a26b10
|
|
@ -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 ###
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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表示无限制
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from .agent_task import (
|
||||||
AgentTaskStatus, AgentTaskPhase, AgentEventType,
|
AgentTaskStatus, AgentTaskPhase, AgentEventType,
|
||||||
VulnerabilitySeverity, VulnerabilityType, FindingStatus
|
VulnerabilitySeverity, VulnerabilityType, FindingStatus
|
||||||
)
|
)
|
||||||
|
from .ci import PRReview
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>"
|
||||||
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
737
backend/uv.lock
737
backend/uv.lock
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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}")
|
||||||
Loading…
Reference in New Issue