Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
219 changes: 216 additions & 3 deletions src/main/git-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand Down Expand Up @@ -460,13 +473,192 @@ 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<GitPullRequestDraft> {
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<string, { insertions: number; deletions: number }>();
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);
}
Comment on lines +587 to +615

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the full context around the mentioned lines
head -n 650 src/main/git-service.ts | tail -n 100

Repository: echoes-sh/echoes-code

Length of output: 3268


🏁 Script executed:

# Find gitStatus function to verify backfill logic
rg -A 20 "function gitStatus|const gitStatus" src/main/git-service.ts

Repository: echoes-sh/echoes-code

Length of output: 973


🏁 Script executed:

# Check lines 643-647 specifically
sed -n '643,647p' src/main/git-service.ts

Repository: echoes-sh/echoes-code

Length of output: 197


🏁 Script executed:

# Find runStackedAction to verify git add -A behavior
rg -A 10 "runStackedAction" src/main/git-service.ts | head -n 50

Repository: echoes-sh/echoes-code

Length of output: 410


🏁 Script executed:

# Find hasWorkingTreeChanges to understand the logic
rg -B 5 -A 10 "hasWorkingTreeChanges" src/main/git-service.ts

Repository: echoes-sh/echoes-code

Length of output: 3522


🏁 Script executed:

# Find parseNumstatEntries to understand what it captures
rg -A 15 "function parseNumstatEntries|const parseNumstatEntries" src/main/git-service.ts

Repository: echoes-sh/echoes-code

Length of output: 877


🏁 Script executed:

# Look for how gitStatus builds its final file list to understand the backfill
rg -A 50 "for (const entry of .*numstat" src/main/git-service.ts | head -n 80

Repository: echoes-sh/echoes-code

Length of output: 149


🏁 Script executed:

# Check the full gitStatus implementation around file aggregation
sed -n '250,380p' src/main/git-service.ts

Repository: echoes-sh/echoes-code

Length of output: 3888


Keep draft file stats aligned with the actual PR contents.

gitStatus() explicitly backfills files from porcelain that don't appear in numstat, but this draft builder only aggregates the two git diff --numstat calls. Git's numstat only covers tracked files; untracked files and other porcelain-only changes won't appear in the draft file list, even though runStackedAction() will later include them via git add -A. Additionally, hasWorkingTreeChanges on line 643 uses a simple numstat check and can be false when porcelain detects untracked files. Reuse the same porcelain+numstat aggregation here, or extract a shared helper.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/git-service.ts` around lines 587 - 615, The current draft file stats
only sum parseNumstatEntries(committedDiffResult.stdout) and
parseNumstatEntries(workingDiffResult.stdout), which misses
untracked/porcelain-only files; call the same porcelain status helper used by
gitStatus (or run git status --porcelain and parse its entries) and merge those
paths into fileStatMap with {insertions:0,deletions:0} if absent so
untracked/renamed/other porcelain-only files are included; update the same
combined aggregation used here and the hasWorkingTreeChanges check (which relies
only on numstat) to use the porcelain+numstat merge so both fileStatMap and
hasWorkingTreeChanges reflect the true PR/working-tree contents (refer to
gitAllowFail, parseNumstatEntries, fileStatMap, gitStatus,
hasWorkingTreeChanges, runStackedAction, committedDiffResult,
workingDiffResult).


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<GitPullRequestDraft> {
return generatePullRequestDraftInternal(cwd);
}

// ─── Stacked Actions ─────────────────────────────────────────────────────────

export async function runStackedAction(
cwd: string,
action: GitStackedAction,
commitMessage?: string,
featureBranch?: boolean,
prOptions?: GitPullRequestOptions,
): Promise<GitStackedActionResult> {
const wantsPush = action !== "commit";
const wantsPr = action === "commit_push_pr";
Expand Down Expand Up @@ -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,
);
Expand All @@ -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,
};
Expand Down
19 changes: 13 additions & 6 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -750,15 +752,20 @@ 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);
},
);

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(
Expand Down Expand Up @@ -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));
Comment on lines 1076 to +1082

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clean up the Codex mapping when startTurn fails.

The mapping now gets inserted before server.startTurn(...), but there is no rollback on rejection. If the turn fails to start, codexThreadToConvexId keeps a stale entry and later provider events can be routed to the wrong Convex thread.

🐛 Suggested fix
     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));
+    try {
+      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 };
+    } catch (err) {
+      if (tid && convexThreadId) {
+        codexThreadToConvexId.delete(tid);
+      }
+      throw err;
+    }
-    return { turnId: turn.turnId, threadId: tid };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/index.ts` around lines 876 - 882, The mapping codexThreadToConvexId
is set before awaiting server.startTurn(tid, messages, model) and isn't removed
if startTurn rejects; modify the flow so you only keep the mapping on success or
remove/rollback it on error: either move codexThreadToConvexId.set(tid,
convexThreadId) to after a successful await of server.startTurn(...) or add a
try/catch around the await that deletes codexThreadToConvexId.delete(tid) in the
catch before rethrowing the error (refer to the variables tid, convexThreadId
and the server.startTurn call to locate the change).

return { turnId: turn.turnId, threadId: tid };
},
);
Expand Down
5 changes: 4 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Comment on lines +959 to +966

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fallback approvals still miss sidebar badges and notifications.

This block only populates the transient store. threadsNeedingInput and the notification effect later in the file still resolve thread IDs exclusively through resolveConvexThreadId(...), so the exact unmapped-thread case handled here will still have no sidebar badge and no offscreen notification. Reuse the same convexThreadId fallback when building those derived sets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/src/App.tsx` around lines 956 - 963, The transient store
population uses a fallback convexThreadId (fallbackThreadId) for unmapped
providerThreadId entries but the derived sets/notifications still call
resolveConvexThreadId(...) and thus miss badges/notifications; update the logic
that builds threadsNeedingInput and the notification effect to consult the same
fallbackThreadId (the one computed from approvalMap iteration) when
resolveConvexThreadId returns undefined for a providerThreadId, ensuring the
same convexThreadId used in next[userInputRequests] (created via
codexApprovalToUserInput) is reused for sidebar badges and offscreen
notifications.

}
};

addProvider("codex", codexToConvexThread.current, codex.streamingContent, codex.streamingReasoning, codex.tokenUsage, codex.commandExecutions, codex.approvalRequests, codex.threadsWaitingOnApproval);
Expand Down
Loading
Loading