feat(repo): add project-level review prompt with UI redesign

- Add database migration and repository for project review prompts
- Add API endpoint for setting project-level prompts
- Integrate project prompts into Agent and Codex review flows
- Redesign repository management UI with dialog-based prompt editor
- Replace flat buttons with Switch for webhook toggle and dedicated prompt button
- Add Dialog and DropdownMenu UI components from Radix UI
- Add comprehensive tests for wiring and interactions
This commit is contained in:
jeffusion
2026-03-26 12:14:39 +08:00
committed by 路遥知码力
parent c313764b61
commit d5deb75231
30 changed files with 1439 additions and 123 deletions

View File

@@ -5,6 +5,8 @@
"": { "": {
"name": "frontend", "name": "frontend",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
@@ -216,10 +218,14 @@
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""], "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""], "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
@@ -228,6 +234,8 @@
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""], "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""], "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],

View File

@@ -14,6 +14,8 @@
"ui:visual:update": "playwright test -c playwright.config.ts --update-snapshots" "ui:visual:update": "playwright test -c playwright.config.ts --update-snapshots"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",

View File

@@ -0,0 +1,141 @@
"use client"
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Settings, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import api from '@/lib/api';
import type { Repository } from '@/services/repositoryService';
interface RepositoryConfigCellProps {
repo: Repository;
}
export function RepositoryConfigCell({ repo }: RepositoryConfigCellProps) {
const queryClient = useQueryClient();
const [isPromptDialogOpen, setIsPromptDialogOpen] = useState(false);
const [draftPrompt, setDraftPrompt] = useState(repo.project_review_prompt ?? '');
const promptMutation = useMutation({
mutationFn: async (prompt: string) => {
const { data } = await api.put(`/repositories/${repo.name}/project-prompt`, {
project_review_prompt: prompt,
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repositories'] });
setIsPromptDialogOpen(false);
toast.success(`已更新 ${repo.name} 的项目级提示词`);
},
onError: (error: Error) => {
toast.error(`更新失败: ${error.message}`);
},
});
const handleSavePrompt = () => {
promptMutation.mutate(draftPrompt.trim());
};
const handleOpenDialog = () => {
setDraftPrompt(repo.project_review_prompt ?? '');
setIsPromptDialogOpen(true);
};
const hasPrompt = !!repo.project_review_prompt?.trim();
return (
<>
<Button
variant={hasPrompt ? "outline" : "ghost"}
size="sm"
className={`h-8 gap-1.5 text-xs ${
hasPrompt
? "border-primary/50 text-primary hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={handleOpenDialog}
>
<Settings className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{hasPrompt ? '已配置' : '配置'}</span>
{hasPrompt && <span className="ml-1 h-1.5 w-1.5 rounded-full bg-primary" />}
</Button>
<Dialog open={isPromptDialogOpen} onOpenChange={setIsPromptDialogOpen}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<code className="rounded bg-muted px-1 py-0.5 text-xs">{repo.name}</code>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{hasPrompt && (
<div className="rounded-lg bg-muted/50 border border-border/50 p-3">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground"></span>
</div>
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap break-all max-h-[80px] overflow-y-auto">
{repo.project_review_prompt}
</p>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Textarea
value={draftPrompt}
onChange={(e) => setDraftPrompt(e.target.value)}
placeholder="输入项目级审查提示词,例如:重点关注 API 安全性、空值处理和错误边界..."
className="min-h-[120px] resize-none text-sm leading-relaxed focus-visible:ring-1 focus-visible:ring-primary/50"
disabled={promptMutation.isPending}
/>
<p className="text-xs text-muted-foreground">
AI
{hasPrompt && ' 留空保存将清除当前配置。'}
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsPromptDialogOpen(false)}
disabled={promptMutation.isPending}
>
</Button>
<Button
onClick={handleSavePrompt}
disabled={
promptMutation.isPending ||
draftPrompt.trim() === (repo.project_review_prompt ?? '').trim()
}
>
{promptMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -17,17 +17,17 @@ function DataTableSkeleton() {
<Table> <Table>
<TableHeader className="bg-muted/60 border-b border-border/50"> <TableHeader className="bg-muted/60 border-b border-border/50">
<TableRow className="border-border/50"> <TableRow className="border-border/50">
<TableHead className="w-[40%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead> <TableHead className="w-[50%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
<TableHead className="w-[30%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead> <TableHead className="w-[25%]"><Skeleton className="h-5 w-16 bg-muted" /></TableHead>
<TableHead className="w-[30%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-muted" /></TableHead> <TableHead className="w-[25%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-muted" /></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{Array.from({ length: 10 }).map((_, i) => ( {Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i} className="border-border/50"> <TableRow key={i} className="border-border/50">
<TableCell><Skeleton className="h-5 w-3/4 bg-muted/70" /></TableCell> <TableCell><Skeleton className="h-5 w-3/4 bg-muted/70" /></TableCell>
<TableCell><Skeleton className="h-6 w-20 bg-muted/70 rounded-full" /></TableCell> <TableCell><Skeleton className="h-5 w-20 bg-muted/70" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-8 w-16 ml-auto bg-muted/70" /></TableCell> <TableCell className="text-right"><Skeleton className="h-8 w-24 ml-auto bg-muted/70" /></TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -2,42 +2,34 @@
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
import type { Repository } from "@/services/repositoryService" import type { Repository } from "@/services/repositoryService"
import { WebhookToggleButton } from "@/components/WebhookToggleButton" import { RepositoryConfigCell } from "@/components/RepositoryConfigCell"
import { WebhookToggleCell } from "@/components/WebhookToggleCell"
export const columns: ColumnDef<Repository>[] = [ export const columns: ColumnDef<Repository>[] = [
{ {
accessorKey: "name", accessorKey: "name",
header: "仓库名称", header: "仓库名称",
cell: ({ row }) => <div className="font-medium text-foreground text-sm">{row.getValue("name")}</div>, cell: ({ row }) => (
<div className="font-medium text-foreground text-sm">
{row.getValue("name")}
</div>
),
}, },
{ {
accessorKey: "webhook_status", accessorKey: "webhook_status",
header: "Webhook 状态", header: "Webhook",
cell: ({ row }) => { cell: ({ row }) => {
const status = row.getValue("webhook_status") as Repository["webhook_status"] const repo = row.original
const isActive = status === 'active' return <WebhookToggleCell repo={repo} />
return (
<div className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold border ${isActive ? 'bg-success/10 text-success border-success/30' : 'bg-transparent text-muted-foreground border-border theme-border-soft'}`}>
{isActive && <span className="w-1.5 h-1.5 rounded-full bg-success animate-pulse theme-glow-success"></span>}
{isActive ? '已启用' : '未启用'}
</div>
)
}, },
}, },
{ {
id: "actions", id: "actions",
header: () => <div className="text-right text-muted-foreground"></div>, header: () => <div className="text-right text-muted-foreground text-xs"></div>,
cell: ({ row }) => { cell: ({ row }) => (
const repo = row.original <div className="text-right">
return ( <RepositoryConfigCell repo={row.original} />
<div className="text-right"> </div>
<WebhookToggleButton ),
repoName={repo.name}
status={repo.webhook_status}
hookId={repo.hook_id}
/>
</div>
)
},
}, },
] ]

View File

@@ -1,58 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from "sonner";
interface WebhookToggleButtonProps {
repoName: string;
status: 'active' | 'inactive';
hookId: number | null;
}
const createWebhook = (repoName: string) => api.post(`/repositories/${repoName}/webhook`);
const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) => api.delete(`/repositories/${repoName}/webhook/${hookId}`);
export function WebhookToggleButton({ repoName, status, hookId }: WebhookToggleButtonProps) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: status === 'active'
? () => deleteWebhook({ repoName, hookId: hookId! })
: () => createWebhook(repoName),
onSuccess: () => {
// 操作成功后使仓库列表的查询失效React Query会自动重新获取最新数据
queryClient.invalidateQueries({ queryKey: ['repositories'] });
toast.success(`Webhook for ${repoName} has been ${status === 'active' ? 'disabled' : 'enabled'}.`);
},
onError: (error) => {
console.error("操作失败:", error);
toast.error(`Operation failed: ${error.message}`);
},
});
return (
<Button
variant={status === 'active' ? 'outline' : 'default'}
size="sm"
className={
status === 'active'
? "border-danger/50 bg-transparent text-danger hover:bg-danger/10 hover:text-danger transition-colors"
: "bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 tech-glow"
}
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
{mutation.isPending ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
<span className="font-mono text-xs">...</span>
</>
) : status === 'active' ? (
<span className="font-mono text-xs"></span>
) : (
<span className="font-mono text-xs"></span>
)}
</Button>
);
}

View File

@@ -0,0 +1,57 @@
"use client"
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Switch } from '@/components/ui/switch';
import api from '@/lib/api';
import type { Repository } from '@/services/repositoryService';
interface WebhookToggleCellProps {
repo: Repository;
}
const createWebhook = (repoName: string) =>
api.post(`/repositories/${repoName}/webhook`);
const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) =>
api.delete(`/repositories/${repoName}/webhook/${hookId}`);
export function WebhookToggleCell({ repo }: WebhookToggleCellProps) {
const queryClient = useQueryClient();
const isActive = repo.webhook_status === 'active';
const webhookMutation = useMutation({
mutationFn: async () => {
if (isActive && repo.hook_id) {
return deleteWebhook({ repoName: repo.name, hookId: repo.hook_id });
}
return createWebhook(repo.name);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repositories'] });
const action = isActive ? '已禁用' : '已启用';
toast.success(`${repo.name} 的 Webhook ${action}`);
},
onError: (error: Error) => {
toast.error(`操作失败: ${error.message}`);
},
});
return (
<div className="flex items-center gap-2">
{webhookMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Switch
checked={isActive}
onCheckedChange={() => webhookMutation.mutate()}
disabled={webhookMutation.isPending}
aria-label={isActive ? '禁用 Webhook' : '启用 Webhook'}
/>
)}
<span className={`text-xs ${isActive ? 'text-success' : 'text-muted-foreground'}`}>
{isActive ? '已启用' : '未启用'}
</span>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RepositoryConfigCell } from '../RepositoryConfigCell';
import type { Repository } from '@/services/repositoryService';
const apiMocks = vi.hoisted(() => ({
put: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
default: apiMocks,
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
function renderWithQuery(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
function makeRepo(overrides: Partial<Repository> = {}): Repository {
return {
name: 'demo-owner/demo-repo',
webhook_status: 'inactive',
hook_id: null,
project_review_prompt: null,
...overrides,
};
}
describe('RepositoryConfigCell', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('opens prompt dialog and saves project prompt', async () => {
apiMocks.put.mockResolvedValueOnce({
data: {
success: true,
project_review_prompt: 'focus null safety',
},
});
const user = userEvent.setup();
renderWithQuery(<RepositoryConfigCell repo={makeRepo()} />);
await user.click(screen.getByRole('button', { name: /配置/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('配置项目级提示词')).toBeInTheDocument();
const textarea = screen.getByRole('textbox');
await user.type(textarea, ' focus null safety ');
await user.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
expect(apiMocks.put).toHaveBeenCalledWith(
'/repositories/demo-owner/demo-repo/project-prompt',
{ project_review_prompt: 'focus null safety' }
);
});
});
});

View File

@@ -0,0 +1,84 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WebhookToggleCell } from '../WebhookToggleCell';
import type { Repository } from '@/services/repositoryService';
const apiMocks = vi.hoisted(() => ({
post: vi.fn(),
delete: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
default: apiMocks,
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
function renderWithQuery(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
function makeRepo(overrides: Partial<Repository> = {}): Repository {
return {
name: 'demo-owner/demo-repo',
webhook_status: 'inactive',
hook_id: null,
project_review_prompt: null,
...overrides,
};
}
describe('WebhookToggleCell', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('toggles webhook via switch to enable', async () => {
apiMocks.post.mockResolvedValueOnce({ data: { success: true } });
const user = userEvent.setup();
renderWithQuery(<WebhookToggleCell repo={makeRepo()} />);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'false');
expect(screen.getByText('未启用')).toBeInTheDocument();
await user.click(switchEl);
await waitFor(() => {
expect(apiMocks.post).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook');
});
});
it('toggles webhook via switch to disable', async () => {
apiMocks.delete.mockResolvedValueOnce({ data: { success: true } });
const user = userEvent.setup();
renderWithQuery(<WebhookToggleCell repo={makeRepo({ webhook_status: 'active', hook_id: 123 })} />);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('已启用')).toBeInTheDocument();
await user.click(switchEl);
await waitFor(() => {
expect(apiMocks.delete).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook/123');
});
});
});

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -4,6 +4,7 @@ export interface Repository {
name: string; name: string;
webhook_status: 'active' | 'inactive'; webhook_status: 'active' | 'inactive';
hook_id: number | null; hook_id: number | null;
project_review_prompt: string | null;
} }
export interface PaginatedRepositories { export interface PaginatedRepositories {
@@ -19,3 +20,13 @@ export const fetchRepositories = async (page: number = 1, query: string = ""): P
}); });
return data; return data;
}; };
export const updateRepositoryProjectPrompt = async (
repoName: string,
projectReviewPrompt: string
): Promise<{ success: boolean; project_review_prompt: string | null }> => {
const { data } = await api.put(`/repositories/${repoName}/project-prompt`, {
project_review_prompt: projectReviewPrompt,
});
return data;
};

View File

@@ -2,8 +2,18 @@ import type { Page, Route } from '@playwright/test';
const repositories = { const repositories = {
data: [ data: [
{ name: 'demo-repo-1', webhook_status: 'active', hook_id: 101 }, {
{ name: 'demo-repo-2', webhook_status: 'inactive', hook_id: null }, name: 'demo-repo-1',
webhook_status: 'active',
hook_id: 101,
project_review_prompt: '重点检查 API 错误处理与鉴权边界。',
},
{
name: 'demo-repo-2',
webhook_status: 'inactive',
hook_id: null,
project_review_prompt: null,
},
], ],
totalCount: 2, totalCount: 2,
page: 1, page: 1,
@@ -296,14 +306,27 @@ export async function installVisualApiMocks(page: Page) {
return json(route, repositories); return json(route, repositories);
} }
if (method === 'POST' && /\/admin\/api\/repositories\/[^/]+\/webhook$/.test(path)) { if (method === 'POST' && /\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/webhook$/.test(path)) {
return json(route, { hook_id: 101, webhook_status: 'active' }); return json(route, { hook_id: 101, webhook_status: 'active' });
} }
if (method === 'DELETE' && /\/admin\/api\/repositories\/[^/]+\/webhook\/\d+$/.test(path)) { if (
method === 'DELETE' &&
/\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/webhook\/\d+$/.test(path)
) {
return route.fulfill({ status: 204, body: '' }); return route.fulfill({ status: 204, body: '' });
} }
if (
method === 'PUT' &&
/\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/project-prompt$/.test(path)
) {
return json(route, {
success: true,
project_review_prompt: 'updated prompt',
});
}
if (method === 'GET' && path.endsWith('/admin/api/config')) { if (method === 'GET' && path.endsWith('/admin/api/config')) {
return json(route, configResponse); return json(route, configResponse);
} }

View File

@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Hono } from 'hono';
import { repositoryReviewPromptRepo } from '../../db/repositories/repository-review-prompt-repo';
import { giteaService } from '../../services/gitea';
import { adminController } from '../admin';
type RepoRecord = { full_name: string };
type HookRecord = { id: number; config: { url: string } };
function createTestApp(): Hono {
const app = new Hono();
app.route('/admin/api', adminController.protectedRoutes);
return app;
}
describe('admin repositories route', () => {
const originalListAllRepositories = giteaService.listAllRepositories;
const originalListWebhooks = giteaService.listWebhooks;
const originalListProjectPrompts = repositoryReviewPromptRepo.listProjectPrompts;
beforeEach(() => {
const repos: RepoRecord[] = [
{ full_name: 'team/inactive-alpha' },
{ full_name: 'team/active-beta' },
{ full_name: 'team/inactive-gamma' },
{ full_name: 'team/active-delta' },
];
giteaService.listAllRepositories = async () => ({
repos,
totalCount: repos.length,
});
giteaService.listWebhooks = async (_owner: string, repo: string) => {
if (repo.startsWith('active-')) {
return [{ id: 101, config: { url: 'http://localhost/webhook/gitea' } }] as HookRecord[];
}
return [] as HookRecord[];
};
repositoryReviewPromptRepo.listProjectPrompts = () => ({
'team/active-beta': 'focus security',
});
});
afterEach(() => {
giteaService.listAllRepositories = originalListAllRepositories;
giteaService.listWebhooks = originalListWebhooks;
repositoryReviewPromptRepo.listProjectPrompts = originalListProjectPrompts;
});
test('returns active webhook repositories first', async () => {
const app = createTestApp();
const response = await app.request('http://localhost/admin/api/repositories?page=1');
const payload = (await response.json()) as {
data: Array<{
name: string;
webhook_status: 'active' | 'inactive';
hook_id: number | null;
project_review_prompt: string | null;
}>;
totalCount: number;
page: number;
limit: number;
};
expect(response.status).toBe(200);
expect(payload.totalCount).toBe(4);
expect(payload.page).toBe(1);
expect(payload.limit).toBe(30);
expect(payload.data.map((repo) => repo.name)).toEqual([
'team/active-beta',
'team/active-delta',
'team/inactive-alpha',
'team/inactive-gamma',
]);
expect(payload.data.map((repo) => repo.webhook_status)).toEqual([
'active',
'active',
'inactive',
'inactive',
]);
expect(payload.data[0]?.project_review_prompt).toBe('focus security');
expect(payload.data[1]?.project_review_prompt).toBeNull();
});
});

View File

@@ -1,6 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { sign } from 'hono/jwt'; import { sign } from 'hono/jwt';
import config from '../config'; import config from '../config';
import { repositoryReviewPromptRepo } from '../db/repositories/repository-review-prompt-repo';
import { reviewEngine } from '../review/engine'; import { reviewEngine } from '../review/engine';
import { giteaService } from '../services/gitea'; import { giteaService } from '../services/gitea';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
@@ -37,6 +38,10 @@ protectedRoutes.get('/repositories', async (c) => {
const { repos, totalCount } = await giteaService.listAllRepositories(page, limit, query); const { repos, totalCount } = await giteaService.listAllRepositories(page, limit, query);
const webhookUrl = c.req.url.replace(/\/admin\/api\/repositories.*$/, '/webhook/gitea'); const webhookUrl = c.req.url.replace(/\/admin\/api\/repositories.*$/, '/webhook/gitea');
const fullNames = repos
.map((repo) => (typeof repo.full_name === 'string' ? repo.full_name : null))
.filter((name): name is string => name !== null);
const promptMap = repositoryReviewPromptRepo.listProjectPrompts(fullNames);
const reposWithStatus = await Promise.all( const reposWithStatus = await Promise.all(
repos.map(async (repo) => { repos.map(async (repo) => {
@@ -47,10 +52,18 @@ protectedRoutes.get('/repositories', async (c) => {
name: repo.full_name, name: repo.full_name,
webhook_status: webhook ? 'active' : 'inactive', webhook_status: webhook ? 'active' : 'inactive',
hook_id: webhook ? webhook.id : null, hook_id: webhook ? webhook.id : null,
project_review_prompt: promptMap[repo.full_name] || null,
}; };
}) })
); );
reposWithStatus.sort((a, b) => {
if (a.webhook_status === b.webhook_status) {
return 0;
}
return a.webhook_status === 'active' ? -1 : 1;
});
return c.json({ return c.json({
data: reposWithStatus, data: reposWithStatus,
totalCount, totalCount,
@@ -63,6 +76,29 @@ protectedRoutes.get('/repositories', async (c) => {
} }
}); });
protectedRoutes.put('/repositories/:owner/:repo/project-prompt', async (c) => {
const { owner, repo } = c.req.param();
try {
const body = (await c.req.json()) as { project_review_prompt?: unknown };
if (typeof body.project_review_prompt !== 'string') {
return c.json({ message: 'project_review_prompt must be a string' }, 400);
}
const normalizedPrompt = body.project_review_prompt.trim();
if (!normalizedPrompt) {
repositoryReviewPromptRepo.clearProjectPrompt(owner, repo);
return c.json({ success: true, project_review_prompt: null });
}
const updated = repositoryReviewPromptRepo.setProjectPrompt(owner, repo, normalizedPrompt);
return c.json({ success: true, project_review_prompt: updated.project_prompt });
} catch (error: any) {
logger.error(`更新 ${owner}/${repo} 的项目级审查提示词失败:`, error);
return c.json({ message: 'Failed to update project review prompt', error: error.message }, 500);
}
});
// 创建 Webhook // 创建 Webhook
protectedRoutes.post('/repositories/:owner/:repo/webhook', async (c) => { protectedRoutes.post('/repositories/:owner/:repo/webhook', async (c) => {
const { owner, repo } = c.req.param(); const { owner, repo } = c.req.param();

View File

@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { closeDatabase, initDatabase } from '../database';
import { repositoryReviewPromptRepo } from '../repositories/repository-review-prompt-repo';
describe('repository-review-prompt-repo', () => {
let dbPath: string;
const savedDbPath = process.env.DATABASE_PATH;
beforeEach(() => {
const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
process.env.DATABASE_PATH = dbPath;
initDatabase();
});
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
if (existsSync(dbPath)) unlinkSync(dbPath);
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
});
test('sets and gets project prompt by owner/repo', () => {
repositoryReviewPromptRepo.setProjectPrompt('acme', 'assistant', 'focus on API correctness');
const prompt = repositoryReviewPromptRepo.getProjectPrompt('acme', 'assistant');
expect(prompt).toBe('focus on API correctness');
});
test('normalizes surrounding whitespace when setting prompt', () => {
repositoryReviewPromptRepo.setProjectPrompt('acme', 'assistant', ' use chinese output ');
const row = repositoryReviewPromptRepo.getByFullName('acme/assistant');
expect(row?.project_prompt).toBe('use chinese output');
});
test('clears prompt for repository', () => {
repositoryReviewPromptRepo.setProjectPrompt('acme', 'assistant', 'focus on security');
const deleted = repositoryReviewPromptRepo.clearProjectPrompt('acme', 'assistant');
expect(deleted).toBe(true);
expect(repositoryReviewPromptRepo.getProjectPrompt('acme', 'assistant')).toBeUndefined();
});
test('lists prompt map for repository names', () => {
repositoryReviewPromptRepo.setProjectPrompt('acme', 'a', 'prompt-a');
repositoryReviewPromptRepo.setProjectPrompt('acme', 'b', 'prompt-b');
const map = repositoryReviewPromptRepo.listProjectPrompts(['acme/a', 'acme/b', 'acme/c']);
expect(map).toEqual({
'acme/a': 'prompt-a',
'acme/b': 'prompt-b',
});
});
});

View File

@@ -11,6 +11,7 @@ import { dirname, resolve } from 'node:path';
import { migration001Init } from './migrations/001_init'; import { migration001Init } from './migrations/001_init';
import { migration002RemoveLegacyReviewMode } from './migrations/002_remove_legacy_review_mode'; import { migration002RemoveLegacyReviewMode } from './migrations/002_remove_legacy_review_mode';
import { migration003RepositoryReviewPrompts } from './migrations/003_repository_review_prompts';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -26,7 +27,11 @@ export interface Migration {
// Migration registry (ordered by version) // Migration registry (ordered by version)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const MIGRATIONS: Migration[] = [migration001Init, migration002RemoveLegacyReviewMode]; const MIGRATIONS: Migration[] = [
migration001Init,
migration002RemoveLegacyReviewMode,
migration003RepositoryReviewPrompts,
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Database singleton // Database singleton

View File

@@ -0,0 +1,21 @@
import type { Database } from 'bun:sqlite';
import type { Migration } from '../database';
export const migration003RepositoryReviewPrompts: Migration = {
version: 3,
name: 'add_repository_review_prompts',
up(db: Database): void {
db.exec(`
CREATE TABLE repository_review_prompts (
full_name TEXT PRIMARY KEY,
project_prompt TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(
'CREATE INDEX idx_repository_review_prompts_updated_at ON repository_review_prompts(updated_at)'
);
},
};

View File

@@ -0,0 +1,94 @@
import { getDatabase } from '../database';
export interface RepositoryReviewPromptRow {
full_name: string;
project_prompt: string;
updated_at: string;
}
function toFullName(owner: string, repo: string): string {
return `${owner}/${repo}`;
}
export const repositoryReviewPromptRepo = {
getByFullName(fullName: string): RepositoryReviewPromptRow | null {
const db = getDatabase();
return (
(db
.query(
'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?'
)
.get(fullName) as RepositoryReviewPromptRow | null) || null
);
},
getProjectPrompt(owner: string, repo: string): string | undefined {
const row = this.getByFullName(toFullName(owner, repo));
if (!row) return undefined;
const normalized = row.project_prompt.trim();
return normalized.length > 0 ? normalized : undefined;
},
upsertByFullName(fullName: string, projectPrompt: string): RepositoryReviewPromptRow {
const db = getDatabase();
const normalized = projectPrompt.trim();
if (!normalized) {
throw new Error('projectPrompt must be non-empty');
}
db.query(
`INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(full_name) DO UPDATE SET
project_prompt = excluded.project_prompt,
updated_at = datetime('now')`
).run(fullName, normalized);
const row = this.getByFullName(fullName);
if (!row) {
throw new Error('Failed to load repository review prompt after upsert');
}
return row;
},
setProjectPrompt(owner: string, repo: string, projectPrompt: string): RepositoryReviewPromptRow {
return this.upsertByFullName(toFullName(owner, repo), projectPrompt);
},
deleteByFullName(fullName: string): boolean {
const db = getDatabase();
const result = db
.query('DELETE FROM repository_review_prompts WHERE full_name = ?')
.run(fullName);
return result.changes > 0;
},
clearProjectPrompt(owner: string, repo: string): boolean {
return this.deleteByFullName(toFullName(owner, repo));
},
listProjectPrompts(fullNames: string[]): Record<string, string> {
if (fullNames.length === 0) {
return {};
}
const db = getDatabase();
const placeholders = fullNames.map(() => '?').join(', ');
const rows = db
.query(
`SELECT full_name, project_prompt
FROM repository_review_prompts
WHERE full_name IN (${placeholders})`
)
.all(...fullNames) as Array<Pick<RepositoryReviewPromptRow, 'full_name' | 'project_prompt'>>;
const map: Record<string, string> = {};
for (const row of rows) {
const normalized = row.project_prompt.trim();
if (normalized) {
map[row.full_name] = normalized;
}
}
return map;
},
};

View File

@@ -0,0 +1,244 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import type { DiffExtractor } from '../context/diff-extractor';
import type { LocalRepoManager, LocalRepoPaths } from '../context/local-repo-manager';
import type { FileReviewStore } from '../store/file-review-store';
import type { Finding, ReviewContext, ReviewRun, ReviewTask } from '../types';
function makeRun(overrides: Partial<ReviewRun> = {}): ReviewRun {
return {
id: 'run-project-prompt',
idempotencyKey: 'owner/repo#8:base...head',
eventType: 'pull_request',
status: 'in_progress',
owner: 'owner',
repo: 'repo',
cloneUrl: 'https://example.com/repo.git',
prNumber: 8,
baseSha: 'base-sha',
headSha: 'head-sha',
attempts: 1,
maxAttempts: 3,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createStoreMock() {
return {
markRunIgnored: mock(async () => undefined),
addStep: mock(async () => undefined),
getRunDetails: mock(async () => ({ comments: [], findings: [] })),
addFindings: mock(async () => undefined),
markFindingPublished: mock(async () => true),
addCommentRecord: mock(async () => undefined),
};
}
function createLocalRepoManagerMock() {
const repoPaths: LocalRepoPaths = {
mirrorPath: '/tmp/mirror',
workspacePath: '/tmp/workspace',
};
return {
manager: {
prepareWorkspace: mock(async () => repoPaths),
resolveReviewedRef: mock(async () => null),
saveReviewedRef: mock(async () => undefined),
cleanupWorkspace: mock(async () => undefined),
},
repoPaths,
};
}
function createDiffExtractorMock() {
const context: ReviewContext = {
workspacePath: '/tmp/workspace',
mirrorPath: '/tmp/mirror',
diff: 'diff --git a/src/app.ts b/src/app.ts\n+const x = 1;',
changedFiles: [
{
path: 'src/app.ts',
status: 'M',
additions: 3,
deletions: 1,
},
],
parsedDiff: [],
fileContents: {},
};
return {
context,
extractor: {
getSandbox: mock(() => ({
execute: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
})),
buildContext: mock(async () => context),
},
};
}
describe('project prompt wiring', () => {
beforeEach(() => {
mock.restore();
});
afterEach(() => {
mock.restore();
});
test('orchestrator forwards resolved project prompt to triage and specialist execution options', async () => {
const projectPrompt = `repo-policy-${'P'.repeat(360)}`;
mock.module('../project-review-prompt', () => ({
resolveProjectReviewPrompt: () => projectPrompt,
}));
const { ReviewOrchestrator } = await import('../orchestrator');
const store = createStoreMock();
const { manager } = createLocalRepoManagerMock();
const { extractor } = createDiffExtractorMock();
const orchestrator = new ReviewOrchestrator(
store as unknown as FileReviewStore,
manager as unknown as LocalRepoManager,
extractor as unknown as DiffExtractor
);
type TriageResultLike = {
complexity: 'trivial' | 'standard' | 'complex';
reviewSize: 'small' | 'medium' | 'large';
mode: 'skip' | 'light' | 'full';
tasks: ReviewTask[];
riskTags: string[];
rationale: string;
};
type ReviewFinding = Array<Omit<Finding, 'id' | 'runId' | 'published'>>;
type InternalOrchestrator = {
triageAgent: {
analyze: (
context: ReviewContext,
options?: { projectPrompt?: string }
) => Promise<TriageResultLike>;
};
agentMap: Record<
string,
{
reviewWithOptions: (
run: ReviewRun,
context: ReviewContext,
options: { projectPrompt?: string }
) => Promise<{ findings: ReviewFinding }>;
reviewWithReflection: (
run: ReviewRun,
context: ReviewContext,
maxRounds?: number,
options?: { projectPrompt?: string }
) => Promise<{ findings: ReviewFinding }>;
}
>;
judgeAgent: {
judge: (findings: ReviewFinding) => { summaryMarkdown: string; findings: ReviewFinding };
};
publishSummary: (run: ReviewRun, summary: string, gatedCount: number) => Promise<void>;
publishLineComments: (
run: ReviewRun,
comments: Array<{ path: string; line: number; comment: string }>
) => Promise<boolean>;
};
const internal = orchestrator as unknown as InternalOrchestrator;
const task: ReviewTask = {
domain: 'correctness',
paths: ['src/app.ts'],
riskTags: [],
mode: 'light',
tokenBudget: 1200,
maxIterations: 1,
allowTools: false,
allowReflection: false,
allowDebate: false,
};
const triageAnalyzeMock = mock(async () => ({
complexity: 'standard' as const,
reviewSize: 'small' as const,
mode: 'light' as const,
tasks: [task],
riskTags: [],
rationale: 'project prompt wiring test',
}));
const reviewWithOptionsMock = mock(async () => ({
findings: [] as ReviewFinding,
}));
const reviewWithReflectionMock = mock(async () => ({
findings: [] as ReviewFinding,
}));
internal.triageAgent = {
analyze: triageAnalyzeMock,
};
internal.agentMap = {
correctness: {
reviewWithOptions: reviewWithOptionsMock,
reviewWithReflection: reviewWithReflectionMock,
},
};
internal.judgeAgent = {
judge: mock(() => ({
summaryMarkdown: 'ok',
findings: [] as ReviewFinding,
})),
};
internal.publishSummary = mock(async () => undefined);
internal.publishLineComments = mock(async () => false);
const run = makeRun();
await orchestrator.execute(run);
expect(triageAnalyzeMock).toHaveBeenCalledWith(expect.anything(), { projectPrompt });
expect(reviewWithOptionsMock).toHaveBeenCalledWith(
run,
expect.anything(),
expect.objectContaining({ projectPrompt })
);
});
test('codex prompt builder includes resolved project-level prompt section', async () => {
const projectPrompt = `codex-policy-${'X'.repeat(320)}`;
mock.module('../project-review-prompt', () => ({
resolveProjectReviewPrompt: () => projectPrompt,
}));
const { CodexRunner } = await import('../codex/codex-runner');
const store = createStoreMock();
const { manager } = createLocalRepoManagerMock();
const runner = new CodexRunner(
store as unknown as FileReviewStore,
manager as unknown as LocalRepoManager
);
const internal = runner as unknown as {
buildReviewPrompt: (run: ReviewRun, lastReviewedHead?: string) => string;
};
const prompt = internal.buildReviewPrompt(makeRun(), undefined);
expect(prompt).toContain('## 项目级审查要求');
expect(prompt).toContain(projectPrompt);
});
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMChatResponse, ModelRole } from '../../llm/types'; import type { LLMChatResponse, ModelRole } from '../../llm/types';
import { TriageAgent } from '../agents/triage-agent'; import { TriageAgent } from '../agents/triage-agent';
import type { ChangedFile, FindingCategory, ReviewContext } from '../types'; import type { ChangedFile, FindingCategory, ReviewContext } from '../types';
@@ -204,6 +205,44 @@ describe('TriageAgent task-based routing', () => {
expect(result.rationale).toBe('跨文件业务逻辑调整'); expect(result.rationale).toBe('跨文件业务逻辑调整');
}); });
test('LLM fallback: planner system message keeps full project prompt', async () => {
const longProjectPrompt = `repo-policy-${'P'.repeat(420)}`;
const { gateway, getCalls } = createMockGateway(async () =>
makeChatResponse(
JSON.stringify({
complexity: 'standard',
review_size: 'medium',
mode: 'light',
relevant_domains: ['correctness'],
risk_tags: ['maintainability-hotspot'],
rationale: '需要模型判断',
})
)
);
const agent = new TriageAgent(gateway as unknown as LLMGateway);
await agent.analyze(
makeContext({
changedFiles: [
makeChangedFile({ path: 'src/service/order.ts', additions: 20, deletions: 10 }),
makeChangedFile({ path: 'src/controller/order.ts', additions: 18, deletions: 12 }),
makeChangedFile({ path: 'src/repo/order.ts', additions: 15, deletions: 12 }),
makeChangedFile({ path: 'src/model/order.ts', additions: 14, deletions: 13 }),
],
}),
{ projectPrompt: longProjectPrompt }
);
const calls = getCalls();
expect(calls).toHaveLength(1);
const plannerMessages = calls[0].request.messages as Array<{ role: string; content: string }>;
const plannerSystemMessage = plannerMessages.find((message) => message.role === 'system');
expect(plannerSystemMessage?.content).toContain(longProjectPrompt);
});
test('LLM fallback: planner throws -> default full review with all domains', async () => { test('LLM fallback: planner throws -> default full review with all domains', async () => {
const { gateway, getCalls } = createMockGateway(async () => { const { gateway, getCalls } = createMockGateway(async () => {
throw new Error('planner unavailable'); throw new Error('planner unavailable');

View File

@@ -1,7 +1,7 @@
import config from '../../config'; import config from '../../config';
import type { LLMGateway } from '../../llm/gateway'; import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types'; import type { LLMMessage } from '../../llm/types';
import { withCoreGlobalPrompt } from '../../utils/global-prompt'; import { mergeReviewPrompts, withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter'; import { tokenCounter } from '../context/token-counter';
import { Finding, ReviewContext } from '../types'; import { Finding, ReviewContext } from '../types';
@@ -25,7 +25,8 @@ export class CriticAgent {
async critique( async critique(
findings: Omit<Finding, 'id' | 'runId' | 'published'>[], findings: Omit<Finding, 'id' | 'runId' | 'published'>[],
context: ReviewContext context: ReviewContext,
projectPrompt?: string
): Promise<CritiqueResult> { ): Promise<CritiqueResult> {
if (findings.length === 0) { if (findings.length === 0) {
return { return {
@@ -75,7 +76,7 @@ ${tokenCounter.clip(context.diff, 1000)}
role: 'system', role: 'system',
content: withCoreGlobalPrompt( content: withCoreGlobalPrompt(
'你是严格的代码审查质量评估专家以高标准评估findings的质量。', '你是严格的代码审查质量评估专家以高标准评估findings的质量。',
config.review.globalPrompt mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
), ),
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
@@ -132,7 +133,8 @@ ${tokenCounter.clip(context.diff, 1000)}
async evaluateSingleFinding( async evaluateSingleFinding(
finding: Omit<Finding, 'id' | 'runId' | 'published'>, finding: Omit<Finding, 'id' | 'runId' | 'published'>,
context: ReviewContext context: ReviewContext,
projectPrompt?: string
): Promise<{ ): Promise<{
isValid: boolean; isValid: boolean;
confidence: number; confidence: number;
@@ -166,7 +168,10 @@ ${tokenCounter.clip(context.diff, 700)}
const messages: LLMMessage[] = [ const messages: LLMMessage[] = [
{ {
role: 'system', role: 'system',
content: withCoreGlobalPrompt('你是代码审查质量评估专家。', config.review.globalPrompt), content: withCoreGlobalPrompt(
'你是代码审查质量评估专家。',
mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
),
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
]; ];

View File

@@ -1,7 +1,7 @@
import config from '../../config'; import config from '../../config';
import type { LLMGateway } from '../../llm/gateway'; import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types'; import type { LLMMessage } from '../../llm/types';
import { withCoreGlobalPrompt } from '../../utils/global-prompt'; import { mergeReviewPrompts, withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { Finding, FindingSeverity } from '../types'; import { Finding, FindingSeverity } from '../types';
import { SpecialistAgent } from './specialist-agent'; import { SpecialistAgent } from './specialist-agent';
@@ -24,7 +24,8 @@ export class DebateOrchestrator {
async conductDebate( async conductDebate(
finding: Omit<Finding, 'id' | 'runId' | 'published'>, finding: Omit<Finding, 'id' | 'runId' | 'published'>,
agents: SpecialistAgent[], agents: SpecialistAgent[],
maxRounds = 2 maxRounds = 2,
projectPrompt?: string
): Promise<Omit<Finding, 'id' | 'runId' | 'published'>> { ): Promise<Omit<Finding, 'id' | 'runId' | 'published'>> {
if (agents.length < 2) { if (agents.length < 2) {
logger.debug('Debate需要至少2个agents跳过'); logger.debug('Debate需要至少2个agents跳过');
@@ -41,7 +42,7 @@ export class DebateOrchestrator {
// 收集初始意见 // 收集初始意见
for (const agent of agents) { for (const agent of agents) {
const opinion = await this.getAgentOpinion(agent, finding); const opinion = await this.getAgentOpinion(agent, finding, projectPrompt);
opinions.set((agent as any).agentName, opinion); opinions.set((agent as any).agentName, opinion);
} }
@@ -55,7 +56,13 @@ export class DebateOrchestrator {
const agentName = (agent as any).agentName; const agentName = (agent as any).agentName;
const otherOpinions = Array.from(opinions.entries()).filter(([name]) => name !== agentName); const otherOpinions = Array.from(opinions.entries()).filter(([name]) => name !== agentName);
const revisedOpinion = await this.reviseOpinion(agent, finding, otherOpinions, opinions); const revisedOpinion = await this.reviseOpinion(
agent,
finding,
otherOpinions,
opinions,
projectPrompt
);
opinions.set(agentName, revisedOpinion); opinions.set(agentName, revisedOpinion);
} }
@@ -75,7 +82,8 @@ export class DebateOrchestrator {
private async getAgentOpinion( private async getAgentOpinion(
agent: SpecialistAgent, agent: SpecialistAgent,
finding: Omit<Finding, 'id' | 'runId' | 'published'> finding: Omit<Finding, 'id' | 'runId' | 'published'>,
projectPrompt?: string
): Promise<AgentOpinion> { ): Promise<AgentOpinion> {
const agentName = (agent as any).agentName; const agentName = (agent as any).agentName;
const prompt = `你是${agentName}。评估以下代码问题的严重性、置信度和有效性。 const prompt = `你是${agentName}。评估以下代码问题的严重性、置信度和有效性。
@@ -107,7 +115,7 @@ export class DebateOrchestrator {
role: 'system', role: 'system',
content: withCoreGlobalPrompt( content: withCoreGlobalPrompt(
`你是${agentName},从你的专业角度独立评估代码问题。`, `你是${agentName},从你的专业角度独立评估代码问题。`,
config.review.globalPrompt mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
), ),
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
@@ -153,7 +161,8 @@ export class DebateOrchestrator {
agent: SpecialistAgent, agent: SpecialistAgent,
finding: Omit<Finding, 'id' | 'runId' | 'published'>, finding: Omit<Finding, 'id' | 'runId' | 'published'>,
otherOpinions: [string, AgentOpinion][], otherOpinions: [string, AgentOpinion][],
opinions: Map<string, AgentOpinion> opinions: Map<string, AgentOpinion>,
projectPrompt?: string
): Promise<AgentOpinion> { ): Promise<AgentOpinion> {
const agentName = (agent as any).agentName; const agentName = (agent as any).agentName;
const prompt = `你是${agentName}。重新评估以下问题,考虑其他专家的意见。 const prompt = `你是${agentName}。重新评估以下问题,考虑其他专家的意见。
@@ -188,7 +197,7 @@ ${otherOpinions
role: 'system', role: 'system',
content: withCoreGlobalPrompt( content: withCoreGlobalPrompt(
`你是${agentName},根据同行意见重新评估,但也要坚持你的专业判断。`, `你是${agentName},根据同行意见重新评估,但也要坚持你的专业判断。`,
config.review.globalPrompt mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
), ),
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },

View File

@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import config from '../../config'; import config from '../../config';
import type { LLMGateway } from '../../llm/gateway'; import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types'; import type { LLMMessage } from '../../llm/types';
import { withGlobalPrompt } from '../../utils/global-prompt'; import { mergeReviewPrompts, withGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter'; import { tokenCounter } from '../context/token-counter';
import { LearningSystem } from '../learning/learning-system'; import { LearningSystem } from '../learning/learning-system';
@@ -43,6 +43,7 @@ export class ReflexionAgent extends SpecialistAgent {
let bestFindings: Omit<Finding, 'id' | 'runId' | 'published'>[] = []; let bestFindings: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
let bestQualityScore = 0; let bestQualityScore = 0;
let currentFindings: Omit<Finding, 'id' | 'runId' | 'published'>[] = []; let currentFindings: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
const projectPrompt = options?.projectPrompt;
for (let round = 0; round < maxReflectionRounds; round++) { for (let round = 0; round < maxReflectionRounds; round++) {
logger.info(`${this.agentName} Reflection Round ${round + 1}/${maxReflectionRounds}`, { logger.info(`${this.agentName} Reflection Round ${round + 1}/${maxReflectionRounds}`, {
@@ -53,7 +54,7 @@ export class ReflexionAgent extends SpecialistAgent {
const draft = await this.generateDraft(run, context, currentFindings, round, options); const draft = await this.generateDraft(run, context, currentFindings, round, options);
// 自我批评 // 自我批评
const critique = await this.criticAgent.critique(draft, context); const critique = await this.criticAgent.critique(draft, context, projectPrompt);
logger.info(`${this.agentName} Critique结果`, { logger.info(`${this.agentName} Critique结果`, {
runId: run.id, runId: run.id,
@@ -82,7 +83,7 @@ export class ReflexionAgent extends SpecialistAgent {
// 如果还有改进空间继续优化refine后需要在下一轮重新评估 // 如果还有改进空间继续优化refine后需要在下一轮重新评估
if (round < maxReflectionRounds - 1) { if (round < maxReflectionRounds - 1) {
currentFindings = await this.refine(draft, critique, context, run); currentFindings = await this.refine(draft, critique, context, run, projectPrompt);
} }
} }
@@ -113,7 +114,8 @@ export class ReflexionAgent extends SpecialistAgent {
draft: Omit<Finding, 'id' | 'runId' | 'published'>[], draft: Omit<Finding, 'id' | 'runId' | 'published'>[],
critique: CritiqueResult, critique: CritiqueResult,
context: ReviewContext, context: ReviewContext,
run: ReviewRun run: ReviewRun,
projectPrompt?: string
): Promise<Omit<Finding, 'id' | 'runId' | 'published'>[]> { ): Promise<Omit<Finding, 'id' | 'runId' | 'published'>[]> {
const prompt = `你是${this.agentName}。根据以下批评意见,改进审查结果。 const prompt = `你是${this.agentName}。根据以下批评意见,改进审查结果。
@@ -150,7 +152,7 @@ ${tokenCounter.clip(context.diff, 1000)}
role: 'system', role: 'system',
content: withGlobalPrompt( content: withGlobalPrompt(
`你是${this.agentName},根据批评反馈改进审查结果。`, `你是${this.agentName},根据批评反馈改进审查结果。`,
config.review.globalPrompt mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
), ),
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },

View File

@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import config from '../../config'; import config from '../../config';
import type { LLMGateway } from '../../llm/gateway'; import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage, LLMToolCall } from '../../llm/types'; import type { LLMMessage, LLMToolCall } from '../../llm/types';
import { withGlobalPrompt } from '../../utils/global-prompt'; import { mergeReviewPrompts, withGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter'; import { tokenCounter } from '../context/token-counter';
import type { LearningSystem } from '../learning/learning-system'; import type { LearningSystem } from '../learning/learning-system';
@@ -36,6 +36,7 @@ export interface SpecialistReviewOptions {
maxIterations?: number; maxIterations?: number;
mode?: ReviewMode; mode?: ReviewMode;
maxContextTokens?: number; maxContextTokens?: number;
projectPrompt?: string;
} }
function toCompactContext(context: ReviewContext, options?: CompactContextOptions): string { function toCompactContext(context: ReviewContext, options?: CompactContextOptions): string {
@@ -185,7 +186,7 @@ ${toCompactContext(context, {
role: 'system', role: 'system',
content: withGlobalPrompt( content: withGlobalPrompt(
'你是严格的代码审查专家。返回结构化JSON不输出额外文字。confidence取值范围0到1。line必须是正整数且引用新增行。', '你是严格的代码审查专家。返回结构化JSON不输出额外文字。confidence取值范围0到1。line必须是正整数且引用新增行。',
config.review.globalPrompt mergeReviewPrompts(config.review.globalPrompt, options?.projectPrompt)
), ),
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
@@ -271,7 +272,7 @@ ${this.toolRegistry!.getAll()
"need_more_investigation": false "need_more_investigation": false
} }
每个 finding 对象的所有字段都是必填的。无问题时返回空数组 {"findings": [], "need_more_investigation": false}。`, 每个 finding 对象的所有字段都是必填的。无问题时返回空数组 {"findings": [], "need_more_investigation": false}。`,
config.review.globalPrompt mergeReviewPrompts(config.review.globalPrompt, options?.projectPrompt)
), ),
}, },
]; ];

View File

@@ -12,7 +12,7 @@
import config from '../../config'; import config from '../../config';
import type { LLMGateway } from '../../llm/gateway'; import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types'; import type { LLMMessage } from '../../llm/types';
import { withCoreGlobalPrompt } from '../../utils/global-prompt'; import { mergeReviewPrompts, withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import type { import type {
ChangedFile, ChangedFile,
@@ -41,6 +41,10 @@ export interface TriageResult {
rationale: string; rationale: string;
} }
export interface TriageOptions {
projectPrompt?: string;
}
/** All valid finding categories. */ /** All valid finding categories. */
const ALL_DOMAINS: FindingCategory[] = [ const ALL_DOMAINS: FindingCategory[] = [
'correctness', 'correctness',
@@ -334,7 +338,7 @@ export class TriageAgent {
* If the planner role is not configured or the call fails, * If the planner role is not configured or the call fails,
* falls back to a heuristic-based triage. * falls back to a heuristic-based triage.
*/ */
async analyze(context: ReviewContext): Promise<TriageResult> { async analyze(context: ReviewContext, options?: TriageOptions): Promise<TriageResult> {
// First try heuristic-based fast path (no LLM call needed for obvious cases) // First try heuristic-based fast path (no LLM call needed for obvious cases)
const heuristicResult = this.heuristicTriage(context.changedFiles); const heuristicResult = this.heuristicTriage(context.changedFiles);
if (heuristicResult) { if (heuristicResult) {
@@ -349,7 +353,7 @@ export class TriageAgent {
// Fall back to LLM-based triage // Fall back to LLM-based triage
try { try {
return await this.llmTriage(context); return await this.llmTriage(context, options);
} catch (error) { } catch (error) {
logger.warn('Triage: LLM 调用失败,回退到启发式全量派发', { logger.warn('Triage: LLM 调用失败,回退到启发式全量派发', {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@@ -454,7 +458,7 @@ export class TriageAgent {
/** /**
* LLM-based triage using the 'planner' role. * LLM-based triage using the 'planner' role.
*/ */
private async llmTriage(context: ReviewContext): Promise<TriageResult> { private async llmTriage(context: ReviewContext, options?: TriageOptions): Promise<TriageResult> {
const policy = getReviewBudgetPolicy(); const policy = getReviewBudgetPolicy();
const riskTags = collectRiskTags(context.changedFiles); const riskTags = collectRiskTags(context.changedFiles);
const fileSummary = context.changedFiles const fileSummary = context.changedFiles
@@ -494,7 +498,7 @@ ${diffPreview}
role: 'system', role: 'system',
content: withCoreGlobalPrompt( content: withCoreGlobalPrompt(
'你是代码变更分流专家,快速判断变更复杂度。返回结构化 JSON不输出额外文字。', '你是代码变更分流专家,快速判断变更复杂度。返回结构化 JSON不输出额外文字。',
config.review.globalPrompt mergeReviewPrompts(config.review.globalPrompt, options?.projectPrompt)
), ),
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },

View File

@@ -3,6 +3,7 @@ import path from 'node:path';
import config from '../../config'; import config from '../../config';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import type { LocalRepoManager, LocalRepoPaths } from '../context/local-repo-manager'; import type { LocalRepoManager, LocalRepoPaths } from '../context/local-repo-manager';
import { resolveProjectReviewPrompt } from '../project-review-prompt';
import type { FileReviewStore } from '../store/file-review-store'; import type { FileReviewStore } from '../store/file-review-store';
import type { ReviewRun } from '../types'; import type { ReviewRun } from '../types';
import { type ReviewRunContext, mcpToolExecutor } from './mcp-tools'; import { type ReviewRunContext, mcpToolExecutor } from './mcp-tools';
@@ -306,7 +307,7 @@ export class CodexRunner {
} }
private resolveProjectPrompt(_run: ReviewRun): string | undefined { private resolveProjectPrompt(_run: ReviewRun): string | undefined {
return undefined; return resolveProjectReviewPrompt(_run.owner, _run.repo);
} }
/** /**

View File

@@ -12,6 +12,7 @@ import { LocalRepoManager, LocalRepoPaths } from './context/local-repo-manager';
import { LearningSystem } from './learning/learning-system'; import { LearningSystem } from './learning/learning-system';
import { VectorMemoryStore } from './memory/vector-store'; import { VectorMemoryStore } from './memory/vector-store';
import { applyPublishPolicy } from './policy/publish-policy'; import { applyPublishPolicy } from './policy/publish-policy';
import { resolveProjectReviewPrompt } from './project-review-prompt';
import { FileReviewStore } from './store/file-review-store'; import { FileReviewStore } from './store/file-review-store';
import { createCodeSearchTool } from './tools/code-search-tool'; import { createCodeSearchTool } from './tools/code-search-tool';
import { createFileReadTool } from './tools/file-read-tool'; import { createFileReadTool } from './tools/file-read-tool';
@@ -229,6 +230,8 @@ export class ReviewOrchestrator {
return; return;
} }
const projectPrompt = resolveProjectReviewPrompt(run.owner, run.repo);
// ── Triage: 决定哪些 specialist 需要参与 ───────────────────────── // ── Triage: 决定哪些 specialist 需要参与 ─────────────────────────
let triage: TriageResult | null = null; let triage: TriageResult | null = null;
const enableTriage = config.review.enableTriage ?? true; const enableTriage = config.review.enableTriage ?? true;
@@ -242,7 +245,7 @@ export class ReviewOrchestrator {
startedAt: new Date(triageStart).toISOString(), startedAt: new Date(triageStart).toISOString(),
}); });
triage = await this.triageAgent.analyze(context); triage = await this.triageAgent.analyze(context, { projectPrompt });
await this.store.addStep({ await this.store.addStep({
runId: run.id, runId: run.id,
@@ -330,6 +333,7 @@ export class ReviewOrchestrator {
maxIterations: task.maxIterations, maxIterations: task.maxIterations,
mode: task.mode, mode: task.mode,
maxContextTokens: Math.max(1500, Math.floor(task.tokenBudget * 0.7)), maxContextTokens: Math.max(1500, Math.floor(task.tokenBudget * 0.7)),
projectPrompt,
} as const; } as const;
const useReflection = const useReflection =
@@ -411,7 +415,9 @@ export class ReviewOrchestrator {
const uniqueDebateAgents = [...new Set(debateAgents)]; const uniqueDebateAgents = [...new Set(debateAgents)];
const debatedFinding = await this.debateOrchestrator.conductDebate( const debatedFinding = await this.debateOrchestrator.conductDebate(
finding, finding,
uniqueDebateAgents uniqueDebateAgents,
2,
projectPrompt
); );
debatedFindings.push(debatedFinding); debatedFindings.push(debatedFinding);
} }

View File

@@ -0,0 +1,19 @@
import { repositoryReviewPromptRepo } from '../db/repositories/repository-review-prompt-repo';
import { logger } from '../utils/logger';
export function resolveProjectReviewPrompt(owner: string, repo: string): string | undefined {
try {
return repositoryReviewPromptRepo.getProjectPrompt(owner, repo);
} catch (error) {
if (error instanceof Error && error.message.includes('Database not initialized')) {
return undefined;
}
logger.warn('读取项目级审查提示词失败,回退为仅全局提示词', {
owner,
repo,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}

View File

@@ -11,14 +11,32 @@ export function withGlobalPrompt(systemContent: string, globalPrompt: string | u
return `${systemContent}\n\n${globalPrompt}`; return `${systemContent}\n\n${globalPrompt}`;
} }
export function mergeReviewPrompts(
globalPrompt: string | undefined,
projectPrompt: string | undefined
): string | undefined {
const normalized = [globalPrompt, projectPrompt]
.map((item) => item?.trim())
.filter((item): item is string => !!item && item.length > 0);
if (normalized.length === 0) {
return undefined;
}
return normalized.join('\n\n');
}
export function withCoreGlobalPrompt( export function withCoreGlobalPrompt(
systemContent: string, systemContent: string,
globalPrompt: string | undefined, globalPrompt: string | undefined,
maxChars = 240 maxChars?: number
): string { ): string {
if (!globalPrompt || globalPrompt.trim() === '') { if (!globalPrompt || globalPrompt.trim() === '') {
return systemContent; return systemContent;
} }
const compact = globalPrompt.trim().slice(0, maxChars);
const normalized = globalPrompt.trim();
const compact =
typeof maxChars === 'number' && maxChars > 0 ? normalized.slice(0, maxChars) : normalized;
return `${systemContent}\n\n${compact}`; return `${systemContent}\n\n${compact}`;
} }