zdev 0.2.2 ā 0.2.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/dist/index.js +343 -284
- package/package.json +1 -1
- package/src/commands/pr.ts +155 -111
- package/src/index.ts +2 -0
package/package.json
CHANGED
package/src/commands/pr.ts
CHANGED
|
@@ -8,6 +8,123 @@ export interface PrOptions {
|
|
|
8
8
|
body?: string;
|
|
9
9
|
draft?: boolean;
|
|
10
10
|
web?: boolean;
|
|
11
|
+
ai?: boolean; // Use AI (Claude) to generate title/body
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Use Claude to generate PR title and description from diff
|
|
16
|
+
*/
|
|
17
|
+
function generateWithAI(diff: string, commits: string[], worktreePath: string): { title: string; body: string } | null {
|
|
18
|
+
// Check if claude CLI is available
|
|
19
|
+
const claudeCheck = run("which", ["claude"]);
|
|
20
|
+
if (!claudeCheck.success) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const prompt = `You are generating a GitHub PR title and description.
|
|
25
|
+
|
|
26
|
+
Based on the following git diff and commit messages, generate:
|
|
27
|
+
1. A concise PR title (max 72 chars, no quotes)
|
|
28
|
+
2. A brief description of the changes (2-4 bullet points)
|
|
29
|
+
|
|
30
|
+
Commit messages:
|
|
31
|
+
${commits.map(c => `- ${c}`).join("\n")}
|
|
32
|
+
|
|
33
|
+
Diff summary (first 3000 chars):
|
|
34
|
+
${diff.slice(0, 3000)}
|
|
35
|
+
|
|
36
|
+
Respond in this exact format:
|
|
37
|
+
TITLE: <your title here>
|
|
38
|
+
BODY:
|
|
39
|
+
- <bullet point 1>
|
|
40
|
+
- <bullet point 2>
|
|
41
|
+
- <bullet point 3>`;
|
|
42
|
+
|
|
43
|
+
const result = run("claude", ["-p", prompt, "--no-input"], {
|
|
44
|
+
cwd: worktreePath,
|
|
45
|
+
env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: "zdev" }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!result.success || !result.stdout) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const output = result.stdout.trim();
|
|
53
|
+
const titleMatch = output.match(/TITLE:\s*(.+)/);
|
|
54
|
+
const bodyMatch = output.match(/BODY:\s*([\s\S]+)/);
|
|
55
|
+
|
|
56
|
+
if (!titleMatch) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
title: titleMatch[1].trim().replace(/^["']|["']$/g, ""),
|
|
62
|
+
body: bodyMatch ? bodyMatch[1].trim() : "",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Generate a smart PR title based on changed files and commits
|
|
68
|
+
*/
|
|
69
|
+
function generateSmartTitle(files: string[], commits: string[], featureName: string): string {
|
|
70
|
+
const components = new Set<string>();
|
|
71
|
+
const areas = new Set<string>();
|
|
72
|
+
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
if (!file.match(/\.(tsx?|jsx?|css|scss)$/)) continue;
|
|
75
|
+
|
|
76
|
+
const componentMatch = file.match(/components\/([^/]+)\/([^/]+)\.(tsx?|jsx?)$/);
|
|
77
|
+
if (componentMatch) {
|
|
78
|
+
components.add(componentMatch[2].replace(/\.(tsx?|jsx?)$/, ""));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const singleComponent = file.match(/components\/([^/]+)\.(tsx?|jsx?)$/);
|
|
83
|
+
if (singleComponent) {
|
|
84
|
+
components.add(singleComponent[1]);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const routeMatch = file.match(/routes\/(.+)\.(tsx?|jsx?)$/);
|
|
89
|
+
if (routeMatch) {
|
|
90
|
+
const routeName = routeMatch[1].replace(/[[\]$_.]/g, " ").trim();
|
|
91
|
+
if (routeName && routeName !== "index") {
|
|
92
|
+
areas.add(routeName);
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const pathParts = file.split("/");
|
|
98
|
+
if (pathParts.length > 1) {
|
|
99
|
+
const folder = pathParts[pathParts.length - 2];
|
|
100
|
+
if (!["src", "web", "app", "lib", "utils"].includes(folder)) {
|
|
101
|
+
areas.add(folder);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const items = [...components, ...areas].slice(0, 3);
|
|
107
|
+
|
|
108
|
+
if (items.length > 0) {
|
|
109
|
+
let action = "Update";
|
|
110
|
+
const commitText = commits.join(" ").toLowerCase();
|
|
111
|
+
if (commitText.includes("fix")) action = "Fix";
|
|
112
|
+
else if (commitText.includes("add") || commitText.includes("new")) action = "Add";
|
|
113
|
+
else if (commitText.includes("refactor")) action = "Refactor";
|
|
114
|
+
else if (commitText.includes("improve") || commitText.includes("enhance")) action = "Improve";
|
|
115
|
+
else if (commitText.includes("mobile") || commitText.includes("responsive")) action = "Improve";
|
|
116
|
+
|
|
117
|
+
return `${action} ${items.join(", ")}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (commits.length > 0 && commits[0].length < 72) {
|
|
121
|
+
return commits[0];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return featureName
|
|
125
|
+
.split(/[-_]/)
|
|
126
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
127
|
+
.join(" ");
|
|
11
128
|
}
|
|
12
129
|
|
|
13
130
|
export async function pr(
|
|
@@ -17,22 +134,20 @@ export async function pr(
|
|
|
17
134
|
): Promise<void> {
|
|
18
135
|
const fullPath = resolve(projectPath);
|
|
19
136
|
|
|
20
|
-
// Check if we're in a worktree or need to find one
|
|
21
137
|
let worktreePath = fullPath;
|
|
22
138
|
let allocation: WorktreeAllocation | undefined;
|
|
23
139
|
let projectName = getRepoName(fullPath) || basename(fullPath);
|
|
24
140
|
|
|
25
141
|
const config = loadConfig();
|
|
26
142
|
|
|
27
|
-
//
|
|
143
|
+
// Find allocation if featureName provided
|
|
28
144
|
if (featureName) {
|
|
29
145
|
const allocKey = `${projectName}-${featureName}`;
|
|
30
146
|
allocation = config.allocations[allocKey];
|
|
31
147
|
|
|
32
148
|
if (!allocation) {
|
|
33
|
-
// Try to find by just feature name
|
|
34
149
|
const found = Object.entries(config.allocations).find(
|
|
35
|
-
([key
|
|
150
|
+
([key]) => key.endsWith(`-${featureName}`)
|
|
36
151
|
);
|
|
37
152
|
if (found) {
|
|
38
153
|
allocation = found[1];
|
|
@@ -44,7 +159,7 @@ export async function pr(
|
|
|
44
159
|
worktreePath = allocation.worktreePath || resolve(config.worktreesDir, `${projectName}-${featureName}`);
|
|
45
160
|
}
|
|
46
161
|
} else {
|
|
47
|
-
//
|
|
162
|
+
// Detect from current directory
|
|
48
163
|
const cwd = process.cwd();
|
|
49
164
|
const found = Object.entries(config.allocations).find(
|
|
50
165
|
([_, alloc]) => alloc.worktreePath === cwd || cwd.startsWith(alloc.worktreePath || "")
|
|
@@ -76,149 +191,82 @@ export async function pr(
|
|
|
76
191
|
console.log(` Feature: ${featureName}`);
|
|
77
192
|
}
|
|
78
193
|
|
|
79
|
-
// Check
|
|
194
|
+
// Check gh CLI
|
|
80
195
|
const ghCheck = run("which", ["gh"]);
|
|
81
196
|
if (!ghCheck.success) {
|
|
82
197
|
console.error("ā GitHub CLI (gh) not found. Install: https://cli.github.com");
|
|
83
198
|
process.exit(1);
|
|
84
199
|
}
|
|
85
200
|
|
|
86
|
-
// Check if authenticated
|
|
87
201
|
const authCheck = run("gh", ["auth", "status"], { cwd: worktreePath });
|
|
88
202
|
if (!authCheck.success) {
|
|
89
203
|
console.error("ā Not authenticated with GitHub. Run: gh auth login");
|
|
90
204
|
process.exit(1);
|
|
91
205
|
}
|
|
92
206
|
|
|
93
|
-
// Push branch
|
|
207
|
+
// Push branch
|
|
94
208
|
console.log(`\nš¤ Pushing branch...`);
|
|
95
209
|
const pushResult = run("git", ["push", "-u", "origin", branch], { cwd: worktreePath });
|
|
96
|
-
if (!pushResult.success) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
console.error(` Failed to push: ${pushResult.stderr}`);
|
|
100
|
-
process.exit(1);
|
|
101
|
-
}
|
|
102
|
-
console.log(` Already up to date`);
|
|
103
|
-
} else {
|
|
104
|
-
console.log(` Pushed to origin/${branch}`);
|
|
210
|
+
if (!pushResult.success && !pushResult.stderr.includes("Everything up-to-date")) {
|
|
211
|
+
console.error(` Failed to push: ${pushResult.stderr}`);
|
|
212
|
+
process.exit(1);
|
|
105
213
|
}
|
|
214
|
+
console.log(` Pushed to origin/${branch}`);
|
|
106
215
|
|
|
107
|
-
// Get base branch
|
|
216
|
+
// Get base branch
|
|
108
217
|
const defaultBranch = run("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], { cwd: worktreePath });
|
|
109
218
|
const baseBranch = defaultBranch.success ? defaultBranch.stdout.trim().replace("origin/", "") : "main";
|
|
110
219
|
|
|
111
|
-
// Get commits
|
|
220
|
+
// Get commits and diff info
|
|
112
221
|
const commitsResult = run("git", ["log", `origin/${baseBranch}..HEAD`, "--pretty=format:%s"], { cwd: worktreePath });
|
|
113
222
|
const commits = commitsResult.success ? commitsResult.stdout.trim().split("\n").filter(Boolean) : [];
|
|
114
223
|
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
const filesSummary = filesResult.success ? filesResult.stdout.trim() : "";
|
|
224
|
+
const diffResult = run("git", ["diff", `origin/${baseBranch}..HEAD`], { cwd: worktreePath });
|
|
225
|
+
const diff = diffResult.success ? diffResult.stdout : "";
|
|
118
226
|
|
|
119
|
-
// Get short diff summary
|
|
120
227
|
const diffStatResult = run("git", ["diff", `origin/${baseBranch}..HEAD`, "--shortstat"], { cwd: worktreePath });
|
|
121
228
|
const diffStat = diffStatResult.success ? diffStatResult.stdout.trim() : "";
|
|
122
229
|
|
|
123
|
-
// Get changed files for smart title generation
|
|
124
230
|
const changedFilesResult = run("git", ["diff", `origin/${baseBranch}..HEAD`, "--name-only"], { cwd: worktreePath });
|
|
125
231
|
const changedFiles = changedFilesResult.success ? changedFilesResult.stdout.trim().split("\n").filter(Boolean) : [];
|
|
126
232
|
|
|
127
|
-
//
|
|
233
|
+
// Generate title and body
|
|
128
234
|
let title = options.title;
|
|
129
|
-
|
|
130
|
-
// Try to generate smart title from changed files
|
|
131
|
-
title = generateSmartTitle(changedFiles, commits, featureName || branch.replace(/^feature\//, ""));
|
|
132
|
-
}
|
|
133
|
-
}
|
|
235
|
+
let aiBody = "";
|
|
134
236
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (!file.match(/\.(tsx?|jsx?|css|scss)$/)) continue;
|
|
146
|
-
|
|
147
|
-
// Extract component names from paths like src/components/OrderCard.tsx
|
|
148
|
-
const componentMatch = file.match(/components\/([^/]+)\/([^/]+)\.(tsx?|jsx?)$/);
|
|
149
|
-
if (componentMatch) {
|
|
150
|
-
components.add(componentMatch[2].replace(/\.(tsx?|jsx?)$/, ""));
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Single component file
|
|
155
|
-
const singleComponent = file.match(/components\/([^/]+)\.(tsx?|jsx?)$/);
|
|
156
|
-
if (singleComponent) {
|
|
157
|
-
components.add(singleComponent[1]);
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Routes
|
|
162
|
-
const routeMatch = file.match(/routes\/(.+)\.(tsx?|jsx?)$/);
|
|
163
|
-
if (routeMatch) {
|
|
164
|
-
const routeName = routeMatch[1].replace(/[[\]$_.]/g, " ").trim();
|
|
165
|
-
if (routeName && routeName !== "index") {
|
|
166
|
-
areas.add(routeName);
|
|
167
|
-
}
|
|
168
|
-
continue;
|
|
237
|
+
// Try AI generation if --ai flag or no title provided
|
|
238
|
+
if (options.ai || (!title && diff.length > 0)) {
|
|
239
|
+
console.log(`\nš¤ Generating PR content with AI...`);
|
|
240
|
+
const aiResult = generateWithAI(diff, commits, worktreePath);
|
|
241
|
+
if (aiResult) {
|
|
242
|
+
if (!title) title = aiResult.title;
|
|
243
|
+
aiBody = aiResult.body;
|
|
244
|
+
console.log(` Generated title: ${title}`);
|
|
245
|
+
} else if (options.ai) {
|
|
246
|
+
console.log(` AI generation failed, using smart fallback`);
|
|
169
247
|
}
|
|
170
|
-
|
|
171
|
-
// Other meaningful paths
|
|
172
|
-
const pathParts = file.split("/");
|
|
173
|
-
if (pathParts.length > 1) {
|
|
174
|
-
const folder = pathParts[pathParts.length - 2];
|
|
175
|
-
if (!["src", "web", "app", "lib", "utils"].includes(folder)) {
|
|
176
|
-
areas.add(folder);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Build title from components/areas
|
|
182
|
-
const items = [...components, ...areas].slice(0, 3);
|
|
183
|
-
|
|
184
|
-
if (items.length > 0) {
|
|
185
|
-
// Determine action from commits
|
|
186
|
-
let action = "Update";
|
|
187
|
-
const commitText = commits.join(" ").toLowerCase();
|
|
188
|
-
if (commitText.includes("fix")) action = "Fix";
|
|
189
|
-
else if (commitText.includes("add") || commitText.includes("new")) action = "Add";
|
|
190
|
-
else if (commitText.includes("refactor")) action = "Refactor";
|
|
191
|
-
else if (commitText.includes("improve") || commitText.includes("enhance")) action = "Improve";
|
|
192
|
-
else if (commitText.includes("mobile") || commitText.includes("responsive")) action = "Improve";
|
|
193
|
-
|
|
194
|
-
return `${action} ${items.join(", ")}`;
|
|
195
248
|
}
|
|
196
|
-
|
|
197
|
-
// Fallback
|
|
198
|
-
if (
|
|
199
|
-
|
|
249
|
+
|
|
250
|
+
// Fallback to smart title
|
|
251
|
+
if (!title) {
|
|
252
|
+
title = generateSmartTitle(changedFiles, commits, featureName || branch.replace(/^feature\//, ""));
|
|
200
253
|
}
|
|
201
|
-
|
|
202
|
-
// Final fallback: humanize feature name
|
|
203
|
-
return featureName
|
|
204
|
-
.split(/[-_]/)
|
|
205
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
206
|
-
.join(" ");
|
|
207
254
|
|
|
208
255
|
// Build PR body
|
|
209
256
|
let body = options.body || "";
|
|
210
257
|
|
|
211
|
-
// Add preview URL
|
|
212
|
-
if (allocation
|
|
258
|
+
// Add preview URL
|
|
259
|
+
if (allocation?.publicUrl) {
|
|
213
260
|
body += `## Preview\nš ${allocation.publicUrl}\n\n`;
|
|
214
261
|
} else if (config.devDomain && featureName && projectName) {
|
|
215
|
-
// Try to construct preview URL
|
|
216
262
|
const previewUrl = `https://${projectName}-${featureName}.${config.devDomain}`;
|
|
217
263
|
body += `## Preview\nš ${previewUrl}\n\n`;
|
|
218
264
|
}
|
|
219
265
|
|
|
220
|
-
// Add
|
|
221
|
-
if (
|
|
266
|
+
// Add AI-generated body or commits
|
|
267
|
+
if (aiBody) {
|
|
268
|
+
body += `## Changes\n${aiBody}\n\n`;
|
|
269
|
+
} else if (commits.length > 0) {
|
|
222
270
|
body += `## Changes\n`;
|
|
223
271
|
commits.forEach((commit) => {
|
|
224
272
|
body += `- ${commit}\n`;
|
|
@@ -226,7 +274,7 @@ function generateSmartTitle(files: string[], commits: string[], featureName: str
|
|
|
226
274
|
body += "\n";
|
|
227
275
|
}
|
|
228
276
|
|
|
229
|
-
// Add
|
|
277
|
+
// Add stats
|
|
230
278
|
if (diffStat) {
|
|
231
279
|
body += `## Summary\n\`\`\`\n${diffStat}\n\`\`\`\n\n`;
|
|
232
280
|
}
|
|
@@ -251,6 +299,7 @@ function generateSmartTitle(files: string[], commits: string[], featureName: str
|
|
|
251
299
|
|
|
252
300
|
// Create PR
|
|
253
301
|
console.log(`\nš Creating pull request...`);
|
|
302
|
+
console.log(` Title: ${title}`);
|
|
254
303
|
|
|
255
304
|
const prArgs = ["pr", "create", "--title", title, "--body", body];
|
|
256
305
|
|
|
@@ -272,7 +321,6 @@ function generateSmartTitle(files: string[], commits: string[], featureName: str
|
|
|
272
321
|
const prResult = run("gh", prArgs, { cwd: worktreePath });
|
|
273
322
|
|
|
274
323
|
if (!prResult.success) {
|
|
275
|
-
// Check if it's because PR already exists
|
|
276
324
|
if (prResult.stderr.includes("already exists")) {
|
|
277
325
|
console.log(` PR already exists for this branch`);
|
|
278
326
|
const viewResult = run("gh", ["pr", "view", "--json", "url"], { cwd: worktreePath });
|
|
@@ -280,9 +328,7 @@ function generateSmartTitle(files: string[], commits: string[], featureName: str
|
|
|
280
328
|
try {
|
|
281
329
|
const prData = JSON.parse(viewResult.stdout);
|
|
282
330
|
console.log(`\nš ${prData.url}`);
|
|
283
|
-
} catch {
|
|
284
|
-
// Ignore
|
|
285
|
-
}
|
|
331
|
+
} catch {}
|
|
286
332
|
}
|
|
287
333
|
return;
|
|
288
334
|
}
|
|
@@ -290,13 +336,11 @@ function generateSmartTitle(files: string[], commits: string[], featureName: str
|
|
|
290
336
|
process.exit(1);
|
|
291
337
|
}
|
|
292
338
|
|
|
293
|
-
// Extract PR URL from output
|
|
294
339
|
const prUrl = prResult.stdout.trim();
|
|
295
340
|
|
|
296
341
|
console.log(`\nā
Pull request created!`);
|
|
297
342
|
console.log(`\nš ${prUrl}`);
|
|
298
343
|
|
|
299
|
-
// Show preview URL again for easy access
|
|
300
344
|
if (allocation?.publicUrl) {
|
|
301
345
|
console.log(`\nš± Preview: ${allocation.publicUrl}`);
|
|
302
346
|
}
|
package/src/index.ts
CHANGED
|
@@ -134,12 +134,14 @@ program
|
|
|
134
134
|
.option("-b, --body <body>", "PR body (preview URL auto-added)")
|
|
135
135
|
.option("-d, --draft", "Create as draft PR")
|
|
136
136
|
.option("-w, --web", "Open in browser instead of CLI")
|
|
137
|
+
.option("--ai", "Use Claude to generate title and description from diff")
|
|
137
138
|
.action(async (feature, options) => {
|
|
138
139
|
await pr(feature, options.project, {
|
|
139
140
|
title: options.title,
|
|
140
141
|
body: options.body,
|
|
141
142
|
draft: options.draft,
|
|
142
143
|
web: options.web,
|
|
144
|
+
ai: options.ai,
|
|
143
145
|
});
|
|
144
146
|
});
|
|
145
147
|
|