diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3cedbc7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + name: Lint (typecheck) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Type check + run: bun run typecheck + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests + run: bun test diff --git a/src/main/git-service.ts b/src/main/git-service.ts index 227f499..1a413f6 100644 --- a/src/main/git-service.ts +++ b/src/main/git-service.ts @@ -67,6 +67,19 @@ export interface GitPullResult { upstreamBranch: string | null; } +export interface GitPullRequestDraft { + title: string; + body: string; + baseBranch: string; + headBranch: string; +} + +export interface GitPullRequestOptions { + title?: string; + body?: string; + baseBranch?: string; +} + // ─── Helpers ───────────────────────────────────────────────────────────────── function git(args: string[], cwd: string, timeoutMs = 15_000): Promise { @@ -460,6 +473,184 @@ async function createFeatureBranch(cwd: string, preferredName: string): Promise< throw new Error(`Could not find available branch name for '${preferredName}'`); } +function capitalizeText(value: string): string { + if (value.length === 0) return value; + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function humanizeBranchName(branch: string): string { + const normalized = branch.trim(); + if (normalized.length === 0) return ""; + const withoutPrefix = normalized.replace( + /^(feature|feat|fix|bugfix|hotfix|chore|docs|refactor|test|ci|perf|release)\//i, + "", + ); + return capitalizeText( + withoutPrefix + .replace(/[-_]+/g, " ") + .replace(/\s+/g, " ") + .trim(), + ); +} + +function summarizeTouchedPaths(files: GitFileChange[], maxItems = 3): string | null { + if (files.length === 0) return null; + const selected = files + .slice() + .sort((a, b) => b.insertions + b.deletions - (a.insertions + a.deletions)) + .slice(0, maxItems) + .map((file) => `\`${file.path}\``); + + if (selected.length === 0) return null; + if (files.length > maxItems) { + return `${selected.join(", ")}, and ${files.length - maxItems} more`; + } + return selected.join(", "); +} + +function generatePullRequestTitle(input: { + branch: string; + commits: Array<{ subject: string }>; + files: GitFileChange[]; +}): string { + const latestCommitSubject = input.commits[0]?.subject?.trim(); + if (input.commits.length === 1 && latestCommitSubject) { + return latestCommitSubject; + } + + const branchLabel = humanizeBranchName(input.branch); + if (branchLabel.length > 0) { + return branchLabel; + } + + const fallbackStat = input.files + .map((file) => `${file.path} | ${file.insertions + file.deletions}`) + .join("\n"); + return generateSimpleCommitMessage(fallbackStat); +} + +function generatePullRequestBrief(input: { + branch: string; + baseBranch: string; + commits: Array<{ subject: string }>; + files: GitFileChange[]; + insertions: number; + deletions: number; + hasWorkingTreeChanges: boolean; +}): string { + const fileCount = input.files.length; + const summaryLine = + fileCount > 0 + ? `Updates ${fileCount} file${fileCount === 1 ? "" : "s"} (+${input.insertions}/-${input.deletions}).` + : `Prepares \`${input.branch}\` for merge into \`${input.baseBranch}\`.`; + const touchedPaths = summarizeTouchedPaths(input.files); + const touchedLine = touchedPaths + ? `Main touchpoints: ${touchedPaths}.` + : `Branch \`${input.branch}\` is ready to merge into \`${input.baseBranch}\`.`; + + let commitLine: string; + if (input.commits.length > 0) { + commitLine = `${input.commits.length} commit${input.commits.length === 1 ? "" : "s"} ahead of \`${input.baseBranch}\`.`; + } else if (input.hasWorkingTreeChanges) { + commitLine = `Includes local working tree changes that will be committed before opening the PR.`; + } else { + commitLine = `Ready to open from \`${input.branch}\` into \`${input.baseBranch}\`.`; + } + + return `## Summary +- ${summaryLine} +- ${touchedLine} +- ${commitLine} + +## Testing +- Not run`; +} + +async function generatePullRequestDraftInternal( + cwd: string, + branchOverride?: string, + baseBranchOverride?: string, +): Promise { + const currentBranch = + branchOverride ?? + (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd, 5_000)).trim(); + + if (!currentBranch || currentBranch === "HEAD") { + throw new Error("Cannot create a PR from detached HEAD."); + } + + const baseBranch = baseBranchOverride ?? (await resolveBaseBranch(cwd, currentBranch)); + if (!baseBranch || baseBranch === currentBranch) { + throw new Error(`Cannot determine a PR base branch for "${currentBranch}".`); + } + + const [commitsResult, committedDiffResult, workingDiffResult] = await Promise.all([ + gitAllowFail(["log", "--format=%H%x1f%s", `${baseBranch}..HEAD`], cwd, 5_000), + gitAllowFail(["diff", "--numstat", `${baseBranch}...HEAD`], cwd, 5_000), + gitAllowFail(["diff", "--numstat", "HEAD"], cwd, 5_000), + ]); + + const commits = commitsResult.stdout + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const [sha, subject] = line.split("\u001f"); + return { + sha: (sha ?? "").trim(), + subject: (subject ?? "").trim(), + }; + }) + .filter((entry) => entry.subject.length > 0); + + const fileStatMap = new Map(); + for (const entry of [ + ...parseNumstatEntries(committedDiffResult.stdout), + ...parseNumstatEntries(workingDiffResult.stdout), + ]) { + const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; + existing.insertions += entry.insertions; + existing.deletions += entry.deletions; + fileStatMap.set(entry.path, existing); + } + + const files = Array.from(fileStatMap.entries()) + .map(([path, stat]) => ({ + path, + insertions: stat.insertions, + deletions: stat.deletions, + })) + .sort((a, b) => a.path.localeCompare(b.path)); + + const totals = files.reduce( + (acc, file) => { + acc.insertions += file.insertions; + acc.deletions += file.deletions; + return acc; + }, + { insertions: 0, deletions: 0 }, + ); + + return { + title: generatePullRequestTitle({ branch: currentBranch, commits, files }), + body: generatePullRequestBrief({ + branch: currentBranch, + baseBranch, + commits, + files, + insertions: totals.insertions, + deletions: totals.deletions, + hasWorkingTreeChanges: workingDiffResult.stdout.trim().length > 0, + }), + baseBranch, + headBranch: currentBranch, + }; +} + +export async function generatePullRequestDraft(cwd: string): Promise { + return generatePullRequestDraftInternal(cwd); +} + // ─── Stacked Actions ───────────────────────────────────────────────────────── export async function runStackedAction( @@ -467,6 +658,7 @@ export async function runStackedAction( action: GitStackedAction, commitMessage?: string, featureBranch?: boolean, + prOptions?: GitPullRequestOptions, ): Promise { const wantsPush = action !== "commit"; const wantsPr = action === "commit_push_pr"; @@ -580,15 +772,36 @@ export async function runStackedAction( } else { // Create new PR const baseBranch = await resolveBaseBranch(cwd, currentBranch); - if (!baseBranch || baseBranch === currentBranch) { + const requestedBaseBranch = prOptions?.baseBranch?.trim(); + const finalBaseBranch = requestedBaseBranch || baseBranch; + if (!finalBaseBranch || finalBaseBranch === currentBranch) { throw new Error( `Cannot create PR: branch "${currentBranch}" is the default branch.`, ); } + const prDraft = await generatePullRequestDraftInternal( + cwd, + currentBranch, + finalBaseBranch, + ); + const finalTitle = prOptions?.title?.trim() || prDraft.title; + const finalBody = prOptions?.body?.trim() || prDraft.body; + try { await gh( - ["pr", "create", "--base", baseBranch, "--head", currentBranch, "--fill"], + [ + "pr", + "create", + "--base", + finalBaseBranch, + "--head", + currentBranch, + "--title", + finalTitle, + "--body", + finalBody, + ], cwd, 30_000, ); @@ -604,7 +817,7 @@ export async function runStackedAction( status: "created", url: createdPr?.url, number: createdPr?.number, - baseBranch: createdPr?.baseBranch ?? baseBranch, + baseBranch: createdPr?.baseBranch ?? finalBaseBranch, headBranch: createdPr?.headBranch ?? currentBranch, title: createdPr?.title, }; diff --git a/src/main/index.ts b/src/main/index.ts index b43dc91..1093a9f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,10 +16,12 @@ import { pushWorktreeBranch, } from "./worktree-service"; import { + generatePullRequestDraft as generatePullRequestDraftService, + gitPull as gitPullService, gitStatus as gitStatusService, runStackedAction as runStackedActionService, - gitPull as gitPullService, type GitStackedAction, + type GitPullRequestOptions, } from "./git-service"; import { CONVEX_SITE_URL } from "./build-constants"; @@ -750,8 +752,9 @@ ipcMain.handle( action: GitStackedAction, commitMessage?: string, featureBranch?: boolean, + prOptions?: GitPullRequestOptions, ) => { - return await runStackedActionService(cwd, action, commitMessage, featureBranch); + return await runStackedActionService(cwd, action, commitMessage, featureBranch, prOptions); }, ); @@ -759,6 +762,10 @@ ipcMain.handle("git:pull", async (_event, cwd: string) => { return await gitPullService(cwd); }); +ipcMain.handle("git:generatePullRequestDraft", async (_event, cwd: string) => { + return await generatePullRequestDraftService(cwd); +}); + // --- Worktree Management --- ipcMain.handle( @@ -1066,13 +1073,13 @@ ipcMain.handle( console.log("[codex:send-message] thread/start result:", JSON.stringify(thread)); tid = thread.threadId; } - console.log("[codex:send-message] starting turn:", { tid, model, messageCount: messages.length }); - const turn = await server.startTurn(tid, messages, model); - console.log("[codex:send-message] turn/start result:", JSON.stringify(turn)); - // Store mapping so forwarded events carry the Convex thread ID if (tid && convexThreadId) { + // Approval/status events can arrive before turn/start resolves. codexThreadToConvexId.set(tid, convexThreadId); } + console.log("[codex:send-message] starting turn:", { tid, model, messageCount: messages.length }); + const turn = await server.startTurn(tid, messages, model); + console.log("[codex:send-message] turn/start result:", JSON.stringify(turn)); return { turnId: turn.turnId, threadId: tid }; }, ); diff --git a/src/preload/index.ts b/src/preload/index.ts index 6aabcc0..325a57d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,10 +33,13 @@ contextBridge.exposeInMainWorld("electronAPI", { action: string, commitMessage?: string, featureBranch?: boolean, + prOptions?: { title?: string; body?: string; baseBranch?: string }, ) => - ipcRenderer.invoke("git:runStackedAction", cwd, action, commitMessage, featureBranch), + ipcRenderer.invoke("git:runStackedAction", cwd, action, commitMessage, featureBranch, prOptions), pull: (cwd: string) => ipcRenderer.invoke("git:pull", cwd), + generatePullRequestDraft: (cwd: string) => + ipcRenderer.invoke("git:generatePullRequestDraft", cwd), }, worktree: { create: (projectCwd: string, baseBranch?: string) => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1c3fab7..c0107f8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -953,6 +953,18 @@ export default function App() { needsInput, }; } + + if (provider !== "codex" || !approvalMap) return; + + for (const [providerThreadId, rawApprovals] of approvalMap.entries()) { + if (!rawApprovals.length) continue; + const fallbackThreadId = rawApprovals.find((request) => typeof request.convexThreadId === "string")?.convexThreadId; + if (!fallbackThreadId || next[fallbackThreadId]) continue; + next[fallbackThreadId] = { + userInputRequests: codexApprovalToUserInput(providerThreadId, rawApprovals), + needsInput: true, + }; + } }; addProvider("codex", codexToConvexThread.current, codex.streamingContent, codex.streamingReasoning, codex.tokenUsage, codex.commandExecutions, codex.approvalRequests, codex.threadsWaitingOnApproval); diff --git a/src/renderer/src/components/GitActionsControl.tsx b/src/renderer/src/components/GitActionsControl.tsx index 7de6018..f01efa7 100644 --- a/src/renderer/src/components/GitActionsControl.tsx +++ b/src/renderer/src/components/GitActionsControl.tsx @@ -14,6 +14,14 @@ import { type GitStackedActionResult, type GitStatusResult, } from "./GitActionsControl.logic"; +import { + Dialog, + DialogBody, + DialogClose, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/Dialog"; // ─── Toast System (inline, minimal) ───────────────────────────────────────── @@ -154,6 +162,19 @@ interface GitActionsControlProps { variant?: "inline" | "titlebar"; } +interface PullRequestDialogState { + title: string; + body: string; + baseBranch: string; + commitMessage: string; +} + +interface PullRequestOptions { + title?: string; + body?: string; + baseBranch?: string; +} + // ─── Component ─────────────────────────────────────────────────────────────── export default function GitActionsControl({ gitCwd, defaultBranchNames, variant = "inline" }: GitActionsControlProps) { @@ -163,12 +184,22 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant const [isActionRunning, setIsActionRunning] = useState(false); const [showMenu, setShowMenu] = useState(false); const [showCommitDialog, setShowCommitDialog] = useState(false); + const [showPrDialog, setShowPrDialog] = useState(false); + const [prDraftLoading, setPrDraftLoading] = useState(false); + const [prDraftError, setPrDraftError] = useState(null); + const [prDialog, setPrDialog] = useState({ + title: "", + body: "", + baseBranch: "", + commitMessage: "", + }); const [commitMessage, setCommitMessage] = useState(""); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState<{ action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; commitMessage?: string; + prOptions?: PullRequestOptions; } | null>(null); const [toast, setToast] = useState(null); const menuRef = useRef(null); @@ -263,6 +294,58 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant [], ); + const closePrDialog = useCallback(() => { + setShowPrDialog(false); + setPrDraftError(null); + setPrDialog({ + title: "", + body: "", + baseBranch: "", + commitMessage: "", + }); + }, []); + + const loadPullRequestDraft = useCallback(async () => { + if (!gitCwd) return; + setPrDraftLoading(true); + setPrDraftError(null); + try { + const draftGenerator = window.electronAPI.git?.generatePullRequestDraft; + if (typeof draftGenerator !== "function") { + setPrDraftError("PR draft generation is unavailable in this build. You can still enter the PR details manually."); + return; + } + const draft = await draftGenerator(gitCwd); + setPrDialog((prev) => ({ + ...prev, + title: draft.title, + body: draft.body, + baseBranch: draft.baseBranch, + })); + } catch (err) { + setPrDraftError( + err instanceof Error ? err.message : "Failed to generate a pull request brief.", + ); + } finally { + setPrDraftLoading(false); + } + }, [gitCwd]); + + const openPrDialog = useCallback(() => { + setShowMenu(false); + setShowPrDialog(true); + setPrDraftError(null); + setPrDialog((prev) => ({ + ...prev, + commitMessage: gitStatus?.hasWorkingTreeChanges ? prev.commitMessage : "", + })); + }, [gitStatus?.hasWorkingTreeChanges]); + + useEffect(() => { + if (!showPrDialog) return; + void loadPullRequestDraft(); + }, [showPrDialog, loadPullRequestDraft]); + // ── Default branch detection ── const isDefaultBranch = useMemo(() => { const branch = gitStatus?.branch; @@ -290,12 +373,14 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant forcePushOnly = false, skipDefaultBranchPrompt = false, featureBranch = false, + prOptions, }: { action: GitStackedAction; commitMessage?: string; forcePushOnly?: boolean; skipDefaultBranchPrompt?: boolean; featureBranch?: boolean; + prOptions?: PullRequestOptions; }) => { if (!gitCwd) return; @@ -313,6 +398,7 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant branchName: gitStatus.branch, includesCommit, commitMessage: msg, + prOptions, }); return; } @@ -345,6 +431,7 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant action, msg, featureBranch, + prOptions, ); clearInterval(stageInterval); setIsActionRunning(false); @@ -476,9 +563,13 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant } else if (quickAction.kind === "run_pull") { runPull(); } else if (quickAction.kind === "run_action" && quickAction.action) { - runGitAction({ action: quickAction.action }); + if (quickAction.action === "commit_push_pr") { + openPrDialog(); + } else { + runGitAction({ action: quickAction.action }); + } } - }, [quickAction, openExistingPr, runPull, runGitAction]); + }, [quickAction, openExistingPr, openPrDialog, runPull, runGitAction]); // ── Menu item handler ── const handleMenuItem = useCallback( @@ -492,12 +583,12 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant if (item.dialogAction === "push") { runGitAction({ action: "commit_push", forcePushOnly: true }); } else if (item.dialogAction === "create_pr") { - runGitAction({ action: "commit_push_pr" }); + openPrDialog(); } else if (item.dialogAction === "commit") { setShowCommitDialog(true); } }, - [openExistingPr, runGitAction], + [openExistingPr, openPrDialog, runGitAction], ); // ── Commit dialog ── @@ -523,27 +614,43 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant // ── Default branch dialog ── const handleContinueOnDefault = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage: msg } = pendingDefaultBranchAction; + const { action, commitMessage: msg, prOptions } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); runGitAction({ action, commitMessage: msg, skipDefaultBranchPrompt: true, + prOptions, }); }, [pendingDefaultBranchAction, runGitAction]); const handleFeatureBranchAndContinue = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage: msg } = pendingDefaultBranchAction; + const { action, commitMessage: msg, prOptions } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); runGitAction({ action, commitMessage: msg, featureBranch: true, skipDefaultBranchPrompt: true, + prOptions, }); }, [pendingDefaultBranchAction, runGitAction]); + const handlePrDialogSubmit = useCallback(() => { + setShowPrDialog(false); + setPrDraftError(null); + runGitAction({ + action: "commit_push_pr", + commitMessage: prDialog.commitMessage.trim() || undefined, + prOptions: { + title: prDialog.title.trim() || undefined, + body: prDialog.body.trim(), + baseBranch: prDialog.baseBranch.trim() || undefined, + }, + }); + }, [prDialog, runGitAction]); + const defaultBranchCopy = pendingDefaultBranchAction ? resolveDefaultBranchActionDialogCopy({ action: pendingDefaultBranchAction.action, @@ -609,7 +716,7 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant className={ isTitlebar ? `h-10 w-4 inline-flex items-center justify-center bg-transparent border-none text-fg-3 cursor-pointer transition-colors duration-[120ms] hover:bg-hover disabled:opacity-50 disabled:cursor-not-allowed` - : `inline-flex items-center py-1 px-1 rounded-r-sm bg-transparent border-none border-l border-l-border text-fg-2 text-[11px] cursor-pointer transition-all duration-[120ms] hover:bg-hover disabled:opacity-50 disabled:cursor-not-allowed` + : `inline-flex items-center py-1 px-1 rounded-r-sm bg-transparent border-none border-l border-l-edge text-fg-2 text-[11px] cursor-pointer transition-all duration-[120ms] hover:bg-hover disabled:opacity-50 disabled:cursor-not-allowed` } disabled={isActionRunning} onClick={() => { @@ -674,7 +781,7 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant ? "border-green-500/30 bg-green-900/80 text-green-200" : toast.type === "error" ? "border-red-500/30 bg-red-900/80 text-red-200" - : "border-edge bg-surface text-fg-2" + : "border-edge bg-elevated text-fg-2" }`} style={{ backdropFilter: "blur(12px)" }} > @@ -702,139 +809,259 @@ export default function GitActionsControl({ gitCwd, defaultBranchNames, variant )} - {/* ── Commit Dialog ── */} - {showCommitDialog && ( -
{ - if (e.target === e.currentTarget) { + { + setShowCommitDialog(false); + setCommitMessage(""); + }} + width={420} + > + + Commit changes + { setShowCommitDialog(false); setCommitMessage(""); - } - }} - > -
-

Commit changes

-

- Leave message blank to auto-generate. -

- - {/* File list */} - {gitStatus && gitStatus.workingTree.files.length > 0 && ( -
-

- {gitStatus.workingTree.files.length} changed file(s) -

- {gitStatus.workingTree.files.map((file) => ( -
- {file.path} - - +{file.insertions} - / - -{file.deletions} - -
- ))} -
- +{gitStatus.workingTree.insertions} - / - -{gitStatus.workingTree.deletions} + }} + /> + + +

Leave message blank to auto-generate.

+ + {gitStatus && gitStatus.workingTree.files.length > 0 && ( +
+

+ {gitStatus.workingTree.files.length} changed file(s) +

+ {gitStatus.workingTree.files.map((file) => ( +
+ {file.path} + + +{file.insertions} + / + -{file.deletions} +
+ ))} +
+ +{gitStatus.workingTree.insertions} + / + -{gitStatus.workingTree.deletions}
- )} +
+ )} - {/* Branch info */} -
- Branch: - - {gitStatus?.branch ?? "(detached HEAD)"} - - {isDefaultBranch && ( - default branch - )} +
+ Branch: + {gitStatus?.branch ?? "(detached HEAD)"} + {isDefaultBranch && default branch} +
+ +