@warpmetrics/coder 0.2.3 → 0.2.5
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 +20 -4
- package/package.json +1 -1
- package/src/agent.js +45 -5
- package/src/boards/github-projects.js +84 -33
- package/src/claude.js +55 -16
- package/src/git.js +25 -4
- package/src/revise.js +71 -14
- package/src/warp.js +16 -5
- package/src/watch.js +18 -6
package/bin/cli.js
CHANGED
|
@@ -56,12 +56,28 @@ async function runInit() {
|
|
|
56
56
|
log('');
|
|
57
57
|
|
|
58
58
|
// 1. Ensure gh has the right scopes (before readline takes over stdin)
|
|
59
|
-
|
|
59
|
+
// Need 'project' (read:project is not enough) and 'repo'
|
|
60
|
+
log(' Checking GitHub CLI scopes...');
|
|
60
61
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
let authOutput = '';
|
|
63
|
+
try {
|
|
64
|
+
authOutput = execSync('gh auth status 2>&1', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
65
|
+
} catch (err) {
|
|
66
|
+
authOutput = err.stdout || err.stderr || '';
|
|
67
|
+
}
|
|
68
|
+
const scopesMatch = authOutput.match(/Token scopes:\s*(.+)/i);
|
|
69
|
+
const scopeList = (scopesMatch?.[1]?.match(/'([^']+)'/g) || []).map(s => s.replace(/'/g, ''));
|
|
70
|
+
const missing = ['project', 'repo'].filter(s => !scopeList.includes(s));
|
|
71
|
+
|
|
72
|
+
if (missing.length === 0) {
|
|
73
|
+
log(' \u2713 GitHub CLI scopes OK');
|
|
74
|
+
} else {
|
|
75
|
+
log(` Missing scopes: ${missing.join(', ')}`);
|
|
76
|
+
execSync(`gh auth refresh -s ${missing.join(',')}`, { stdio: 'inherit' });
|
|
77
|
+
log(' \u2713 GitHub CLI scopes updated');
|
|
78
|
+
}
|
|
63
79
|
} catch {
|
|
64
|
-
log(' \u26a0 Could not
|
|
80
|
+
log(' \u26a0 Could not verify gh scopes — run manually: gh auth refresh -s project,repo');
|
|
65
81
|
}
|
|
66
82
|
log('');
|
|
67
83
|
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -17,7 +17,7 @@ export async function implement(item, { board, config, log }) {
|
|
|
17
17
|
const issueTitle = item.content?.title || `Issue #${issueNumber}`;
|
|
18
18
|
const issueBody = item.content?.body || '';
|
|
19
19
|
const repo = config.repo;
|
|
20
|
-
const repoName = repo.replace(/\.git$/, '').
|
|
20
|
+
const repoName = repo.replace(/\.git$/, '').replace(/^.*github\.com[:\/]/, '');
|
|
21
21
|
const branch = `agent/issue-${issueNumber}`;
|
|
22
22
|
const workdir = join(tmpdir(), 'warp-coder', String(issueNumber));
|
|
23
23
|
const configDir = join(process.cwd(), CONFIG_DIR);
|
|
@@ -27,11 +27,16 @@ export async function implement(item, { board, config, log }) {
|
|
|
27
27
|
// Move to In Progress
|
|
28
28
|
try {
|
|
29
29
|
await board.moveToInProgress(item);
|
|
30
|
+
log(' moved to In Progress');
|
|
30
31
|
} catch (err) {
|
|
31
32
|
log(` warning: could not move to In Progress: ${err.message}`);
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
// Pre-generate act ID for chaining (embedded in PR body so warp-review can link back)
|
|
36
|
+
const actId = config.warpmetricsApiKey ? warp.generateId('act') : null;
|
|
37
|
+
|
|
34
38
|
// WarpMetrics: start pipeline
|
|
39
|
+
let runId = null;
|
|
35
40
|
let groupId = null;
|
|
36
41
|
if (config.warpmetricsApiKey) {
|
|
37
42
|
try {
|
|
@@ -41,8 +46,9 @@ export async function implement(item, { board, config, log }) {
|
|
|
41
46
|
issueNumber,
|
|
42
47
|
issueTitle,
|
|
43
48
|
});
|
|
49
|
+
runId = pipeline.runId;
|
|
44
50
|
groupId = pipeline.groupId;
|
|
45
|
-
log(` pipeline: run=${
|
|
51
|
+
log(` pipeline: run=${runId} group=${groupId}`);
|
|
46
52
|
} catch (err) {
|
|
47
53
|
log(` warning: pipeline start failed: ${err.message}`);
|
|
48
54
|
}
|
|
@@ -102,7 +108,7 @@ export async function implement(item, { board, config, log }) {
|
|
|
102
108
|
'1. Read the codebase to understand relevant context',
|
|
103
109
|
'2. Implement the changes',
|
|
104
110
|
'3. Run tests to verify nothing is broken',
|
|
105
|
-
'4. Commit with a clear message',
|
|
111
|
+
'4. Commit all changes with a clear message — this is critical, do not skip the commit',
|
|
106
112
|
'',
|
|
107
113
|
'Do NOT create branches, push, or open PRs — just implement and commit.',
|
|
108
114
|
'If the issue is unclear or you cannot implement it, explain what is missing.',
|
|
@@ -118,6 +124,8 @@ export async function implement(item, { board, config, log }) {
|
|
|
118
124
|
maxTurns: config.claude?.maxTurns,
|
|
119
125
|
});
|
|
120
126
|
log(` claude done (cost: $${claudeResult.costUsd ?? '?'})`);
|
|
127
|
+
log(` git status: ${git.status(workdir) || '(clean)'}`);
|
|
128
|
+
log(` git log: ${git.hasNewCommits(workdir) ? 'has new commits' : 'NO new commits'}`);
|
|
121
129
|
|
|
122
130
|
// Hook: onBeforePush
|
|
123
131
|
try {
|
|
@@ -128,12 +136,29 @@ export async function implement(item, { board, config, log }) {
|
|
|
128
136
|
throw err;
|
|
129
137
|
}
|
|
130
138
|
|
|
139
|
+
// Auto-commit if Claude left uncommitted changes
|
|
140
|
+
if (!git.hasNewCommits(workdir) && git.status(workdir)) {
|
|
141
|
+
log(' claude forgot to commit — auto-committing');
|
|
142
|
+
git.commitAll(workdir, `Implement #${issueNumber}: ${issueTitle}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!git.hasNewCommits(workdir)) {
|
|
146
|
+
throw new Error('Claude did not produce any changes.');
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
// Push + PR
|
|
132
150
|
log(' pushing...');
|
|
133
151
|
git.push(workdir, branch);
|
|
152
|
+
const prBody = [
|
|
153
|
+
`Closes #${issueNumber}`,
|
|
154
|
+
'',
|
|
155
|
+
'Implemented by warp-coder.',
|
|
156
|
+
...(actId ? ['', `<!-- wm:act:${actId} -->`] : []),
|
|
157
|
+
].join('\n');
|
|
134
158
|
const pr = git.createPR(workdir, {
|
|
135
159
|
title: issueTitle,
|
|
136
|
-
body:
|
|
160
|
+
body: prBody,
|
|
161
|
+
head: branch,
|
|
137
162
|
});
|
|
138
163
|
log(` PR created: ${pr.url}`);
|
|
139
164
|
|
|
@@ -149,6 +174,7 @@ export async function implement(item, { board, config, log }) {
|
|
|
149
174
|
// Move to In Review
|
|
150
175
|
try {
|
|
151
176
|
await board.moveToReview(item);
|
|
177
|
+
log(' moved to In Review');
|
|
152
178
|
} catch (err) {
|
|
153
179
|
log(` warning: could not move to In Review: ${err.message}`);
|
|
154
180
|
}
|
|
@@ -157,11 +183,16 @@ export async function implement(item, { board, config, log }) {
|
|
|
157
183
|
} catch (err) {
|
|
158
184
|
taskError = err.message;
|
|
159
185
|
log(` failed: ${err.message}`);
|
|
186
|
+
try {
|
|
187
|
+
await board.moveToBlocked(item);
|
|
188
|
+
} catch (moveErr) {
|
|
189
|
+
log(` warning: could not move to Blocked: ${moveErr.message}`);
|
|
190
|
+
}
|
|
160
191
|
} finally {
|
|
161
192
|
// WarpMetrics: record outcome
|
|
162
193
|
if (config.warpmetricsApiKey && groupId) {
|
|
163
194
|
try {
|
|
164
|
-
const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
|
|
195
|
+
const outcome = await warp.recordOutcome(config.warpmetricsApiKey, { runId, groupId }, {
|
|
165
196
|
step: 'implement',
|
|
166
197
|
success,
|
|
167
198
|
costUsd: claudeResult?.costUsd,
|
|
@@ -170,6 +201,15 @@ export async function implement(item, { board, config, log }) {
|
|
|
170
201
|
issueNumber,
|
|
171
202
|
});
|
|
172
203
|
log(` outcome: ${outcome.name}`);
|
|
204
|
+
|
|
205
|
+
// Emit act so warp-review can link its run as a follow-up
|
|
206
|
+
if (success && actId) {
|
|
207
|
+
await warp.emitAct(config.warpmetricsApiKey, {
|
|
208
|
+
outcomeId: outcome.id,
|
|
209
|
+
actId,
|
|
210
|
+
name: 'review',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
173
213
|
} catch (err) {
|
|
174
214
|
log(` warning: outcome recording failed: ${err.message}`);
|
|
175
215
|
}
|
|
@@ -21,12 +21,52 @@ export function create({ project, owner, statusField = 'Status', columns = {} })
|
|
|
21
21
|
blocked: columns.blocked || 'Blocked',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// PR lookup cache (issue number → PR number or null). Persists across polls.
|
|
25
|
+
const prCache = new Map();
|
|
26
|
+
|
|
27
|
+
// Find the PR linked to an issue item (via "Closes #N" convention)
|
|
28
|
+
function findLinkedPR(item) {
|
|
29
|
+
// If the item is already a PR, use its number directly
|
|
30
|
+
if (item.content?.type === 'PullRequest') return item.content.number;
|
|
31
|
+
// Otherwise look for a linked PR via the branch naming convention
|
|
32
|
+
const issueNumber = item.content?.number;
|
|
33
|
+
if (!issueNumber) return null;
|
|
34
|
+
if (prCache.has(issueNumber)) return prCache.get(issueNumber);
|
|
35
|
+
let result = null;
|
|
36
|
+
try {
|
|
37
|
+
const repo = item.content.repository || `${owner}/${item.content.repository}`;
|
|
38
|
+
// Search for PRs that reference this issue
|
|
39
|
+
const prs = ghJson(`api repos/${repo}/pulls?state=open&head=agent/issue-${issueNumber} --jq '.[0].number'`);
|
|
40
|
+
if (prs && typeof prs === 'number') result = prs;
|
|
41
|
+
} catch {}
|
|
42
|
+
if (!result) {
|
|
43
|
+
try {
|
|
44
|
+
// Fallback: search via gh pr list
|
|
45
|
+
const repo = item.content.repository;
|
|
46
|
+
const out = gh(`pr list --repo ${repo} --search "Closes #${issueNumber}" --json number --jq '.[0].number'`);
|
|
47
|
+
if (out) result = parseInt(out, 10);
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
prCache.set(issueNumber, result);
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Cache field/option IDs and project node ID (discovered on first use)
|
|
55
|
+
let projectNodeId = null;
|
|
25
56
|
let fieldId = null;
|
|
26
57
|
let optionIds = null;
|
|
27
58
|
|
|
59
|
+
function discoverProject() {
|
|
60
|
+
if (projectNodeId) return;
|
|
61
|
+
const projects = ghJson(`project list --owner ${owner} --format json`);
|
|
62
|
+
const proj = projects?.projects?.find(p => p.number === project);
|
|
63
|
+
if (!proj) throw new Error(`Project #${project} not found for owner ${owner}`);
|
|
64
|
+
projectNodeId = proj.id;
|
|
65
|
+
}
|
|
66
|
+
|
|
28
67
|
function discoverField() {
|
|
29
68
|
if (fieldId) return;
|
|
69
|
+
discoverProject();
|
|
30
70
|
const fields = ghJson(`project field-list ${project} --owner ${owner} --format json`);
|
|
31
71
|
const field = fields?.fields?.find(f => f.name === statusField);
|
|
32
72
|
if (!field) throw new Error(`Status field "${statusField}" not found in project ${project}`);
|
|
@@ -45,56 +85,67 @@ export function create({ project, owner, statusField = 'Status', columns = {} })
|
|
|
45
85
|
return id;
|
|
46
86
|
}
|
|
47
87
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
88
|
+
// Cached item list — refreshed once per poll cycle via refresh()
|
|
89
|
+
let cachedItems = null;
|
|
90
|
+
let cachedReviewClassification = null;
|
|
91
|
+
|
|
92
|
+
function getItemsByStatus(statusName) {
|
|
93
|
+
return (cachedItems?.items || []).filter(item => {
|
|
51
94
|
const status = item.status || item.fields?.find(f => f.name === statusField)?.value;
|
|
52
95
|
return status === statusName;
|
|
53
96
|
});
|
|
54
97
|
}
|
|
55
98
|
|
|
99
|
+
// Classify "In Review" items in a single pass — one review API call per item.
|
|
100
|
+
// Result is cached per poll cycle so listInReview + listApproved share one pass.
|
|
101
|
+
function classifyInReview() {
|
|
102
|
+
if (cachedReviewClassification) return cachedReviewClassification;
|
|
103
|
+
const items = getItemsByStatus(colNames.inReview);
|
|
104
|
+
const needsRevision = [];
|
|
105
|
+
const approved = [];
|
|
106
|
+
for (const item of items) {
|
|
107
|
+
if (!item.content?.number) continue;
|
|
108
|
+
try {
|
|
109
|
+
const prNumber = findLinkedPR(item);
|
|
110
|
+
if (!prNumber) continue;
|
|
111
|
+
const repo = item.content.repository || `${owner}/${item.content.repository}`;
|
|
112
|
+
const reviews = ghJson(`api repos/${repo}/pulls/${prNumber}/reviews`);
|
|
113
|
+
item._prNumber = prNumber;
|
|
114
|
+
if (reviews?.some(r => r.state === 'APPROVED')) {
|
|
115
|
+
approved.push(item);
|
|
116
|
+
} else if (reviews?.some(r => r.state === 'COMMENTED' || r.state === 'CHANGES_REQUESTED')) {
|
|
117
|
+
needsRevision.push(item);
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Skip items we can't check
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
cachedReviewClassification = { needsRevision, approved };
|
|
124
|
+
return cachedReviewClassification;
|
|
125
|
+
}
|
|
126
|
+
|
|
56
127
|
async function moveItem(item, colKey) {
|
|
57
128
|
discoverField();
|
|
58
129
|
const optId = getOptionId(colKey);
|
|
59
|
-
gh(`project item-edit --id ${item.id} --project-id ${
|
|
130
|
+
gh(`project item-edit --id ${item.id} --project-id ${projectNodeId} --field-id ${fieldId} --single-select-option-id ${optId}`);
|
|
60
131
|
}
|
|
61
132
|
|
|
62
133
|
return {
|
|
134
|
+
refresh() {
|
|
135
|
+
cachedItems = ghJson(`project item-list ${project} --owner ${owner} --format json`);
|
|
136
|
+
cachedReviewClassification = null;
|
|
137
|
+
},
|
|
138
|
+
|
|
63
139
|
async listTodo() {
|
|
64
|
-
return
|
|
140
|
+
return getItemsByStatus(colNames.todo);
|
|
65
141
|
},
|
|
66
142
|
|
|
67
143
|
async listInReview() {
|
|
68
|
-
|
|
69
|
-
// Filter to items that have new reviews
|
|
70
|
-
const withReviews = [];
|
|
71
|
-
for (const item of items) {
|
|
72
|
-
if (!item.content?.number) continue;
|
|
73
|
-
try {
|
|
74
|
-
const reviews = ghJson(`api repos/${owner}/${item.content.repository}/pulls/${item.content.number}/reviews`);
|
|
75
|
-
const hasNew = reviews?.some(r => r.state === 'COMMENTED' || r.state === 'CHANGES_REQUESTED');
|
|
76
|
-
if (hasNew) withReviews.push(item);
|
|
77
|
-
} catch {
|
|
78
|
-
// Skip items we can't check
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return withReviews;
|
|
144
|
+
return classifyInReview().needsRevision;
|
|
82
145
|
},
|
|
83
146
|
|
|
84
147
|
async listApproved() {
|
|
85
|
-
|
|
86
|
-
const approved = [];
|
|
87
|
-
for (const item of items) {
|
|
88
|
-
if (!item.content?.number) continue;
|
|
89
|
-
try {
|
|
90
|
-
const reviews = ghJson(`api repos/${owner}/${item.content.repository}/pulls/${item.content.number}/reviews`);
|
|
91
|
-
const isApproved = reviews?.some(r => r.state === 'APPROVED');
|
|
92
|
-
if (isApproved) approved.push(item);
|
|
93
|
-
} catch {
|
|
94
|
-
// Skip
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return approved;
|
|
148
|
+
return classifyInReview().approved;
|
|
98
149
|
},
|
|
99
150
|
|
|
100
151
|
moveToInProgress(item) { return moveItem(item, 'inProgress'); },
|
package/src/claude.js
CHANGED
|
@@ -2,7 +2,13 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
|
|
3
3
|
export function run({ prompt, workdir, allowedTools = 'Bash,Read,Edit,Write,Glob,Grep', maxTurns, verbose = true }) {
|
|
4
4
|
return new Promise((resolve, reject) => {
|
|
5
|
-
const args = [
|
|
5
|
+
const args = [
|
|
6
|
+
'-p', prompt,
|
|
7
|
+
'--output-format', 'stream-json',
|
|
8
|
+
'--verbose',
|
|
9
|
+
'--allowedTools', allowedTools,
|
|
10
|
+
'--dangerously-skip-permissions',
|
|
11
|
+
];
|
|
6
12
|
if (maxTurns) args.push('--max-turns', String(maxTurns));
|
|
7
13
|
|
|
8
14
|
const proc = spawn('claude', args, {
|
|
@@ -10,29 +16,62 @@ export function run({ prompt, workdir, allowedTools = 'Bash,Read,Edit,Write,Glob
|
|
|
10
16
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
11
17
|
});
|
|
12
18
|
|
|
19
|
+
let resultEvent = null;
|
|
20
|
+
let lastAssistantText = '';
|
|
13
21
|
let stdout = '';
|
|
14
22
|
let stderr = '';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
let buffer = '';
|
|
24
|
+
let pendingTools = [];
|
|
25
|
+
|
|
26
|
+
function flushTools() {
|
|
27
|
+
if (pendingTools.length === 0) return;
|
|
28
|
+
process.stderr.write(` claude: ${pendingTools.join(', ')}\n`);
|
|
29
|
+
pendingTools = [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
proc.stdout.on('data', d => {
|
|
33
|
+
stdout += d;
|
|
34
|
+
buffer += d;
|
|
35
|
+
const lines = buffer.split('\n');
|
|
36
|
+
buffer = lines.pop();
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
if (!line.trim()) continue;
|
|
39
|
+
try {
|
|
40
|
+
const event = JSON.parse(line);
|
|
41
|
+
if (event.type === 'result') {
|
|
42
|
+
resultEvent = event;
|
|
43
|
+
} else if (event.type === 'assistant' && event.message?.content) {
|
|
44
|
+
for (const block of event.message.content) {
|
|
45
|
+
if (block.type === 'text') {
|
|
46
|
+
lastAssistantText = block.text;
|
|
47
|
+
if (verbose) {
|
|
48
|
+
flushTools();
|
|
49
|
+
const text = block.text.replace(/\n/g, ' ').trim();
|
|
50
|
+
if (text) process.stderr.write(` claude: ${text.slice(0, 300)}\n`);
|
|
51
|
+
}
|
|
52
|
+
} else if (block.type === 'tool_use') {
|
|
53
|
+
if (verbose) pendingTools.push(block.name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// not valid JSON, skip
|
|
59
|
+
}
|
|
60
|
+
}
|
|
19
61
|
});
|
|
20
62
|
|
|
63
|
+
proc.stderr.on('data', d => { stderr += d; });
|
|
64
|
+
|
|
21
65
|
proc.on('close', code => {
|
|
66
|
+
if (verbose) flushTools();
|
|
22
67
|
if (code !== 0) {
|
|
23
68
|
return reject(new Error(`claude exited with code ${code}: ${stderr}`));
|
|
24
69
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
costUsd: result.cost_usd || null,
|
|
31
|
-
});
|
|
32
|
-
} catch {
|
|
33
|
-
// Non-JSON output — still succeeded
|
|
34
|
-
resolve({ result: stdout, sessionId: null, costUsd: null });
|
|
35
|
-
}
|
|
70
|
+
resolve({
|
|
71
|
+
result: resultEvent?.result ?? lastAssistantText ?? stdout,
|
|
72
|
+
sessionId: resultEvent?.session_id ?? null,
|
|
73
|
+
costUsd: resultEvent?.total_cost_usd ?? null,
|
|
74
|
+
});
|
|
36
75
|
});
|
|
37
76
|
|
|
38
77
|
proc.on('error', reject);
|
package/src/git.js
CHANGED
|
@@ -4,8 +4,9 @@ function run(cmd, opts = {}) {
|
|
|
4
4
|
return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], ...opts }).trim();
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
export function cloneRepo(repoUrl, dest) {
|
|
8
|
-
|
|
7
|
+
export function cloneRepo(repoUrl, dest, { branch } = {}) {
|
|
8
|
+
const branchFlag = branch ? ` --branch ${branch}` : '';
|
|
9
|
+
run(`git clone --depth 1${branchFlag} ${repoUrl} ${dest}`);
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export function checkoutBranch(dir, branch) {
|
|
@@ -16,12 +17,27 @@ export function createBranch(dir, name) {
|
|
|
16
17
|
run(`git checkout -b ${name}`, { cwd: dir });
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
export function hasNewCommits(dir, base = 'main') {
|
|
21
|
+
const log = run(`git log ${base}..HEAD --oneline`, { cwd: dir });
|
|
22
|
+
return log.length > 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function status(dir) {
|
|
26
|
+
return run(`git status --short`, { cwd: dir });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function commitAll(dir, message) {
|
|
30
|
+
run(`git add -A`, { cwd: dir });
|
|
31
|
+
run(`git commit -m ${JSON.stringify(message)}`, { cwd: dir });
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
export function push(dir, branch) {
|
|
20
35
|
run(`git push -u origin ${branch}`, { cwd: dir });
|
|
21
36
|
}
|
|
22
37
|
|
|
23
|
-
export function createPR(dir, { title, body, base = 'main' }) {
|
|
24
|
-
const
|
|
38
|
+
export function createPR(dir, { title, body, base = 'main', head }) {
|
|
39
|
+
const headFlag = head ? ` --head ${head}` : '';
|
|
40
|
+
const out = run(`gh pr create --title ${JSON.stringify(title)} --body ${JSON.stringify(body)} --base ${base}${headFlag}`, { cwd: dir });
|
|
25
41
|
// gh pr create prints the PR URL as the last line
|
|
26
42
|
const lines = out.split('\n');
|
|
27
43
|
const url = lines[lines.length - 1];
|
|
@@ -38,6 +54,11 @@ export function getReviews(prNumber, { repo }) {
|
|
|
38
54
|
return JSON.parse(out);
|
|
39
55
|
}
|
|
40
56
|
|
|
57
|
+
export function getReviewComments(prNumber, { repo }) {
|
|
58
|
+
const out = run(`gh api repos/${repo}/pulls/${prNumber}/comments`);
|
|
59
|
+
return JSON.parse(out);
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
export function getPRBranch(prNumber, { repo }) {
|
|
42
63
|
const out = run(`gh pr view ${prNumber} --repo ${repo} --json headRefName --jq .headRefName`);
|
|
43
64
|
return out;
|
package/src/revise.js
CHANGED
|
@@ -13,15 +13,23 @@ import { reflect } from './reflect.js';
|
|
|
13
13
|
const CONFIG_DIR = '.warp-coder';
|
|
14
14
|
|
|
15
15
|
export async function revise(item, { board, config, log }) {
|
|
16
|
-
const prNumber = item.content?.number;
|
|
16
|
+
const prNumber = item._prNumber || item.content?.number;
|
|
17
17
|
const repo = config.repo;
|
|
18
|
-
const repoName = repo.replace(/\.git$/, '').
|
|
18
|
+
const repoName = repo.replace(/\.git$/, '').replace(/^.*github\.com[:\/]/, '');
|
|
19
19
|
const maxRevisions = config.maxRevisions || 3;
|
|
20
20
|
const workdir = join(tmpdir(), 'warp-coder', `revise-${prNumber}`);
|
|
21
21
|
const configDir = join(process.cwd(), CONFIG_DIR);
|
|
22
22
|
|
|
23
23
|
log(`Revising PR #${prNumber}`);
|
|
24
24
|
|
|
25
|
+
// Move to In Progress
|
|
26
|
+
try {
|
|
27
|
+
await board.moveToInProgress(item);
|
|
28
|
+
log(' moved to In Progress');
|
|
29
|
+
} catch (err) {
|
|
30
|
+
log(` warning: could not move to In Progress: ${err.message}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
// Check revision limit
|
|
26
34
|
if (config.warpmetricsApiKey) {
|
|
27
35
|
try {
|
|
@@ -38,6 +46,7 @@ export async function revise(item, { board, config, log }) {
|
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
// WarpMetrics: start pipeline
|
|
49
|
+
let runId = null;
|
|
41
50
|
let groupId = null;
|
|
42
51
|
if (config.warpmetricsApiKey) {
|
|
43
52
|
try {
|
|
@@ -46,8 +55,9 @@ export async function revise(item, { board, config, log }) {
|
|
|
46
55
|
repo: repoName,
|
|
47
56
|
prNumber,
|
|
48
57
|
});
|
|
58
|
+
runId = pipeline.runId;
|
|
49
59
|
groupId = pipeline.groupId;
|
|
50
|
-
log(` pipeline: run=${
|
|
60
|
+
log(` pipeline: run=${runId} group=${groupId}`);
|
|
51
61
|
} catch (err) {
|
|
52
62
|
log(` warning: pipeline start failed: ${err.message}`);
|
|
53
63
|
}
|
|
@@ -63,23 +73,52 @@ export async function revise(item, { board, config, log }) {
|
|
|
63
73
|
// Clone + checkout PR branch
|
|
64
74
|
rmSync(workdir, { recursive: true, force: true });
|
|
65
75
|
mkdirSync(workdir, { recursive: true });
|
|
66
|
-
log(` cloning into ${workdir}`);
|
|
67
|
-
git.cloneRepo(repo, workdir);
|
|
68
|
-
|
|
69
76
|
const branch = git.getPRBranch(prNumber, { repo: repoName });
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
log(` cloning into ${workdir} (branch: ${branch})`);
|
|
78
|
+
git.cloneRepo(repo, workdir, { branch });
|
|
72
79
|
|
|
73
80
|
// Fetch review comments for context
|
|
81
|
+
let inlineComments = [];
|
|
74
82
|
try {
|
|
75
83
|
reviewComments = git.getReviews(prNumber, { repo: repoName });
|
|
76
84
|
} catch (err) {
|
|
77
85
|
log(` warning: could not fetch reviews: ${err.message}`);
|
|
78
86
|
}
|
|
87
|
+
try {
|
|
88
|
+
inlineComments = git.getReviewComments(prNumber, { repo: repoName });
|
|
89
|
+
} catch (err) {
|
|
90
|
+
log(` warning: could not fetch inline comments: ${err.message}`);
|
|
91
|
+
}
|
|
79
92
|
|
|
80
93
|
// Load memory for prompt enrichment
|
|
81
94
|
const memory = config.memory?.enabled !== false ? loadMemory(configDir) : '';
|
|
82
95
|
|
|
96
|
+
// Build review feedback section (truncate to ~20k chars to stay within context)
|
|
97
|
+
const maxReviewChars = 20000;
|
|
98
|
+
let reviewSection = '';
|
|
99
|
+
|
|
100
|
+
for (const r of reviewComments) {
|
|
101
|
+
const body = (r.body || '').trim();
|
|
102
|
+
if (!body) continue;
|
|
103
|
+
const user = r.user?.login || 'unknown';
|
|
104
|
+
reviewSection += `**${user}** (${r.state || 'COMMENT'}):\n${body}\n\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (inlineComments.length > 0) {
|
|
108
|
+
reviewSection += '### Inline comments\n\n';
|
|
109
|
+
for (const c of inlineComments) {
|
|
110
|
+
const user = c.user?.login || 'unknown';
|
|
111
|
+
const body = (c.body || '').trim();
|
|
112
|
+
if (!body) continue;
|
|
113
|
+
const location = c.path ? `\`${c.path}${c.line ? `:${c.line}` : ''}\`` : '';
|
|
114
|
+
reviewSection += `${location} — **${user}**:\n${body}\n\n`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (reviewSection.length > maxReviewChars) {
|
|
119
|
+
reviewSection = reviewSection.slice(0, maxReviewChars) + '\n\n(Review truncated — focus on the comments shown above.)\n';
|
|
120
|
+
}
|
|
121
|
+
|
|
83
122
|
// Claude
|
|
84
123
|
const promptParts = [
|
|
85
124
|
`You are working on PR #${prNumber} in ${repoName}.`,
|
|
@@ -95,13 +134,20 @@ export async function revise(item, { board, config, log }) {
|
|
|
95
134
|
);
|
|
96
135
|
}
|
|
97
136
|
|
|
137
|
+
if (reviewSection) {
|
|
138
|
+
promptParts.push('A code review has been submitted. Here is the feedback:');
|
|
139
|
+
promptParts.push('');
|
|
140
|
+
promptParts.push(reviewSection);
|
|
141
|
+
} else {
|
|
142
|
+
promptParts.push('A code review has been submitted but no comments could be fetched — check the PR manually.');
|
|
143
|
+
promptParts.push('');
|
|
144
|
+
}
|
|
98
145
|
promptParts.push(
|
|
99
|
-
'
|
|
146
|
+
'Your job:',
|
|
100
147
|
'',
|
|
101
|
-
'1.
|
|
102
|
-
'2.
|
|
103
|
-
'3.
|
|
104
|
-
'4. Commit the fixes with a message like "Address review feedback"',
|
|
148
|
+
'1. Apply the suggested fixes',
|
|
149
|
+
'2. Run tests to make sure everything passes',
|
|
150
|
+
'3. Commit all changes with a message like "Address review feedback" — this is critical, do not skip the commit',
|
|
105
151
|
'',
|
|
106
152
|
'Do NOT open a new PR — just implement the fixes and commit.',
|
|
107
153
|
);
|
|
@@ -126,6 +172,12 @@ export async function revise(item, { board, config, log }) {
|
|
|
126
172
|
throw err;
|
|
127
173
|
}
|
|
128
174
|
|
|
175
|
+
// Auto-commit if Claude left uncommitted changes
|
|
176
|
+
if (git.status(workdir)) {
|
|
177
|
+
log(' claude forgot to commit — auto-committing');
|
|
178
|
+
git.commitAll(workdir, 'Address review feedback');
|
|
179
|
+
}
|
|
180
|
+
|
|
129
181
|
// Push
|
|
130
182
|
log(' pushing...');
|
|
131
183
|
git.push(workdir, branch);
|
|
@@ -141,11 +193,16 @@ export async function revise(item, { board, config, log }) {
|
|
|
141
193
|
} catch (err) {
|
|
142
194
|
taskError = err.message;
|
|
143
195
|
log(` failed: ${err.message}`);
|
|
196
|
+
try {
|
|
197
|
+
await board.moveToBlocked(item);
|
|
198
|
+
} catch (moveErr) {
|
|
199
|
+
log(` warning: could not move to Blocked: ${moveErr.message}`);
|
|
200
|
+
}
|
|
144
201
|
} finally {
|
|
145
202
|
// WarpMetrics: record outcome
|
|
146
203
|
if (config.warpmetricsApiKey && groupId) {
|
|
147
204
|
try {
|
|
148
|
-
const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
|
|
205
|
+
const outcome = await warp.recordOutcome(config.warpmetricsApiKey, { runId, groupId }, {
|
|
149
206
|
step: 'revise',
|
|
150
207
|
success,
|
|
151
208
|
costUsd: claudeResult?.costUsd,
|
package/src/warp.js
CHANGED
|
@@ -117,14 +117,13 @@ export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitl
|
|
|
117
117
|
return { runId, groupId };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
export async function recordOutcome(apiKey, groupId, { step, success, costUsd, error, hooksFailed, issueNumber, prNumber, reviewCommentCount }) {
|
|
120
|
+
export async function recordOutcome(apiKey, { runId, groupId }, { step, success, costUsd, error, hooksFailed, issueNumber, prNumber, reviewCommentCount }) {
|
|
121
121
|
const names = {
|
|
122
122
|
implement: { true: 'PR Created', false: 'Implementation Failed' },
|
|
123
123
|
revise: { true: 'Fixes Applied', false: 'Revision Failed' },
|
|
124
124
|
};
|
|
125
125
|
|
|
126
126
|
const name = names[step]?.[String(success)] || `${step}: ${success ? 'success' : 'failure'}`;
|
|
127
|
-
const id = generateId('oc');
|
|
128
127
|
const now = new Date().toISOString();
|
|
129
128
|
|
|
130
129
|
const opts = { status: success ? 'success' : 'failure', step };
|
|
@@ -135,11 +134,23 @@ export async function recordOutcome(apiKey, groupId, { step, success, costUsd, e
|
|
|
135
134
|
if (prNumber) opts.pr_number = String(prNumber);
|
|
136
135
|
if (reviewCommentCount) opts.review_comments = String(reviewCommentCount);
|
|
137
136
|
|
|
137
|
+
const outcomes = [
|
|
138
|
+
{ id: generateId('oc'), refId: groupId, name, opts, timestamp: now },
|
|
139
|
+
];
|
|
140
|
+
if (runId) {
|
|
141
|
+
outcomes.push({ id: generateId('oc'), refId: runId, name, opts, timestamp: now });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await sendEvents(apiKey, { outcomes });
|
|
145
|
+
|
|
146
|
+
return { id: outcomes[0].id, name };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function emitAct(apiKey, { outcomeId, actId, name, opts }) {
|
|
150
|
+
const now = new Date().toISOString();
|
|
138
151
|
await sendEvents(apiKey, {
|
|
139
|
-
|
|
152
|
+
acts: [{ id: actId, refId: outcomeId, name, opts: opts || null, timestamp: now }],
|
|
140
153
|
});
|
|
141
|
-
|
|
142
|
-
return { id, name };
|
|
143
154
|
}
|
|
144
155
|
|
|
145
156
|
export async function countRevisions(apiKey, { prNumber, repo }) {
|
package/src/watch.js
CHANGED
|
@@ -13,9 +13,15 @@ export async function watch() {
|
|
|
13
13
|
const pollInterval = (config.pollInterval || 30) * 1000;
|
|
14
14
|
|
|
15
15
|
let running = true;
|
|
16
|
+
let sleepResolve = null;
|
|
16
17
|
const shutdown = () => {
|
|
17
|
-
|
|
18
|
+
if (!running) {
|
|
19
|
+
console.log('\nForce exit.');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
console.log('\nShutting down... (Ctrl+C again to force)');
|
|
18
23
|
running = false;
|
|
24
|
+
if (sleepResolve) sleepResolve();
|
|
19
25
|
};
|
|
20
26
|
process.on('SIGINT', shutdown);
|
|
21
27
|
process.on('SIGTERM', shutdown);
|
|
@@ -29,6 +35,9 @@ export async function watch() {
|
|
|
29
35
|
|
|
30
36
|
while (running) {
|
|
31
37
|
try {
|
|
38
|
+
// Fetch all project items once per poll cycle
|
|
39
|
+
board.refresh();
|
|
40
|
+
|
|
32
41
|
// 1. Pick up new tasks from Todo
|
|
33
42
|
const todoItems = await board.listTodo();
|
|
34
43
|
if (todoItems.length > 0) {
|
|
@@ -41,7 +50,7 @@ export async function watch() {
|
|
|
41
50
|
const reviewItems = await board.listInReview();
|
|
42
51
|
for (const item of reviewItems) {
|
|
43
52
|
if (!running) break;
|
|
44
|
-
log(`Found review feedback: PR #${item.content?.number}`);
|
|
53
|
+
log(`Found review feedback: PR #${item._prNumber || item.content?.number}`);
|
|
45
54
|
await revise(item, { board, config, log });
|
|
46
55
|
}
|
|
47
56
|
|
|
@@ -49,8 +58,8 @@ export async function watch() {
|
|
|
49
58
|
const approvedItems = await board.listApproved();
|
|
50
59
|
for (const item of approvedItems) {
|
|
51
60
|
if (!running) break;
|
|
52
|
-
const prNumber = item.content?.number;
|
|
53
|
-
const repoName = config.repo.replace(/\.git$/, '').
|
|
61
|
+
const prNumber = item._prNumber || item.content?.number;
|
|
62
|
+
const repoName = config.repo.replace(/\.git$/, '').replace(/^.*github\.com[:\/]/, '');
|
|
54
63
|
log(`Merging approved PR #${prNumber}`);
|
|
55
64
|
try {
|
|
56
65
|
runHook('onBeforeMerge', config, { prNumber, repo: repoName });
|
|
@@ -66,9 +75,12 @@ export async function watch() {
|
|
|
66
75
|
log(`Poll error: ${err.message}`);
|
|
67
76
|
}
|
|
68
77
|
|
|
69
|
-
// Sleep
|
|
78
|
+
// Sleep (interruptible)
|
|
70
79
|
if (running) {
|
|
71
|
-
await new Promise(resolve =>
|
|
80
|
+
await new Promise(resolve => {
|
|
81
|
+
sleepResolve = resolve;
|
|
82
|
+
setTimeout(() => { sleepResolve = null; resolve(); }, pollInterval);
|
|
83
|
+
});
|
|
72
84
|
}
|
|
73
85
|
}
|
|
74
86
|
|