@bobbyg603/mog 1.5.2 → 1.6.0
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 +47 -0
- package/package.json +1 -1
- package/src/config.test.ts +147 -0
- package/src/config.ts +92 -0
- package/src/github.test.ts +68 -0
- package/src/github.ts +76 -31
- package/src/index.ts +179 -53
- package/src/sandbox.test.ts +77 -0
- package/src/sandbox.ts +87 -15
- package/src/version.test.ts +30 -0
- package/src/worktree.ts +8 -1
package/README.md
CHANGED
|
@@ -74,6 +74,33 @@ mog 123 --include .env --include serviceAccountKey.json
|
|
|
74
74
|
mog list
|
|
75
75
|
mog list --verbose
|
|
76
76
|
mog owner/repo list --verbose
|
|
77
|
+
|
|
78
|
+
# Check version
|
|
79
|
+
mog --version
|
|
80
|
+
mog -v
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Git identity
|
|
84
|
+
|
|
85
|
+
`mog` automatically configures the git identity inside the sandbox so commits are attributed correctly. Identity is resolved via a 3-tier priority chain:
|
|
86
|
+
|
|
87
|
+
1. **Per-repo mog config** (`~/.mog/repos/<owner>/<repo>/config.json`)
|
|
88
|
+
2. **Host git config** (auto-detected at runtime from your local `git config`)
|
|
89
|
+
3. **Global mog config** (`~/.mog/config.json`)
|
|
90
|
+
|
|
91
|
+
Most users need zero configuration — `mog` reads your host git identity automatically. Use `mog config` to override when needed:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# View current per-repo config (auto-detected from git remote)
|
|
95
|
+
mog config
|
|
96
|
+
|
|
97
|
+
# Set per-repo git identity
|
|
98
|
+
mog config user.name "Your Name"
|
|
99
|
+
mog config user.email "you@example.com"
|
|
100
|
+
|
|
101
|
+
# Set global fallback identity
|
|
102
|
+
mog config --global user.name "Your Name"
|
|
103
|
+
mog config --global user.email "you@example.com"
|
|
77
104
|
```
|
|
78
105
|
|
|
79
106
|
### Re-mogging
|
|
@@ -122,12 +149,32 @@ mog 123 --fresh
|
|
|
122
149
|
|
|
123
150
|
## Configuration
|
|
124
151
|
|
|
152
|
+
### Environment variables
|
|
153
|
+
|
|
125
154
|
| Environment Variable | Default | Description |
|
|
126
155
|
|---|---|---|
|
|
127
156
|
| `MOG_REPOS_DIR` | `~/mog-repos` | Where repos are cloned and worktrees created (also the sandbox workspace) |
|
|
128
157
|
| `MOG_MAX_ITERATIONS` | `30` | Max build loop iterations per issue |
|
|
129
158
|
| `MOG_MAX_CONTINUATIONS` | — | Legacy alias for `MOG_MAX_ITERATIONS` |
|
|
130
159
|
|
|
160
|
+
### Config files
|
|
161
|
+
|
|
162
|
+
`mog config` manages git identity settings stored in `~/.mog/`:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
~/.mog/
|
|
166
|
+
config.json ← global config
|
|
167
|
+
repos/
|
|
168
|
+
owner/repo/config.json ← per-repo config
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
| Config Key | Description |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `user.name` | Git author name for commits inside the sandbox |
|
|
174
|
+
| `user.email` | Git author email for commits inside the sandbox |
|
|
175
|
+
|
|
176
|
+
See [Git identity](#git-identity) for details on how these are resolved.
|
|
177
|
+
|
|
131
178
|
## Worktree management
|
|
132
179
|
|
|
133
180
|
`mog` uses bare clones and git worktrees so you can run multiple issues concurrently without conflicts:
|
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { loadConfig, saveConfig, loadRepoConfig, saveRepoConfig, getGitIdentity } from "./config";
|
|
6
|
+
|
|
7
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mog-config-test-"));
|
|
8
|
+
const originalHome = process.env.HOME;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
process.env.HOME = tmpDir;
|
|
12
|
+
const mogDir = path.join(tmpDir, ".mog");
|
|
13
|
+
if (fs.existsSync(mogDir)) {
|
|
14
|
+
fs.rmSync(mogDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
process.env.HOME = originalHome;
|
|
20
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("loadConfig", () => {
|
|
24
|
+
test("returns empty object when no config file exists", () => {
|
|
25
|
+
expect(loadConfig()).toEqual({});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns parsed config from file", () => {
|
|
29
|
+
const mogDir = path.join(tmpDir, ".mog");
|
|
30
|
+
fs.mkdirSync(mogDir, { recursive: true });
|
|
31
|
+
fs.writeFileSync(
|
|
32
|
+
path.join(mogDir, "config.json"),
|
|
33
|
+
JSON.stringify({ "user.name": "Alice", "user.email": "alice@example.com" })
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(loadConfig()).toEqual({
|
|
37
|
+
"user.name": "Alice",
|
|
38
|
+
"user.email": "alice@example.com",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("returns empty object for invalid JSON", () => {
|
|
43
|
+
const mogDir = path.join(tmpDir, ".mog");
|
|
44
|
+
fs.mkdirSync(mogDir, { recursive: true });
|
|
45
|
+
fs.writeFileSync(path.join(mogDir, "config.json"), "not json");
|
|
46
|
+
|
|
47
|
+
expect(loadConfig()).toEqual({});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("saveConfig", () => {
|
|
52
|
+
test("creates .mog directory and writes config", () => {
|
|
53
|
+
saveConfig({ "user.name": "Bob", "user.email": "bob@example.com" });
|
|
54
|
+
|
|
55
|
+
const configPath = path.join(tmpDir, ".mog", "config.json");
|
|
56
|
+
expect(fs.existsSync(configPath)).toBe(true);
|
|
57
|
+
|
|
58
|
+
const content = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
59
|
+
expect(content).toEqual({ "user.name": "Bob", "user.email": "bob@example.com" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("overwrites existing config", () => {
|
|
63
|
+
saveConfig({ "user.name": "First" });
|
|
64
|
+
saveConfig({ "user.name": "Second", "user.email": "second@example.com" });
|
|
65
|
+
|
|
66
|
+
const configPath = path.join(tmpDir, ".mog", "config.json");
|
|
67
|
+
const content = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
68
|
+
expect(content).toEqual({ "user.name": "Second", "user.email": "second@example.com" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("saves empty config", () => {
|
|
72
|
+
saveConfig({});
|
|
73
|
+
|
|
74
|
+
const configPath = path.join(tmpDir, ".mog", "config.json");
|
|
75
|
+
const content = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
76
|
+
expect(content).toEqual({});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("loadRepoConfig", () => {
|
|
81
|
+
test("returns empty object when no repo config file exists", () => {
|
|
82
|
+
expect(loadRepoConfig("owner/repo")).toEqual({});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("returns parsed repo config from file", () => {
|
|
86
|
+
saveRepoConfig("owner/repo", { "user.name": "Alice", "user.email": "alice@work.com" });
|
|
87
|
+
expect(loadRepoConfig("owner/repo")).toEqual({
|
|
88
|
+
"user.name": "Alice",
|
|
89
|
+
"user.email": "alice@work.com",
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("keeps repo configs separate", () => {
|
|
94
|
+
saveRepoConfig("owner/repo-a", { "user.name": "Alice", "user.email": "alice@work.com" });
|
|
95
|
+
saveRepoConfig("owner/repo-b", { "user.name": "Bob", "user.email": "bob@personal.com" });
|
|
96
|
+
|
|
97
|
+
expect(loadRepoConfig("owner/repo-a")).toEqual({ "user.name": "Alice", "user.email": "alice@work.com" });
|
|
98
|
+
expect(loadRepoConfig("owner/repo-b")).toEqual({ "user.name": "Bob", "user.email": "bob@personal.com" });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("getGitIdentity", () => {
|
|
103
|
+
test("falls back to global config when no repo specified", () => {
|
|
104
|
+
saveConfig({ "user.name": "Global", "user.email": "global@example.com" });
|
|
105
|
+
// Note: this test also picks up host git config (tier 2), which runs before global.
|
|
106
|
+
// Since we can't easily mock host git, we just verify it returns something non-null.
|
|
107
|
+
const identity = getGitIdentity();
|
|
108
|
+
expect(identity).not.toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("per-repo config takes priority over global config", () => {
|
|
112
|
+
saveConfig({ "user.name": "Global", "user.email": "global@example.com" });
|
|
113
|
+
saveRepoConfig("owner/repo", { "user.name": "RepoUser", "user.email": "repo@work.com" });
|
|
114
|
+
|
|
115
|
+
expect(getGitIdentity("owner/repo")).toEqual({
|
|
116
|
+
name: "RepoUser",
|
|
117
|
+
email: "repo@work.com",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("falls back through chain when per-repo config is incomplete", () => {
|
|
122
|
+
saveRepoConfig("owner/repo", { "user.name": "RepoUser" }); // missing email
|
|
123
|
+
saveConfig({ "user.name": "Global", "user.email": "global@example.com" });
|
|
124
|
+
|
|
125
|
+
// Should skip per-repo (incomplete), pick up host git or global
|
|
126
|
+
const identity = getGitIdentity("owner/repo");
|
|
127
|
+
expect(identity).not.toBeNull();
|
|
128
|
+
// Should NOT be the incomplete repo config
|
|
129
|
+
expect(identity!.email).not.toBe("");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("returns null when no config exists anywhere and host git is empty", () => {
|
|
133
|
+
// With a fake HOME, host git config may still return the real system config.
|
|
134
|
+
// This test just verifies it doesn't crash with no mog configs at all.
|
|
135
|
+
const identity = getGitIdentity("nonexistent/repo");
|
|
136
|
+
// Can't assert null since host git may be configured, but it shouldn't throw
|
|
137
|
+
expect(identity === null || (identity.name && identity.email)).toBeTruthy();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("returns null for global config with empty strings", () => {
|
|
141
|
+
saveConfig({ "user.name": "", "user.email": "" });
|
|
142
|
+
// With empty mog config and potentially no host git, may return null
|
|
143
|
+
const identity = getGitIdentity();
|
|
144
|
+
// If host git is configured it'll return that, otherwise null — both are valid
|
|
145
|
+
expect(identity === null || (identity.name && identity.email)).toBeTruthy();
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export interface MogConfig {
|
|
5
|
+
"user.name"?: string;
|
|
6
|
+
"user.email"?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface GitIdentity {
|
|
10
|
+
name: string;
|
|
11
|
+
email: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getConfigPath(): string {
|
|
15
|
+
return path.join(process.env.HOME || "~", ".mog", "config.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getRepoConfigPath(repo: string): string {
|
|
19
|
+
return path.join(process.env.HOME || "~", ".mog", "repos", repo, "config.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadConfig(): MogConfig {
|
|
23
|
+
const configPath = getConfigPath();
|
|
24
|
+
try {
|
|
25
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
26
|
+
return JSON.parse(raw) as MogConfig;
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveConfig(config: MogConfig): void {
|
|
33
|
+
const configPath = getConfigPath();
|
|
34
|
+
const dir = path.dirname(configPath);
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function loadRepoConfig(repo: string): MogConfig {
|
|
40
|
+
const configPath = getRepoConfigPath(repo);
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
43
|
+
return JSON.parse(raw) as MogConfig;
|
|
44
|
+
} catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function saveRepoConfig(repo: string, config: MogConfig): void {
|
|
50
|
+
const configPath = getRepoConfigPath(repo);
|
|
51
|
+
const dir = path.dirname(configPath);
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function detectHostGitIdentity(): GitIdentity | null {
|
|
57
|
+
const nameResult = Bun.spawnSync(["git", "config", "user.name"]);
|
|
58
|
+
const emailResult = Bun.spawnSync(["git", "config", "user.email"]);
|
|
59
|
+
|
|
60
|
+
const name = nameResult.exitCode === 0 ? nameResult.stdout.toString().trim() : "";
|
|
61
|
+
const email = emailResult.exitCode === 0 ? emailResult.stdout.toString().trim() : "";
|
|
62
|
+
|
|
63
|
+
if (name && email) {
|
|
64
|
+
return { name, email };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function identityFromConfig(config: MogConfig): GitIdentity | null {
|
|
71
|
+
const name = config["user.name"];
|
|
72
|
+
const email = config["user.email"];
|
|
73
|
+
if (name && email) {
|
|
74
|
+
return { name, email };
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getGitIdentity(repo?: string): GitIdentity | null {
|
|
80
|
+
// 1. Per-repo mog config (explicit override)
|
|
81
|
+
if (repo) {
|
|
82
|
+
const repoIdentity = identityFromConfig(loadRepoConfig(repo));
|
|
83
|
+
if (repoIdentity) return repoIdentity;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. Host git config (auto-detected from cwd)
|
|
87
|
+
const hostIdentity = detectHostGitIdentity();
|
|
88
|
+
if (hostIdentity) return hostIdentity;
|
|
89
|
+
|
|
90
|
+
// 3. Global mog config (fallback)
|
|
91
|
+
return identityFromConfig(loadConfig());
|
|
92
|
+
}
|
|
@@ -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getGitIdentity } from "./config";
|
|
1
2
|
import { log } from "./log";
|
|
2
3
|
|
|
3
4
|
export interface Issue {
|
|
@@ -127,6 +128,37 @@ export function fetchPRFeedback(repo: string, branchName: string): PRFeedback |
|
|
|
127
128
|
return { prNumber, prUrl, reviews };
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
export function closePR(repo: string, prNumber: number): void {
|
|
132
|
+
const result = Bun.spawnSync([
|
|
133
|
+
"gh", "pr", "close", String(prNumber),
|
|
134
|
+
"--repo", repo,
|
|
135
|
+
"--delete-branch",
|
|
136
|
+
]);
|
|
137
|
+
if (result.exitCode !== 0) {
|
|
138
|
+
log.warn(`Failed to close PR #${prNumber}.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function cleanIssueTitle(title: string): string {
|
|
143
|
+
return title
|
|
144
|
+
.replace(/^(feat|fix|chore|docs|refactor|test|ci|build|perf|style):\s*/i, "")
|
|
145
|
+
.replace(/\s*\[#\d+\]/g, "")
|
|
146
|
+
.trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function getConventionalPrefix(issue: Issue): string {
|
|
150
|
+
const labels = issue.labels.split(", ").map(l => l.trim());
|
|
151
|
+
return labels.includes("enhancement") || labels.includes("feature") ? "feat" : "fix";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function gitWithIdentity(repo: string, ...args: string[]): string[] {
|
|
155
|
+
const identity = getGitIdentity(repo);
|
|
156
|
+
if (identity) {
|
|
157
|
+
return ["git", `-c`, `user.name=${identity.name}`, `-c`, `user.email=${identity.email}`, ...args];
|
|
158
|
+
}
|
|
159
|
+
return ["git", ...args];
|
|
160
|
+
}
|
|
161
|
+
|
|
130
162
|
export function pushAndCreatePR(
|
|
131
163
|
repo: string,
|
|
132
164
|
worktreeDir: string,
|
|
@@ -151,6 +183,8 @@ export function pushAndCreatePR(
|
|
|
151
183
|
return;
|
|
152
184
|
}
|
|
153
185
|
|
|
186
|
+
const prefix = getConventionalPrefix(issue);
|
|
187
|
+
|
|
154
188
|
// Stage any unstaged changes Claude might have left
|
|
155
189
|
if (hasUncommitted) {
|
|
156
190
|
log.info("Staging uncommitted changes...");
|
|
@@ -158,32 +192,33 @@ export function pushAndCreatePR(
|
|
|
158
192
|
if (addResult.exitCode !== 0) {
|
|
159
193
|
log.die("Failed to stage changes.");
|
|
160
194
|
}
|
|
161
|
-
const
|
|
162
|
-
const commitResult = Bun.spawnSync(["git", "commit", "-m", `${prefix}: address issue #${issueNum} - ${issue.title}`], { cwd: worktreeDir });
|
|
195
|
+
const commitResult = Bun.spawnSync(gitWithIdentity(repo, "commit", "-m", `${prefix}: address issue #${issueNum} - ${cleanIssueTitle(issue.title)}`), { cwd: worktreeDir });
|
|
163
196
|
if (commitResult.exitCode !== 0) {
|
|
164
197
|
log.warn("Commit failed — changes may already be committed.");
|
|
165
198
|
}
|
|
166
199
|
}
|
|
167
200
|
|
|
168
|
-
// Squash all commits into one
|
|
169
|
-
const
|
|
201
|
+
// Squash all commits into one (use origin ref — local branch may be stale)
|
|
202
|
+
const mergeBase = `origin/${defaultBranch}`;
|
|
203
|
+
const commitCount = Bun.spawnSync(["git", "rev-list", "--count", `${mergeBase}..HEAD`], { cwd: worktreeDir });
|
|
170
204
|
const count = parseInt(commitCount.stdout.toString().trim(), 10) || 0;
|
|
171
205
|
if (count > 1) {
|
|
172
206
|
log.info(`Squashing ${count} commits into one...`);
|
|
173
|
-
const
|
|
174
|
-
const squash = Bun.spawnSync(["git", "reset", "--soft", defaultBranch], { cwd: worktreeDir });
|
|
207
|
+
const squash = Bun.spawnSync(["git", "reset", "--soft", mergeBase], { cwd: worktreeDir });
|
|
175
208
|
if (squash.exitCode === 0) {
|
|
176
|
-
const msg = `${prefix}: ${issue.title.toLowerCase()} (#${issueNum})`;
|
|
177
|
-
Bun.spawnSync(
|
|
209
|
+
const msg = `${prefix}: ${cleanIssueTitle(issue.title).toLowerCase()} (#${issueNum})`;
|
|
210
|
+
Bun.spawnSync(gitWithIdentity(repo, "commit", "-m", msg), { cwd: worktreeDir });
|
|
178
211
|
log.ok("Commits squashed.");
|
|
179
212
|
} else {
|
|
180
213
|
log.warn("Failed to squash — pushing individual commits instead.");
|
|
181
214
|
}
|
|
182
215
|
}
|
|
183
216
|
|
|
184
|
-
// Push
|
|
217
|
+
// Push — force-with-lease if the remote branch already exists
|
|
185
218
|
log.info(`Pushing branch '${branchName}' to origin...`);
|
|
186
|
-
const
|
|
219
|
+
const remoteRef = Bun.spawnSync(["git", "ls-remote", "--heads", "origin", branchName], { cwd: worktreeDir });
|
|
220
|
+
const remoteBranchExists = remoteRef.stdout.toString().trim().length > 0;
|
|
221
|
+
const pushArgs = remoteBranchExists
|
|
187
222
|
? ["git", "push", "--force-with-lease", "-u", "origin", branchName]
|
|
188
223
|
: ["git", "push", "-u", "origin", branchName];
|
|
189
224
|
const push = Bun.spawnSync(pushArgs, { cwd: worktreeDir });
|
|
@@ -192,8 +227,22 @@ export function pushAndCreatePR(
|
|
|
192
227
|
}
|
|
193
228
|
log.ok("Branch pushed.");
|
|
194
229
|
|
|
230
|
+
const prTitle = `${prefix}: ${cleanIssueTitle(issue.title)} [#${issueNum}]`;
|
|
231
|
+
const prBody = buildPRBody(issueNum, summary);
|
|
232
|
+
|
|
195
233
|
if (existingPR) {
|
|
196
|
-
// Update existing PR
|
|
234
|
+
// Update existing PR title and description
|
|
235
|
+
const edit = Bun.spawnSync([
|
|
236
|
+
"gh", "pr", "edit", String(existingPR.prNumber),
|
|
237
|
+
"--repo", repo,
|
|
238
|
+
"--title", prTitle,
|
|
239
|
+
"--body", prBody,
|
|
240
|
+
], { cwd: worktreeDir });
|
|
241
|
+
|
|
242
|
+
if (edit.exitCode !== 0) {
|
|
243
|
+
log.warn("Failed to update PR title/description.");
|
|
244
|
+
}
|
|
245
|
+
|
|
197
246
|
log.ok("Existing PR updated!");
|
|
198
247
|
console.log(`\x1b[0;32m${existingPR.prUrl}\x1b[0m`);
|
|
199
248
|
console.log();
|
|
@@ -206,26 +255,6 @@ export function pushAndCreatePR(
|
|
|
206
255
|
// Create PR
|
|
207
256
|
log.info("Opening pull request...");
|
|
208
257
|
|
|
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
258
|
const pr = Bun.spawnSync([
|
|
230
259
|
"gh", "pr", "create",
|
|
231
260
|
"--repo", repo,
|
|
@@ -248,3 +277,19 @@ ${issue.body}
|
|
|
248
277
|
log.info(`Worktree: ${worktreeDir}`);
|
|
249
278
|
log.info(`To clean up the worktree later: git worktree remove ${worktreeDir}`);
|
|
250
279
|
}
|
|
280
|
+
|
|
281
|
+
function buildPRBody(issueNum: string, summary?: string): string {
|
|
282
|
+
const summarySection = summary
|
|
283
|
+
? `### What was done\n\n${summary}\n\n`
|
|
284
|
+
: "";
|
|
285
|
+
|
|
286
|
+
return `## Summary
|
|
287
|
+
|
|
288
|
+
Closes #${issueNum}
|
|
289
|
+
|
|
290
|
+
${summarySection}---
|
|
291
|
+
|
|
292
|
+
This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
|
|
293
|
+
|
|
294
|
+
*Please review the changes carefully before merging.*`;
|
|
295
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
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
|
-
import { runClaude } from "./sandbox";
|
|
8
|
-
import {
|
|
7
|
+
import { runClaude, applySandboxGitConfig } from "./sandbox";
|
|
8
|
+
import { loadConfig, saveConfig, loadRepoConfig, saveRepoConfig } from "./config";
|
|
9
|
+
import type { MogConfig } from "./config";
|
|
9
10
|
import type { PRFeedback } from "./github";
|
|
10
11
|
import { log } from "./log";
|
|
11
12
|
|
|
@@ -60,9 +61,38 @@ async function init() {
|
|
|
60
61
|
log.ok("mog is ready. Run: mog <issue_number> (from a git repo) or mog <owner/repo> <issue_number>");
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
function printUsage(): void {
|
|
65
|
+
console.log("Usage:");
|
|
66
|
+
console.log(" mog init — one-time setup (create sandbox & login)");
|
|
67
|
+
console.log(" mog <issue_num> — auto-detect repo from git remote");
|
|
68
|
+
console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
|
|
69
|
+
console.log(" mog list [--verbose] — list open issues (auto-detect repo)");
|
|
70
|
+
console.log(" mog <owner/repo> list [--verbose] — list open issues for a repo");
|
|
71
|
+
console.log();
|
|
72
|
+
console.log("Options:");
|
|
73
|
+
console.log(" --include <file> — copy a file into the worktree (repeatable)");
|
|
74
|
+
console.log(" --fresh — ignore existing PR, start a brand new one");
|
|
75
|
+
console.log(" --version, -v — print the current version");
|
|
76
|
+
console.log();
|
|
77
|
+
console.log("Example:");
|
|
78
|
+
console.log(" mog init");
|
|
79
|
+
console.log(" mog 123");
|
|
80
|
+
console.log(" mog 123 --include .env");
|
|
81
|
+
console.log(" mog workingdevshero/automate-it 123");
|
|
82
|
+
console.log(" mog list");
|
|
83
|
+
console.log(" mog list --verbose");
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
async function main() {
|
|
64
87
|
const args = process.argv.slice(2);
|
|
65
88
|
|
|
89
|
+
// Handle --version / -v before anything else (no dependencies required)
|
|
90
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
91
|
+
const pkg = await Bun.file(path.join(import.meta.dir, "..", "package.json")).json();
|
|
92
|
+
console.log(pkg.version);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
66
96
|
// Validate dependencies
|
|
67
97
|
for (const cmd of ["gh", "git", "docker"]) {
|
|
68
98
|
const which = Bun.spawnSync(["which", cmd]);
|
|
@@ -87,6 +117,65 @@ async function main() {
|
|
|
87
117
|
return;
|
|
88
118
|
}
|
|
89
119
|
|
|
120
|
+
// mog config [--global] [key] [value]
|
|
121
|
+
if (args[0] === "config") {
|
|
122
|
+
const configArgs = args.slice(1);
|
|
123
|
+
const isGlobal = configArgs.includes("--global");
|
|
124
|
+
const filtered = configArgs.filter(a => a !== "--global");
|
|
125
|
+
const key = filtered[0];
|
|
126
|
+
const value = filtered[1];
|
|
127
|
+
const validKeys: (keyof MogConfig)[] = ["user.name", "user.email"];
|
|
128
|
+
|
|
129
|
+
// Determine which config to use: --global or per-repo (auto-detected)
|
|
130
|
+
let repo: string | null = null;
|
|
131
|
+
if (!isGlobal) {
|
|
132
|
+
repo = detectRepo();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const load = () => repo ? loadRepoConfig(repo) : loadConfig();
|
|
136
|
+
const save = (c: MogConfig) => repo ? saveRepoConfig(repo, c) : saveConfig(c);
|
|
137
|
+
const scope = repo ? `repo (${repo})` : "global";
|
|
138
|
+
|
|
139
|
+
if (!key) {
|
|
140
|
+
// Show all config values
|
|
141
|
+
const config = load();
|
|
142
|
+
if (Object.keys(config).length === 0) {
|
|
143
|
+
log.info(`No ${scope} config values set.`);
|
|
144
|
+
} else {
|
|
145
|
+
log.info(`${scope} config:`);
|
|
146
|
+
for (const [k, v] of Object.entries(config)) {
|
|
147
|
+
console.log(`${k}=${v}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!validKeys.includes(key as keyof MogConfig)) {
|
|
154
|
+
log.die(`Unknown config key: '${key}'. Valid keys: ${validKeys.join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const configKey = key as keyof MogConfig;
|
|
158
|
+
|
|
159
|
+
if (value === undefined) {
|
|
160
|
+
// Read a single value
|
|
161
|
+
const config = load();
|
|
162
|
+
const val = config[configKey];
|
|
163
|
+
if (val !== undefined) {
|
|
164
|
+
console.log(val);
|
|
165
|
+
} else {
|
|
166
|
+
log.die(`Config key '${key}' is not set (${scope}).`);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Set a value
|
|
172
|
+
const config = load();
|
|
173
|
+
config[configKey] = value;
|
|
174
|
+
save(config);
|
|
175
|
+
log.ok(`${key}=${value} (${scope})`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
90
179
|
// mog list [--verbose] or mog <owner/repo> list [--verbose]
|
|
91
180
|
if (args[0] === "list" || args[1] === "list") {
|
|
92
181
|
let repo: string;
|
|
@@ -107,24 +196,7 @@ async function main() {
|
|
|
107
196
|
}
|
|
108
197
|
|
|
109
198
|
if (args.length < 1) {
|
|
110
|
-
|
|
111
|
-
console.log(" mog init — one-time setup (create sandbox & login)");
|
|
112
|
-
console.log(" mog <issue_num> — auto-detect repo from git remote");
|
|
113
|
-
console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
|
|
114
|
-
console.log(" mog list [--verbose] — list open issues (auto-detect repo)");
|
|
115
|
-
console.log(" mog <owner/repo> list [--verbose] — list open issues for a repo");
|
|
116
|
-
console.log();
|
|
117
|
-
console.log("Options:");
|
|
118
|
-
console.log(" --include <file> — copy a file into the worktree (repeatable)");
|
|
119
|
-
console.log(" --fresh — ignore existing PR, start a brand new one");
|
|
120
|
-
console.log();
|
|
121
|
-
console.log("Example:");
|
|
122
|
-
console.log(" mog init");
|
|
123
|
-
console.log(" mog 123");
|
|
124
|
-
console.log(" mog 123 --include .env");
|
|
125
|
-
console.log(" mog workingdevshero/automate-it 123");
|
|
126
|
-
console.log(" mog list");
|
|
127
|
-
console.log(" mog list --verbose");
|
|
199
|
+
printUsage();
|
|
128
200
|
return;
|
|
129
201
|
}
|
|
130
202
|
|
|
@@ -166,24 +238,7 @@ async function main() {
|
|
|
166
238
|
log.die(`Invalid issue number: '${issueNum}'. Must be a positive integer.`);
|
|
167
239
|
}
|
|
168
240
|
} else {
|
|
169
|
-
|
|
170
|
-
console.log(" mog init — one-time setup (create sandbox & login)");
|
|
171
|
-
console.log(" mog <issue_num> — auto-detect repo from git remote");
|
|
172
|
-
console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
|
|
173
|
-
console.log(" mog list [--verbose] — list open issues (auto-detect repo)");
|
|
174
|
-
console.log(" mog <owner/repo> list [--verbose] — list open issues for a repo");
|
|
175
|
-
console.log();
|
|
176
|
-
console.log("Options:");
|
|
177
|
-
console.log(" --include <file> — copy a file into the worktree (repeatable)");
|
|
178
|
-
console.log(" --fresh — ignore existing PR, start a brand new one");
|
|
179
|
-
console.log();
|
|
180
|
-
console.log("Example:");
|
|
181
|
-
console.log(" mog init");
|
|
182
|
-
console.log(" mog 123");
|
|
183
|
-
console.log(" mog 123 --include .env");
|
|
184
|
-
console.log(" mog workingdevshero/automate-it 123");
|
|
185
|
-
console.log(" mog list");
|
|
186
|
-
console.log(" mog list --verbose");
|
|
241
|
+
printUsage();
|
|
187
242
|
return;
|
|
188
243
|
}
|
|
189
244
|
|
|
@@ -217,19 +272,21 @@ async function main() {
|
|
|
217
272
|
log.info(`Default branch: ${defaultBranch}`);
|
|
218
273
|
|
|
219
274
|
const { worktreeDir, branchName, reused } = createWorktree(
|
|
220
|
-
reposDir, owner, repoName, defaultBranch, issueNum, issue.title
|
|
275
|
+
reposDir, owner, repoName, defaultBranch, issueNum, cleanIssueTitle(issue.title), fresh
|
|
221
276
|
);
|
|
222
277
|
|
|
223
|
-
// Check for existing PR
|
|
278
|
+
// Check for existing PR
|
|
224
279
|
let existingPR: PRFeedback | undefined;
|
|
225
|
-
let isRetry = reused;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
280
|
+
let isRetry = fresh ? false : reused;
|
|
281
|
+
const pr = fetchPRFeedback(repo, branchName);
|
|
282
|
+
if (pr && fresh) {
|
|
283
|
+
log.info(`--fresh: closing existing PR #${pr.prNumber} and deleting remote branch...`);
|
|
284
|
+
closePR(repo, pr.prNumber);
|
|
285
|
+
log.ok(`PR #${pr.prNumber} closed. A new PR will be created.`);
|
|
286
|
+
} else if (pr) {
|
|
287
|
+
existingPR = pr;
|
|
288
|
+
isRetry = true;
|
|
289
|
+
log.ok(`Found existing PR #${pr.prNumber} — will include review feedback and update it.`);
|
|
233
290
|
}
|
|
234
291
|
|
|
235
292
|
// Copy included files into worktree
|
|
@@ -243,11 +300,15 @@ async function main() {
|
|
|
243
300
|
}
|
|
244
301
|
|
|
245
302
|
// Build prompts
|
|
246
|
-
const prFeedback = existingPR?.reviews || "";
|
|
303
|
+
const prFeedback = fresh ? "" : (existingPR?.reviews || "");
|
|
247
304
|
const planningPrompt = buildPlanningPrompt(repo, issueNum, issue, prFeedback, isRetry);
|
|
248
305
|
const buildingPromptFn = (remaining: string[], plan: string) =>
|
|
249
306
|
buildBuildingPrompt(repo, issueNum, issue, remaining, plan);
|
|
250
|
-
const reviewPrompt = buildReviewPrompt(repo, issueNum, issue);
|
|
307
|
+
const reviewPrompt = buildReviewPrompt(repo, issueNum, issue, defaultBranch);
|
|
308
|
+
const summaryPrompt = buildSummaryPrompt(repo, issueNum, issue, defaultBranch);
|
|
309
|
+
|
|
310
|
+
// Apply git identity inside sandbox before running Claude
|
|
311
|
+
applySandboxGitConfig(SANDBOX_NAME, repo);
|
|
251
312
|
|
|
252
313
|
// Run Claude in sandbox
|
|
253
314
|
log.info("Launching Claude Code in sandbox...");
|
|
@@ -255,7 +316,7 @@ async function main() {
|
|
|
255
316
|
log.info(`Worktree: ${worktreeDir}`);
|
|
256
317
|
console.log();
|
|
257
318
|
|
|
258
|
-
const summary = await runClaude(SANDBOX_NAME, worktreeDir, planningPrompt, buildingPromptFn, reviewPrompt);
|
|
319
|
+
const summary = await runClaude(SANDBOX_NAME, worktreeDir, defaultBranch, planningPrompt, buildingPromptFn, reviewPrompt, summaryPrompt);
|
|
259
320
|
|
|
260
321
|
// Remove included files so they don't end up in the PR
|
|
261
322
|
for (const filePath of copiedFiles) {
|
|
@@ -272,6 +333,35 @@ async function main() {
|
|
|
272
333
|
pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue, summary, existingPR);
|
|
273
334
|
}
|
|
274
335
|
|
|
336
|
+
function printUsage(): void {
|
|
337
|
+
console.log("Usage:");
|
|
338
|
+
console.log(" mog init — one-time setup (create sandbox & login)");
|
|
339
|
+
console.log(" mog config [key] [value] — get/set per-repo config (auto-detected)");
|
|
340
|
+
console.log(" mog config --global [key] [value] — get/set global config");
|
|
341
|
+
console.log(" mog <issue_num> — auto-detect repo from git remote");
|
|
342
|
+
console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
|
|
343
|
+
console.log(" mog list [--verbose] — list open issues (auto-detect repo)");
|
|
344
|
+
console.log(" mog <owner/repo> list [--verbose] — list open issues for a repo");
|
|
345
|
+
console.log();
|
|
346
|
+
console.log("Options:");
|
|
347
|
+
console.log(" --include <file> — copy a file into the worktree (repeatable)");
|
|
348
|
+
console.log(" --fresh — ignore existing PR, start a brand new one");
|
|
349
|
+
console.log();
|
|
350
|
+
console.log("Config keys: user.name, user.email");
|
|
351
|
+
console.log(" Git identity is auto-detected from your repo's git config.");
|
|
352
|
+
console.log(" Use 'mog config' to override per-repo, or --global for all repos.");
|
|
353
|
+
console.log();
|
|
354
|
+
console.log("Example:");
|
|
355
|
+
console.log(" mog init");
|
|
356
|
+
console.log(" mog config user.name \"Your Name\"");
|
|
357
|
+
console.log(" mog config --global user.email \"you@example.com\"");
|
|
358
|
+
console.log(" mog 123");
|
|
359
|
+
console.log(" mog 123 --include .env");
|
|
360
|
+
console.log(" mog workingdevshero/automate-it 123");
|
|
361
|
+
console.log(" mog list");
|
|
362
|
+
console.log(" mog list --verbose");
|
|
363
|
+
}
|
|
364
|
+
|
|
275
365
|
function getReposDir(): string {
|
|
276
366
|
return process.env.MOG_REPOS_DIR || `${process.env.HOME}/mog-repos`;
|
|
277
367
|
}
|
|
@@ -439,10 +529,46 @@ Rules:
|
|
|
439
529
|
5. Do NOT work on any other tasks after committing.`;
|
|
440
530
|
}
|
|
441
531
|
|
|
532
|
+
function buildSummaryPrompt(
|
|
533
|
+
repo: string,
|
|
534
|
+
issueNum: string,
|
|
535
|
+
issue: { title: string; body: string; labels: string; comments: string },
|
|
536
|
+
defaultBranch: string,
|
|
537
|
+
): string {
|
|
538
|
+
return `You are summarizing the changes made for GitHub issue #${issueNum} in the repository ${repo}.
|
|
539
|
+
|
|
540
|
+
## Issue: ${issue.title}
|
|
541
|
+
|
|
542
|
+
### Description
|
|
543
|
+
${issue.body}
|
|
544
|
+
|
|
545
|
+
## Instructions
|
|
546
|
+
|
|
547
|
+
Run \`git diff ${defaultBranch}...HEAD\` to see all changes made on this branch.
|
|
548
|
+
|
|
549
|
+
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.
|
|
550
|
+
|
|
551
|
+
CRITICAL FORMATTING RULES — the file contents will be inserted directly into a PR body as-is:
|
|
552
|
+
1. Do NOT make any code changes or commits.
|
|
553
|
+
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.
|
|
554
|
+
3. Use a flat list of markdown bullet points (\`- \`). No headings, no sub-sections, no numbered lists.
|
|
555
|
+
4. Each bullet should describe a concrete change: what was added, modified, or fixed and where.
|
|
556
|
+
5. Keep it short — aim for 3-8 bullets. A reviewer should be able to scan it in 10 seconds.
|
|
557
|
+
6. Do NOT list things that were reviewed but not changed. Only describe what actually changed.
|
|
558
|
+
7. Do NOT repeat the issue title or description — the PR already includes those.
|
|
559
|
+
8. End with the last bullet point. No closing remarks, no sign-off, no "Let me know" or "Done." after the bullets.
|
|
560
|
+
|
|
561
|
+
Example of correct SUMMARY.md contents:
|
|
562
|
+
- Added \`FooService\` class in \`src/services/foo.ts\` with retry logic and error handling
|
|
563
|
+
- Updated \`src/routes/api.ts\` to wire up the new \`/foo\` endpoint
|
|
564
|
+
- Added unit tests for \`FooService\` in \`src/services/__tests__/foo.test.ts\``;
|
|
565
|
+
}
|
|
566
|
+
|
|
442
567
|
function buildReviewPrompt(
|
|
443
568
|
repo: string,
|
|
444
569
|
issueNum: string,
|
|
445
570
|
issue: { title: string; body: string; labels: string; comments: string },
|
|
571
|
+
defaultBranch: string,
|
|
446
572
|
): string {
|
|
447
573
|
return `You are reviewing changes made for GitHub issue #${issueNum} in the repository ${repo}.
|
|
448
574
|
|
|
@@ -452,7 +578,7 @@ ${formatIssueContext(issueNum, issue)}
|
|
|
452
578
|
|
|
453
579
|
All implementation tasks are complete. Your job is to **review the entire branch** for quality and completeness.
|
|
454
580
|
|
|
455
|
-
Run \`git diff
|
|
581
|
+
Run \`git diff ${defaultBranch}...HEAD\` to see all changes made.
|
|
456
582
|
|
|
457
583
|
Check for:
|
|
458
584
|
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import { log } from "./log";
|
|
3
|
+
import { getGitIdentity } from "./config";
|
|
3
4
|
|
|
4
5
|
interface StreamEvent {
|
|
5
6
|
type: string;
|
|
@@ -22,6 +23,26 @@ const MAX_ITERATIONS = parseInt(
|
|
|
22
23
|
);
|
|
23
24
|
const MAX_STALLS = 2;
|
|
24
25
|
const PLAN_FILENAME = "IMPLEMENTATION_PLAN.md";
|
|
26
|
+
const SUMMARY_FILENAME = "SUMMARY.md";
|
|
27
|
+
|
|
28
|
+
export function applySandboxGitConfig(sandboxName: string, repo?: string): void {
|
|
29
|
+
const identity = getGitIdentity(repo);
|
|
30
|
+
if (!identity) return;
|
|
31
|
+
|
|
32
|
+
Bun.spawnSync([
|
|
33
|
+
"docker", "sandbox", "exec",
|
|
34
|
+
sandboxName,
|
|
35
|
+
"git", "config", "--global", "user.name", identity.name,
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
Bun.spawnSync([
|
|
39
|
+
"docker", "sandbox", "exec",
|
|
40
|
+
sandboxName,
|
|
41
|
+
"git", "config", "--global", "user.email", identity.email,
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
log.ok(`Sandbox git identity: ${identity.name} <${identity.email}>`);
|
|
45
|
+
}
|
|
25
46
|
|
|
26
47
|
export function readPlanFile(worktreeDir: string): string | null {
|
|
27
48
|
const planPath = `${worktreeDir}/${PLAN_FILENAME}`;
|
|
@@ -43,14 +64,24 @@ export function isPlanComplete(planContent: string): boolean {
|
|
|
43
64
|
return unchecked.length === 0 && (checked?.length ?? 0) > 0;
|
|
44
65
|
}
|
|
45
66
|
|
|
46
|
-
function getCommitCount(sandboxName: string, worktreeDir: string): number {
|
|
67
|
+
function getCommitCount(sandboxName: string, worktreeDir: string, defaultBranch: string): number {
|
|
47
68
|
const result = Bun.spawnSync([
|
|
48
69
|
"docker", "sandbox", "exec",
|
|
49
70
|
"-w", worktreeDir,
|
|
50
71
|
sandboxName,
|
|
51
|
-
"git", "rev-list", "
|
|
72
|
+
"git", "rev-list", "--count", `origin/${defaultBranch}..HEAD`,
|
|
52
73
|
]);
|
|
53
|
-
if (result.exitCode !== 0)
|
|
74
|
+
if (result.exitCode !== 0) {
|
|
75
|
+
// Fallback: count commits not on any remote (works even if origin ref is missing)
|
|
76
|
+
const fallback = Bun.spawnSync([
|
|
77
|
+
"docker", "sandbox", "exec",
|
|
78
|
+
"-w", worktreeDir,
|
|
79
|
+
sandboxName,
|
|
80
|
+
"git", "rev-list", "HEAD", "--not", "--remotes", "--count",
|
|
81
|
+
]);
|
|
82
|
+
if (fallback.exitCode !== 0) return 0;
|
|
83
|
+
return parseInt(fallback.stdout.toString().trim(), 10) || 0;
|
|
84
|
+
}
|
|
54
85
|
return parseInt(result.stdout.toString().trim(), 10) || 0;
|
|
55
86
|
}
|
|
56
87
|
|
|
@@ -71,17 +102,54 @@ function cleanupPlanFile(sandboxName: string, worktreeDir: string): void {
|
|
|
71
102
|
]);
|
|
72
103
|
}
|
|
73
104
|
|
|
105
|
+
function readSummaryFile(worktreeDir: string): string | null {
|
|
106
|
+
const summaryPath = `${worktreeDir}/${SUMMARY_FILENAME}`;
|
|
107
|
+
try {
|
|
108
|
+
const content = fs.readFileSync(summaryPath, "utf-8").trim();
|
|
109
|
+
// Clean up the file so it doesn't end up in commits
|
|
110
|
+
fs.unlinkSync(summaryPath);
|
|
111
|
+
return content || null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function runSummaryPhase(
|
|
118
|
+
sandboxName: string,
|
|
119
|
+
worktreeDir: string,
|
|
120
|
+
defaultBranch: string,
|
|
121
|
+
summaryPrompt?: string,
|
|
122
|
+
): Promise<string> {
|
|
123
|
+
if (!summaryPrompt || getCommitCount(sandboxName, worktreeDir, defaultBranch) <= 0) {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
log.info("Summarizing changes...");
|
|
128
|
+
await execClaude(sandboxName, worktreeDir, ["-p", summaryPrompt]);
|
|
129
|
+
|
|
130
|
+
const summary = readSummaryFile(worktreeDir);
|
|
131
|
+
if (summary) {
|
|
132
|
+
log.ok("Summary generated.");
|
|
133
|
+
return summary;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
log.warn("Claude did not write SUMMARY.md — PR will not have a summary section.");
|
|
137
|
+
return "";
|
|
138
|
+
}
|
|
139
|
+
|
|
74
140
|
export async function runClaude(
|
|
75
141
|
sandboxName: string,
|
|
76
142
|
worktreeDir: string,
|
|
143
|
+
defaultBranch: string,
|
|
77
144
|
planningPrompt: string,
|
|
78
145
|
buildingPromptFn: (remainingItems: string[], planContent: string) => string,
|
|
79
146
|
reviewPrompt?: string,
|
|
147
|
+
summaryPrompt?: string,
|
|
80
148
|
): Promise<string> {
|
|
81
149
|
let lastResult = "";
|
|
82
150
|
|
|
83
|
-
//
|
|
84
|
-
log.info("
|
|
151
|
+
// Planning
|
|
152
|
+
log.info("Planning implementation...");
|
|
85
153
|
await execClaude(sandboxName, worktreeDir, ["-p", planningPrompt]);
|
|
86
154
|
|
|
87
155
|
const planContent = readPlanFile(worktreeDir);
|
|
@@ -95,7 +163,7 @@ export async function runClaude(
|
|
|
95
163
|
if (fallbackResult) lastResult = fallbackResult;
|
|
96
164
|
|
|
97
165
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
98
|
-
if (getCommitCount(sandboxName, worktreeDir) > 0)
|
|
166
|
+
if (getCommitCount(sandboxName, worktreeDir, defaultBranch) > 0) break;
|
|
99
167
|
log.warn(`No commits yet — continuing Claude (attempt ${i + 2}/${MAX_ITERATIONS + 1})...`);
|
|
100
168
|
const contResult = await execClaude(sandboxName, worktreeDir, [
|
|
101
169
|
"--continue", "-p",
|
|
@@ -104,15 +172,17 @@ export async function runClaude(
|
|
|
104
172
|
if (contResult) lastResult = contResult;
|
|
105
173
|
}
|
|
106
174
|
|
|
107
|
-
if (getCommitCount(sandboxName, worktreeDir) === 0) {
|
|
175
|
+
if (getCommitCount(sandboxName, worktreeDir, defaultBranch) === 0) {
|
|
108
176
|
log.warn("Claude did not produce any commits after all attempts.");
|
|
109
177
|
}
|
|
110
|
-
|
|
178
|
+
|
|
179
|
+
const summary = await runSummaryPhase(sandboxName, worktreeDir, defaultBranch, summaryPrompt);
|
|
180
|
+
return summary || lastResult;
|
|
111
181
|
}
|
|
112
182
|
|
|
113
183
|
log.ok(`Implementation plan created with ${unchecked.length} task(s).`);
|
|
114
184
|
|
|
115
|
-
//
|
|
185
|
+
// Building loop
|
|
116
186
|
let stallCount = 0;
|
|
117
187
|
|
|
118
188
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
@@ -128,7 +198,7 @@ export async function runClaude(
|
|
|
128
198
|
break;
|
|
129
199
|
}
|
|
130
200
|
|
|
131
|
-
const commitsBefore = getCommitCount(sandboxName, worktreeDir);
|
|
201
|
+
const commitsBefore = getCommitCount(sandboxName, worktreeDir, defaultBranch);
|
|
132
202
|
const uncheckedBefore = remaining.length;
|
|
133
203
|
|
|
134
204
|
log.info(`Iteration ${i + 1}/${MAX_ITERATIONS}: ${remaining[0]!.replace("- [ ] ", "")}`);
|
|
@@ -139,7 +209,7 @@ export async function runClaude(
|
|
|
139
209
|
|
|
140
210
|
const planAfter = readPlanFile(worktreeDir);
|
|
141
211
|
const uncheckedAfter = planAfter ? getUncheckedItems(planAfter).length : 0;
|
|
142
|
-
const commitsAfter = getCommitCount(sandboxName, worktreeDir);
|
|
212
|
+
const commitsAfter = getCommitCount(sandboxName, worktreeDir, defaultBranch);
|
|
143
213
|
|
|
144
214
|
if (uncheckedAfter >= uncheckedBefore && commitsAfter <= commitsBefore) {
|
|
145
215
|
stallCount++;
|
|
@@ -153,14 +223,14 @@ export async function runClaude(
|
|
|
153
223
|
}
|
|
154
224
|
}
|
|
155
225
|
|
|
156
|
-
//
|
|
226
|
+
// Review
|
|
157
227
|
if (reviewPrompt) {
|
|
158
|
-
log.info("
|
|
228
|
+
log.info("Reviewing changes for quality and completeness...");
|
|
159
229
|
const reviewResult = await execClaude(sandboxName, worktreeDir, ["-p", reviewPrompt]);
|
|
160
230
|
if (reviewResult) lastResult = reviewResult;
|
|
161
231
|
}
|
|
162
232
|
|
|
163
|
-
//
|
|
233
|
+
// Cleanup
|
|
164
234
|
cleanupPlanFile(sandboxName, worktreeDir);
|
|
165
235
|
|
|
166
236
|
const finalPlan = readPlanFile(worktreeDir);
|
|
@@ -173,7 +243,9 @@ export async function runClaude(
|
|
|
173
243
|
log.ok("Plan file cleaned up.");
|
|
174
244
|
}
|
|
175
245
|
|
|
176
|
-
|
|
246
|
+
// Summary
|
|
247
|
+
const summary = await runSummaryPhase(sandboxName, worktreeDir, defaultBranch, summaryPrompt);
|
|
248
|
+
return summary || lastResult;
|
|
177
249
|
}
|
|
178
250
|
|
|
179
251
|
async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs: string[]): Promise<string> {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const entrypoint = path.join(import.meta.dir, "index.ts");
|
|
5
|
+
const bunPath = Bun.which("bun") ?? process.execPath;
|
|
6
|
+
const packageJson = require("../package.json");
|
|
7
|
+
|
|
8
|
+
describe("--version flag", () => {
|
|
9
|
+
test("--version prints the version from package.json", async () => {
|
|
10
|
+
const proc = Bun.spawn([bunPath, "run", entrypoint, "--version"], {
|
|
11
|
+
stdout: "pipe",
|
|
12
|
+
stderr: "pipe",
|
|
13
|
+
});
|
|
14
|
+
const stdout = await new Response(proc.stdout).text();
|
|
15
|
+
await proc.exited;
|
|
16
|
+
expect(proc.exitCode).toBe(0);
|
|
17
|
+
expect(stdout.trim()).toBe(packageJson.version);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("-v prints the version from package.json", async () => {
|
|
21
|
+
const proc = Bun.spawn([bunPath, "run", entrypoint, "-v"], {
|
|
22
|
+
stdout: "pipe",
|
|
23
|
+
stderr: "pipe",
|
|
24
|
+
});
|
|
25
|
+
const stdout = await new Response(proc.stdout).text();
|
|
26
|
+
await proc.exited;
|
|
27
|
+
expect(proc.exitCode).toBe(0);
|
|
28
|
+
expect(stdout.trim()).toBe(packageJson.version);
|
|
29
|
+
});
|
|
30
|
+
});
|
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 };
|