diff options
Diffstat (limited to 'makima/frontend/src/components/mesh/PRPreview.tsx')
| -rw-r--r-- | makima/frontend/src/components/mesh/PRPreview.tsx | 314 |
1 files changed, 314 insertions, 0 deletions
diff --git a/makima/frontend/src/components/mesh/PRPreview.tsx b/makima/frontend/src/components/mesh/PRPreview.tsx new file mode 100644 index 0000000..fc202b0 --- /dev/null +++ b/makima/frontend/src/components/mesh/PRPreview.tsx @@ -0,0 +1,314 @@ +import { useState, useMemo } from "react"; +import type { TaskWithSubtasks, TaskSummary } from "../../lib/api"; +import { OverlayDiffViewer } from "./OverlayDiffViewer"; + +interface PRPreviewProps { + task: TaskWithSubtasks; + diff?: string; + changedFiles?: string[]; + loading?: boolean; + onCreatePR?: (title: string, body: string, draft: boolean) => Promise<void>; + onAutoMerge?: () => Promise<void>; + onClose: () => void; +} + +interface PRFormData { + title: string; + body: string; + isDraft: boolean; +} + +function generatePRTitle(task: TaskWithSubtasks): string { + // Generate a PR title based on the task name + const prefix = task.parentTaskId ? "feat" : "feat"; + return `${prefix}: ${task.name}`; +} + +function generatePRBody(task: TaskWithSubtasks, changedFiles?: string[]): string { + const sections: string[] = []; + + // Summary + sections.push("## Summary\n"); + if (task.description) { + sections.push(task.description + "\n"); + } else { + sections.push("_Add a brief description of the changes..._\n"); + } + + // Plan/Implementation details + sections.push("\n## Implementation\n"); + if (task.plan) { + // Truncate if too long + const planPreview = task.plan.length > 500 + ? task.plan.substring(0, 500) + "..." + : task.plan; + sections.push("```\n" + planPreview + "\n```\n"); + } + + // Subtasks summary + if (task.subtasks.length > 0) { + sections.push("\n## Subtasks\n"); + task.subtasks.forEach((subtask: TaskSummary) => { + const emoji = subtask.status === "done" || subtask.status === "merged" ? "+" : + subtask.status === "running" ? "~" : "-"; + sections.push(`- [${emoji === "+" ? "x" : " "}] ${subtask.name} (${subtask.status})\n`); + }); + } + + // Changed files + if (changedFiles && changedFiles.length > 0) { + sections.push("\n## Changed Files\n"); + changedFiles.slice(0, 20).forEach((file) => { + sections.push(`- \`${file}\`\n`); + }); + if (changedFiles.length > 20) { + sections.push(`\n_...and ${changedFiles.length - 20} more files_\n`); + } + } + + // Test plan + sections.push("\n## Test Plan\n"); + sections.push("- [ ] Manual testing completed\n"); + sections.push("- [ ] Unit tests added/updated\n"); + sections.push("- [ ] Integration tests passing\n"); + + // Footer + sections.push("\n---\n"); + sections.push("_Generated by makima mesh orchestrator_\n"); + + return sections.join(""); +} + +export function PRPreview({ + task, + diff = "", + changedFiles = [], + loading = false, + onCreatePR, + onAutoMerge, + onClose, +}: PRPreviewProps) { + const [showDiff, setShowDiff] = useState(false); + const [creating, setCreating] = useState(false); + const [error, setError] = useState<string | null>(null); + + const [formData, setFormData] = useState<PRFormData>(() => ({ + title: generatePRTitle(task), + body: generatePRBody(task, changedFiles), + isDraft: false, + })); + + const handleCreatePR = async () => { + if (!onCreatePR || creating) return; + + setCreating(true); + setError(null); + + try { + await onCreatePR(formData.title, formData.body, formData.isDraft); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create PR"); + } finally { + setCreating(false); + } + }; + + const handleAutoMerge = async () => { + if (!onAutoMerge || creating) return; + + if (!confirm("Are you sure you want to auto-merge this task directly to the target branch?")) { + return; + } + + setCreating(true); + setError(null); + + try { + await onAutoMerge(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to auto-merge"); + } finally { + setCreating(false); + } + }; + + // Calculate stats + const stats = useMemo(() => { + const completedSubtasks = task.subtasks.filter( + (s) => s.status === "done" || s.status === "merged" + ).length; + return { + filesChanged: changedFiles.length, + subtasksCompleted: completedSubtasks, + subtasksTotal: task.subtasks.length, + isReady: completedSubtasks === task.subtasks.length || task.subtasks.length === 0, + }; + }, [task.subtasks, changedFiles]); + + return ( + <div className="panel flex flex-col max-h-[80vh] overflow-hidden"> + {/* Header */} + <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Create Pull Request + </div> + <button + onClick={onClose} + className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]" + > + Cancel + </button> + </div> + + {/* Content */} + <div className="flex-1 overflow-y-auto p-4 space-y-4"> + {/* Status badges */} + <div className="flex flex-wrap gap-2"> + <span className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]"> + {task.baseBranch || "main"} → {task.targetBranch || task.baseBranch || "main"} + </span> + <span className="px-2 py-0.5 font-mono text-[10px] text-[#9bc3ff] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]"> + {stats.filesChanged} files changed + </span> + {task.subtasks.length > 0 && ( + <span + className={`px-2 py-0.5 font-mono text-[10px] border ${ + stats.isReady + ? "text-green-400 bg-green-400/10 border-green-400/20" + : "text-yellow-400 bg-yellow-400/10 border-yellow-400/20" + }`} + > + {stats.subtasksCompleted}/{stats.subtasksTotal} subtasks complete + </span> + )} + </div> + + {/* Warning if subtasks not complete */} + {!stats.isReady && ( + <div className="bg-yellow-400/10 border border-yellow-400/30 p-3 font-mono text-xs text-yellow-400"> + Some subtasks are not yet complete. Consider waiting before creating the PR. + </div> + )} + + {/* Error message */} + {error && ( + <div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400"> + {error} + </div> + )} + + {/* PR Title */} + <div className="space-y-2"> + <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Title + </label> + <input + type="text" + value={formData.title} + onChange={(e) => setFormData({ ...formData, title: e.target.value })} + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]" + placeholder="PR title" + disabled={creating} + /> + </div> + + {/* PR Body */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Description + </label> + <button + onClick={() => setFormData({ + ...formData, + body: generatePRBody(task, changedFiles), + })} + className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]" + > + Regenerate + </button> + </div> + <textarea + value={formData.body} + onChange={(e) => setFormData({ ...formData, body: e.target.value })} + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[200px] resize-y" + placeholder="PR description (markdown)" + disabled={creating} + /> + </div> + + {/* Options */} + <div className="flex items-center gap-4"> + <label className="flex items-center gap-2 cursor-pointer"> + <input + type="checkbox" + checked={formData.isDraft} + onChange={(e) => setFormData({ ...formData, isDraft: e.target.checked })} + className="w-4 h-4 accent-[#75aafc]" + disabled={creating} + /> + <span className="font-mono text-xs text-[#9bc3ff]">Create as draft</span> + </label> + </div> + + {/* Diff preview toggle */} + <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> + <button + onClick={() => setShowDiff(!showDiff)} + className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff]" + > + <span>{showDiff ? "▼" : "▶"}</span> + <span> + {showDiff ? "Hide" : "Show"} diff preview ({stats.filesChanged} files) + </span> + </button> + </div> + + {/* Inline diff viewer */} + {showDiff && ( + <div className="border border-[rgba(117,170,252,0.2)]"> + <OverlayDiffViewer + diff={diff} + changedFiles={changedFiles} + loading={loading} + title="Changes to be merged" + /> + </div> + )} + </div> + + {/* Footer actions */} + <div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0"> + <div className="font-mono text-[10px] text-[#555]"> + {task.repositoryUrl && ( + <span className="truncate max-w-[200px] inline-block align-middle"> + {task.repositoryUrl} + </span> + )} + </div> + <div className="flex items-center gap-2"> + {task.mergeMode === "auto" && onAutoMerge && ( + <button + onClick={handleAutoMerge} + disabled={creating || !stats.isReady} + className="px-4 py-2 font-mono text-xs text-yellow-400 border border-yellow-400/30 hover:border-yellow-400/50 hover:bg-yellow-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {creating ? "..." : "Auto-Merge"} + </button> + )} + {onCreatePR && ( + <button + onClick={handleCreatePR} + disabled={creating || !formData.title.trim()} + className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {creating ? "Creating..." : formData.isDraft ? "Create Draft PR" : "Create PR"} + </button> + )} + </div> + </div> + </div> + ); +} |
