summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/mesh/PRPreview.tsx
blob: fc202b0a97073ef61dd0656573de7ca7bfde7eaa (plain) (tree)

























































































































































































































































































































                                                                                                                                                                                                                               
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>
  );
}