create-claude-workspace 2.1.11 → 2.2.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/dist/scheduler/agents/prompt-builder.mjs +21 -0
- package/dist/scheduler/git/manager.mjs +78 -0
- package/dist/scheduler/git/manager.spec.js +63 -1
- package/dist/scheduler/git/pr-manager.mjs +214 -0
- package/dist/scheduler/git/pr-manager.spec.js +34 -0
- package/dist/scheduler/index.mjs +41 -1
- package/dist/scheduler/integration.spec.js +1 -0
- package/dist/scheduler/loop.mjs +239 -42
- package/dist/scheduler/loop.spec.js +2 -0
- package/dist/scheduler/state/state.mjs +1 -0
- package/dist/scheduler/state/state.spec.js +1 -0
- package/dist/scheduler/tasks/issue-source.mjs +156 -0
- package/dist/scheduler/tasks/issue-source.spec.js +104 -0
- package/dist/scheduler/types.mjs +1 -0
- package/dist/scheduler/util/idle-poll.mjs +27 -2
- package/dist/template/.claude/CLAUDE.md +26 -61
- package/dist/template/.claude/agents/backend-ts-architect.md +2 -2
- package/dist/template/.claude/agents/deployment-engineer.md +3 -3
- package/dist/template/.claude/agents/devops-integrator.md +13 -10
- package/dist/template/.claude/agents/it-analyst.md +108 -0
- package/dist/template/.claude/agents/orchestrator.md +118 -153
- package/dist/template/.claude/agents/product-owner.md +5 -9
- package/dist/template/.claude/agents/project-initializer.md +297 -342
- package/dist/template/.claude/agents/senior-code-reviewer.md +1 -1
- package/dist/template/.claude/agents/technical-planner.md +7 -12
- package/dist/template/.claude/agents/test-engineer.md +1 -1
- package/dist/template/.claude/agents/ui-engineer.md +3 -3
- package/dist/template/.claude/templates/claude-md.md +2 -1
- package/package.json +1 -1
|
@@ -178,6 +178,27 @@ export function buildCIFixPrompt(ctx, ciLogs) {
|
|
|
178
178
|
`- Run build/lint/tests locally to verify the fix`,
|
|
179
179
|
].join('\n');
|
|
180
180
|
}
|
|
181
|
+
// ─── PR comment resolution prompt ───
|
|
182
|
+
export function buildPRCommentPrompt(ctx, comments) {
|
|
183
|
+
return [
|
|
184
|
+
`Address the following code review comments on your PR/MR.`,
|
|
185
|
+
``,
|
|
186
|
+
`## Task`,
|
|
187
|
+
`**${ctx.task.title}**`,
|
|
188
|
+
``,
|
|
189
|
+
`## Working Directory`,
|
|
190
|
+
ctx.worktreePath,
|
|
191
|
+
``,
|
|
192
|
+
`## Review Comments`,
|
|
193
|
+
comments,
|
|
194
|
+
``,
|
|
195
|
+
`## Instructions`,
|
|
196
|
+
`- Address each comment by making the requested changes`,
|
|
197
|
+
`- If a comment is incorrect or unnecessary, add a brief explanation in the code`,
|
|
198
|
+
`- Run build/lint/tests locally to verify your changes`,
|
|
199
|
+
`- Do NOT dismiss or ignore valid feedback`,
|
|
200
|
+
].join('\n');
|
|
201
|
+
}
|
|
181
202
|
// ─── Phase transition prompt (for product-owner re-evaluation) ───
|
|
182
203
|
export function buildPhaseTransitionPrompt(completedPhase, nextPhase) {
|
|
183
204
|
return [
|
|
@@ -219,6 +219,84 @@ export function pushTags(projectDir) {
|
|
|
219
219
|
return false;
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
+
export function rebaseOnMain(worktreePath, projectDir) {
|
|
223
|
+
try {
|
|
224
|
+
const main = getMainBranch(projectDir);
|
|
225
|
+
git(`fetch origin "${main}"`, worktreePath, PUSH_TIMEOUT);
|
|
226
|
+
git(`rebase "origin/${main}"`, worktreePath, PUSH_TIMEOUT);
|
|
227
|
+
return { success: true, conflict: false };
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
// Check if rebase is in progress (conflict)
|
|
231
|
+
try {
|
|
232
|
+
const status = git('status --porcelain', worktreePath);
|
|
233
|
+
if (status.includes('UU ') || status.includes('AA ') || status.includes('DD ')) {
|
|
234
|
+
git('rebase --abort', worktreePath);
|
|
235
|
+
return { success: false, conflict: true, error: err.message };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch { /* ignore */ }
|
|
239
|
+
// Abort any in-progress rebase
|
|
240
|
+
try {
|
|
241
|
+
git('rebase --abort', worktreePath);
|
|
242
|
+
}
|
|
243
|
+
catch { /* ignore */ }
|
|
244
|
+
return { success: false, conflict: false, error: err.message };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// ─── Stash operations ───
|
|
248
|
+
export function stashChanges(dir) {
|
|
249
|
+
const status = git('status --porcelain', dir);
|
|
250
|
+
if (!status)
|
|
251
|
+
return false;
|
|
252
|
+
try {
|
|
253
|
+
git('stash --include-untracked', dir);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
export function popStash(dir) {
|
|
261
|
+
try {
|
|
262
|
+
git('stash pop', dir);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ─── Dirty main evaluation ───
|
|
270
|
+
const TRACKING_FILES = new Set(['TODO.md', 'MEMORY.md']);
|
|
271
|
+
export function evaluateDirtyMain(projectDir) {
|
|
272
|
+
const status = git('status --porcelain', projectDir);
|
|
273
|
+
if (!status)
|
|
274
|
+
return { trackingFiles: [], userFiles: [] };
|
|
275
|
+
const trackingFiles = [];
|
|
276
|
+
const userFiles = [];
|
|
277
|
+
for (const line of status.split('\n')) {
|
|
278
|
+
// Format: "XY filename" where XY is 2-char status
|
|
279
|
+
const file = line.slice(3).trim();
|
|
280
|
+
if (!file)
|
|
281
|
+
continue;
|
|
282
|
+
if (TRACKING_FILES.has(file)) {
|
|
283
|
+
trackingFiles.push(file);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
userFiles.push(file);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { trackingFiles, userFiles };
|
|
290
|
+
}
|
|
291
|
+
export function discardFiles(dir, files) {
|
|
292
|
+
if (files.length === 0)
|
|
293
|
+
return;
|
|
294
|
+
const fileArgs = files.map(f => `"${f}"`).join(' ');
|
|
295
|
+
try {
|
|
296
|
+
git(`checkout -- ${fileArgs}`, dir);
|
|
297
|
+
}
|
|
298
|
+
catch { /* file may not exist in index */ }
|
|
299
|
+
}
|
|
222
300
|
// ─── Helpers ───
|
|
223
301
|
function escapeMsg(msg) {
|
|
224
302
|
return msg.replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
@@ -4,7 +4,7 @@ import { mkdtempSync, writeFileSync, existsSync } from 'node:fs';
|
|
|
4
4
|
import { resolve, join } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { rmSync } from 'node:fs';
|
|
7
|
-
import { createWorktree, cleanupWorktree, listWorktrees, listOrphanedWorktrees, commitInWorktree, getChangedFiles, hasUncommittedChanges, mergeToMain, getMainBranch, getCurrentBranch, branchExists, isBranchMerged, hasGitIdentity, createTag, getLatestTag, } from './manager.mjs';
|
|
7
|
+
import { createWorktree, cleanupWorktree, listWorktrees, listOrphanedWorktrees, commitInWorktree, getChangedFiles, hasUncommittedChanges, mergeToMain, getMainBranch, getCurrentBranch, branchExists, isBranchMerged, hasGitIdentity, createTag, getLatestTag, rebaseOnMain, stashChanges, popStash, evaluateDirtyMain, discardFiles, } from './manager.mjs';
|
|
8
8
|
let repoDir;
|
|
9
9
|
function gitCmd(args, cwd) {
|
|
10
10
|
return execSync(`git ${args}`, { cwd: cwd ?? repoDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
@@ -196,3 +196,65 @@ describe('createTag / getLatestTag', () => {
|
|
|
196
196
|
expect(getLatestTag(repoDir)).toBe('v0.2.0');
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
|
+
describe('rebaseOnMain', () => {
|
|
200
|
+
it('rebases worktree onto main successfully', () => {
|
|
201
|
+
// Create worktree with a commit
|
|
202
|
+
const wtPath = createWorktree(repoDir, 'feat/rebase-test');
|
|
203
|
+
writeFileSync(resolve(wtPath, 'feature.ts'), 'export const x = 1;');
|
|
204
|
+
commitInWorktree(wtPath, 'feat: add feature');
|
|
205
|
+
// Add a commit on main to create divergence
|
|
206
|
+
writeFileSync(resolve(repoDir, 'main-change.ts'), 'export const y = 2;');
|
|
207
|
+
gitCmd('add .', repoDir);
|
|
208
|
+
gitCmd('commit -m "main: parallel change"', repoDir);
|
|
209
|
+
const result = rebaseOnMain(wtPath, repoDir);
|
|
210
|
+
// May fail without remote — test the interface
|
|
211
|
+
expect(result).toHaveProperty('success');
|
|
212
|
+
expect(result).toHaveProperty('conflict');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe('stashChanges / popStash', () => {
|
|
216
|
+
it('stashes and pops changes', () => {
|
|
217
|
+
writeFileSync(resolve(repoDir, 'dirty.txt'), 'uncommitted');
|
|
218
|
+
expect(stashChanges(repoDir)).toBe(true);
|
|
219
|
+
expect(hasUncommittedChanges(repoDir)).toBe(false);
|
|
220
|
+
expect(popStash(repoDir)).toBe(true);
|
|
221
|
+
expect(hasUncommittedChanges(repoDir)).toBe(true);
|
|
222
|
+
// Cleanup
|
|
223
|
+
gitCmd('checkout -- .', repoDir);
|
|
224
|
+
});
|
|
225
|
+
it('returns false when nothing to stash', () => {
|
|
226
|
+
expect(stashChanges(repoDir)).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
describe('evaluateDirtyMain', () => {
|
|
230
|
+
it('returns empty when clean', () => {
|
|
231
|
+
const result = evaluateDirtyMain(repoDir);
|
|
232
|
+
expect(result.trackingFiles).toEqual([]);
|
|
233
|
+
expect(result.userFiles).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
it('separates tracking files from user files', () => {
|
|
236
|
+
writeFileSync(resolve(repoDir, 'TODO.md'), '# TODO');
|
|
237
|
+
writeFileSync(resolve(repoDir, 'MEMORY.md'), '# Memory');
|
|
238
|
+
writeFileSync(resolve(repoDir, 'custom.ts'), 'code');
|
|
239
|
+
gitCmd('add .', repoDir);
|
|
240
|
+
const result = evaluateDirtyMain(repoDir);
|
|
241
|
+
expect(result.trackingFiles).toContain('TODO.md');
|
|
242
|
+
expect(result.trackingFiles).toContain('MEMORY.md');
|
|
243
|
+
expect(result.userFiles).toContain('custom.ts');
|
|
244
|
+
// Cleanup
|
|
245
|
+
gitCmd('reset HEAD .', repoDir);
|
|
246
|
+
gitCmd('checkout -- .', repoDir);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
describe('discardFiles', () => {
|
|
250
|
+
it('discards specific files', () => {
|
|
251
|
+
writeFileSync(resolve(repoDir, 'README.md'), '# Modified');
|
|
252
|
+
expect(hasUncommittedChanges(repoDir)).toBe(true);
|
|
253
|
+
discardFiles(repoDir, ['README.md']);
|
|
254
|
+
expect(hasUncommittedChanges(repoDir)).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
it('does nothing for empty file list', () => {
|
|
257
|
+
discardFiles(repoDir, []);
|
|
258
|
+
expect(hasUncommittedChanges(repoDir)).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// ─── PR/MR lifecycle management ───
|
|
2
|
+
// Deterministic TS, zero AI tokens. Uses gh/glab CLI.
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
const READ_TIMEOUT = 30_000;
|
|
5
|
+
const WRITE_TIMEOUT = 60_000;
|
|
6
|
+
function cli(cmd, cwd, timeout = READ_TIMEOUT) {
|
|
7
|
+
return execSync(cmd, { cwd, timeout, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
8
|
+
}
|
|
9
|
+
export function createPR(opts) {
|
|
10
|
+
const body = opts.issueNumber
|
|
11
|
+
? `${opts.body}\n\nCloses #${opts.issueNumber}`
|
|
12
|
+
: opts.body;
|
|
13
|
+
// Escape body for shell — write to temp approach avoided, use stdin-like quoting
|
|
14
|
+
const escapedBody = body.replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
15
|
+
const escapedTitle = opts.title.replace(/"/g, '\\"');
|
|
16
|
+
if (opts.platform === 'github') {
|
|
17
|
+
const output = cli(`gh pr create --title "${escapedTitle}" --body "${escapedBody}" --base "${opts.baseBranch}" --head "${opts.branch}" --json number,url`, opts.cwd, WRITE_TIMEOUT);
|
|
18
|
+
const pr = JSON.parse(output);
|
|
19
|
+
return {
|
|
20
|
+
number: pr.number,
|
|
21
|
+
url: pr.url,
|
|
22
|
+
status: 'open',
|
|
23
|
+
ciStatus: 'pending',
|
|
24
|
+
approvals: 0,
|
|
25
|
+
mergeable: false,
|
|
26
|
+
comments: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// GitLab
|
|
30
|
+
const output = cli(`glab mr create --title "${escapedTitle}" --description "${escapedBody}" --target-branch "${opts.baseBranch}" --source-branch "${opts.branch}" --no-editor --json iid,web_url`, opts.cwd, WRITE_TIMEOUT);
|
|
31
|
+
const mr = JSON.parse(output);
|
|
32
|
+
return {
|
|
33
|
+
number: mr.iid,
|
|
34
|
+
url: mr.web_url,
|
|
35
|
+
status: 'open',
|
|
36
|
+
ciStatus: 'pending',
|
|
37
|
+
approvals: 0,
|
|
38
|
+
mergeable: false,
|
|
39
|
+
comments: [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// ─── PR status ───
|
|
43
|
+
export function getPRStatus(cwd, platform, branch) {
|
|
44
|
+
if (platform === 'github') {
|
|
45
|
+
return getGitHubPRStatus(cwd, branch);
|
|
46
|
+
}
|
|
47
|
+
return getGitLabMRStatus(cwd, branch);
|
|
48
|
+
}
|
|
49
|
+
function getGitHubPRStatus(cwd, branch) {
|
|
50
|
+
const output = cli(`gh pr view "${branch}" --json number,url,state,mergeable,reviewDecision,statusCheckRollup,comments`, cwd);
|
|
51
|
+
const pr = JSON.parse(output);
|
|
52
|
+
const ciStatus = resolveGitHubCIStatus(pr.statusCheckRollup ?? []);
|
|
53
|
+
const approvals = pr.reviewDecision === 'APPROVED' ? 1 : 0;
|
|
54
|
+
const mergeable = pr.mergeable === 'MERGEABLE' && ciStatus === 'passed';
|
|
55
|
+
return {
|
|
56
|
+
number: pr.number,
|
|
57
|
+
url: pr.url,
|
|
58
|
+
status: pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : 'open',
|
|
59
|
+
ciStatus,
|
|
60
|
+
approvals,
|
|
61
|
+
mergeable,
|
|
62
|
+
comments: parseGitHubComments(pr.comments ?? []),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function resolveGitHubCIStatus(checks) {
|
|
66
|
+
if (checks.length === 0)
|
|
67
|
+
return 'not-found';
|
|
68
|
+
const states = checks.map(c => c.state);
|
|
69
|
+
if (states.every(s => s === 'SUCCESS'))
|
|
70
|
+
return 'passed';
|
|
71
|
+
if (states.some(s => s === 'FAILURE' || s === 'ERROR'))
|
|
72
|
+
return 'failed';
|
|
73
|
+
if (states.some(s => s === 'PENDING' || s === 'EXPECTED'))
|
|
74
|
+
return 'pending';
|
|
75
|
+
return 'passed';
|
|
76
|
+
}
|
|
77
|
+
function parseGitHubComments(comments) {
|
|
78
|
+
return comments.map(c => ({
|
|
79
|
+
id: c.id,
|
|
80
|
+
author: c.author.login,
|
|
81
|
+
body: c.body,
|
|
82
|
+
resolved: false, // GitHub PR comments don't have resolved state (only review threads do)
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
function getGitLabMRStatus(cwd, branch) {
|
|
86
|
+
const output = cli(`glab mr view "${branch}" --json iid,web_url,state,merge_status,head_pipeline,user_notes_count`, cwd);
|
|
87
|
+
const mr = JSON.parse(output);
|
|
88
|
+
const ciStatus = resolveGitLabCIStatus(mr.head_pipeline);
|
|
89
|
+
const mergeable = mr.merge_status === 'can_be_merged' && ciStatus === 'passed';
|
|
90
|
+
return {
|
|
91
|
+
number: mr.iid,
|
|
92
|
+
url: mr.web_url,
|
|
93
|
+
status: mr.state === 'merged' ? 'merged' : mr.state === 'closed' ? 'closed' : 'open',
|
|
94
|
+
ciStatus,
|
|
95
|
+
approvals: 0, // Would need separate API call; scheduler handles via mergeable check
|
|
96
|
+
mergeable,
|
|
97
|
+
comments: [], // Fetched separately via getPRComments
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function resolveGitLabCIStatus(pipeline) {
|
|
101
|
+
if (!pipeline)
|
|
102
|
+
return 'not-found';
|
|
103
|
+
switch (pipeline.status) {
|
|
104
|
+
case 'success': return 'passed';
|
|
105
|
+
case 'failed': return 'failed';
|
|
106
|
+
case 'canceled': return 'canceled';
|
|
107
|
+
default: return 'pending';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ─── PR comments ───
|
|
111
|
+
export function getPRComments(cwd, platform, prNumber) {
|
|
112
|
+
if (platform === 'github') {
|
|
113
|
+
return getGitHubPRComments(cwd, prNumber);
|
|
114
|
+
}
|
|
115
|
+
return getGitLabMRComments(cwd, prNumber);
|
|
116
|
+
}
|
|
117
|
+
function getGitHubPRComments(cwd, prNumber) {
|
|
118
|
+
try {
|
|
119
|
+
const output = cli(`gh api repos/{owner}/{repo}/pulls/${prNumber}/reviews --jq '.[] | select(.state != "APPROVED") | {id: .id, user: .user.login, body: .body, state: .state}'`, cwd);
|
|
120
|
+
if (!output)
|
|
121
|
+
return [];
|
|
122
|
+
return output.split('\n').filter(Boolean).map(line => {
|
|
123
|
+
const review = JSON.parse(line);
|
|
124
|
+
return {
|
|
125
|
+
id: review.id,
|
|
126
|
+
author: review.user,
|
|
127
|
+
body: review.body,
|
|
128
|
+
resolved: review.state === 'DISMISSED',
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function getGitLabMRComments(cwd, mrIid) {
|
|
137
|
+
try {
|
|
138
|
+
const output = cli(`glab api projects/:id/merge_requests/${mrIid}/notes --paginate`, cwd);
|
|
139
|
+
if (!output)
|
|
140
|
+
return [];
|
|
141
|
+
const notes = JSON.parse(output);
|
|
142
|
+
return notes
|
|
143
|
+
.filter(n => n.resolvable)
|
|
144
|
+
.map(n => ({
|
|
145
|
+
id: n.id,
|
|
146
|
+
author: n.author.username,
|
|
147
|
+
body: n.body,
|
|
148
|
+
resolved: n.resolved,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// ─── Merge PR ───
|
|
156
|
+
export function mergePR(cwd, platform, prNumber, method = 'merge') {
|
|
157
|
+
try {
|
|
158
|
+
if (platform === 'github') {
|
|
159
|
+
const flag = method === 'squash' ? '--squash' : method === 'rebase' ? '--rebase' : '--merge';
|
|
160
|
+
cli(`gh pr merge ${prNumber} ${flag} --delete-branch`, cwd, WRITE_TIMEOUT);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const flag = method === 'squash' ? '--squash' : '';
|
|
164
|
+
cli(`glab mr merge ${mrIid(prNumber)} ${flag} --remove-source-branch --yes`, cwd, WRITE_TIMEOUT);
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function mrIid(n) {
|
|
173
|
+
return String(n);
|
|
174
|
+
}
|
|
175
|
+
// ─── Issue label management ───
|
|
176
|
+
export function updateIssueLabels(cwd, platform, issueNumber, add, remove) {
|
|
177
|
+
try {
|
|
178
|
+
if (platform === 'github') {
|
|
179
|
+
if (remove.length > 0) {
|
|
180
|
+
for (const label of remove) {
|
|
181
|
+
try {
|
|
182
|
+
cli(`gh issue edit ${issueNumber} --remove-label "${label}"`, cwd);
|
|
183
|
+
}
|
|
184
|
+
catch { /* label may not exist */ }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (add.length > 0) {
|
|
188
|
+
cli(`gh issue edit ${issueNumber} --add-label "${add.join(',')}"`, cwd);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
// GitLab: comma-separated labels
|
|
193
|
+
if (remove.length > 0) {
|
|
194
|
+
cli(`glab issue update ${issueNumber} --unlabel "${remove.join(',')}"`, cwd);
|
|
195
|
+
}
|
|
196
|
+
if (add.length > 0) {
|
|
197
|
+
cli(`glab issue update ${issueNumber} --label "${add.join(',')}"`, cwd);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch { /* best-effort label update */ }
|
|
202
|
+
}
|
|
203
|
+
// ─── Close PR ───
|
|
204
|
+
export function closePR(cwd, platform, prNumber) {
|
|
205
|
+
try {
|
|
206
|
+
if (platform === 'github') {
|
|
207
|
+
cli(`gh pr close ${prNumber}`, cwd);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
cli(`glab mr close ${prNumber}`, cwd);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch { /* best-effort */ }
|
|
214
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
// These tests verify the internal parsing/mapping logic.
|
|
3
|
+
// CLI-dependent functions (createPR, mergePR, etc.) require a real remote
|
|
4
|
+
// and are tested via integration tests.
|
|
5
|
+
// Since the module uses execSync directly, we test the exported types and behavior
|
|
6
|
+
// that can be validated without a real remote. For full coverage, see integration tests.
|
|
7
|
+
describe('PR manager types', () => {
|
|
8
|
+
it('PRInfo has expected shape', async () => {
|
|
9
|
+
// Verify type compatibility
|
|
10
|
+
const info = {
|
|
11
|
+
number: 42,
|
|
12
|
+
url: 'https://github.com/owner/repo/pull/42',
|
|
13
|
+
status: 'open',
|
|
14
|
+
ciStatus: 'passed',
|
|
15
|
+
approvals: 1,
|
|
16
|
+
mergeable: true,
|
|
17
|
+
comments: [],
|
|
18
|
+
};
|
|
19
|
+
expect(info.number).toBe(42);
|
|
20
|
+
expect(info.status).toBe('open');
|
|
21
|
+
});
|
|
22
|
+
it('PRComment has expected shape', async () => {
|
|
23
|
+
const comment = {
|
|
24
|
+
id: 1,
|
|
25
|
+
author: 'reviewer',
|
|
26
|
+
body: 'Fix this',
|
|
27
|
+
resolved: false,
|
|
28
|
+
path: 'src/auth.ts',
|
|
29
|
+
line: 42,
|
|
30
|
+
};
|
|
31
|
+
expect(comment.resolved).toBe(false);
|
|
32
|
+
expect(comment.path).toBe('src/auth.ts');
|
|
33
|
+
});
|
|
34
|
+
});
|
package/dist/scheduler/index.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { resolve } from 'node:path';
|
|
|
5
5
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
6
6
|
import { config as dotenvConfig } from '@dotenvx/dotenvx';
|
|
7
7
|
import { SCHEDULER_DEFAULTS } from './types.mjs';
|
|
8
|
+
import { detectCIPlatform } from './git/ci-watcher.mjs';
|
|
9
|
+
import { fetchOpenIssues } from './tasks/issue-source.mjs';
|
|
8
10
|
import { emptyState, readState, writeState, appendEvent, createEvent } from './state/state.mjs';
|
|
9
11
|
import { WorkerPool } from './agents/worker-pool.mjs';
|
|
10
12
|
import { OrchestratorClient } from './agents/orchestrator.mjs';
|
|
@@ -122,6 +124,16 @@ export function parseSchedulerArgs(argv) {
|
|
|
122
124
|
i++;
|
|
123
125
|
continue;
|
|
124
126
|
}
|
|
127
|
+
if (arg === '--task-mode') {
|
|
128
|
+
const val = strFlag(arg, i);
|
|
129
|
+
if (val !== 'local' && val !== 'platform') {
|
|
130
|
+
console.error(`--task-mode must be 'local' or 'platform'`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
mutable.taskMode = val;
|
|
134
|
+
i++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
125
137
|
if (arg.startsWith('--')) {
|
|
126
138
|
console.error(`Unknown option: ${arg}`);
|
|
127
139
|
process.exit(1);
|
|
@@ -129,6 +141,19 @@ export function parseSchedulerArgs(argv) {
|
|
|
129
141
|
}
|
|
130
142
|
return opts;
|
|
131
143
|
}
|
|
144
|
+
function detectTaskMode(projectDir) {
|
|
145
|
+
const platform = detectCIPlatform(projectDir);
|
|
146
|
+
if (platform === 'none')
|
|
147
|
+
return 'local';
|
|
148
|
+
try {
|
|
149
|
+
const issues = fetchOpenIssues(projectDir, platform);
|
|
150
|
+
const hasStatusLabels = issues.some(i => i.labels.some(l => l.startsWith('status::')));
|
|
151
|
+
if (hasStatusLabels)
|
|
152
|
+
return 'platform';
|
|
153
|
+
}
|
|
154
|
+
catch { /* CLI not available or no issues */ }
|
|
155
|
+
return 'local';
|
|
156
|
+
}
|
|
132
157
|
function printSchedulerHelp() {
|
|
133
158
|
console.log(`
|
|
134
159
|
Multi-agent scheduler for autonomous development.
|
|
@@ -220,6 +245,21 @@ export async function runScheduler(opts) {
|
|
|
220
245
|
else {
|
|
221
246
|
state = emptyState(opts.concurrency);
|
|
222
247
|
}
|
|
248
|
+
// Task mode detection
|
|
249
|
+
if (opts.taskMode) {
|
|
250
|
+
state.taskMode = opts.taskMode;
|
|
251
|
+
}
|
|
252
|
+
else if (!existing) {
|
|
253
|
+
// Auto-detect: platform mode if remote has issues with status:: labels
|
|
254
|
+
state.taskMode = detectTaskMode(opts.projectDir);
|
|
255
|
+
logger.info(`Task mode auto-detected: ${state.taskMode}`);
|
|
256
|
+
}
|
|
257
|
+
// If resuming existing state, keep the stored taskMode
|
|
258
|
+
// Platform mode: shorter idle poll interval (30s vs 5min)
|
|
259
|
+
const mutableOpts = opts;
|
|
260
|
+
if (state.taskMode === 'platform' && !opts.taskMode && opts.idlePollInterval === SCHEDULER_DEFAULTS.idlePollInterval) {
|
|
261
|
+
mutableOpts.idlePollInterval = 30_000;
|
|
262
|
+
}
|
|
223
263
|
// Worker pool
|
|
224
264
|
const pool = new WorkerPool({
|
|
225
265
|
concurrency: opts.concurrency,
|
|
@@ -276,7 +316,7 @@ export async function runScheduler(opts) {
|
|
|
276
316
|
logger.info('No work available. Entering idle polling...');
|
|
277
317
|
const idleStart = Date.now();
|
|
278
318
|
while (!stopping) {
|
|
279
|
-
const poll = await pollForNewWork(opts.projectDir, logger);
|
|
319
|
+
const poll = await pollForNewWork(opts.projectDir, logger, state.taskMode, state.completedTasks);
|
|
280
320
|
if (poll.hasWork) {
|
|
281
321
|
logger.info(`New work detected (${poll.source}). Resuming...`);
|
|
282
322
|
break;
|