@warpmetrics/coder 0.2.4 → 0.2.6
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 +16 -11
- package/package.json +1 -1
- package/src/agent.js +47 -6
- package/src/boards/github-projects.js +92 -33
- package/src/claude.js +55 -16
- package/src/git.js +33 -4
- package/src/revise.js +99 -15
- package/src/warp.js +51 -7
- package/src/watch.js +88 -9
package/bin/cli.js
CHANGED
|
@@ -56,23 +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
|
+
// Need 'project' (read:project is not enough) and 'repo'
|
|
59
60
|
log(' Checking GitHub CLI scopes...');
|
|
60
61
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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) {
|
|
64
73
|
log(' \u2713 GitHub CLI scopes OK');
|
|
65
74
|
} else {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} catch {
|
|
69
|
-
log(' Requesting required scopes (project, repo)...');
|
|
70
|
-
try {
|
|
71
|
-
execSync('gh auth refresh -s project,repo', { stdio: 'inherit' });
|
|
75
|
+
log(` Missing scopes: ${missing.join(', ')}`);
|
|
76
|
+
execSync(`gh auth refresh -s ${missing.join(',')}`, { stdio: 'inherit' });
|
|
72
77
|
log(' \u2713 GitHub CLI scopes updated');
|
|
73
|
-
} catch {
|
|
74
|
-
log(' \u26a0 Could not refresh gh scopes — run manually: gh auth refresh -s project,repo');
|
|
75
78
|
}
|
|
79
|
+
} catch {
|
|
80
|
+
log(' \u26a0 Could not verify gh scopes — run manually: gh auth refresh -s project,repo');
|
|
76
81
|
}
|
|
77
82
|
log('');
|
|
78
83
|
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -12,12 +12,12 @@ import { reflect } from './reflect.js';
|
|
|
12
12
|
|
|
13
13
|
const CONFIG_DIR = '.warp-coder';
|
|
14
14
|
|
|
15
|
-
export async function implement(item, { board, config, log }) {
|
|
15
|
+
export async function implement(item, { board, config, log, refActId }) {
|
|
16
16
|
const issueNumber = item.content?.number;
|
|
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 {
|
|
@@ -40,9 +45,11 @@ export async function implement(item, { board, config, log }) {
|
|
|
40
45
|
repo: repoName,
|
|
41
46
|
issueNumber,
|
|
42
47
|
issueTitle,
|
|
48
|
+
refActId,
|
|
43
49
|
});
|
|
50
|
+
runId = pipeline.runId;
|
|
44
51
|
groupId = pipeline.groupId;
|
|
45
|
-
log(` pipeline: run=${
|
|
52
|
+
log(` pipeline: run=${runId} group=${groupId}`);
|
|
46
53
|
} catch (err) {
|
|
47
54
|
log(` warning: pipeline start failed: ${err.message}`);
|
|
48
55
|
}
|
|
@@ -102,7 +109,7 @@ export async function implement(item, { board, config, log }) {
|
|
|
102
109
|
'1. Read the codebase to understand relevant context',
|
|
103
110
|
'2. Implement the changes',
|
|
104
111
|
'3. Run tests to verify nothing is broken',
|
|
105
|
-
'4. Commit with a clear message',
|
|
112
|
+
'4. Commit all changes with a clear message — this is critical, do not skip the commit',
|
|
106
113
|
'',
|
|
107
114
|
'Do NOT create branches, push, or open PRs — just implement and commit.',
|
|
108
115
|
'If the issue is unclear or you cannot implement it, explain what is missing.',
|
|
@@ -118,6 +125,8 @@ export async function implement(item, { board, config, log }) {
|
|
|
118
125
|
maxTurns: config.claude?.maxTurns,
|
|
119
126
|
});
|
|
120
127
|
log(` claude done (cost: $${claudeResult.costUsd ?? '?'})`);
|
|
128
|
+
log(` git status: ${git.status(workdir) || '(clean)'}`);
|
|
129
|
+
log(` git log: ${git.hasNewCommits(workdir) ? 'has new commits' : 'NO new commits'}`);
|
|
121
130
|
|
|
122
131
|
// Hook: onBeforePush
|
|
123
132
|
try {
|
|
@@ -128,12 +137,29 @@ export async function implement(item, { board, config, log }) {
|
|
|
128
137
|
throw err;
|
|
129
138
|
}
|
|
130
139
|
|
|
140
|
+
// Auto-commit if Claude left uncommitted changes
|
|
141
|
+
if (!git.hasNewCommits(workdir) && git.status(workdir)) {
|
|
142
|
+
log(' claude forgot to commit — auto-committing');
|
|
143
|
+
git.commitAll(workdir, `Implement #${issueNumber}: ${issueTitle}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!git.hasNewCommits(workdir)) {
|
|
147
|
+
throw new Error('Claude did not produce any changes.');
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
// Push + PR
|
|
132
151
|
log(' pushing...');
|
|
133
152
|
git.push(workdir, branch);
|
|
153
|
+
const prBody = [
|
|
154
|
+
`Closes #${issueNumber}`,
|
|
155
|
+
'',
|
|
156
|
+
'Implemented by warp-coder.',
|
|
157
|
+
...(actId ? ['', `<!-- wm:act:${actId} -->`] : []),
|
|
158
|
+
].join('\n');
|
|
134
159
|
const pr = git.createPR(workdir, {
|
|
135
160
|
title: issueTitle,
|
|
136
|
-
body:
|
|
161
|
+
body: prBody,
|
|
162
|
+
head: branch,
|
|
137
163
|
});
|
|
138
164
|
log(` PR created: ${pr.url}`);
|
|
139
165
|
|
|
@@ -149,6 +175,7 @@ export async function implement(item, { board, config, log }) {
|
|
|
149
175
|
// Move to In Review
|
|
150
176
|
try {
|
|
151
177
|
await board.moveToReview(item);
|
|
178
|
+
log(' moved to In Review');
|
|
152
179
|
} catch (err) {
|
|
153
180
|
log(` warning: could not move to In Review: ${err.message}`);
|
|
154
181
|
}
|
|
@@ -157,11 +184,16 @@ export async function implement(item, { board, config, log }) {
|
|
|
157
184
|
} catch (err) {
|
|
158
185
|
taskError = err.message;
|
|
159
186
|
log(` failed: ${err.message}`);
|
|
187
|
+
try {
|
|
188
|
+
await board.moveToBlocked(item);
|
|
189
|
+
} catch (moveErr) {
|
|
190
|
+
log(` warning: could not move to Blocked: ${moveErr.message}`);
|
|
191
|
+
}
|
|
160
192
|
} finally {
|
|
161
193
|
// WarpMetrics: record outcome
|
|
162
194
|
if (config.warpmetricsApiKey && groupId) {
|
|
163
195
|
try {
|
|
164
|
-
const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
|
|
196
|
+
const outcome = await warp.recordOutcome(config.warpmetricsApiKey, { runId, groupId }, {
|
|
165
197
|
step: 'implement',
|
|
166
198
|
success,
|
|
167
199
|
costUsd: claudeResult?.costUsd,
|
|
@@ -170,6 +202,15 @@ export async function implement(item, { board, config, log }) {
|
|
|
170
202
|
issueNumber,
|
|
171
203
|
});
|
|
172
204
|
log(` outcome: ${outcome.name}`);
|
|
205
|
+
|
|
206
|
+
// Emit act so warp-review can link its run as a follow-up
|
|
207
|
+
if (success && actId && outcome.runOutcomeId) {
|
|
208
|
+
await warp.emitAct(config.warpmetricsApiKey, {
|
|
209
|
+
outcomeId: outcome.runOutcomeId,
|
|
210
|
+
actId,
|
|
211
|
+
name: 'review',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
173
214
|
} catch (err) {
|
|
174
215
|
log(` warning: outcome recording failed: ${err.message}`);
|
|
175
216
|
}
|
|
@@ -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,75 @@ 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
|
+
|
|
115
|
+
// Parse act ID from the most recent warp-review review body
|
|
116
|
+
const actMatch = reviews
|
|
117
|
+
?.slice().reverse()
|
|
118
|
+
.map(r => (r.body || '').match(/<!-- wm:act:(wm_act_\w+) -->/))
|
|
119
|
+
.find(m => m);
|
|
120
|
+
if (actMatch) item._reviewActId = actMatch[1];
|
|
121
|
+
|
|
122
|
+
if (reviews?.some(r => r.state === 'APPROVED')) {
|
|
123
|
+
approved.push(item);
|
|
124
|
+
} else if (reviews?.some(r => r.state === 'COMMENTED' || r.state === 'CHANGES_REQUESTED')) {
|
|
125
|
+
needsRevision.push(item);
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// Skip items we can't check
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
cachedReviewClassification = { needsRevision, approved };
|
|
132
|
+
return cachedReviewClassification;
|
|
133
|
+
}
|
|
134
|
+
|
|
56
135
|
async function moveItem(item, colKey) {
|
|
57
136
|
discoverField();
|
|
58
137
|
const optId = getOptionId(colKey);
|
|
59
|
-
gh(`project item-edit --id ${item.id} --project-id ${
|
|
138
|
+
gh(`project item-edit --id ${item.id} --project-id ${projectNodeId} --field-id ${fieldId} --single-select-option-id ${optId}`);
|
|
60
139
|
}
|
|
61
140
|
|
|
62
141
|
return {
|
|
142
|
+
refresh() {
|
|
143
|
+
cachedItems = ghJson(`project item-list ${project} --owner ${owner} --format json`);
|
|
144
|
+
cachedReviewClassification = null;
|
|
145
|
+
},
|
|
146
|
+
|
|
63
147
|
async listTodo() {
|
|
64
|
-
return
|
|
148
|
+
return getItemsByStatus(colNames.todo);
|
|
65
149
|
},
|
|
66
150
|
|
|
67
151
|
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;
|
|
152
|
+
return classifyInReview().needsRevision;
|
|
82
153
|
},
|
|
83
154
|
|
|
84
155
|
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;
|
|
156
|
+
return classifyInReview().approved;
|
|
98
157
|
},
|
|
99
158
|
|
|
100
159
|
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,19 @@ 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
|
+
|
|
62
|
+
export function updatePRBody(prNumber, { repo, body }) {
|
|
63
|
+
run(`gh pr edit ${prNumber} --repo ${repo} --body ${JSON.stringify(body)}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getPRBody(prNumber, { repo }) {
|
|
67
|
+
return run(`gh pr view ${prNumber} --repo ${repo} --json body --jq .body`);
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
export function getPRBranch(prNumber, { repo }) {
|
|
42
71
|
const out = run(`gh pr view ${prNumber} --repo ${repo} --json headRefName --jq .headRefName`);
|
|
43
72
|
return out;
|
package/src/revise.js
CHANGED
|
@@ -12,16 +12,27 @@ import { reflect } from './reflect.js';
|
|
|
12
12
|
|
|
13
13
|
const CONFIG_DIR = '.warp-coder';
|
|
14
14
|
|
|
15
|
-
export async function revise(item, { board, config, log }) {
|
|
16
|
-
const prNumber = item.content?.number;
|
|
15
|
+
export async function revise(item, { board, config, log, refActId }) {
|
|
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
|
+
// Pre-generate act ID for chaining (update PR body so warp-review can link next review)
|
|
24
|
+
const actId = config.warpmetricsApiKey ? warp.generateId('act') : null;
|
|
25
|
+
|
|
23
26
|
log(`Revising PR #${prNumber}`);
|
|
24
27
|
|
|
28
|
+
// Move to In Progress
|
|
29
|
+
try {
|
|
30
|
+
await board.moveToInProgress(item);
|
|
31
|
+
log(' moved to In Progress');
|
|
32
|
+
} catch (err) {
|
|
33
|
+
log(` warning: could not move to In Progress: ${err.message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
// Check revision limit
|
|
26
37
|
if (config.warpmetricsApiKey) {
|
|
27
38
|
try {
|
|
@@ -38,6 +49,7 @@ export async function revise(item, { board, config, log }) {
|
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
// WarpMetrics: start pipeline
|
|
52
|
+
let runId = null;
|
|
41
53
|
let groupId = null;
|
|
42
54
|
if (config.warpmetricsApiKey) {
|
|
43
55
|
try {
|
|
@@ -45,9 +57,11 @@ export async function revise(item, { board, config, log }) {
|
|
|
45
57
|
step: 'revise',
|
|
46
58
|
repo: repoName,
|
|
47
59
|
prNumber,
|
|
60
|
+
refActId,
|
|
48
61
|
});
|
|
62
|
+
runId = pipeline.runId;
|
|
49
63
|
groupId = pipeline.groupId;
|
|
50
|
-
log(` pipeline: run=${
|
|
64
|
+
log(` pipeline: run=${runId} group=${groupId}`);
|
|
51
65
|
} catch (err) {
|
|
52
66
|
log(` warning: pipeline start failed: ${err.message}`);
|
|
53
67
|
}
|
|
@@ -63,23 +77,52 @@ export async function revise(item, { board, config, log }) {
|
|
|
63
77
|
// Clone + checkout PR branch
|
|
64
78
|
rmSync(workdir, { recursive: true, force: true });
|
|
65
79
|
mkdirSync(workdir, { recursive: true });
|
|
66
|
-
log(` cloning into ${workdir}`);
|
|
67
|
-
git.cloneRepo(repo, workdir);
|
|
68
|
-
|
|
69
80
|
const branch = git.getPRBranch(prNumber, { repo: repoName });
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
log(` cloning into ${workdir} (branch: ${branch})`);
|
|
82
|
+
git.cloneRepo(repo, workdir, { branch });
|
|
72
83
|
|
|
73
84
|
// Fetch review comments for context
|
|
85
|
+
let inlineComments = [];
|
|
74
86
|
try {
|
|
75
87
|
reviewComments = git.getReviews(prNumber, { repo: repoName });
|
|
76
88
|
} catch (err) {
|
|
77
89
|
log(` warning: could not fetch reviews: ${err.message}`);
|
|
78
90
|
}
|
|
91
|
+
try {
|
|
92
|
+
inlineComments = git.getReviewComments(prNumber, { repo: repoName });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
log(` warning: could not fetch inline comments: ${err.message}`);
|
|
95
|
+
}
|
|
79
96
|
|
|
80
97
|
// Load memory for prompt enrichment
|
|
81
98
|
const memory = config.memory?.enabled !== false ? loadMemory(configDir) : '';
|
|
82
99
|
|
|
100
|
+
// Build review feedback section (truncate to ~20k chars to stay within context)
|
|
101
|
+
const maxReviewChars = 20000;
|
|
102
|
+
let reviewSection = '';
|
|
103
|
+
|
|
104
|
+
for (const r of reviewComments) {
|
|
105
|
+
const body = (r.body || '').trim();
|
|
106
|
+
if (!body) continue;
|
|
107
|
+
const user = r.user?.login || 'unknown';
|
|
108
|
+
reviewSection += `**${user}** (${r.state || 'COMMENT'}):\n${body}\n\n`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (inlineComments.length > 0) {
|
|
112
|
+
reviewSection += '### Inline comments\n\n';
|
|
113
|
+
for (const c of inlineComments) {
|
|
114
|
+
const user = c.user?.login || 'unknown';
|
|
115
|
+
const body = (c.body || '').trim();
|
|
116
|
+
if (!body) continue;
|
|
117
|
+
const location = c.path ? `\`${c.path}${c.line ? `:${c.line}` : ''}\`` : '';
|
|
118
|
+
reviewSection += `${location} — **${user}**:\n${body}\n\n`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (reviewSection.length > maxReviewChars) {
|
|
123
|
+
reviewSection = reviewSection.slice(0, maxReviewChars) + '\n\n(Review truncated — focus on the comments shown above.)\n';
|
|
124
|
+
}
|
|
125
|
+
|
|
83
126
|
// Claude
|
|
84
127
|
const promptParts = [
|
|
85
128
|
`You are working on PR #${prNumber} in ${repoName}.`,
|
|
@@ -95,13 +138,20 @@ export async function revise(item, { board, config, log }) {
|
|
|
95
138
|
);
|
|
96
139
|
}
|
|
97
140
|
|
|
141
|
+
if (reviewSection) {
|
|
142
|
+
promptParts.push('A code review has been submitted. Here is the feedback:');
|
|
143
|
+
promptParts.push('');
|
|
144
|
+
promptParts.push(reviewSection);
|
|
145
|
+
} else {
|
|
146
|
+
promptParts.push('A code review has been submitted but no comments could be fetched — check the PR manually.');
|
|
147
|
+
promptParts.push('');
|
|
148
|
+
}
|
|
98
149
|
promptParts.push(
|
|
99
|
-
'
|
|
150
|
+
'Your job:',
|
|
100
151
|
'',
|
|
101
|
-
'1.
|
|
102
|
-
'2.
|
|
103
|
-
'3.
|
|
104
|
-
'4. Commit the fixes with a message like "Address review feedback"',
|
|
152
|
+
'1. Apply the suggested fixes',
|
|
153
|
+
'2. Run tests to make sure everything passes',
|
|
154
|
+
'3. Commit all changes with a message like "Address review feedback" — this is critical, do not skip the commit',
|
|
105
155
|
'',
|
|
106
156
|
'Do NOT open a new PR — just implement the fixes and commit.',
|
|
107
157
|
);
|
|
@@ -126,10 +176,30 @@ export async function revise(item, { board, config, log }) {
|
|
|
126
176
|
throw err;
|
|
127
177
|
}
|
|
128
178
|
|
|
179
|
+
// Auto-commit if Claude left uncommitted changes
|
|
180
|
+
if (git.status(workdir)) {
|
|
181
|
+
log(' claude forgot to commit — auto-committing');
|
|
182
|
+
git.commitAll(workdir, 'Address review feedback');
|
|
183
|
+
}
|
|
184
|
+
|
|
129
185
|
// Push
|
|
130
186
|
log(' pushing...');
|
|
131
187
|
git.push(workdir, branch);
|
|
132
188
|
|
|
189
|
+
// Update PR body with new act ID for next review cycle
|
|
190
|
+
if (actId) {
|
|
191
|
+
try {
|
|
192
|
+
let body = git.getPRBody(prNumber, { repo: repoName });
|
|
193
|
+
body = body.replace(/<!-- wm:act:wm_act_\w+ -->/, `<!-- wm:act:${actId} -->`);
|
|
194
|
+
if (!body.includes(`<!-- wm:act:${actId} -->`)) {
|
|
195
|
+
body += `\n\n<!-- wm:act:${actId} -->`;
|
|
196
|
+
}
|
|
197
|
+
git.updatePRBody(prNumber, { repo: repoName, body });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
log(` warning: could not update PR body with act ID: ${err.message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
133
203
|
// Move back to In Review
|
|
134
204
|
try {
|
|
135
205
|
await board.moveToReview(item);
|
|
@@ -141,11 +211,16 @@ export async function revise(item, { board, config, log }) {
|
|
|
141
211
|
} catch (err) {
|
|
142
212
|
taskError = err.message;
|
|
143
213
|
log(` failed: ${err.message}`);
|
|
214
|
+
try {
|
|
215
|
+
await board.moveToBlocked(item);
|
|
216
|
+
} catch (moveErr) {
|
|
217
|
+
log(` warning: could not move to Blocked: ${moveErr.message}`);
|
|
218
|
+
}
|
|
144
219
|
} finally {
|
|
145
220
|
// WarpMetrics: record outcome
|
|
146
221
|
if (config.warpmetricsApiKey && groupId) {
|
|
147
222
|
try {
|
|
148
|
-
const outcome = await warp.recordOutcome(config.warpmetricsApiKey, groupId, {
|
|
223
|
+
const outcome = await warp.recordOutcome(config.warpmetricsApiKey, { runId, groupId }, {
|
|
149
224
|
step: 'revise',
|
|
150
225
|
success,
|
|
151
226
|
costUsd: claudeResult?.costUsd,
|
|
@@ -155,6 +230,15 @@ export async function revise(item, { board, config, log }) {
|
|
|
155
230
|
reviewCommentCount: reviewComments.length,
|
|
156
231
|
});
|
|
157
232
|
log(` outcome: ${outcome.name}`);
|
|
233
|
+
|
|
234
|
+
// Emit act so warp-review can link its next review as a follow-up
|
|
235
|
+
if (success && actId && outcome.runOutcomeId) {
|
|
236
|
+
await warp.emitAct(config.warpmetricsApiKey, {
|
|
237
|
+
outcomeId: outcome.runOutcomeId,
|
|
238
|
+
actId,
|
|
239
|
+
name: 'review',
|
|
240
|
+
});
|
|
241
|
+
}
|
|
158
242
|
} catch (err) {
|
|
159
243
|
log(` warning: outcome recording failed: ${err.message}`);
|
|
160
244
|
}
|
package/src/warp.js
CHANGED
|
@@ -69,6 +69,8 @@ export async function registerClassifications(apiKey) {
|
|
|
69
69
|
const items = [
|
|
70
70
|
{ name: 'PR Created', classification: 'success' },
|
|
71
71
|
{ name: 'Fixes Applied', classification: 'success' },
|
|
72
|
+
{ name: 'Merged', classification: 'success' },
|
|
73
|
+
{ name: 'Shipped', classification: 'success' },
|
|
72
74
|
{ name: 'Issue Understood', classification: 'success' },
|
|
73
75
|
{ name: 'Needs Clarification', classification: 'neutral' },
|
|
74
76
|
{ name: 'Needs Human', classification: 'neutral' },
|
|
@@ -94,11 +96,37 @@ export async function registerClassifications(apiKey) {
|
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Issue run — the root of the lifecycle chain
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
export async function createIssueRun(apiKey, { repo, issueNumber, issueTitle }) {
|
|
104
|
+
const runId = generateId('run');
|
|
105
|
+
const outcomeId = generateId('oc');
|
|
106
|
+
const actId = generateId('act');
|
|
107
|
+
const now = new Date().toISOString();
|
|
108
|
+
|
|
109
|
+
await sendEvents(apiKey, {
|
|
110
|
+
runs: [{ id: runId, label: 'issue', opts: { repo, issue: String(issueNumber), title: issueTitle }, refId: null, timestamp: now }],
|
|
111
|
+
outcomes: [{ id: outcomeId, refId: runId, name: 'Started', opts: null, timestamp: now }],
|
|
112
|
+
acts: [{ id: actId, refId: outcomeId, name: 'implement', opts: null, timestamp: now }],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return { runId, actId };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function closeIssueRun(apiKey, { runId, name, opts }) {
|
|
119
|
+
const now = new Date().toISOString();
|
|
120
|
+
await sendEvents(apiKey, {
|
|
121
|
+
outcomes: [{ id: generateId('oc'), refId: runId, name, opts: opts || null, timestamp: now }],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
97
125
|
// ---------------------------------------------------------------------------
|
|
98
126
|
// Pipeline helpers — start run/group and record outcome
|
|
99
127
|
// ---------------------------------------------------------------------------
|
|
100
128
|
|
|
101
|
-
export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitle, prNumber }) {
|
|
129
|
+
export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitle, prNumber, refActId }) {
|
|
102
130
|
const runId = generateId('run');
|
|
103
131
|
const groupId = generateId('grp');
|
|
104
132
|
const now = new Date().toISOString();
|
|
@@ -109,7 +137,7 @@ export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitl
|
|
|
109
137
|
if (prNumber) opts.pr_number = String(prNumber);
|
|
110
138
|
|
|
111
139
|
await sendEvents(apiKey, {
|
|
112
|
-
runs: [{ id: runId, label: 'agent-pipeline', opts, refId: null, timestamp: now }],
|
|
140
|
+
runs: [{ id: runId, label: 'agent-pipeline', opts, refId: refActId || null, timestamp: now }],
|
|
113
141
|
groups: [{ id: groupId, label: step, opts: { triggered_at: now }, timestamp: now }],
|
|
114
142
|
links: [{ parentId: runId, childId: groupId, type: 'group', timestamp: now }],
|
|
115
143
|
});
|
|
@@ -117,14 +145,14 @@ export async function startPipeline(apiKey, { step, repo, issueNumber, issueTitl
|
|
|
117
145
|
return { runId, groupId };
|
|
118
146
|
}
|
|
119
147
|
|
|
120
|
-
export async function recordOutcome(apiKey, groupId, { step, success, costUsd, error, hooksFailed, issueNumber, prNumber, reviewCommentCount }) {
|
|
148
|
+
export async function recordOutcome(apiKey, { runId, groupId }, { step, success, costUsd, error, hooksFailed, issueNumber, prNumber, reviewCommentCount }) {
|
|
121
149
|
const names = {
|
|
122
150
|
implement: { true: 'PR Created', false: 'Implementation Failed' },
|
|
123
151
|
revise: { true: 'Fixes Applied', false: 'Revision Failed' },
|
|
152
|
+
merge: { true: 'Merged', false: 'Merge Failed' },
|
|
124
153
|
};
|
|
125
154
|
|
|
126
155
|
const name = names[step]?.[String(success)] || `${step}: ${success ? 'success' : 'failure'}`;
|
|
127
|
-
const id = generateId('oc');
|
|
128
156
|
const now = new Date().toISOString();
|
|
129
157
|
|
|
130
158
|
const opts = { status: success ? 'success' : 'failure', step };
|
|
@@ -135,11 +163,27 @@ export async function recordOutcome(apiKey, groupId, { step, success, costUsd, e
|
|
|
135
163
|
if (prNumber) opts.pr_number = String(prNumber);
|
|
136
164
|
if (reviewCommentCount) opts.review_comments = String(reviewCommentCount);
|
|
137
165
|
|
|
166
|
+
const groupOutcomeId = generateId('oc');
|
|
167
|
+
const outcomes = [
|
|
168
|
+
{ id: groupOutcomeId, refId: groupId, name, opts, timestamp: now },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
let runOutcomeId = null;
|
|
172
|
+
if (runId) {
|
|
173
|
+
runOutcomeId = generateId('oc');
|
|
174
|
+
outcomes.push({ id: runOutcomeId, refId: runId, name, opts, timestamp: now });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await sendEvents(apiKey, { outcomes });
|
|
178
|
+
|
|
179
|
+
return { id: groupOutcomeId, runOutcomeId, name };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function emitAct(apiKey, { outcomeId, actId, name, opts }) {
|
|
183
|
+
const now = new Date().toISOString();
|
|
138
184
|
await sendEvents(apiKey, {
|
|
139
|
-
|
|
185
|
+
acts: [{ id: actId, refId: outcomeId, name, opts: opts || null, timestamp: now }],
|
|
140
186
|
});
|
|
141
|
-
|
|
142
|
-
return { id, name };
|
|
143
187
|
}
|
|
144
188
|
|
|
145
189
|
export async function countRevisions(apiKey, { prNumber, repo }) {
|
package/src/watch.js
CHANGED
|
@@ -5,17 +5,28 @@ import { createBoard } from './boards/index.js';
|
|
|
5
5
|
import { implement } from './agent.js';
|
|
6
6
|
import { revise } from './revise.js';
|
|
7
7
|
import * as git from './git.js';
|
|
8
|
+
import * as warp from './warp.js';
|
|
8
9
|
import { runHook } from './hooks.js';
|
|
9
10
|
|
|
10
11
|
export async function watch() {
|
|
11
12
|
const config = loadConfig();
|
|
12
13
|
const board = createBoard(config);
|
|
13
14
|
const pollInterval = (config.pollInterval || 30) * 1000;
|
|
15
|
+
const repoName = config.repo.replace(/\.git$/, '').replace(/^.*github\.com[:\/]/, '');
|
|
16
|
+
|
|
17
|
+
// Track issue runs across poll cycles (issueNumber → { runId })
|
|
18
|
+
const issueRuns = new Map();
|
|
14
19
|
|
|
15
20
|
let running = true;
|
|
21
|
+
let sleepResolve = null;
|
|
16
22
|
const shutdown = () => {
|
|
17
|
-
|
|
23
|
+
if (!running) {
|
|
24
|
+
console.log('\nForce exit.');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
console.log('\nShutting down... (Ctrl+C again to force)');
|
|
18
28
|
running = false;
|
|
29
|
+
if (sleepResolve) sleepResolve();
|
|
19
30
|
};
|
|
20
31
|
process.on('SIGINT', shutdown);
|
|
21
32
|
process.on('SIGTERM', shutdown);
|
|
@@ -29,46 +40,114 @@ export async function watch() {
|
|
|
29
40
|
|
|
30
41
|
while (running) {
|
|
31
42
|
try {
|
|
43
|
+
// Fetch all project items once per poll cycle
|
|
44
|
+
board.refresh();
|
|
45
|
+
|
|
32
46
|
// 1. Pick up new tasks from Todo
|
|
33
47
|
const todoItems = await board.listTodo();
|
|
34
48
|
if (todoItems.length > 0) {
|
|
35
49
|
const item = todoItems[0];
|
|
36
|
-
|
|
37
|
-
|
|
50
|
+
const issueNumber = item.content?.number;
|
|
51
|
+
const issueTitle = item.content?.title;
|
|
52
|
+
log(`Found todo: #${issueNumber} — ${issueTitle}`);
|
|
53
|
+
|
|
54
|
+
// Create issue run (root of the lifecycle chain)
|
|
55
|
+
let implementActId = null;
|
|
56
|
+
if (config.warpmetricsApiKey && !issueRuns.has(issueNumber)) {
|
|
57
|
+
try {
|
|
58
|
+
const issue = await warp.createIssueRun(config.warpmetricsApiKey, {
|
|
59
|
+
repo: repoName, issueNumber, issueTitle,
|
|
60
|
+
});
|
|
61
|
+
issueRuns.set(issueNumber, { runId: issue.runId });
|
|
62
|
+
implementActId = issue.actId;
|
|
63
|
+
log(` issue run: ${issue.runId}`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log(` warning: issue run creation failed: ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await implement(item, { board, config, log, refActId: implementActId });
|
|
38
70
|
}
|
|
39
71
|
|
|
40
72
|
// 2. Check for items needing revision
|
|
41
73
|
const reviewItems = await board.listInReview();
|
|
42
74
|
for (const item of reviewItems) {
|
|
43
75
|
if (!running) break;
|
|
44
|
-
log(`Found review feedback: PR #${item.content?.number}`);
|
|
45
|
-
await revise(item, { board, config, log });
|
|
76
|
+
log(`Found review feedback: PR #${item._prNumber || item.content?.number}`);
|
|
77
|
+
await revise(item, { board, config, log, refActId: item._reviewActId });
|
|
46
78
|
}
|
|
47
79
|
|
|
48
80
|
// 3. Merge approved PRs
|
|
49
81
|
const approvedItems = await board.listApproved();
|
|
50
82
|
for (const item of approvedItems) {
|
|
51
83
|
if (!running) break;
|
|
52
|
-
const prNumber = item.content?.number;
|
|
53
|
-
const
|
|
84
|
+
const prNumber = item._prNumber || item.content?.number;
|
|
85
|
+
const refActId = item._reviewActId;
|
|
54
86
|
log(`Merging approved PR #${prNumber}`);
|
|
87
|
+
|
|
88
|
+
// Create merge run (follow-up of review's "merge" act)
|
|
89
|
+
let mergeRunId = null;
|
|
90
|
+
let mergeGroupId = null;
|
|
91
|
+
if (config.warpmetricsApiKey) {
|
|
92
|
+
try {
|
|
93
|
+
const pipeline = await warp.startPipeline(config.warpmetricsApiKey, {
|
|
94
|
+
step: 'merge', repo: repoName, prNumber, refActId,
|
|
95
|
+
});
|
|
96
|
+
mergeRunId = pipeline.runId;
|
|
97
|
+
mergeGroupId = pipeline.groupId;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log(` warning: merge pipeline failed: ${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
55
103
|
try {
|
|
56
104
|
runHook('onBeforeMerge', config, { prNumber, repo: repoName });
|
|
57
105
|
git.mergePR(prNumber, { repo: repoName });
|
|
58
106
|
runHook('onMerged', config, { prNumber, repo: repoName });
|
|
59
107
|
await board.moveToDone(item);
|
|
60
108
|
log(` merged and moved to Done`);
|
|
109
|
+
|
|
110
|
+
// Record merge outcome
|
|
111
|
+
if (config.warpmetricsApiKey && mergeGroupId) {
|
|
112
|
+
try {
|
|
113
|
+
await warp.recordOutcome(config.warpmetricsApiKey, { runId: mergeRunId, groupId: mergeGroupId }, {
|
|
114
|
+
step: 'merge', success: true, prNumber,
|
|
115
|
+
});
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Close the issue run with "Shipped"
|
|
120
|
+
const issueNumber = item.content?.number;
|
|
121
|
+
const issueCtx = issueRuns.get(issueNumber);
|
|
122
|
+
if (config.warpmetricsApiKey && issueCtx) {
|
|
123
|
+
try {
|
|
124
|
+
await warp.closeIssueRun(config.warpmetricsApiKey, {
|
|
125
|
+
runId: issueCtx.runId, name: 'Shipped',
|
|
126
|
+
});
|
|
127
|
+
issueRuns.delete(issueNumber);
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
61
130
|
} catch (err) {
|
|
62
131
|
log(` merge failed: ${err.message}`);
|
|
132
|
+
if (config.warpmetricsApiKey && mergeGroupId) {
|
|
133
|
+
try {
|
|
134
|
+
await warp.recordOutcome(config.warpmetricsApiKey, { runId: mergeRunId, groupId: mergeGroupId }, {
|
|
135
|
+
step: 'merge', success: false, error: err.message, prNumber,
|
|
136
|
+
});
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
63
139
|
}
|
|
64
140
|
}
|
|
65
141
|
} catch (err) {
|
|
66
142
|
log(`Poll error: ${err.message}`);
|
|
67
143
|
}
|
|
68
144
|
|
|
69
|
-
// Sleep
|
|
145
|
+
// Sleep (interruptible)
|
|
70
146
|
if (running) {
|
|
71
|
-
await new Promise(resolve =>
|
|
147
|
+
await new Promise(resolve => {
|
|
148
|
+
sleepResolve = resolve;
|
|
149
|
+
setTimeout(() => { sleepResolve = null; resolve(); }, pollInterval);
|
|
150
|
+
});
|
|
72
151
|
}
|
|
73
152
|
}
|
|
74
153
|
|