@bobbyg603/mog 1.5.1 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -13
- package/package.json +1 -1
- package/src/github.test.ts +68 -0
- package/src/github.ts +66 -30
- package/src/index.ts +54 -16
- package/src/sandbox.test.ts +77 -0
- package/src/sandbox.ts +67 -15
- package/src/worktree.ts +8 -1
package/README.md
CHANGED
|
@@ -11,10 +11,13 @@ mog workingdevshero/automate-it 123
|
|
|
11
11
|
|
|
12
12
|
That's it. `mog` will:
|
|
13
13
|
|
|
14
|
-
1. Fetch the issue title, description, and
|
|
14
|
+
1. Fetch the issue title, description, labels, and comments via `gh` CLI
|
|
15
15
|
2. Create a git worktree on a clean branch (`123-fix-broken-login`)
|
|
16
16
|
3. Run Claude Code inside a persistent Docker sandbox (microVM) with `--dangerously-skip-permissions`
|
|
17
|
-
4.
|
|
17
|
+
4. **Plan** — analyze the codebase and create an implementation plan
|
|
18
|
+
5. **Build** — execute each task in the plan, one at a time
|
|
19
|
+
6. **Review** — self-review all changes for missed patterns, duplication, and quality
|
|
20
|
+
7. Squash commits, push the branch, and open a PR that `Closes #123`
|
|
18
21
|
|
|
19
22
|
## Prerequisites
|
|
20
23
|
|
|
@@ -57,12 +60,37 @@ If your session ever expires, just run `mog init` again to re-authenticate.
|
|
|
57
60
|
# One-time setup
|
|
58
61
|
mog init
|
|
59
62
|
|
|
60
|
-
#
|
|
61
|
-
mog
|
|
63
|
+
# Auto-detect repo from git remote (run from inside a git repo)
|
|
64
|
+
mog 123
|
|
62
65
|
|
|
63
|
-
#
|
|
64
|
-
mog
|
|
65
|
-
|
|
66
|
+
# Explicit repo
|
|
67
|
+
mog owner/repo 123
|
|
68
|
+
|
|
69
|
+
# Include files the project needs at runtime (e.g. .env, credentials)
|
|
70
|
+
# Files are copied into the worktree and removed before pushing
|
|
71
|
+
mog 123 --include .env --include serviceAccountKey.json
|
|
72
|
+
|
|
73
|
+
# List open issues
|
|
74
|
+
mog list
|
|
75
|
+
mog list --verbose
|
|
76
|
+
mog owner/repo list --verbose
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Re-mogging
|
|
80
|
+
|
|
81
|
+
Running `mog` again on an issue that already has an open PR will:
|
|
82
|
+
|
|
83
|
+
1. Fetch review comments and feedback from the existing PR
|
|
84
|
+
2. Include that feedback in the prompt so Claude addresses it
|
|
85
|
+
3. Start fresh from the default branch
|
|
86
|
+
4. Force-push to update the existing PR
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Re-mog after getting PR feedback — Claude sees reviewer comments
|
|
90
|
+
mog 123
|
|
91
|
+
|
|
92
|
+
# Start completely over, ignoring the existing PR
|
|
93
|
+
mog 123 --fresh
|
|
66
94
|
```
|
|
67
95
|
|
|
68
96
|
## How it works
|
|
@@ -71,7 +99,8 @@ mog sparx-tech/hub-firmware 45
|
|
|
71
99
|
┌──────────────────────────────────────────────────────────┐
|
|
72
100
|
│ Host machine │
|
|
73
101
|
│ │
|
|
74
|
-
│ 1. gh issue view #123 → fetch title, body, labels
|
|
102
|
+
│ 1. gh issue view #123 → fetch title, body, labels, │
|
|
103
|
+
│ comments, and PR review feedback (if re-mogging) │
|
|
75
104
|
│ 2. git worktree add → clean branch from default branch │
|
|
76
105
|
│ │
|
|
77
106
|
│ ┌────────────────────────────────────────────────────┐ │
|
|
@@ -80,12 +109,14 @@ mog sparx-tech/hub-firmware 45
|
|
|
80
109
|
│ │ • ~/mog-repos mounted as workspace │ │
|
|
81
110
|
│ │ • Auth persists across runs (login once) │ │
|
|
82
111
|
│ │ • Isolated from host (own Docker daemon) │ │
|
|
83
|
-
│ │ •
|
|
84
|
-
│ │ •
|
|
112
|
+
│ │ • Phase 1: Plan — analyze codebase, create plan │ │
|
|
113
|
+
│ │ • Phase 2: Build — execute tasks one at a time │ │
|
|
114
|
+
│ │ • Phase 3: Review — self-review for quality │ │
|
|
85
115
|
│ └────────────────────────────────────────────────────┘ │
|
|
86
116
|
│ │
|
|
87
|
-
│ 3.
|
|
88
|
-
│ 4.
|
|
117
|
+
│ 3. Squash commits into one │
|
|
118
|
+
│ 4. git push origin branch (force-push if updating PR) │
|
|
119
|
+
│ 5. gh pr create --body "Closes #123" (or update PR) │
|
|
89
120
|
└──────────────────────────────────────────────────────────┘
|
|
90
121
|
```
|
|
91
122
|
|
|
@@ -94,7 +125,7 @@ mog sparx-tech/hub-firmware 45
|
|
|
94
125
|
| Environment Variable | Default | Description |
|
|
95
126
|
|---|---|---|
|
|
96
127
|
| `MOG_REPOS_DIR` | `~/mog-repos` | Where repos are cloned and worktrees created (also the sandbox workspace) |
|
|
97
|
-
| `MOG_MAX_ITERATIONS` | `
|
|
128
|
+
| `MOG_MAX_ITERATIONS` | `30` | Max build loop iterations per issue |
|
|
98
129
|
| `MOG_MAX_CONTINUATIONS` | — | Legacy alias for `MOG_MAX_ITERATIONS` |
|
|
99
130
|
|
|
100
131
|
## Worktree management
|
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { cleanIssueTitle, getConventionalPrefix } from "./github";
|
|
3
|
+
import type { Issue } from "./github";
|
|
4
|
+
|
|
5
|
+
function makeIssue(labels: string): Issue {
|
|
6
|
+
return { title: "", body: "", labels, comments: "" };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("cleanIssueTitle", () => {
|
|
10
|
+
test("strips conventional commit prefix", () => {
|
|
11
|
+
expect(cleanIssueTitle("fix: BlueSky Integration")).toBe("BlueSky Integration");
|
|
12
|
+
expect(cleanIssueTitle("feat: Add new feature")).toBe("Add new feature");
|
|
13
|
+
expect(cleanIssueTitle("chore: Update deps")).toBe("Update deps");
|
|
14
|
+
expect(cleanIssueTitle("refactor: Clean up code")).toBe("Clean up code");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("strips trailing issue references", () => {
|
|
18
|
+
expect(cleanIssueTitle("BlueSky Integration [#154]")).toBe("BlueSky Integration");
|
|
19
|
+
expect(cleanIssueTitle("Some feature [#42]")).toBe("Some feature");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("strips both prefix and issue reference", () => {
|
|
23
|
+
expect(cleanIssueTitle("fix: BlueSky Integration [#154]")).toBe("BlueSky Integration");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("strips multiple issue references", () => {
|
|
27
|
+
expect(cleanIssueTitle("fix: Something [#1] [#2]")).toBe("Something");
|
|
28
|
+
expect(cleanIssueTitle("BlueSky [#154] Integration [#184]")).toBe("BlueSky Integration");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("leaves clean titles unchanged", () => {
|
|
32
|
+
expect(cleanIssueTitle("BlueSky Integration")).toBe("BlueSky Integration");
|
|
33
|
+
expect(cleanIssueTitle("Add support for webhooks")).toBe("Add support for webhooks");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("is case-insensitive for prefix", () => {
|
|
37
|
+
expect(cleanIssueTitle("Fix: BlueSky Integration")).toBe("BlueSky Integration");
|
|
38
|
+
expect(cleanIssueTitle("FIX: BlueSky Integration")).toBe("BlueSky Integration");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("getConventionalPrefix", () => {
|
|
43
|
+
test("returns feat for enhancement label", () => {
|
|
44
|
+
expect(getConventionalPrefix(makeIssue("enhancement"))).toBe("feat");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns feat for feature label", () => {
|
|
48
|
+
expect(getConventionalPrefix(makeIssue("feature"))).toBe("feat");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns fix for bug label", () => {
|
|
52
|
+
expect(getConventionalPrefix(makeIssue("bug"))).toBe("fix");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns fix for no labels", () => {
|
|
56
|
+
expect(getConventionalPrefix(makeIssue("none"))).toBe("fix");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("does not false-positive on substring matches", () => {
|
|
60
|
+
expect(getConventionalPrefix(makeIssue("no-enhancement"))).toBe("fix");
|
|
61
|
+
expect(getConventionalPrefix(makeIssue("feature-request"))).toBe("fix");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("handles multiple labels", () => {
|
|
65
|
+
expect(getConventionalPrefix(makeIssue("bug, enhancement"))).toBe("feat");
|
|
66
|
+
expect(getConventionalPrefix(makeIssue("priority, bug"))).toBe("fix");
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/github.ts
CHANGED
|
@@ -127,6 +127,29 @@ export function fetchPRFeedback(repo: string, branchName: string): PRFeedback |
|
|
|
127
127
|
return { prNumber, prUrl, reviews };
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
export function closePR(repo: string, prNumber: number): void {
|
|
131
|
+
const result = Bun.spawnSync([
|
|
132
|
+
"gh", "pr", "close", String(prNumber),
|
|
133
|
+
"--repo", repo,
|
|
134
|
+
"--delete-branch",
|
|
135
|
+
]);
|
|
136
|
+
if (result.exitCode !== 0) {
|
|
137
|
+
log.warn(`Failed to close PR #${prNumber}.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function cleanIssueTitle(title: string): string {
|
|
142
|
+
return title
|
|
143
|
+
.replace(/^(feat|fix|chore|docs|refactor|test|ci|build|perf|style):\s*/i, "")
|
|
144
|
+
.replace(/\s*\[#\d+\]/g, "")
|
|
145
|
+
.trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getConventionalPrefix(issue: Issue): string {
|
|
149
|
+
const labels = issue.labels.split(", ").map(l => l.trim());
|
|
150
|
+
return labels.includes("enhancement") || labels.includes("feature") ? "feat" : "fix";
|
|
151
|
+
}
|
|
152
|
+
|
|
130
153
|
export function pushAndCreatePR(
|
|
131
154
|
repo: string,
|
|
132
155
|
worktreeDir: string,
|
|
@@ -151,6 +174,8 @@ export function pushAndCreatePR(
|
|
|
151
174
|
return;
|
|
152
175
|
}
|
|
153
176
|
|
|
177
|
+
const prefix = getConventionalPrefix(issue);
|
|
178
|
+
|
|
154
179
|
// Stage any unstaged changes Claude might have left
|
|
155
180
|
if (hasUncommitted) {
|
|
156
181
|
log.info("Staging uncommitted changes...");
|
|
@@ -158,22 +183,21 @@ export function pushAndCreatePR(
|
|
|
158
183
|
if (addResult.exitCode !== 0) {
|
|
159
184
|
log.die("Failed to stage changes.");
|
|
160
185
|
}
|
|
161
|
-
const
|
|
162
|
-
const commitResult = Bun.spawnSync(["git", "commit", "-m", `${prefix}: address issue #${issueNum} - ${issue.title}`], { cwd: worktreeDir });
|
|
186
|
+
const commitResult = Bun.spawnSync(["git", "commit", "-m", `${prefix}: address issue #${issueNum} - ${cleanIssueTitle(issue.title)}`], { cwd: worktreeDir });
|
|
163
187
|
if (commitResult.exitCode !== 0) {
|
|
164
188
|
log.warn("Commit failed — changes may already be committed.");
|
|
165
189
|
}
|
|
166
190
|
}
|
|
167
191
|
|
|
168
|
-
// Squash all commits into one
|
|
169
|
-
const
|
|
192
|
+
// Squash all commits into one (use origin ref — local branch may be stale)
|
|
193
|
+
const mergeBase = `origin/${defaultBranch}`;
|
|
194
|
+
const commitCount = Bun.spawnSync(["git", "rev-list", "--count", `${mergeBase}..HEAD`], { cwd: worktreeDir });
|
|
170
195
|
const count = parseInt(commitCount.stdout.toString().trim(), 10) || 0;
|
|
171
196
|
if (count > 1) {
|
|
172
197
|
log.info(`Squashing ${count} commits into one...`);
|
|
173
|
-
const
|
|
174
|
-
const squash = Bun.spawnSync(["git", "reset", "--soft", defaultBranch], { cwd: worktreeDir });
|
|
198
|
+
const squash = Bun.spawnSync(["git", "reset", "--soft", mergeBase], { cwd: worktreeDir });
|
|
175
199
|
if (squash.exitCode === 0) {
|
|
176
|
-
const msg = `${prefix}: ${issue.title.toLowerCase()} (#${issueNum})`;
|
|
200
|
+
const msg = `${prefix}: ${cleanIssueTitle(issue.title).toLowerCase()} (#${issueNum})`;
|
|
177
201
|
Bun.spawnSync(["git", "commit", "-m", msg], { cwd: worktreeDir });
|
|
178
202
|
log.ok("Commits squashed.");
|
|
179
203
|
} else {
|
|
@@ -181,9 +205,11 @@ export function pushAndCreatePR(
|
|
|
181
205
|
}
|
|
182
206
|
}
|
|
183
207
|
|
|
184
|
-
// Push
|
|
208
|
+
// Push — force-with-lease if the remote branch already exists
|
|
185
209
|
log.info(`Pushing branch '${branchName}' to origin...`);
|
|
186
|
-
const
|
|
210
|
+
const remoteRef = Bun.spawnSync(["git", "ls-remote", "--heads", "origin", branchName], { cwd: worktreeDir });
|
|
211
|
+
const remoteBranchExists = remoteRef.stdout.toString().trim().length > 0;
|
|
212
|
+
const pushArgs = remoteBranchExists
|
|
187
213
|
? ["git", "push", "--force-with-lease", "-u", "origin", branchName]
|
|
188
214
|
: ["git", "push", "-u", "origin", branchName];
|
|
189
215
|
const push = Bun.spawnSync(pushArgs, { cwd: worktreeDir });
|
|
@@ -192,8 +218,22 @@ export function pushAndCreatePR(
|
|
|
192
218
|
}
|
|
193
219
|
log.ok("Branch pushed.");
|
|
194
220
|
|
|
221
|
+
const prTitle = `${prefix}: ${cleanIssueTitle(issue.title)} [#${issueNum}]`;
|
|
222
|
+
const prBody = buildPRBody(issueNum, summary);
|
|
223
|
+
|
|
195
224
|
if (existingPR) {
|
|
196
|
-
// Update existing PR
|
|
225
|
+
// Update existing PR title and description
|
|
226
|
+
const edit = Bun.spawnSync([
|
|
227
|
+
"gh", "pr", "edit", String(existingPR.prNumber),
|
|
228
|
+
"--repo", repo,
|
|
229
|
+
"--title", prTitle,
|
|
230
|
+
"--body", prBody,
|
|
231
|
+
], { cwd: worktreeDir });
|
|
232
|
+
|
|
233
|
+
if (edit.exitCode !== 0) {
|
|
234
|
+
log.warn("Failed to update PR title/description.");
|
|
235
|
+
}
|
|
236
|
+
|
|
197
237
|
log.ok("Existing PR updated!");
|
|
198
238
|
console.log(`\x1b[0;32m${existingPR.prUrl}\x1b[0m`);
|
|
199
239
|
console.log();
|
|
@@ -206,26 +246,6 @@ export function pushAndCreatePR(
|
|
|
206
246
|
// Create PR
|
|
207
247
|
log.info("Opening pull request...");
|
|
208
248
|
|
|
209
|
-
const summarySection = summary
|
|
210
|
-
? `### What was done\n\n${summary}\n\n`
|
|
211
|
-
: "";
|
|
212
|
-
|
|
213
|
-
const prBody = `## Summary
|
|
214
|
-
|
|
215
|
-
Closes #${issueNum}
|
|
216
|
-
|
|
217
|
-
${summarySection}This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
|
|
218
|
-
|
|
219
|
-
### Issue: ${issue.title}
|
|
220
|
-
|
|
221
|
-
${issue.body}
|
|
222
|
-
|
|
223
|
-
---
|
|
224
|
-
*Please review the changes carefully before merging.*`;
|
|
225
|
-
|
|
226
|
-
const prefix = issue.labels.includes("enhancement") || issue.labels.includes("feature") ? "feat" : "fix";
|
|
227
|
-
const prTitle = `${prefix}: ${issue.title} [#${issueNum}]`;
|
|
228
|
-
|
|
229
249
|
const pr = Bun.spawnSync([
|
|
230
250
|
"gh", "pr", "create",
|
|
231
251
|
"--repo", repo,
|
|
@@ -248,3 +268,19 @@ ${issue.body}
|
|
|
248
268
|
log.info(`Worktree: ${worktreeDir}`);
|
|
249
269
|
log.info(`To clean up the worktree later: git worktree remove ${worktreeDir}`);
|
|
250
270
|
}
|
|
271
|
+
|
|
272
|
+
function buildPRBody(issueNum: string, summary?: string): string {
|
|
273
|
+
const summarySection = summary
|
|
274
|
+
? `### What was done\n\n${summary}\n\n`
|
|
275
|
+
: "";
|
|
276
|
+
|
|
277
|
+
return `## Summary
|
|
278
|
+
|
|
279
|
+
Closes #${issueNum}
|
|
280
|
+
|
|
281
|
+
${summarySection}---
|
|
282
|
+
|
|
283
|
+
This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
|
|
284
|
+
|
|
285
|
+
*Please review the changes carefully before merging.*`;
|
|
286
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import path from "path";
|
|
5
|
-
import { fetchIssue, listIssues, fetchPRFeedback } from "./github";
|
|
5
|
+
import { fetchIssue, listIssues, fetchPRFeedback, closePR, cleanIssueTitle, pushAndCreatePR } from "./github";
|
|
6
6
|
import { detectRepo, ensureRepo, createWorktree } from "./worktree";
|
|
7
7
|
import { runClaude } from "./sandbox";
|
|
8
|
-
import { pushAndCreatePR } from "./github";
|
|
9
8
|
import type { PRFeedback } from "./github";
|
|
10
9
|
import { log } from "./log";
|
|
11
10
|
|
|
@@ -217,19 +216,21 @@ async function main() {
|
|
|
217
216
|
log.info(`Default branch: ${defaultBranch}`);
|
|
218
217
|
|
|
219
218
|
const { worktreeDir, branchName, reused } = createWorktree(
|
|
220
|
-
reposDir, owner, repoName, defaultBranch, issueNum, issue.title
|
|
219
|
+
reposDir, owner, repoName, defaultBranch, issueNum, cleanIssueTitle(issue.title), fresh
|
|
221
220
|
);
|
|
222
221
|
|
|
223
|
-
// Check for existing PR
|
|
222
|
+
// Check for existing PR
|
|
224
223
|
let existingPR: PRFeedback | undefined;
|
|
225
|
-
let isRetry = reused;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
224
|
+
let isRetry = fresh ? false : reused;
|
|
225
|
+
const pr = fetchPRFeedback(repo, branchName);
|
|
226
|
+
if (pr && fresh) {
|
|
227
|
+
log.info(`--fresh: closing existing PR #${pr.prNumber} and deleting remote branch...`);
|
|
228
|
+
closePR(repo, pr.prNumber);
|
|
229
|
+
log.ok(`PR #${pr.prNumber} closed. A new PR will be created.`);
|
|
230
|
+
} else if (pr) {
|
|
231
|
+
existingPR = pr;
|
|
232
|
+
isRetry = true;
|
|
233
|
+
log.ok(`Found existing PR #${pr.prNumber} — will include review feedback and update it.`);
|
|
233
234
|
}
|
|
234
235
|
|
|
235
236
|
// Copy included files into worktree
|
|
@@ -243,11 +244,12 @@ async function main() {
|
|
|
243
244
|
}
|
|
244
245
|
|
|
245
246
|
// Build prompts
|
|
246
|
-
const prFeedback = existingPR?.reviews || "";
|
|
247
|
+
const prFeedback = fresh ? "" : (existingPR?.reviews || "");
|
|
247
248
|
const planningPrompt = buildPlanningPrompt(repo, issueNum, issue, prFeedback, isRetry);
|
|
248
249
|
const buildingPromptFn = (remaining: string[], plan: string) =>
|
|
249
250
|
buildBuildingPrompt(repo, issueNum, issue, remaining, plan);
|
|
250
|
-
const reviewPrompt = buildReviewPrompt(repo, issueNum, issue);
|
|
251
|
+
const reviewPrompt = buildReviewPrompt(repo, issueNum, issue, defaultBranch);
|
|
252
|
+
const summaryPrompt = buildSummaryPrompt(repo, issueNum, issue, defaultBranch);
|
|
251
253
|
|
|
252
254
|
// Run Claude in sandbox
|
|
253
255
|
log.info("Launching Claude Code in sandbox...");
|
|
@@ -255,7 +257,7 @@ async function main() {
|
|
|
255
257
|
log.info(`Worktree: ${worktreeDir}`);
|
|
256
258
|
console.log();
|
|
257
259
|
|
|
258
|
-
const summary = await runClaude(SANDBOX_NAME, worktreeDir, planningPrompt, buildingPromptFn, reviewPrompt);
|
|
260
|
+
const summary = await runClaude(SANDBOX_NAME, worktreeDir, defaultBranch, planningPrompt, buildingPromptFn, reviewPrompt, summaryPrompt);
|
|
259
261
|
|
|
260
262
|
// Remove included files so they don't end up in the PR
|
|
261
263
|
for (const filePath of copiedFiles) {
|
|
@@ -439,10 +441,46 @@ Rules:
|
|
|
439
441
|
5. Do NOT work on any other tasks after committing.`;
|
|
440
442
|
}
|
|
441
443
|
|
|
444
|
+
function buildSummaryPrompt(
|
|
445
|
+
repo: string,
|
|
446
|
+
issueNum: string,
|
|
447
|
+
issue: { title: string; body: string; labels: string; comments: string },
|
|
448
|
+
defaultBranch: string,
|
|
449
|
+
): string {
|
|
450
|
+
return `You are summarizing the changes made for GitHub issue #${issueNum} in the repository ${repo}.
|
|
451
|
+
|
|
452
|
+
## Issue: ${issue.title}
|
|
453
|
+
|
|
454
|
+
### Description
|
|
455
|
+
${issue.body}
|
|
456
|
+
|
|
457
|
+
## Instructions
|
|
458
|
+
|
|
459
|
+
Run \`git diff ${defaultBranch}...HEAD\` to see all changes made on this branch.
|
|
460
|
+
|
|
461
|
+
Write a concise summary of **all changes** made to resolve the issue and save it to a file called \`SUMMARY.md\` in the root of the repository. Do NOT commit this file.
|
|
462
|
+
|
|
463
|
+
CRITICAL FORMATTING RULES — the file contents will be inserted directly into a PR body as-is:
|
|
464
|
+
1. Do NOT make any code changes or commits.
|
|
465
|
+
2. The file must start IMMEDIATELY with the first bullet point. No intro text like "Here's what was done" or "Let me summarize". No lead-in sentences whatsoever.
|
|
466
|
+
3. Use a flat list of markdown bullet points (\`- \`). No headings, no sub-sections, no numbered lists.
|
|
467
|
+
4. Each bullet should describe a concrete change: what was added, modified, or fixed and where.
|
|
468
|
+
5. Keep it short — aim for 3-8 bullets. A reviewer should be able to scan it in 10 seconds.
|
|
469
|
+
6. Do NOT list things that were reviewed but not changed. Only describe what actually changed.
|
|
470
|
+
7. Do NOT repeat the issue title or description — the PR already includes those.
|
|
471
|
+
8. End with the last bullet point. No closing remarks, no sign-off, no "Let me know" or "Done." after the bullets.
|
|
472
|
+
|
|
473
|
+
Example of correct SUMMARY.md contents:
|
|
474
|
+
- Added \`FooService\` class in \`src/services/foo.ts\` with retry logic and error handling
|
|
475
|
+
- Updated \`src/routes/api.ts\` to wire up the new \`/foo\` endpoint
|
|
476
|
+
- Added unit tests for \`FooService\` in \`src/services/__tests__/foo.test.ts\``;
|
|
477
|
+
}
|
|
478
|
+
|
|
442
479
|
function buildReviewPrompt(
|
|
443
480
|
repo: string,
|
|
444
481
|
issueNum: string,
|
|
445
482
|
issue: { title: string; body: string; labels: string; comments: string },
|
|
483
|
+
defaultBranch: string,
|
|
446
484
|
): string {
|
|
447
485
|
return `You are reviewing changes made for GitHub issue #${issueNum} in the repository ${repo}.
|
|
448
486
|
|
|
@@ -452,7 +490,7 @@ ${formatIssueContext(issueNum, issue)}
|
|
|
452
490
|
|
|
453
491
|
All implementation tasks are complete. Your job is to **review the entire branch** for quality and completeness.
|
|
454
492
|
|
|
455
|
-
Run \`git diff
|
|
493
|
+
Run \`git diff ${defaultBranch}...HEAD\` to see all changes made.
|
|
456
494
|
|
|
457
495
|
Check for:
|
|
458
496
|
1. **Missed locations**: Search the codebase for similar patterns, logic, or code that handles the same concern as the changes. If the fix or feature was applied in one place but a similar pattern exists elsewhere, apply it there too.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { readPlanFile, getUncheckedItems, isPlanComplete } from "./sandbox";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
|
|
7
|
+
describe("getUncheckedItems", () => {
|
|
8
|
+
test("returns unchecked items from plan content", () => {
|
|
9
|
+
const plan = `# Plan
|
|
10
|
+
- [ ] First task
|
|
11
|
+
- [x] Second task (done)
|
|
12
|
+
- [ ] Third task`;
|
|
13
|
+
expect(getUncheckedItems(plan)).toEqual([
|
|
14
|
+
"- [ ] First task",
|
|
15
|
+
"- [ ] Third task",
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns empty array when all items are checked", () => {
|
|
20
|
+
const plan = `# Plan
|
|
21
|
+
- [x] First task
|
|
22
|
+
- [x] Second task`;
|
|
23
|
+
expect(getUncheckedItems(plan)).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns empty array for content with no checklist items", () => {
|
|
27
|
+
expect(getUncheckedItems("Just some text")).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns empty array for empty string", () => {
|
|
31
|
+
expect(getUncheckedItems("")).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("isPlanComplete", () => {
|
|
36
|
+
test("returns true when all items are checked", () => {
|
|
37
|
+
const plan = `# Plan
|
|
38
|
+
- [x] First task
|
|
39
|
+
- [x] Second task`;
|
|
40
|
+
expect(isPlanComplete(plan)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("returns false when unchecked items remain", () => {
|
|
44
|
+
const plan = `# Plan
|
|
45
|
+
- [x] First task
|
|
46
|
+
- [ ] Second task`;
|
|
47
|
+
expect(isPlanComplete(plan)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns false when there are no checklist items at all", () => {
|
|
51
|
+
expect(isPlanComplete("Just some text")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns false for empty string", () => {
|
|
55
|
+
expect(isPlanComplete("")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("readPlanFile", () => {
|
|
60
|
+
test("returns file content when plan file exists", () => {
|
|
61
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mog-test-"));
|
|
62
|
+
const planContent = "# Plan\n- [ ] Task one\n";
|
|
63
|
+
fs.writeFileSync(path.join(tmpDir, "IMPLEMENTATION_PLAN.md"), planContent);
|
|
64
|
+
|
|
65
|
+
expect(readPlanFile(tmpDir)).toBe(planContent);
|
|
66
|
+
|
|
67
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("returns null when plan file does not exist", () => {
|
|
71
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mog-test-"));
|
|
72
|
+
|
|
73
|
+
expect(readPlanFile(tmpDir)).toBeNull();
|
|
74
|
+
|
|
75
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/sandbox.ts
CHANGED
|
@@ -22,6 +22,7 @@ const MAX_ITERATIONS = parseInt(
|
|
|
22
22
|
);
|
|
23
23
|
const MAX_STALLS = 2;
|
|
24
24
|
const PLAN_FILENAME = "IMPLEMENTATION_PLAN.md";
|
|
25
|
+
const SUMMARY_FILENAME = "SUMMARY.md";
|
|
25
26
|
|
|
26
27
|
export function readPlanFile(worktreeDir: string): string | null {
|
|
27
28
|
const planPath = `${worktreeDir}/${PLAN_FILENAME}`;
|
|
@@ -43,14 +44,24 @@ export function isPlanComplete(planContent: string): boolean {
|
|
|
43
44
|
return unchecked.length === 0 && (checked?.length ?? 0) > 0;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
function getCommitCount(sandboxName: string, worktreeDir: string): number {
|
|
47
|
+
function getCommitCount(sandboxName: string, worktreeDir: string, defaultBranch: string): number {
|
|
47
48
|
const result = Bun.spawnSync([
|
|
48
49
|
"docker", "sandbox", "exec",
|
|
49
50
|
"-w", worktreeDir,
|
|
50
51
|
sandboxName,
|
|
51
|
-
"git", "rev-list", "
|
|
52
|
+
"git", "rev-list", "--count", `origin/${defaultBranch}..HEAD`,
|
|
52
53
|
]);
|
|
53
|
-
if (result.exitCode !== 0)
|
|
54
|
+
if (result.exitCode !== 0) {
|
|
55
|
+
// Fallback: count commits not on any remote (works even if origin ref is missing)
|
|
56
|
+
const fallback = Bun.spawnSync([
|
|
57
|
+
"docker", "sandbox", "exec",
|
|
58
|
+
"-w", worktreeDir,
|
|
59
|
+
sandboxName,
|
|
60
|
+
"git", "rev-list", "HEAD", "--not", "--remotes", "--count",
|
|
61
|
+
]);
|
|
62
|
+
if (fallback.exitCode !== 0) return 0;
|
|
63
|
+
return parseInt(fallback.stdout.toString().trim(), 10) || 0;
|
|
64
|
+
}
|
|
54
65
|
return parseInt(result.stdout.toString().trim(), 10) || 0;
|
|
55
66
|
}
|
|
56
67
|
|
|
@@ -71,17 +82,54 @@ function cleanupPlanFile(sandboxName: string, worktreeDir: string): void {
|
|
|
71
82
|
]);
|
|
72
83
|
}
|
|
73
84
|
|
|
85
|
+
function readSummaryFile(worktreeDir: string): string | null {
|
|
86
|
+
const summaryPath = `${worktreeDir}/${SUMMARY_FILENAME}`;
|
|
87
|
+
try {
|
|
88
|
+
const content = fs.readFileSync(summaryPath, "utf-8").trim();
|
|
89
|
+
// Clean up the file so it doesn't end up in commits
|
|
90
|
+
fs.unlinkSync(summaryPath);
|
|
91
|
+
return content || null;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runSummaryPhase(
|
|
98
|
+
sandboxName: string,
|
|
99
|
+
worktreeDir: string,
|
|
100
|
+
defaultBranch: string,
|
|
101
|
+
summaryPrompt?: string,
|
|
102
|
+
): Promise<string> {
|
|
103
|
+
if (!summaryPrompt || getCommitCount(sandboxName, worktreeDir, defaultBranch) <= 0) {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
log.info("Summarizing changes...");
|
|
108
|
+
await execClaude(sandboxName, worktreeDir, ["-p", summaryPrompt]);
|
|
109
|
+
|
|
110
|
+
const summary = readSummaryFile(worktreeDir);
|
|
111
|
+
if (summary) {
|
|
112
|
+
log.ok("Summary generated.");
|
|
113
|
+
return summary;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
log.warn("Claude did not write SUMMARY.md — PR will not have a summary section.");
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
|
|
74
120
|
export async function runClaude(
|
|
75
121
|
sandboxName: string,
|
|
76
122
|
worktreeDir: string,
|
|
123
|
+
defaultBranch: string,
|
|
77
124
|
planningPrompt: string,
|
|
78
125
|
buildingPromptFn: (remainingItems: string[], planContent: string) => string,
|
|
79
126
|
reviewPrompt?: string,
|
|
127
|
+
summaryPrompt?: string,
|
|
80
128
|
): Promise<string> {
|
|
81
129
|
let lastResult = "";
|
|
82
130
|
|
|
83
|
-
//
|
|
84
|
-
log.info("
|
|
131
|
+
// Planning
|
|
132
|
+
log.info("Planning implementation...");
|
|
85
133
|
await execClaude(sandboxName, worktreeDir, ["-p", planningPrompt]);
|
|
86
134
|
|
|
87
135
|
const planContent = readPlanFile(worktreeDir);
|
|
@@ -95,7 +143,7 @@ export async function runClaude(
|
|
|
95
143
|
if (fallbackResult) lastResult = fallbackResult;
|
|
96
144
|
|
|
97
145
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
98
|
-
if (getCommitCount(sandboxName, worktreeDir) > 0)
|
|
146
|
+
if (getCommitCount(sandboxName, worktreeDir, defaultBranch) > 0) break;
|
|
99
147
|
log.warn(`No commits yet — continuing Claude (attempt ${i + 2}/${MAX_ITERATIONS + 1})...`);
|
|
100
148
|
const contResult = await execClaude(sandboxName, worktreeDir, [
|
|
101
149
|
"--continue", "-p",
|
|
@@ -104,15 +152,17 @@ export async function runClaude(
|
|
|
104
152
|
if (contResult) lastResult = contResult;
|
|
105
153
|
}
|
|
106
154
|
|
|
107
|
-
if (getCommitCount(sandboxName, worktreeDir) === 0) {
|
|
155
|
+
if (getCommitCount(sandboxName, worktreeDir, defaultBranch) === 0) {
|
|
108
156
|
log.warn("Claude did not produce any commits after all attempts.");
|
|
109
157
|
}
|
|
110
|
-
|
|
158
|
+
|
|
159
|
+
const summary = await runSummaryPhase(sandboxName, worktreeDir, defaultBranch, summaryPrompt);
|
|
160
|
+
return summary || lastResult;
|
|
111
161
|
}
|
|
112
162
|
|
|
113
163
|
log.ok(`Implementation plan created with ${unchecked.length} task(s).`);
|
|
114
164
|
|
|
115
|
-
//
|
|
165
|
+
// Building loop
|
|
116
166
|
let stallCount = 0;
|
|
117
167
|
|
|
118
168
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
@@ -128,7 +178,7 @@ export async function runClaude(
|
|
|
128
178
|
break;
|
|
129
179
|
}
|
|
130
180
|
|
|
131
|
-
const commitsBefore = getCommitCount(sandboxName, worktreeDir);
|
|
181
|
+
const commitsBefore = getCommitCount(sandboxName, worktreeDir, defaultBranch);
|
|
132
182
|
const uncheckedBefore = remaining.length;
|
|
133
183
|
|
|
134
184
|
log.info(`Iteration ${i + 1}/${MAX_ITERATIONS}: ${remaining[0]!.replace("- [ ] ", "")}`);
|
|
@@ -139,7 +189,7 @@ export async function runClaude(
|
|
|
139
189
|
|
|
140
190
|
const planAfter = readPlanFile(worktreeDir);
|
|
141
191
|
const uncheckedAfter = planAfter ? getUncheckedItems(planAfter).length : 0;
|
|
142
|
-
const commitsAfter = getCommitCount(sandboxName, worktreeDir);
|
|
192
|
+
const commitsAfter = getCommitCount(sandboxName, worktreeDir, defaultBranch);
|
|
143
193
|
|
|
144
194
|
if (uncheckedAfter >= uncheckedBefore && commitsAfter <= commitsBefore) {
|
|
145
195
|
stallCount++;
|
|
@@ -153,14 +203,14 @@ export async function runClaude(
|
|
|
153
203
|
}
|
|
154
204
|
}
|
|
155
205
|
|
|
156
|
-
//
|
|
206
|
+
// Review
|
|
157
207
|
if (reviewPrompt) {
|
|
158
|
-
log.info("
|
|
208
|
+
log.info("Reviewing changes for quality and completeness...");
|
|
159
209
|
const reviewResult = await execClaude(sandboxName, worktreeDir, ["-p", reviewPrompt]);
|
|
160
210
|
if (reviewResult) lastResult = reviewResult;
|
|
161
211
|
}
|
|
162
212
|
|
|
163
|
-
//
|
|
213
|
+
// Cleanup
|
|
164
214
|
cleanupPlanFile(sandboxName, worktreeDir);
|
|
165
215
|
|
|
166
216
|
const finalPlan = readPlanFile(worktreeDir);
|
|
@@ -173,7 +223,9 @@ export async function runClaude(
|
|
|
173
223
|
log.ok("Plan file cleaned up.");
|
|
174
224
|
}
|
|
175
225
|
|
|
176
|
-
|
|
226
|
+
// Summary
|
|
227
|
+
const summary = await runSummaryPhase(sandboxName, worktreeDir, defaultBranch, summaryPrompt);
|
|
228
|
+
return summary || lastResult;
|
|
177
229
|
}
|
|
178
230
|
|
|
179
231
|
async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs: string[]): Promise<string> {
|
package/src/worktree.ts
CHANGED
|
@@ -72,7 +72,8 @@ export function createWorktree(
|
|
|
72
72
|
repoName: string,
|
|
73
73
|
defaultBranch: string,
|
|
74
74
|
issueNum: string,
|
|
75
|
-
issueTitle: string
|
|
75
|
+
issueTitle: string,
|
|
76
|
+
fresh?: boolean,
|
|
76
77
|
): { worktreeDir: string; branchName: string; reused: boolean } {
|
|
77
78
|
const safeTitle = issueTitle
|
|
78
79
|
.toLowerCase()
|
|
@@ -85,6 +86,12 @@ export function createWorktree(
|
|
|
85
86
|
const repoDir = `${reposDir}/${owner}/${repoName}`;
|
|
86
87
|
const worktreeDir = `${reposDir}/${owner}/${repoName}-worktrees/${branchName}`;
|
|
87
88
|
|
|
89
|
+
if (fs.existsSync(worktreeDir) && fresh) {
|
|
90
|
+
log.info(`--fresh: removing existing worktree at ${worktreeDir}...`);
|
|
91
|
+
Bun.spawnSync(["git", "worktree", "remove", "--force", worktreeDir], { cwd: repoDir });
|
|
92
|
+
Bun.spawnSync(["git", "branch", "-D", branchName], { cwd: repoDir });
|
|
93
|
+
}
|
|
94
|
+
|
|
88
95
|
if (fs.existsSync(worktreeDir)) {
|
|
89
96
|
log.warn(`Worktree already exists at ${worktreeDir}, reusing.`);
|
|
90
97
|
return { worktreeDir, branchName, reused: true };
|