@jojonax/codex-copilot 1.0.2 → 1.1.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/bin/cli.js +33 -22
- package/package.json +1 -1
- package/src/commands/init.js +111 -103
- package/src/commands/reset.js +25 -17
- package/src/commands/run.js +311 -210
- package/src/commands/status.js +37 -20
- package/src/utils/automator.js +279 -0
- package/src/utils/checkpoint.js +129 -0
- package/src/utils/detect-prd.js +18 -18
- package/src/utils/git.js +22 -18
- package/src/utils/github.js +157 -21
- package/src/utils/logger.js +14 -13
- package/src/utils/prompt.js +7 -7
- package/src/utils/provider.js +332 -0
- package/src/utils/update-check.js +103 -0
package/src/commands/run.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* codex-copilot run -
|
|
2
|
+
* codex-copilot run - Main orchestration loop
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Executes tasks one by one: Develop → PR → Review → Fix → Merge → Next
|
|
5
|
+
* Features fine-grained checkpoint/resume at every sub-step.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
8
9
|
import { resolve } from 'path';
|
|
9
|
-
import { execSync } from 'child_process';
|
|
10
10
|
import { log, progressBar } from '../utils/logger.js';
|
|
11
11
|
import { git } from '../utils/git.js';
|
|
12
12
|
import { github } from '../utils/github.js';
|
|
13
13
|
import { ask, confirm, closePrompt } from '../utils/prompt.js';
|
|
14
|
+
import { createCheckpoint } from '../utils/checkpoint.js';
|
|
15
|
+
import { provider } from '../utils/provider.js';
|
|
14
16
|
|
|
15
17
|
function readJSON(path) {
|
|
16
18
|
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
@@ -22,214 +24,287 @@ function writeJSON(path, data) {
|
|
|
22
24
|
|
|
23
25
|
export async function run(projectDir) {
|
|
24
26
|
const tasksPath = resolve(projectDir, '.codex-copilot/tasks.json');
|
|
25
|
-
const statePath = resolve(projectDir, '.codex-copilot/state.json');
|
|
26
27
|
const configPath = resolve(projectDir, '.codex-copilot/config.json');
|
|
28
|
+
const checkpoint = createCheckpoint(projectDir);
|
|
27
29
|
|
|
28
30
|
let tasks;
|
|
29
31
|
let state;
|
|
30
32
|
try {
|
|
31
33
|
tasks = readJSON(tasksPath);
|
|
32
|
-
state =
|
|
34
|
+
state = checkpoint.load();
|
|
33
35
|
} catch (err) {
|
|
34
|
-
log.error(
|
|
35
|
-
log.warn('
|
|
36
|
+
log.error(`Failed to read task/state files: ${err.message}`);
|
|
37
|
+
log.warn('Files may be corrupted. Run: codex-copilot reset');
|
|
36
38
|
closePrompt();
|
|
37
39
|
process.exit(1);
|
|
38
40
|
}
|
|
39
41
|
const config = existsSync(configPath) ? readJSON(configPath) : {};
|
|
40
42
|
|
|
41
|
-
//
|
|
43
|
+
// Validate tasks.json structure
|
|
42
44
|
if (!tasks.tasks || !Array.isArray(tasks.tasks) || !tasks.total) {
|
|
43
|
-
log.error('tasks.json
|
|
44
|
-
log.warn('
|
|
45
|
+
log.error('Invalid tasks.json format: missing tasks array or total field');
|
|
46
|
+
log.warn('Please re-run codex-copilot init and let CodeX regenerate tasks.json');
|
|
45
47
|
closePrompt();
|
|
46
48
|
process.exit(1);
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
const baseBranch = config.base_branch || 'main';
|
|
52
|
+
const providerId = config.provider || 'codex-cli';
|
|
50
53
|
const maxReviewRounds = config.max_review_rounds || 2;
|
|
51
54
|
const pollInterval = config.review_poll_interval || 60;
|
|
52
55
|
const waitTimeout = config.review_wait_timeout || 600;
|
|
53
56
|
|
|
54
|
-
|
|
57
|
+
const providerInfo = provider.getProvider(providerId);
|
|
58
|
+
log.info(`AI Provider: ${providerInfo ? providerInfo.name : providerId}`);
|
|
59
|
+
|
|
60
|
+
// ===== Graceful shutdown handler =====
|
|
61
|
+
let shuttingDown = false;
|
|
62
|
+
const gracefulShutdown = () => {
|
|
63
|
+
if (shuttingDown) {
|
|
64
|
+
log.warn('Force exit');
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
shuttingDown = true;
|
|
68
|
+
log.blank();
|
|
69
|
+
log.warn('Interrupt received — saving checkpoint...');
|
|
70
|
+
// State is already saved at each step, just need to save tasks.json
|
|
71
|
+
writeJSON(tasksPath, tasks);
|
|
72
|
+
log.info('✅ Checkpoint saved. Run `codex-copilot run` to resume.');
|
|
73
|
+
log.blank();
|
|
74
|
+
closePrompt();
|
|
75
|
+
process.exit(0);
|
|
76
|
+
};
|
|
77
|
+
process.on('SIGINT', gracefulShutdown);
|
|
78
|
+
process.on('SIGTERM', gracefulShutdown);
|
|
79
|
+
|
|
80
|
+
log.title('🚀 Starting automated development loop');
|
|
55
81
|
log.blank();
|
|
56
|
-
log.info(
|
|
57
|
-
log.info(
|
|
58
|
-
log.info(
|
|
59
|
-
|
|
82
|
+
log.info(`Project: ${tasks.project}`);
|
|
83
|
+
log.info(`Total tasks: ${tasks.total}`);
|
|
84
|
+
log.info(`Base branch: ${baseBranch}`);
|
|
85
|
+
|
|
86
|
+
// ===== Pre-flight: ensure base branch is committed & pushed =====
|
|
87
|
+
await ensureBaseReady(projectDir, baseBranch);
|
|
88
|
+
|
|
89
|
+
// Show resume info if resuming mid-task
|
|
90
|
+
if (state.phase && state.current_task > 0) {
|
|
91
|
+
log.blank();
|
|
92
|
+
log.info(`⏩ Resuming task #${state.current_task} from: ${state.phase} → ${state.phase_step}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const completedCount = tasks.tasks.filter(t => t.status === 'completed').length;
|
|
60
96
|
log.blank();
|
|
61
|
-
progressBar(
|
|
97
|
+
progressBar(completedCount, tasks.total, `${completedCount}/${tasks.total} tasks done`);
|
|
62
98
|
log.blank();
|
|
63
99
|
|
|
64
|
-
//
|
|
100
|
+
// Execute tasks one by one
|
|
65
101
|
for (const task of tasks.tasks) {
|
|
66
|
-
|
|
102
|
+
// Skip fully completed tasks
|
|
67
103
|
if (task.status === 'completed' || task.status === 'skipped') continue;
|
|
68
104
|
|
|
105
|
+
// Skip tasks whose ID is below the completed threshold (and not the resuming task)
|
|
106
|
+
const isResumingTask = state.current_task === task.id && state.phase;
|
|
107
|
+
if (task.id < state.current_task && !isResumingTask) continue;
|
|
108
|
+
|
|
69
109
|
log.blank();
|
|
70
|
-
log.title(`━━━
|
|
110
|
+
log.title(`━━━ Task ${task.id}/${tasks.total}: ${task.title} ━━━`);
|
|
71
111
|
log.blank();
|
|
72
112
|
|
|
73
|
-
//
|
|
113
|
+
// Check dependencies
|
|
74
114
|
if (task.depends_on && task.depends_on.length > 0) {
|
|
75
115
|
const unfinished = task.depends_on.filter(dep => {
|
|
76
116
|
const depTask = tasks.tasks.find(t => t.id === dep);
|
|
77
117
|
return depTask && depTask.status !== 'completed';
|
|
78
118
|
});
|
|
79
119
|
if (unfinished.length > 0) {
|
|
80
|
-
log.warn(
|
|
120
|
+
log.warn(`Unfinished dependencies: ${unfinished.join(', ')} — skipping`);
|
|
81
121
|
continue;
|
|
82
122
|
}
|
|
83
123
|
}
|
|
84
124
|
|
|
85
|
-
//
|
|
86
|
-
|
|
125
|
+
// Mark task as in_progress
|
|
126
|
+
task.status = 'in_progress';
|
|
127
|
+
writeJSON(tasksPath, tasks);
|
|
87
128
|
|
|
88
|
-
// =====
|
|
89
|
-
|
|
129
|
+
// ===== Phase 1: Develop =====
|
|
130
|
+
if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
|
|
131
|
+
await developPhase(projectDir, task, baseBranch, checkpoint, providerId);
|
|
132
|
+
} else {
|
|
133
|
+
log.dim('⏩ Skipping develop phase (already completed)');
|
|
134
|
+
}
|
|
90
135
|
|
|
91
|
-
// =====
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
136
|
+
// ===== Phase 2: Create PR =====
|
|
137
|
+
let prInfo;
|
|
138
|
+
if (!checkpoint.isStepDone(task.id, 'pr', 'pr_created')) {
|
|
139
|
+
prInfo = await prPhase(projectDir, task, baseBranch, checkpoint);
|
|
140
|
+
} else {
|
|
141
|
+
// PR already created, load from state
|
|
142
|
+
state = checkpoint.load();
|
|
143
|
+
prInfo = { number: state.current_pr, url: '' };
|
|
144
|
+
log.dim(`⏩ Skipping PR phase (PR #${prInfo.number} already created)`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ===== Phase 3: Review loop =====
|
|
148
|
+
if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
|
|
149
|
+
await reviewLoop(projectDir, task, prInfo, {
|
|
150
|
+
maxRounds: maxReviewRounds,
|
|
151
|
+
pollInterval,
|
|
152
|
+
waitTimeout,
|
|
153
|
+
}, checkpoint, providerId);
|
|
154
|
+
} else {
|
|
155
|
+
log.dim('⏩ Skipping review phase (already completed)');
|
|
156
|
+
}
|
|
97
157
|
|
|
98
|
-
// =====
|
|
99
|
-
|
|
158
|
+
// ===== Phase 4: Merge =====
|
|
159
|
+
if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
|
|
160
|
+
await mergePhase(projectDir, task, prInfo, baseBranch, checkpoint);
|
|
161
|
+
} else {
|
|
162
|
+
log.dim('⏩ Skipping merge phase (already merged)');
|
|
163
|
+
}
|
|
100
164
|
|
|
101
|
-
//
|
|
165
|
+
// Mark task complete
|
|
102
166
|
task.status = 'completed';
|
|
103
|
-
state.current_task = task.id;
|
|
104
|
-
state.current_pr = null;
|
|
105
|
-
state.review_round = 0;
|
|
106
167
|
writeJSON(tasksPath, tasks);
|
|
107
|
-
|
|
168
|
+
checkpoint.completeTask(task.id);
|
|
108
169
|
|
|
109
170
|
log.blank();
|
|
110
|
-
|
|
111
|
-
|
|
171
|
+
const done = tasks.tasks.filter(t => t.status === 'completed').length;
|
|
172
|
+
progressBar(done, tasks.total, `${done}/${tasks.total} tasks done`);
|
|
173
|
+
log.info(`✅ Task #${task.id} complete!`);
|
|
112
174
|
}
|
|
113
175
|
|
|
114
176
|
log.blank();
|
|
115
|
-
log.title('🎉
|
|
177
|
+
log.title('🎉 All tasks complete!');
|
|
116
178
|
log.blank();
|
|
179
|
+
process.removeListener('SIGINT', gracefulShutdown);
|
|
180
|
+
process.removeListener('SIGTERM', gracefulShutdown);
|
|
117
181
|
closePrompt();
|
|
118
182
|
}
|
|
119
183
|
|
|
120
184
|
// ──────────────────────────────────────────────
|
|
121
|
-
//
|
|
185
|
+
// Phase 1: Develop
|
|
122
186
|
// ──────────────────────────────────────────────
|
|
123
|
-
async function developPhase(projectDir, task, baseBranch) {
|
|
124
|
-
log.step('
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (codexAvailable) {
|
|
137
|
-
log.info('检测到 CodeX CLI,自动执行...');
|
|
138
|
-
const autoRun = await confirm('自动调用 CodeX 开发?');
|
|
139
|
-
if (autoRun) {
|
|
140
|
-
try {
|
|
141
|
-
const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
|
|
142
|
-
writeFileSync(promptPath, devPrompt);
|
|
143
|
-
execSync(`codex -q --file .codex-copilot/_current_prompt.md`, {
|
|
144
|
-
cwd: projectDir,
|
|
145
|
-
stdio: 'inherit',
|
|
146
|
-
});
|
|
147
|
-
log.info('CodeX 开发完成');
|
|
148
|
-
return;
|
|
149
|
-
} catch (err) {
|
|
150
|
-
log.warn(`CodeX CLI 调用失败: ${err.message}`);
|
|
151
|
-
log.warn('回退到手动模式');
|
|
152
|
-
}
|
|
187
|
+
async function developPhase(projectDir, task, baseBranch, checkpoint, providerId) {
|
|
188
|
+
log.step('Phase 1/4: Develop');
|
|
189
|
+
|
|
190
|
+
// Step 1: Switch to feature branch
|
|
191
|
+
if (!checkpoint.isStepDone(task.id, 'develop', 'branch_created')) {
|
|
192
|
+
git.checkoutBranch(projectDir, task.branch, baseBranch);
|
|
193
|
+
log.info(`Switched to branch: ${task.branch}`);
|
|
194
|
+
checkpoint.saveStep(task.id, 'develop', 'branch_created', { branch: task.branch });
|
|
195
|
+
} else {
|
|
196
|
+
// Ensure we're on the right branch
|
|
197
|
+
const current = git.currentBranch(projectDir);
|
|
198
|
+
if (current !== task.branch) {
|
|
199
|
+
git.checkoutBranch(projectDir, task.branch, baseBranch);
|
|
153
200
|
}
|
|
201
|
+
log.dim('⏩ Branch already created');
|
|
154
202
|
}
|
|
155
203
|
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
|
|
167
|
-
writeFileSync(promptPath, devPrompt);
|
|
168
|
-
log.dim(`Prompt 已保存到 .codex-copilot/_current_prompt.md (可直接复制文件内容)`);
|
|
169
|
-
log.blank();
|
|
170
|
-
|
|
171
|
-
// 尝试复制到剪贴板(通过 stdin 传入,避免 shell 注入)
|
|
172
|
-
copyToClipboard(devPrompt);
|
|
204
|
+
// Step 2: Build development prompt
|
|
205
|
+
if (!checkpoint.isStepDone(task.id, 'develop', 'prompt_ready')) {
|
|
206
|
+
const devPrompt = buildDevPrompt(task);
|
|
207
|
+
const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
|
|
208
|
+
writeFileSync(promptPath, devPrompt);
|
|
209
|
+
checkpoint.saveStep(task.id, 'develop', 'prompt_ready', { branch: task.branch });
|
|
210
|
+
log.info('Development prompt generated');
|
|
211
|
+
} else {
|
|
212
|
+
log.dim('⏩ Prompt already generated');
|
|
213
|
+
}
|
|
173
214
|
|
|
174
|
-
|
|
215
|
+
// Step 3: Execute via AI Provider
|
|
216
|
+
if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
|
|
217
|
+
const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
|
|
218
|
+
const ok = await provider.executePrompt(providerId, promptPath, projectDir);
|
|
219
|
+
if (ok) {
|
|
220
|
+
log.info('Development complete');
|
|
221
|
+
checkpoint.saveStep(task.id, 'develop', 'codex_complete', { branch: task.branch });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
175
224
|
}
|
|
176
225
|
|
|
177
226
|
// ──────────────────────────────────────────────
|
|
178
|
-
//
|
|
227
|
+
// Phase 2: Create PR
|
|
179
228
|
// ──────────────────────────────────────────────
|
|
180
|
-
async function prPhase(projectDir, task, baseBranch) {
|
|
181
|
-
log.step('
|
|
229
|
+
async function prPhase(projectDir, task, baseBranch, checkpoint) {
|
|
230
|
+
log.step('Phase 2/4: Submit PR');
|
|
182
231
|
|
|
183
|
-
//
|
|
184
|
-
if (!
|
|
185
|
-
|
|
186
|
-
|
|
232
|
+
// Step 1: Commit changes
|
|
233
|
+
if (!checkpoint.isStepDone(task.id, 'pr', 'committed')) {
|
|
234
|
+
if (!git.isClean(projectDir)) {
|
|
235
|
+
log.info('Uncommitted changes detected, auto-committing...');
|
|
236
|
+
git.commitAll(projectDir, `feat(task-${task.id}): ${task.title}`);
|
|
237
|
+
}
|
|
238
|
+
checkpoint.saveStep(task.id, 'pr', 'committed', { branch: task.branch });
|
|
239
|
+
log.info('Changes committed');
|
|
240
|
+
} else {
|
|
241
|
+
log.dim('⏩ Changes already committed');
|
|
187
242
|
}
|
|
188
243
|
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
244
|
+
// Step 2: Push
|
|
245
|
+
if (!checkpoint.isStepDone(task.id, 'pr', 'pushed')) {
|
|
246
|
+
git.pushBranch(projectDir, task.branch);
|
|
247
|
+
checkpoint.saveStep(task.id, 'pr', 'pushed', { branch: task.branch });
|
|
248
|
+
log.info('Code pushed');
|
|
249
|
+
} else {
|
|
250
|
+
log.dim('⏩ Code already pushed');
|
|
251
|
+
}
|
|
192
252
|
|
|
193
|
-
//
|
|
194
|
-
|
|
253
|
+
// Step 3: Create PR
|
|
254
|
+
if (!checkpoint.isStepDone(task.id, 'pr', 'pr_created')) {
|
|
255
|
+
const acceptanceList = Array.isArray(task.acceptance) && task.acceptance.length > 0
|
|
256
|
+
? task.acceptance.map(a => `- [ ] ${a}`).join('\n')
|
|
257
|
+
: '- [ ] Feature works correctly';
|
|
258
|
+
const prBody = `## Task #${task.id}: ${task.title}\n\n${task.description}\n\n### Acceptance Criteria\n${acceptanceList}\n\n---\n*Auto-created by Codex-Copilot*`;
|
|
195
259
|
|
|
196
|
-
|
|
197
|
-
const prInfo = github.
|
|
260
|
+
// Use auto-recovery: create → find existing → fix remote → retry
|
|
261
|
+
const prInfo = github.createPRWithRecovery(projectDir, {
|
|
198
262
|
title: `feat(task-${task.id}): ${task.title}`,
|
|
199
263
|
body: prBody,
|
|
200
264
|
base: baseBranch,
|
|
201
265
|
head: task.branch,
|
|
202
266
|
});
|
|
203
|
-
log.info(`PR
|
|
267
|
+
log.info(`PR ready: #${prInfo.number} ${prInfo.url}`);
|
|
268
|
+
|
|
269
|
+
checkpoint.saveStep(task.id, 'pr', 'pr_created', {
|
|
270
|
+
branch: task.branch,
|
|
271
|
+
current_pr: prInfo.number,
|
|
272
|
+
});
|
|
204
273
|
return prInfo;
|
|
205
|
-
} catch (err) {
|
|
206
|
-
// PR 可能已存在
|
|
207
|
-
log.warn(`创建 PR 异常: ${err.message}`);
|
|
208
|
-
const prNumber = await ask('请输入已存在的 PR 编号:');
|
|
209
|
-
const parsed = parseInt(prNumber, 10);
|
|
210
|
-
if (isNaN(parsed) || parsed <= 0) {
|
|
211
|
-
log.error('无效的 PR 编号');
|
|
212
|
-
throw new Error('无效的 PR 编号');
|
|
213
|
-
}
|
|
214
|
-
return { number: parsed, url: '' };
|
|
215
274
|
}
|
|
275
|
+
|
|
276
|
+
// If we get here, PR was already created — load from state
|
|
277
|
+
const state = checkpoint.load();
|
|
278
|
+
return { number: state.current_pr, url: '' };
|
|
216
279
|
}
|
|
217
280
|
|
|
218
281
|
// ──────────────────────────────────────────────
|
|
219
|
-
//
|
|
282
|
+
// Phase 3: Review loop
|
|
220
283
|
// ──────────────────────────────────────────────
|
|
221
|
-
async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout }) {
|
|
284
|
+
async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout }, checkpoint, providerId) {
|
|
222
285
|
let maxRounds = _maxRounds;
|
|
223
|
-
log.step('
|
|
286
|
+
log.step('Phase 3/4: Waiting for review');
|
|
287
|
+
|
|
288
|
+
// Resume from saved review round
|
|
289
|
+
const state = checkpoint.load();
|
|
290
|
+
const startRound = (state.current_task === task.id && state.review_round > 0)
|
|
291
|
+
? state.review_round
|
|
292
|
+
: 1;
|
|
293
|
+
|
|
294
|
+
for (let round = startRound; round <= maxRounds; round++) {
|
|
295
|
+
// Step: Waiting for review
|
|
296
|
+
checkpoint.saveStep(task.id, 'review', 'waiting_review', {
|
|
297
|
+
branch: task.branch,
|
|
298
|
+
current_pr: prInfo.number,
|
|
299
|
+
review_round: round,
|
|
300
|
+
});
|
|
224
301
|
|
|
225
|
-
|
|
226
|
-
// 等待 Review
|
|
227
|
-
log.info(`等待 Review 意见... (超时: ${waitTimeout}s)`);
|
|
302
|
+
log.info(`Waiting for review feedback... (timeout: ${waitTimeout}s)`);
|
|
228
303
|
const gotReview = await waitForReview(projectDir, prInfo.number, pollInterval, waitTimeout);
|
|
229
304
|
|
|
230
305
|
if (!gotReview) {
|
|
231
|
-
log.warn('
|
|
232
|
-
const action = await ask('
|
|
306
|
+
log.warn('Review wait timed out');
|
|
307
|
+
const action = await ask('Enter "skip" (skip review) or "wait" (keep waiting):');
|
|
233
308
|
if (action === 'wait') {
|
|
234
309
|
round--;
|
|
235
310
|
continue;
|
|
@@ -237,28 +312,35 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
|
|
|
237
312
|
break;
|
|
238
313
|
}
|
|
239
314
|
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
315
|
+
// Step: Feedback received
|
|
316
|
+
checkpoint.saveStep(task.id, 'review', 'feedback_received', {
|
|
317
|
+
branch: task.branch,
|
|
318
|
+
current_pr: prInfo.number,
|
|
319
|
+
review_round: round,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Check review status
|
|
323
|
+
const reviewState = github.getLatestReviewState(projectDir, prInfo.number);
|
|
324
|
+
log.info(`Review status: ${reviewState}`);
|
|
243
325
|
|
|
244
|
-
if (
|
|
245
|
-
log.info('✅ Review
|
|
326
|
+
if (reviewState === 'APPROVED') {
|
|
327
|
+
log.info('✅ Review approved!');
|
|
246
328
|
return;
|
|
247
329
|
}
|
|
248
330
|
|
|
249
|
-
//
|
|
331
|
+
// Collect review feedback
|
|
250
332
|
const feedback = github.collectReviewFeedback(projectDir, prInfo.number);
|
|
251
333
|
if (!feedback) {
|
|
252
|
-
log.info('
|
|
334
|
+
log.info('No specific change requests found, proceeding');
|
|
253
335
|
return;
|
|
254
336
|
}
|
|
255
337
|
|
|
256
338
|
log.blank();
|
|
257
|
-
log.warn(
|
|
339
|
+
log.warn(`Received review feedback (round ${round}/${maxRounds})`);
|
|
258
340
|
|
|
259
341
|
if (round >= maxRounds) {
|
|
260
|
-
log.warn(
|
|
261
|
-
const choice = await ask('
|
|
342
|
+
log.warn(`Max fix rounds reached (${maxRounds})`);
|
|
343
|
+
const choice = await ask('Enter "merge" (force merge) / "fix" (one more round) / "skip" (skip):');
|
|
262
344
|
if (choice === 'fix') {
|
|
263
345
|
maxRounds++;
|
|
264
346
|
} else if (choice === 'skip') {
|
|
@@ -268,17 +350,24 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
|
|
|
268
350
|
}
|
|
269
351
|
}
|
|
270
352
|
|
|
271
|
-
//
|
|
272
|
-
await fixPhase(projectDir, task, feedback, round);
|
|
353
|
+
// Let AI fix
|
|
354
|
+
await fixPhase(projectDir, task, feedback, round, providerId);
|
|
355
|
+
|
|
356
|
+
// Step: Fix applied
|
|
357
|
+
checkpoint.saveStep(task.id, 'review', 'fix_applied', {
|
|
358
|
+
branch: task.branch,
|
|
359
|
+
current_pr: prInfo.number,
|
|
360
|
+
review_round: round,
|
|
361
|
+
});
|
|
273
362
|
|
|
274
|
-
//
|
|
363
|
+
// Push fix
|
|
275
364
|
if (!git.isClean(projectDir)) {
|
|
276
365
|
git.commitAll(projectDir, `fix(task-${task.id}): address review comments (round ${round})`);
|
|
277
366
|
}
|
|
278
367
|
git.pushBranch(projectDir, task.branch);
|
|
279
|
-
log.info('
|
|
368
|
+
log.info('Fix pushed, waiting for next review round...');
|
|
280
369
|
|
|
281
|
-
//
|
|
370
|
+
// Brief wait for review bot to react
|
|
282
371
|
await sleep(10000);
|
|
283
372
|
}
|
|
284
373
|
}
|
|
@@ -294,7 +383,7 @@ async function waitForReview(projectDir, prNumber, pollInterval, timeout) {
|
|
|
294
383
|
const currentReviews = github.getReviews(projectDir, prNumber);
|
|
295
384
|
const currentComments = github.getIssueComments(projectDir, prNumber);
|
|
296
385
|
|
|
297
|
-
//
|
|
386
|
+
// Check for new reviews or bot comments
|
|
298
387
|
const hasNewReview = currentReviews.length > startReviewCount;
|
|
299
388
|
const hasBotComment = currentComments.some(c =>
|
|
300
389
|
(c.user?.type === 'Bot' || c.user?.login?.includes('bot')) &&
|
|
@@ -312,95 +401,121 @@ async function waitForReview(projectDir, prNumber, pollInterval, timeout) {
|
|
|
312
401
|
}
|
|
313
402
|
|
|
314
403
|
// ──────────────────────────────────────────────
|
|
315
|
-
//
|
|
404
|
+
// Fix phase
|
|
316
405
|
// ──────────────────────────────────────────────
|
|
317
|
-
async function fixPhase(projectDir, task, feedback, round) {
|
|
318
|
-
log.step(
|
|
406
|
+
async function fixPhase(projectDir, task, feedback, round, providerId) {
|
|
407
|
+
log.step(`Fixing review comments (round ${round})`);
|
|
319
408
|
|
|
320
|
-
const fixPrompt =
|
|
409
|
+
const fixPrompt = `Please fix the following review comments on Task #${task.id}: ${task.title}
|
|
321
410
|
|
|
322
|
-
## Review
|
|
411
|
+
## Review Feedback
|
|
323
412
|
${feedback}
|
|
324
413
|
|
|
325
|
-
##
|
|
326
|
-
1.
|
|
327
|
-
2.
|
|
328
|
-
3.
|
|
329
|
-
4.
|
|
414
|
+
## Requirements
|
|
415
|
+
1. Fix each issue listed above
|
|
416
|
+
2. Suggestions (non-blocking) can be skipped — explain why in the commit message
|
|
417
|
+
3. Ensure fixes don't introduce new issues
|
|
418
|
+
4. When done, run: git add -A && git commit -m "fix(task-${task.id}): address review round ${round}"
|
|
330
419
|
`;
|
|
331
420
|
|
|
332
|
-
//
|
|
421
|
+
// Save to file and execute via provider
|
|
333
422
|
const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
|
|
334
423
|
writeFileSync(promptPath, fixPrompt);
|
|
335
424
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (codexAvailable) {
|
|
340
|
-
const autoFix = await confirm('自动调用 CodeX 修复?');
|
|
341
|
-
if (autoFix) {
|
|
342
|
-
try {
|
|
343
|
-
execSync(`codex -q --file .codex-copilot/_current_prompt.md`, {
|
|
344
|
-
cwd: projectDir,
|
|
345
|
-
stdio: 'inherit',
|
|
346
|
-
});
|
|
347
|
-
log.info('CodeX 修复完成');
|
|
348
|
-
return;
|
|
349
|
-
} catch {
|
|
350
|
-
log.warn('CodeX CLI 调用失败,回退到手动模式');
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// 手动模式
|
|
356
|
-
log.blank();
|
|
357
|
-
log.dim(`Review 修复 Prompt 已保存到 .codex-copilot/_current_prompt.md`);
|
|
358
|
-
log.dim('请将文件内容粘贴到 CodeX 执行');
|
|
359
|
-
|
|
360
|
-
copyToClipboard(fixPrompt);
|
|
361
|
-
|
|
362
|
-
await ask('CodeX 修复完成后按 Enter 继续...');
|
|
425
|
+
await provider.executePrompt(providerId, promptPath, projectDir);
|
|
426
|
+
log.info('Fix complete');
|
|
363
427
|
}
|
|
364
428
|
|
|
365
429
|
// ──────────────────────────────────────────────
|
|
366
|
-
//
|
|
430
|
+
// Phase 4: Merge
|
|
367
431
|
// ──────────────────────────────────────────────
|
|
368
|
-
async function mergePhase(projectDir, task, prInfo, baseBranch) {
|
|
369
|
-
log.step('
|
|
432
|
+
async function mergePhase(projectDir, task, prInfo, baseBranch, checkpoint) {
|
|
433
|
+
log.step('Phase 4/4: Merge PR');
|
|
370
434
|
|
|
371
|
-
const doMerge = await confirm(
|
|
435
|
+
const doMerge = await confirm(`Merge PR #${prInfo.number}?`);
|
|
372
436
|
if (!doMerge) {
|
|
373
|
-
log.warn('
|
|
437
|
+
log.warn('User skipped merge');
|
|
374
438
|
return;
|
|
375
439
|
}
|
|
376
440
|
|
|
377
441
|
try {
|
|
378
442
|
github.mergePR(projectDir, prInfo.number);
|
|
379
|
-
log.info(`PR #${prInfo.number}
|
|
443
|
+
log.info(`PR #${prInfo.number} merged ✅`);
|
|
380
444
|
} catch (err) {
|
|
381
|
-
log.error(
|
|
382
|
-
log.warn('
|
|
383
|
-
await ask('
|
|
445
|
+
log.error(`Merge failed: ${err.message}`);
|
|
446
|
+
log.warn('Please merge manually, then press Enter to continue');
|
|
447
|
+
await ask('Continue...');
|
|
384
448
|
}
|
|
385
449
|
|
|
386
|
-
|
|
450
|
+
checkpoint.saveStep(task.id, 'merge', 'merged', {
|
|
451
|
+
branch: task.branch,
|
|
452
|
+
current_pr: prInfo.number,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Switch back to main branch
|
|
387
456
|
git.checkoutMain(projectDir, baseBranch);
|
|
388
457
|
}
|
|
389
458
|
|
|
459
|
+
// ──────────────────────────────────────────────
|
|
460
|
+
// Pre-flight: ensure base branch has commits & is pushed
|
|
461
|
+
// ──────────────────────────────────────────────
|
|
462
|
+
async function ensureBaseReady(projectDir, baseBranch) {
|
|
463
|
+
// Check if the repo has any commits at all
|
|
464
|
+
const hasCommits = git.execSafe('git rev-parse HEAD', projectDir);
|
|
465
|
+
if (!hasCommits.ok) {
|
|
466
|
+
// No commits yet — brand new repo
|
|
467
|
+
log.warn('No commits found in repository');
|
|
468
|
+
if (!git.isClean(projectDir)) {
|
|
469
|
+
log.info('Creating initial commit from existing code...');
|
|
470
|
+
git.commitAll(projectDir, 'chore: initial commit');
|
|
471
|
+
log.info('✅ Initial commit created');
|
|
472
|
+
} else {
|
|
473
|
+
log.warn('Repository is empty — no files to commit');
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
} else {
|
|
477
|
+
// Has commits, but base branch might have uncommitted changes
|
|
478
|
+
const currentBranch = git.currentBranch(projectDir);
|
|
479
|
+
if (currentBranch === baseBranch && !git.isClean(projectDir)) {
|
|
480
|
+
log.info('Uncommitted changes on base branch, committing first...');
|
|
481
|
+
git.commitAll(projectDir, 'chore: save current progress before automation');
|
|
482
|
+
log.info('✅ Base branch changes committed');
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Ensure we're on the base branch
|
|
487
|
+
const currentBranch = git.currentBranch(projectDir);
|
|
488
|
+
if (currentBranch !== baseBranch) {
|
|
489
|
+
// If base branch doesn't exist locally, create it from current
|
|
490
|
+
const branchExists = git.execSafe(`git rev-parse --verify ${baseBranch}`, projectDir);
|
|
491
|
+
if (!branchExists.ok) {
|
|
492
|
+
log.info(`Creating base branch '${baseBranch}' from current branch...`);
|
|
493
|
+
git.execSafe(`git branch ${baseBranch}`, projectDir);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Ensure base branch is pushed to remote
|
|
498
|
+
github.ensureRemoteBranch(projectDir, baseBranch);
|
|
499
|
+
log.info(`Base branch '${baseBranch}' ready ✓`);
|
|
500
|
+
}
|
|
501
|
+
|
|
390
502
|
function buildDevPrompt(task) {
|
|
391
|
-
|
|
503
|
+
const acceptanceList = Array.isArray(task.acceptance) && task.acceptance.length > 0
|
|
504
|
+
? task.acceptance.map(a => `- ${a}`).join('\n')
|
|
505
|
+
: '- Feature works correctly';
|
|
506
|
+
return `Please complete the following development task:
|
|
392
507
|
|
|
393
|
-
##
|
|
508
|
+
## Task #${task.id}: ${task.title}
|
|
394
509
|
|
|
395
510
|
${task.description}
|
|
396
511
|
|
|
397
|
-
##
|
|
398
|
-
${
|
|
512
|
+
## Acceptance Criteria
|
|
513
|
+
${acceptanceList}
|
|
399
514
|
|
|
400
|
-
##
|
|
401
|
-
1.
|
|
402
|
-
2.
|
|
403
|
-
3.
|
|
515
|
+
## Requirements
|
|
516
|
+
1. Strictly follow the project's existing code style and tech stack
|
|
517
|
+
2. Ensure the code compiles/runs correctly when done
|
|
518
|
+
3. When done, run:
|
|
404
519
|
git add -A
|
|
405
520
|
git commit -m "feat(task-${task.id}): ${task.title}"
|
|
406
521
|
`;
|
|
@@ -410,19 +525,5 @@ function sleep(ms) {
|
|
|
410
525
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
411
526
|
}
|
|
412
527
|
|
|
413
|
-
function isCodexAvailable() {
|
|
414
|
-
try {
|
|
415
|
-
const cmd = process.platform === 'win32' ? 'where codex' : 'which codex';
|
|
416
|
-
execSync(cmd, { stdio: 'pipe' });
|
|
417
|
-
return true;
|
|
418
|
-
} catch {
|
|
419
|
-
return false;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
528
|
|
|
423
|
-
|
|
424
|
-
try {
|
|
425
|
-
execSync('pbcopy', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
426
|
-
log.info('📋 已复制到剪贴板');
|
|
427
|
-
} catch {}
|
|
428
|
-
}
|
|
529
|
+
|