mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
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:
@@ -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" } }, ""],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
141
frontend/src/components/RepositoryConfigCell.tsx
Normal file
141
frontend/src/components/RepositoryConfigCell.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
57
frontend/src/components/WebhookToggleCell.tsx
Normal file
57
frontend/src/components/WebhookToggleCell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
84
frontend/src/components/__tests__/WebhookToggleCell.test.tsx
Normal file
84
frontend/src/components/__tests__/WebhookToggleCell.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
122
frontend/src/components/ui/dialog.tsx
Normal file
122
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
200
frontend/src/components/ui/dropdown-menu.tsx
Normal file
200
frontend/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/controllers/__tests__/admin-repositories.test.ts
Normal file
86
src/controllers/__tests__/admin-repositories.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
67
src/db/__tests__/repository-review-prompt-repo.test.ts
Normal file
67
src/db/__tests__/repository-review-prompt-repo.test.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
21
src/db/migrations/003_repository_review_prompts.ts
Normal file
21
src/db/migrations/003_repository_review_prompts.ts
Normal 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)'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
94
src/db/repositories/repository-review-prompt-repo.ts
Normal file
94
src/db/repositories/repository-review-prompt-repo.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
244
src/review/__tests__/project-prompt-wiring.test.ts
Normal file
244
src/review/__tests__/project-prompt-wiring.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/review/project-review-prompt.ts
Normal file
19
src/review/project-review-prompt.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user