-
Notifications
You must be signed in to change notification settings - Fork 994
feat(desktop): auto-commit messages, built-in PR creation & inline diff comments #1380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,4 @@ | ||
| import { TRPCError } from "@trpc/server"; | ||
| import { shell } from "electron"; | ||
| import simpleGit from "simple-git"; | ||
| import { z } from "zod"; | ||
| import { publicProcedure, router } from "../.."; | ||
|
|
@@ -75,6 +74,155 @@ async function pushWithSetUpstream({ | |
| ]); | ||
| } | ||
|
|
||
| function _buildCommitMessagePrompt({ | ||
| stagedFiles, | ||
| diffStat, | ||
| detailedDiff, | ||
| }: { | ||
| stagedFiles: string; | ||
| diffStat: string; | ||
| detailedDiff: string; | ||
| }): string { | ||
| return [ | ||
| "Generate a concise conventional commit message for these changes.", | ||
| "Use format: <type>(<scope>): <description>", | ||
| "Types: feat, fix, refactor, docs, style, test, chore, perf", | ||
| "Keep the description under 72 characters.", | ||
| "Only output the commit message, nothing else.", | ||
| "", | ||
| "Files changed:", | ||
| stagedFiles, | ||
| "", | ||
| "Diff stat:", | ||
| diffStat, | ||
| "", | ||
| "Diff:", | ||
| detailedDiff, | ||
| ].join("\n"); | ||
| } | ||
|
|
||
| interface DiffContext { | ||
| stagedFiles: string; | ||
| diffStat: string; | ||
| detailedDiff: string; | ||
| } | ||
|
|
||
| async function generateMessageFromDiff(ctx: DiffContext): Promise<string> { | ||
| // Parse file operations from name-status output | ||
| const lines = ctx.stagedFiles.trim().split("\n").filter(Boolean); | ||
| const ops: { type: string; file: string }[] = lines.map((line) => { | ||
| const [status, ...rest] = line.split("\t"); | ||
| return { type: status.trim(), file: rest.join("\t").trim() }; | ||
| }); | ||
|
|
||
| if (ops.length === 0) { | ||
| return "chore: update files"; | ||
| } | ||
|
|
||
| // Detect common patterns | ||
| const allAdded = ops.every((o) => o.type.startsWith("A")); | ||
| const allDeleted = ops.every((o) => o.type.startsWith("D")); | ||
| const allRenamed = ops.every((o) => o.type.startsWith("R")); | ||
| const _allModified = ops.every((o) => o.type.startsWith("M")); | ||
|
|
||
| // Extract scope from common directory | ||
| const paths = ops.map((o) => o.file); | ||
| const scope = getCommonScope(paths); | ||
| const scopePart = scope ? `(${scope})` : ""; | ||
|
|
||
| // Detect type from diff content | ||
| const diffLower = ctx.detailedDiff.toLowerCase(); | ||
| const isTest = | ||
| paths.some((p) => p.includes("test") || p.includes("spec")) || | ||
| diffLower.includes("describe(") || | ||
| diffLower.includes("it(") || | ||
| diffLower.includes("test("); | ||
| const isDocs = paths.some( | ||
| (p) => p.endsWith(".md") || p.includes("docs/") || p.includes("README"), | ||
| ); | ||
|
|
||
| if (ops.length === 1) { | ||
| const op = ops[0]; | ||
| const fileName = op.file.split("/").pop() || op.file; | ||
| if (op.type.startsWith("A")) { | ||
| const type = isTest ? "test" : isDocs ? "docs" : "feat"; | ||
| return `${type}${scopePart}: add ${fileName}`; | ||
| } | ||
| if (op.type.startsWith("D")) { | ||
| return `chore${scopePart}: remove ${fileName}`; | ||
| } | ||
| if (op.type.startsWith("R")) { | ||
| return `refactor${scopePart}: rename ${fileName}`; | ||
| } | ||
| } | ||
|
|
||
| // Multi-file operations | ||
| if (allAdded) { | ||
| const type = isTest ? "test" : isDocs ? "docs" : "feat"; | ||
| return `${type}${scopePart}: add ${ops.length} files`; | ||
| } | ||
| if (allDeleted) { | ||
| return `chore${scopePart}: remove ${ops.length} files`; | ||
| } | ||
| if (allRenamed) { | ||
| return `refactor${scopePart}: rename ${ops.length} files`; | ||
| } | ||
|
|
||
| // Analyze diff for fix indicators | ||
| const isFix = | ||
| diffLower.includes("fix") || | ||
| diffLower.includes("bug") || | ||
| diffLower.includes("error") || | ||
| diffLower.includes("issue"); | ||
|
|
||
| const type = isTest ? "test" : isDocs ? "docs" : isFix ? "fix" : "feat"; | ||
|
Comment on lines
+170
to
+178
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Matching Consider narrowing the heuristic to only match added lines ( 🤖 Prompt for AI Agents |
||
|
|
||
| // Generate a short description from the filenames | ||
| const fileNames = paths.map((p) => p.split("/").pop()).filter(Boolean); | ||
| const uniqueNames = [...new Set(fileNames)]; | ||
| const desc = | ||
| uniqueNames.length <= 3 | ||
| ? `update ${uniqueNames.join(", ")}` | ||
| : `update ${uniqueNames.length} files`; | ||
|
|
||
| return `${type}${scopePart}: ${desc}`; | ||
| } | ||
|
|
||
| function getCommonScope(paths: string[]): string { | ||
| if (paths.length === 0) return ""; | ||
|
|
||
| const parts = paths.map((p) => p.split("/")); | ||
| const minLen = Math.min(...parts.map((p) => p.length)); | ||
|
|
||
| let commonDepth = 0; | ||
| for (let i = 0; i < minLen - 1; i++) { | ||
| const segment = parts[0][i]; | ||
| if (parts.every((p) => p[i] === segment)) { | ||
| commonDepth = i + 1; | ||
| } else { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (commonDepth === 0) return ""; | ||
|
|
||
| // Use the deepest common directory as scope | ||
| const scopePath = parts[0].slice(0, commonDepth).join("/"); | ||
|
|
||
| // Simplify known directory patterns | ||
| if (scopePath.includes("apps/")) { | ||
| const match = scopePath.match(/apps\/([^/]+)/); | ||
| if (match) return match[1]; | ||
| } | ||
| if (scopePath.includes("packages/")) { | ||
| const match = scopePath.match(/packages\/([^/]+)/); | ||
| if (match) return match[1]; | ||
| } | ||
|
|
||
| // Return the last segment of the common path | ||
| return parts[0][commonDepth - 1]; | ||
| } | ||
|
|
||
| function shouldRetryPushWithUpstream(message: string): boolean { | ||
| const lowerMessage = message.toLowerCase(); | ||
| return ( | ||
|
|
@@ -93,6 +241,45 @@ export const createGitOperationsRouter = () => { | |
| // NOTE: saveFile is defined in file-contents.ts with hardened path validation | ||
| // Do NOT add saveFile here - it would overwrite the secure version | ||
|
|
||
| generateCommitMessage: publicProcedure | ||
| .input( | ||
| z.object({ | ||
| worktreePath: z.string(), | ||
| }), | ||
| ) | ||
| .mutation(async ({ input }): Promise<{ message: string }> => { | ||
| assertRegisteredWorktree(input.worktreePath); | ||
|
|
||
| const git = simpleGit(input.worktreePath); | ||
|
|
||
| // Get the staged diff | ||
| const diff = await git.diff(["--cached", "--stat"]); | ||
| if (!diff.trim()) { | ||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: "No staged changes to generate a message for", | ||
| }); | ||
| } | ||
|
|
||
| // Get detailed diff (limited to avoid huge payloads) | ||
| const detailedDiff = await git.diff(["--cached"]); | ||
| const truncatedDiff = | ||
| detailedDiff.length > 8000 | ||
| ? `${detailedDiff.slice(0, 8000)}\n... (truncated)` | ||
| : detailedDiff; | ||
|
|
||
| // Get the list of staged files for context | ||
| const stagedFiles = await git.diff(["--cached", "--name-status"]); | ||
|
|
||
| const message = await generateMessageFromDiff({ | ||
| stagedFiles, | ||
| diffStat: diff, | ||
| detailedDiff: truncatedDiff, | ||
| }); | ||
|
|
||
| return { message }; | ||
| }), | ||
|
|
||
| commit: publicProcedure | ||
| .input( | ||
| z.object({ | ||
|
|
@@ -210,10 +397,20 @@ export const createGitOperationsRouter = () => { | |
| .input( | ||
| z.object({ | ||
| worktreePath: z.string(), | ||
| title: z.string().optional(), | ||
| body: z.string().optional(), | ||
| draft: z.boolean().optional(), | ||
| baseBranch: z.string().optional(), | ||
| }), | ||
| ) | ||
| .mutation( | ||
| async ({ input }): Promise<{ success: boolean; url: string }> => { | ||
| async ({ | ||
| input, | ||
| }): Promise<{ | ||
| success: boolean; | ||
| url: string; | ||
| number: number; | ||
| }> => { | ||
| assertRegisteredWorktree(input.worktreePath); | ||
|
|
||
| const git = simpleGit(input.worktreePath); | ||
|
|
@@ -224,7 +421,6 @@ export const createGitOperationsRouter = () => { | |
| if (!hasUpstream) { | ||
| await pushWithSetUpstream({ git, branch }); | ||
| } else { | ||
| // Push any unpushed commits | ||
| try { | ||
| await git.push(); | ||
| } catch (error) { | ||
|
|
@@ -238,26 +434,105 @@ export const createGitOperationsRouter = () => { | |
| } | ||
| } | ||
|
|
||
| // Get the remote URL to construct the GitHub compare URL | ||
| const remoteUrl = (await git.remote(["get-url", "origin"])) || ""; | ||
| const repoMatch = remoteUrl | ||
| .trim() | ||
| .match(/github\.com[:/](.+?)(?:\.git)?$/); | ||
| // Build gh pr create arguments | ||
| const args = ["pr", "create"]; | ||
|
|
||
| const prTitle = input.title || branch.replace(/[-_/]/g, " ").trim(); | ||
| args.push("--title", prTitle); | ||
|
|
||
| if (input.body) { | ||
| args.push("--body", input.body); | ||
| } else { | ||
| args.push("--body", ""); | ||
| } | ||
|
|
||
| if (input.draft) { | ||
| args.push("--draft"); | ||
| } | ||
|
|
||
| if (!repoMatch) { | ||
| throw new Error("Could not determine GitHub repository URL"); | ||
| if (input.baseBranch) { | ||
| args.push("--base", input.baseBranch); | ||
| } | ||
|
|
||
| const repo = repoMatch[1].replace(/\.git$/, ""); | ||
| const url = `https://github.com/${repo}/compare/${branch}?expand=1`; | ||
| try { | ||
| const { stdout } = await execWithShellEnv("gh", args, { | ||
| cwd: input.worktreePath, | ||
| }); | ||
| const url = stdout.trim(); | ||
|
|
||
| // Extract PR number from URL (e.g., https://github.com/org/repo/pull/123) | ||
| const prNumberMatch = url.match(/\/pull\/(\d+)/); | ||
| const prNumber = prNumberMatch | ||
| ? Number.parseInt(prNumberMatch[1], 10) | ||
| : 0; | ||
|
|
||
| await fetchCurrentBranch(git); | ||
|
|
||
| await shell.openExternal(url); | ||
| await fetchCurrentBranch(git); | ||
| return { success: true, url, number: prNumber }; | ||
| } catch (error) { | ||
| const message = | ||
| error instanceof Error ? error.message : String(error); | ||
| console.error("[git/createPR] Failed to create PR:", message); | ||
|
|
||
| return { success: true, url }; | ||
| if (message.includes("already exists")) { | ||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: "A pull request already exists for this branch", | ||
| }); | ||
| } | ||
|
|
||
| throw new TRPCError({ | ||
| code: "INTERNAL_SERVER_ERROR", | ||
| message: `Failed to create PR: ${message}`, | ||
| }); | ||
| } | ||
| }, | ||
| ), | ||
|
|
||
| generatePRBody: publicProcedure | ||
| .input( | ||
| z.object({ | ||
| worktreePath: z.string(), | ||
| baseBranch: z.string().optional(), | ||
| }), | ||
| ) | ||
| .query(async ({ input }): Promise<{ title: string; body: string }> => { | ||
| assertRegisteredWorktree(input.worktreePath); | ||
|
|
||
| const git = simpleGit(input.worktreePath); | ||
| const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); | ||
| const base = input.baseBranch || "main"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded When 🤖 Prompt for AI Agents |
||
|
|
||
| // Get commit log for the branch | ||
| let logOutput = ""; | ||
| try { | ||
| logOutput = await git.raw([ | ||
| "log", | ||
| `origin/${base}..HEAD`, | ||
| "--format=%s", | ||
| ]); | ||
| } catch { | ||
| // Fall back to just the branch name | ||
| } | ||
|
Comment on lines
+513
to
+516
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silent This violates the guideline to never swallow errors silently. If Proposed fix } catch {
- // Fall back to just the branch name
+ catch (error) {
+ console.warn("[git/generatePRBody] Failed to get commit log, falling back to branch name:", error);
+ }As per coding guidelines: "Never swallow errors silently; at minimum log errors with context before rethrowing or handling them explicitly" 🤖 Prompt for AI Agents |
||
|
|
||
| const commits = logOutput.trim().split("\n").filter(Boolean); | ||
|
|
||
| // Generate title from branch name | ||
| const title = branch | ||
| .replace(/^(feat|fix|chore|refactor|docs|test|perf)[/-]/i, "") | ||
| .replace(/[-_/]/g, " ") | ||
| .replace(/\b\w/g, (c) => c.toUpperCase()) | ||
| .trim(); | ||
|
|
||
| // Generate body from commits | ||
| const body = | ||
| commits.length > 0 | ||
| ? `## Changes\n\n${commits.map((c) => `- ${c}`).join("\n")}` | ||
| : ""; | ||
|
|
||
| return { title, body }; | ||
| }), | ||
|
|
||
| mergePR: publicProcedure | ||
| .input( | ||
| z.object({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
rg -n '_buildCommitMessagePrompt' --type ts --type tsxRepository: superset-sh/superset
Length of output: 91
🏁 Script executed:
rg -n '_buildCommitMessagePrompt'Repository: superset-sh/superset
Length of output: 163
🏁 Script executed:
Repository: superset-sh/superset
Length of output: 103
🏁 Script executed:
Repository: superset-sh/superset
Length of output: 125
🏁 Script executed:
Repository: superset-sh/superset
Length of output: 895
🏁 Script executed:
Repository: superset-sh/superset
Length of output: 46
🏁 Script executed:
Repository: superset-sh/superset
Length of output: 1221
🏁 Script executed:
Repository: superset-sh/superset
Length of output: 1154
_buildCommitMessagePromptis dead code — never called anywhere.This function builds a prompt string but is never invoked. The
generateCommitMessageprocedure delegates to the heuristic-basedgenerateMessageFromDiffinstead. Remove it if it's a leftover from an earlier LLM-based approach, or wire it up if intentional.🤖 Prompt for AI Agents