opencode-pilot 0.11.1 → 0.12.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/package.json +1 -1
- package/service/poll-service.js +11 -1
- package/service/readiness.js +50 -0
- package/service/utils.js +28 -2
- package/test/unit/readiness.test.js +180 -0
- package/test/unit/repo-config.test.js +2 -1
- package/test/unit/utils.test.js +71 -0
package/package.json
CHANGED
package/service/poll-service.js
CHANGED
|
@@ -146,7 +146,17 @@ export async function pollOnce(options = {}) {
|
|
|
146
146
|
const repoKey = repoKeys.length > 0 ? repoKeys[0] : null;
|
|
147
147
|
const repoConfig = repoKey ? getRepoConfig(repoKey) : {};
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
// Merge source-level readiness config with repo config
|
|
150
|
+
// Source readiness takes precedence
|
|
151
|
+
const readinessConfig = {
|
|
152
|
+
...repoConfig,
|
|
153
|
+
readiness: {
|
|
154
|
+
...repoConfig.readiness,
|
|
155
|
+
...source.readiness,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const readiness = evaluateReadiness(item, readinessConfig);
|
|
150
160
|
debug(`Item ${item.id}: ready=${readiness.ready}, reason=${readiness.reason || 'none'}`);
|
|
151
161
|
return {
|
|
152
162
|
...item,
|
package/service/readiness.js
CHANGED
|
@@ -134,6 +134,46 @@ export function checkDependencies(issue, config) {
|
|
|
134
134
|
return { ready: true };
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Check if item fields match required values
|
|
139
|
+
*
|
|
140
|
+
* Generic field-based readiness check. Configured via readiness.fields in config.
|
|
141
|
+
* All specified fields must match their required values for the item to be ready.
|
|
142
|
+
*
|
|
143
|
+
* Example config:
|
|
144
|
+
* readiness:
|
|
145
|
+
* fields:
|
|
146
|
+
* has_notes: true
|
|
147
|
+
* type: "meeting"
|
|
148
|
+
*
|
|
149
|
+
* @param {object} item - Item with fields to check
|
|
150
|
+
* @param {object} config - Config with optional readiness.fields
|
|
151
|
+
* @returns {object} { ready: boolean, reason?: string }
|
|
152
|
+
*/
|
|
153
|
+
export function checkFields(item, config) {
|
|
154
|
+
const readinessConfig = config.readiness || {};
|
|
155
|
+
const fieldsConfig = readinessConfig.fields || {};
|
|
156
|
+
|
|
157
|
+
// No fields configured - skip check
|
|
158
|
+
if (Object.keys(fieldsConfig).length === 0) {
|
|
159
|
+
return { ready: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check each required field
|
|
163
|
+
for (const [field, requiredValue] of Object.entries(fieldsConfig)) {
|
|
164
|
+
const actualValue = item[field];
|
|
165
|
+
|
|
166
|
+
if (actualValue !== requiredValue) {
|
|
167
|
+
return {
|
|
168
|
+
ready: false,
|
|
169
|
+
reason: `Field '${field}' is ${JSON.stringify(actualValue)}, required ${JSON.stringify(requiredValue)}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { ready: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
137
177
|
/**
|
|
138
178
|
* Check if a PR/issue has meaningful (non-bot, non-author) comments
|
|
139
179
|
*
|
|
@@ -246,6 +286,16 @@ export function evaluateReadiness(issue, config) {
|
|
|
246
286
|
};
|
|
247
287
|
}
|
|
248
288
|
|
|
289
|
+
// Check required field values
|
|
290
|
+
const fieldsResult = checkFields(issue, config);
|
|
291
|
+
if (!fieldsResult.ready) {
|
|
292
|
+
return {
|
|
293
|
+
ready: false,
|
|
294
|
+
reason: fieldsResult.reason,
|
|
295
|
+
priority: 0,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
249
299
|
// Calculate priority for ready issues
|
|
250
300
|
const priority = calculatePriority(issue, config);
|
|
251
301
|
|
package/service/utils.js
CHANGED
|
@@ -20,6 +20,26 @@ export function getNestedValue(obj, path) {
|
|
|
20
20
|
return value;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Check if a comment/review is an approval-only (no actionable feedback)
|
|
25
|
+
*
|
|
26
|
+
* PR reviews have a state field (APPROVED, CHANGES_REQUESTED, COMMENTED).
|
|
27
|
+
* An approval without substantive body text doesn't require action from the author.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} comment - Comment or review object with optional state and body
|
|
30
|
+
* @returns {boolean} True if this is a pure approval with no actionable feedback
|
|
31
|
+
*/
|
|
32
|
+
export function isApprovalOnly(comment) {
|
|
33
|
+
// Only applies to PR reviews with APPROVED state
|
|
34
|
+
if (comment.state !== 'APPROVED') return false;
|
|
35
|
+
|
|
36
|
+
// If there's substantive body text, it might contain feedback
|
|
37
|
+
const body = comment.body || '';
|
|
38
|
+
if (body.trim().length > 0) return false;
|
|
39
|
+
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
/**
|
|
24
44
|
* Check if a username represents a bot account
|
|
25
45
|
*
|
|
@@ -50,9 +70,12 @@ export function isBot(username, type) {
|
|
|
50
70
|
* Used to filter out PRs where only bots have commented, since those don't
|
|
51
71
|
* require the author's attention for human feedback.
|
|
52
72
|
*
|
|
73
|
+
* Also skips approval-only reviews (APPROVED state with no body text) since
|
|
74
|
+
* approvals don't require action from the author.
|
|
75
|
+
*
|
|
53
76
|
* @param {Array} comments - Array of comment objects with user.login and user.type
|
|
54
77
|
* @param {string} authorUsername - Username of the PR/issue author
|
|
55
|
-
* @returns {boolean} True if there's at least one non-bot, non-author comment
|
|
78
|
+
* @returns {boolean} True if there's at least one non-bot, non-author, actionable comment
|
|
56
79
|
*/
|
|
57
80
|
export function hasNonBotFeedback(comments, authorUsername) {
|
|
58
81
|
// Handle null/undefined/empty
|
|
@@ -75,7 +98,10 @@ export function hasNonBotFeedback(comments, authorUsername) {
|
|
|
75
98
|
// Skip if it's the author themselves
|
|
76
99
|
if (authorLower && username?.toLowerCase() === authorLower) continue;
|
|
77
100
|
|
|
78
|
-
//
|
|
101
|
+
// Skip approval-only reviews (no actionable feedback)
|
|
102
|
+
if (isApprovalOnly(comment)) continue;
|
|
103
|
+
|
|
104
|
+
// Found a non-bot, non-author, actionable comment
|
|
79
105
|
return true;
|
|
80
106
|
}
|
|
81
107
|
|
|
@@ -172,6 +172,143 @@ describe('readiness.js', () => {
|
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
describe('checkFields', () => {
|
|
176
|
+
test('returns ready when no fields configured', async () => {
|
|
177
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
178
|
+
|
|
179
|
+
const item = { id: 'item-1', has_notes: false };
|
|
180
|
+
const config = {};
|
|
181
|
+
|
|
182
|
+
const result = checkFields(item, config);
|
|
183
|
+
|
|
184
|
+
assert.strictEqual(result.ready, true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('returns ready when field matches required value (boolean)', async () => {
|
|
188
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
189
|
+
|
|
190
|
+
const meeting = { id: 'meeting-1', has_notes: true };
|
|
191
|
+
const config = {
|
|
192
|
+
readiness: {
|
|
193
|
+
fields: {
|
|
194
|
+
has_notes: true
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const result = checkFields(meeting, config);
|
|
200
|
+
|
|
201
|
+
assert.strictEqual(result.ready, true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('returns not ready when field does not match required value', async () => {
|
|
205
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
206
|
+
|
|
207
|
+
const meeting = { id: 'meeting-1', has_notes: false };
|
|
208
|
+
const config = {
|
|
209
|
+
readiness: {
|
|
210
|
+
fields: {
|
|
211
|
+
has_notes: true
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const result = checkFields(meeting, config);
|
|
217
|
+
|
|
218
|
+
assert.strictEqual(result.ready, false);
|
|
219
|
+
assert.ok(result.reason.includes('has_notes'));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('returns not ready when required field is missing', async () => {
|
|
223
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
224
|
+
|
|
225
|
+
const item = { id: 'item-1', title: 'Test' };
|
|
226
|
+
const config = {
|
|
227
|
+
readiness: {
|
|
228
|
+
fields: {
|
|
229
|
+
has_notes: true
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const result = checkFields(item, config);
|
|
235
|
+
|
|
236
|
+
assert.strictEqual(result.ready, false);
|
|
237
|
+
assert.ok(result.reason.includes('has_notes'));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('checks multiple fields (all must match)', async () => {
|
|
241
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
242
|
+
|
|
243
|
+
const item = { id: 'item-1', has_notes: true, type: 'meeting' };
|
|
244
|
+
const config = {
|
|
245
|
+
readiness: {
|
|
246
|
+
fields: {
|
|
247
|
+
has_notes: true,
|
|
248
|
+
type: 'meeting'
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const result = checkFields(item, config);
|
|
254
|
+
|
|
255
|
+
assert.strictEqual(result.ready, true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('fails if any field does not match', async () => {
|
|
259
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
260
|
+
|
|
261
|
+
const item = { id: 'item-1', has_notes: true, type: 'note' };
|
|
262
|
+
const config = {
|
|
263
|
+
readiness: {
|
|
264
|
+
fields: {
|
|
265
|
+
has_notes: true,
|
|
266
|
+
type: 'meeting'
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const result = checkFields(item, config);
|
|
272
|
+
|
|
273
|
+
assert.strictEqual(result.ready, false);
|
|
274
|
+
assert.ok(result.reason.includes('type'));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('supports string field values', async () => {
|
|
278
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
279
|
+
|
|
280
|
+
const item = { id: 'item-1', state: 'open' };
|
|
281
|
+
const config = {
|
|
282
|
+
readiness: {
|
|
283
|
+
fields: {
|
|
284
|
+
state: 'open'
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const result = checkFields(item, config);
|
|
290
|
+
|
|
291
|
+
assert.strictEqual(result.ready, true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('supports numeric field values', async () => {
|
|
295
|
+
const { checkFields } = await import('../../service/readiness.js');
|
|
296
|
+
|
|
297
|
+
const item = { id: 'item-1', participant_count: 3 };
|
|
298
|
+
const config = {
|
|
299
|
+
readiness: {
|
|
300
|
+
fields: {
|
|
301
|
+
participant_count: 3
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const result = checkFields(item, config);
|
|
307
|
+
|
|
308
|
+
assert.strictEqual(result.ready, true);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
175
312
|
describe('evaluateReadiness', () => {
|
|
176
313
|
test('checks bot comments when _comments is present', async () => {
|
|
177
314
|
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
@@ -206,5 +343,48 @@ describe('readiness.js', () => {
|
|
|
206
343
|
|
|
207
344
|
assert.strictEqual(result.ready, true);
|
|
208
345
|
});
|
|
346
|
+
|
|
347
|
+
test('checks fields when readiness.fields is configured', async () => {
|
|
348
|
+
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
349
|
+
|
|
350
|
+
const meeting = {
|
|
351
|
+
id: 'meeting-123',
|
|
352
|
+
title: 'Team Standup',
|
|
353
|
+
has_notes: false
|
|
354
|
+
};
|
|
355
|
+
const config = {
|
|
356
|
+
readiness: {
|
|
357
|
+
fields: {
|
|
358
|
+
has_notes: true
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const result = evaluateReadiness(meeting, config);
|
|
364
|
+
|
|
365
|
+
assert.strictEqual(result.ready, false);
|
|
366
|
+
assert.ok(result.reason.includes('has_notes'));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('passes when field matches required value', async () => {
|
|
370
|
+
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
371
|
+
|
|
372
|
+
const meeting = {
|
|
373
|
+
id: 'meeting-123',
|
|
374
|
+
title: 'Team Standup',
|
|
375
|
+
has_notes: true
|
|
376
|
+
};
|
|
377
|
+
const config = {
|
|
378
|
+
readiness: {
|
|
379
|
+
fields: {
|
|
380
|
+
has_notes: true
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const result = evaluateReadiness(meeting, config);
|
|
386
|
+
|
|
387
|
+
assert.strictEqual(result.ready, true);
|
|
388
|
+
});
|
|
209
389
|
});
|
|
210
390
|
});
|
|
@@ -700,8 +700,9 @@ sources:
|
|
|
700
700
|
loadRepoConfig(configPath);
|
|
701
701
|
const sources = getSources();
|
|
702
702
|
|
|
703
|
-
assert.strictEqual(sources[0].session.name, '{title}', 'linear
|
|
703
|
+
assert.strictEqual(sources[0].session.name, '{title}', 'linear preset should use title');
|
|
704
704
|
});
|
|
705
|
+
|
|
705
706
|
});
|
|
706
707
|
|
|
707
708
|
describe('shorthand syntax', () => {
|
package/test/unit/utils.test.js
CHANGED
|
@@ -100,6 +100,77 @@ describe('utils.js', () => {
|
|
|
100
100
|
|
|
101
101
|
assert.strictEqual(hasNonBotFeedback(comments, 'contributor'), true);
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
test('returns false when only approval-only reviews (no feedback body)', async () => {
|
|
105
|
+
const { hasNonBotFeedback } = await import('../../service/utils.js');
|
|
106
|
+
|
|
107
|
+
// PR reviews with APPROVED state but no body should not trigger feedback
|
|
108
|
+
const comments = [
|
|
109
|
+
{ user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
|
|
110
|
+
{ user: { login: 'reviewer', type: 'User' }, state: 'APPROVED', body: '' },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
assert.strictEqual(hasNonBotFeedback(comments, 'author'), false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('returns true when approval includes feedback body', async () => {
|
|
117
|
+
const { hasNonBotFeedback } = await import('../../service/utils.js');
|
|
118
|
+
|
|
119
|
+
// If someone approves but leaves feedback, we should consider it actionable
|
|
120
|
+
const comments = [
|
|
121
|
+
{ user: { login: 'reviewer', type: 'User' }, state: 'APPROVED', body: 'LGTM but consider adding a test for edge cases' },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
assert.strictEqual(hasNonBotFeedback(comments, 'author'), true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('returns true for CHANGES_REQUESTED reviews', async () => {
|
|
128
|
+
const { hasNonBotFeedback } = await import('../../service/utils.js');
|
|
129
|
+
|
|
130
|
+
const comments = [
|
|
131
|
+
{ user: { login: 'reviewer', type: 'User' }, state: 'CHANGES_REQUESTED', body: 'Please fix this' },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
assert.strictEqual(hasNonBotFeedback(comments, 'author'), true);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('isApprovalOnly', () => {
|
|
139
|
+
test('returns true for APPROVED state with no body', async () => {
|
|
140
|
+
const { isApprovalOnly } = await import('../../service/utils.js');
|
|
141
|
+
|
|
142
|
+
assert.strictEqual(isApprovalOnly({ state: 'APPROVED' }), true);
|
|
143
|
+
assert.strictEqual(isApprovalOnly({ state: 'APPROVED', body: '' }), true);
|
|
144
|
+
assert.strictEqual(isApprovalOnly({ state: 'APPROVED', body: null }), true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('returns false for APPROVED with substantive body', async () => {
|
|
148
|
+
const { isApprovalOnly } = await import('../../service/utils.js');
|
|
149
|
+
|
|
150
|
+
// If someone approves but leaves feedback, we should still consider it feedback
|
|
151
|
+
assert.strictEqual(isApprovalOnly({ state: 'APPROVED', body: 'LGTM but consider renaming this function' }), false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('returns false for CHANGES_REQUESTED', async () => {
|
|
155
|
+
const { isApprovalOnly } = await import('../../service/utils.js');
|
|
156
|
+
|
|
157
|
+
assert.strictEqual(isApprovalOnly({ state: 'CHANGES_REQUESTED', body: 'Please fix this' }), false);
|
|
158
|
+
assert.strictEqual(isApprovalOnly({ state: 'CHANGES_REQUESTED' }), false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('returns false for COMMENTED state', async () => {
|
|
162
|
+
const { isApprovalOnly } = await import('../../service/utils.js');
|
|
163
|
+
|
|
164
|
+
assert.strictEqual(isApprovalOnly({ state: 'COMMENTED', body: 'This looks good' }), false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('returns false for regular comments without state', async () => {
|
|
168
|
+
const { isApprovalOnly } = await import('../../service/utils.js');
|
|
169
|
+
|
|
170
|
+
// Issue comments don't have state field
|
|
171
|
+
assert.strictEqual(isApprovalOnly({ body: 'Please address this' }), false);
|
|
172
|
+
assert.strictEqual(isApprovalOnly({}), false);
|
|
173
|
+
});
|
|
103
174
|
});
|
|
104
175
|
|
|
105
176
|
describe('getNestedValue', () => {
|