opencode-pilot 0.20.3 → 0.20.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/package.json +1 -1
- package/service/actions.js +93 -59
- package/service/poll-service.js +12 -0
- package/service/poller.js +9 -0
- package/service/readiness.js +12 -7
- package/test/integration/session-reuse.test.js +275 -0
- package/test/unit/actions.test.js +399 -0
- package/test/unit/poller.test.js +184 -0
- package/test/unit/readiness.test.js +51 -0
- package/test/unit/server.test.js +96 -0
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -799,6 +799,82 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
799
799
|
}
|
|
800
800
|
}
|
|
801
801
|
|
|
802
|
+
/**
|
|
803
|
+
* Execute session creation/reuse in a specific directory
|
|
804
|
+
* Internal helper for executeAction - handles prompt building, session reuse, and API calls
|
|
805
|
+
*
|
|
806
|
+
* @param {string} serverUrl - OpenCode server URL
|
|
807
|
+
* @param {string} cwd - Working directory for the session
|
|
808
|
+
* @param {object} item - Item to create session for
|
|
809
|
+
* @param {object} config - Repo config with action settings
|
|
810
|
+
* @param {object} [options] - Execution options
|
|
811
|
+
* @returns {Promise<object>} Result with command, success, sessionId, etc.
|
|
812
|
+
*/
|
|
813
|
+
async function executeInDirectory(serverUrl, cwd, item, config, options = {}) {
|
|
814
|
+
// Build prompt from template
|
|
815
|
+
const prompt = buildPromptFromTemplate(config.prompt || "default", item);
|
|
816
|
+
|
|
817
|
+
// Build session title
|
|
818
|
+
const sessionTitle = config.session?.name
|
|
819
|
+
? buildSessionName(config.session.name, item)
|
|
820
|
+
: (item.title || `session-${Date.now()}`);
|
|
821
|
+
|
|
822
|
+
// Check if we should try to reuse an existing session
|
|
823
|
+
const reuseActiveSession = config.reuse_active_session !== false; // default true
|
|
824
|
+
|
|
825
|
+
if (reuseActiveSession && !options.dryRun) {
|
|
826
|
+
const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
|
|
827
|
+
|
|
828
|
+
if (existingSession) {
|
|
829
|
+
debug(`executeInDirectory: found reusable session ${existingSession.id} for ${cwd}`);
|
|
830
|
+
|
|
831
|
+
const reuseCommand = `[API] POST ${serverUrl}/session/${existingSession.id}/message (reusing session)`;
|
|
832
|
+
|
|
833
|
+
const result = await sendMessageToSession(serverUrl, existingSession.id, cwd, prompt, {
|
|
834
|
+
title: sessionTitle,
|
|
835
|
+
agent: config.agent,
|
|
836
|
+
model: config.model,
|
|
837
|
+
fetch: options.fetch,
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
command: reuseCommand,
|
|
842
|
+
success: result.success,
|
|
843
|
+
sessionId: result.sessionId,
|
|
844
|
+
directory: cwd,
|
|
845
|
+
sessionReused: true,
|
|
846
|
+
error: result.error,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
|
|
852
|
+
debug(`executeInDirectory: using HTTP API - ${apiCommand}`);
|
|
853
|
+
|
|
854
|
+
if (options.dryRun) {
|
|
855
|
+
return {
|
|
856
|
+
command: apiCommand,
|
|
857
|
+
directory: cwd,
|
|
858
|
+
dryRun: true,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const result = await createSessionViaApi(serverUrl, cwd, prompt, {
|
|
863
|
+
title: sessionTitle,
|
|
864
|
+
agent: config.agent,
|
|
865
|
+
model: config.model,
|
|
866
|
+
fetch: options.fetch,
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
return {
|
|
870
|
+
command: apiCommand,
|
|
871
|
+
success: result.success,
|
|
872
|
+
sessionId: result.sessionId,
|
|
873
|
+
directory: cwd,
|
|
874
|
+
error: result.error,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
802
878
|
/**
|
|
803
879
|
* Execute an action
|
|
804
880
|
* @param {object} item - Item to create session for
|
|
@@ -841,11 +917,27 @@ export async function executeAction(item, config, options = {}) {
|
|
|
841
917
|
};
|
|
842
918
|
}
|
|
843
919
|
|
|
920
|
+
// If existing_directory is provided (reprocessing same item), use it directly
|
|
921
|
+
// This preserves the worktree from the previous run even if its name doesn't match the template
|
|
922
|
+
if (config.existing_directory) {
|
|
923
|
+
debug(`executeAction: using existing_directory=${config.existing_directory}`);
|
|
924
|
+
const cwd = expandPath(config.existing_directory);
|
|
925
|
+
return await executeInDirectory(serverUrl, cwd, item, config, options);
|
|
926
|
+
}
|
|
927
|
+
|
|
844
928
|
// Resolve worktree directory if configured
|
|
845
929
|
// This allows creating sessions in isolated worktrees instead of the main project
|
|
846
930
|
let worktreeMode = config.worktree;
|
|
847
931
|
|
|
932
|
+
// If worktree_name is configured, enable worktree mode (explicit configuration)
|
|
933
|
+
// This allows presets to specify worktree isolation without requiring existing sandboxes
|
|
934
|
+
if (!worktreeMode && config.worktree_name) {
|
|
935
|
+
debug(`executeAction: worktree_name configured, enabling worktree mode`);
|
|
936
|
+
worktreeMode = 'new';
|
|
937
|
+
}
|
|
938
|
+
|
|
848
939
|
// Auto-detect worktree support: check if the project has sandboxes
|
|
940
|
+
// This is a fallback for when worktree isn't explicitly configured
|
|
849
941
|
if (!worktreeMode) {
|
|
850
942
|
// Look up project info for this specific directory (not just /project/current)
|
|
851
943
|
const projectInfo = await getProjectInfoForDirectory(serverUrl, baseCwd, { fetch: options.fetch });
|
|
@@ -882,63 +974,5 @@ export async function executeAction(item, config, options = {}) {
|
|
|
882
974
|
|
|
883
975
|
debug(`executeAction: using cwd=${cwd}`);
|
|
884
976
|
|
|
885
|
-
|
|
886
|
-
const prompt = buildPromptFromTemplate(config.prompt || "default", item);
|
|
887
|
-
|
|
888
|
-
// Build session title
|
|
889
|
-
const sessionTitle = config.session?.name
|
|
890
|
-
? buildSessionName(config.session.name, item)
|
|
891
|
-
: (item.title || `session-${Date.now()}`);
|
|
892
|
-
|
|
893
|
-
// Check if we should try to reuse an existing session
|
|
894
|
-
const reuseActiveSession = config.reuse_active_session !== false; // default true
|
|
895
|
-
|
|
896
|
-
if (reuseActiveSession && !options.dryRun) {
|
|
897
|
-
const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
|
|
898
|
-
|
|
899
|
-
if (existingSession) {
|
|
900
|
-
debug(`executeAction: found reusable session ${existingSession.id} for ${cwd}`);
|
|
901
|
-
|
|
902
|
-
const reuseCommand = `[API] POST ${serverUrl}/session/${existingSession.id}/message (reusing session)`;
|
|
903
|
-
|
|
904
|
-
const result = await sendMessageToSession(serverUrl, existingSession.id, cwd, prompt, {
|
|
905
|
-
title: sessionTitle,
|
|
906
|
-
agent: config.agent,
|
|
907
|
-
model: config.model,
|
|
908
|
-
fetch: options.fetch,
|
|
909
|
-
});
|
|
910
|
-
|
|
911
|
-
return {
|
|
912
|
-
command: reuseCommand,
|
|
913
|
-
success: result.success,
|
|
914
|
-
sessionId: result.sessionId,
|
|
915
|
-
sessionReused: true,
|
|
916
|
-
error: result.error,
|
|
917
|
-
};
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
|
|
922
|
-
debug(`executeAction: using HTTP API - ${apiCommand}`);
|
|
923
|
-
|
|
924
|
-
if (options.dryRun) {
|
|
925
|
-
return {
|
|
926
|
-
command: apiCommand,
|
|
927
|
-
dryRun: true,
|
|
928
|
-
};
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
const result = await createSessionViaApi(serverUrl, cwd, prompt, {
|
|
932
|
-
title: sessionTitle,
|
|
933
|
-
agent: config.agent,
|
|
934
|
-
model: config.model,
|
|
935
|
-
fetch: options.fetch,
|
|
936
|
-
});
|
|
937
|
-
|
|
938
|
-
return {
|
|
939
|
-
command: apiCommand,
|
|
940
|
-
success: result.success,
|
|
941
|
-
sessionId: result.sessionId,
|
|
942
|
-
error: result.error,
|
|
943
|
-
};
|
|
977
|
+
return await executeInDirectory(serverUrl, cwd, item, config, options);
|
|
944
978
|
}
|
package/service/poll-service.js
CHANGED
|
@@ -200,10 +200,14 @@ export async function pollOnce(options = {}) {
|
|
|
200
200
|
debug(`Processing ${sortedItems.length} sorted items`);
|
|
201
201
|
for (const item of sortedItems) {
|
|
202
202
|
// Check if already processed
|
|
203
|
+
let existingDirectory = null;
|
|
203
204
|
if (pollerInstance && pollerInstance.isProcessed(item.id)) {
|
|
204
205
|
// Check if item should be reprocessed (reopened, status changed, etc.)
|
|
205
206
|
if (pollerInstance.shouldReprocess(item, { reprocessOn })) {
|
|
206
207
|
debug(`Reprocessing ${item.id} - state changed`);
|
|
208
|
+
// Get the stored directory before clearing state (for worktree reuse)
|
|
209
|
+
const prevMeta = pollerInstance.getProcessedMeta(item.id);
|
|
210
|
+
existingDirectory = prevMeta?.directory || null;
|
|
207
211
|
pollerInstance.clearProcessed(item.id);
|
|
208
212
|
console.log(`[poll] Reprocessing ${item.id} (reopened or updated)`);
|
|
209
213
|
} else {
|
|
@@ -215,6 +219,12 @@ export async function pollOnce(options = {}) {
|
|
|
215
219
|
debug(`Executing action for ${item.id}`);
|
|
216
220
|
// Build action config from source and item (resolves repo from item fields)
|
|
217
221
|
const actionConfig = buildActionConfigForItem(source, item);
|
|
222
|
+
|
|
223
|
+
// Pass existing directory for worktree reuse when reprocessing
|
|
224
|
+
if (existingDirectory) {
|
|
225
|
+
actionConfig.existing_directory = existingDirectory;
|
|
226
|
+
debug(`Reusing existing directory: ${existingDirectory}`);
|
|
227
|
+
}
|
|
218
228
|
|
|
219
229
|
// Skip items with no valid local path (prevents sessions in home directory)
|
|
220
230
|
const hasLocalPath = actionConfig.working_dir || actionConfig.path || actionConfig.repo_path;
|
|
@@ -244,11 +254,13 @@ export async function pollOnce(options = {}) {
|
|
|
244
254
|
if (result.success) {
|
|
245
255
|
// Mark as processed to avoid re-triggering
|
|
246
256
|
// Store item state for detecting reopened/updated items
|
|
257
|
+
// Store directory for worktree reuse when reprocessing
|
|
247
258
|
if (pollerInstance) {
|
|
248
259
|
pollerInstance.markProcessed(item.id, {
|
|
249
260
|
repoKey: item.repo_key,
|
|
250
261
|
command: result.command,
|
|
251
262
|
source: sourceName,
|
|
263
|
+
directory: result.directory || null,
|
|
252
264
|
itemState: item.state || item.status || null,
|
|
253
265
|
itemUpdatedAt: item.updated_at || null,
|
|
254
266
|
});
|
package/service/poller.js
CHANGED
|
@@ -722,6 +722,15 @@ export function createPoller(options = {}) {
|
|
|
722
722
|
return processedItems.has(itemId);
|
|
723
723
|
},
|
|
724
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Get metadata for a processed item
|
|
727
|
+
* @param {string} itemId - Item ID
|
|
728
|
+
* @returns {object|null} Metadata or null if not processed
|
|
729
|
+
*/
|
|
730
|
+
getProcessedMeta(itemId) {
|
|
731
|
+
return processedItems.get(itemId) || null;
|
|
732
|
+
},
|
|
733
|
+
|
|
725
734
|
/**
|
|
726
735
|
* Mark an item as processed
|
|
727
736
|
*/
|
package/service/readiness.js
CHANGED
|
@@ -348,13 +348,18 @@ export function evaluateReadiness(issue, config) {
|
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
// Check bot comments (for PRs enriched with _comments)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
351
|
+
// Skip this check when require_attention is set, because checkAttention
|
|
352
|
+
// handles the combined logic (conflicts OR feedback) via _has_attention
|
|
353
|
+
const readinessConfig = config.readiness || {};
|
|
354
|
+
if (!readinessConfig.require_attention) {
|
|
355
|
+
const botResult = checkBotComments(issue, config);
|
|
356
|
+
if (!botResult.ready) {
|
|
357
|
+
return {
|
|
358
|
+
ready: false,
|
|
359
|
+
reason: botResult.reason,
|
|
360
|
+
priority: 0,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
358
363
|
}
|
|
359
364
|
|
|
360
365
|
// Check mergeable status (for PRs enriched with _mergeable)
|
|
@@ -21,6 +21,14 @@ import {
|
|
|
21
21
|
resolveWorktreeDirectory,
|
|
22
22
|
} from "../../service/worktree.js";
|
|
23
23
|
|
|
24
|
+
import {
|
|
25
|
+
computeAttentionLabels,
|
|
26
|
+
} from "../../service/poller.js";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
evaluateReadiness,
|
|
30
|
+
} from "../../service/readiness.js";
|
|
31
|
+
|
|
24
32
|
/**
|
|
25
33
|
* Create a mock OpenCode server for testing
|
|
26
34
|
*/
|
|
@@ -345,3 +353,270 @@ describe("integration: sandbox reuse", () => {
|
|
|
345
353
|
assert.strictEqual(listDirectory, "/path/to/project", "Should pass directory when looking up named worktree");
|
|
346
354
|
});
|
|
347
355
|
});
|
|
356
|
+
|
|
357
|
+
describe("integration: PR attention detection", () => {
|
|
358
|
+
/**
|
|
359
|
+
* These tests verify the full flow of PR attention detection:
|
|
360
|
+
* 1. PRs with merge conflicts should be detected as needing attention
|
|
361
|
+
* 2. PRs with human feedback should be detected as needing attention
|
|
362
|
+
* 3. PRs with only bot comments but conflicts should still be ready (require_attention mode)
|
|
363
|
+
* 4. computeAttentionLabels + evaluateReadiness work together correctly
|
|
364
|
+
*/
|
|
365
|
+
|
|
366
|
+
it("PR with conflicts and only bot comments is ready when require_attention is set", () => {
|
|
367
|
+
// This is the key scenario that was broken: PR has merge conflicts but no human feedback
|
|
368
|
+
// With require_attention, it should be ready because conflicts count as "attention needed"
|
|
369
|
+
const items = [{
|
|
370
|
+
number: 123,
|
|
371
|
+
title: "Test PR",
|
|
372
|
+
user: { login: "author" },
|
|
373
|
+
_mergeable: "CONFLICTING",
|
|
374
|
+
_comments: [
|
|
375
|
+
{ user: { login: "github-actions[bot]", type: "Bot" }, body: "CI passed" },
|
|
376
|
+
{ user: { login: "codecov[bot]", type: "Bot" }, body: "Coverage report" },
|
|
377
|
+
],
|
|
378
|
+
}];
|
|
379
|
+
|
|
380
|
+
// Step 1: Compute attention labels (happens in poll-service)
|
|
381
|
+
const labeled = computeAttentionLabels(items, {});
|
|
382
|
+
|
|
383
|
+
assert.strictEqual(labeled[0]._attention_label, "Conflicts");
|
|
384
|
+
assert.strictEqual(labeled[0]._has_attention, true);
|
|
385
|
+
|
|
386
|
+
// Step 2: Evaluate readiness with require_attention config
|
|
387
|
+
const config = {
|
|
388
|
+
readiness: {
|
|
389
|
+
require_attention: true,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
const result = evaluateReadiness(labeled[0], config);
|
|
393
|
+
|
|
394
|
+
assert.strictEqual(result.ready, true, "PR with conflicts should be ready even with only bot comments");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("PR with human feedback is ready when require_attention is set", () => {
|
|
398
|
+
const items = [{
|
|
399
|
+
number: 456,
|
|
400
|
+
title: "Another PR",
|
|
401
|
+
user: { login: "author" },
|
|
402
|
+
_mergeable: "MERGEABLE",
|
|
403
|
+
_comments: [
|
|
404
|
+
{ user: { login: "reviewer", type: "User" }, body: "Please fix the tests" },
|
|
405
|
+
],
|
|
406
|
+
}];
|
|
407
|
+
|
|
408
|
+
const labeled = computeAttentionLabels(items, {});
|
|
409
|
+
|
|
410
|
+
assert.strictEqual(labeled[0]._attention_label, "Feedback");
|
|
411
|
+
assert.strictEqual(labeled[0]._has_attention, true);
|
|
412
|
+
|
|
413
|
+
const config = {
|
|
414
|
+
readiness: {
|
|
415
|
+
require_attention: true,
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
const result = evaluateReadiness(labeled[0], config);
|
|
419
|
+
|
|
420
|
+
assert.strictEqual(result.ready, true, "PR with human feedback should be ready");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("PR with both conflicts and feedback shows combined label", () => {
|
|
424
|
+
const items = [{
|
|
425
|
+
number: 789,
|
|
426
|
+
title: "Complex PR",
|
|
427
|
+
user: { login: "author" },
|
|
428
|
+
_mergeable: "CONFLICTING",
|
|
429
|
+
_comments: [
|
|
430
|
+
{ user: { login: "reviewer", type: "User" }, body: "Needs changes" },
|
|
431
|
+
],
|
|
432
|
+
}];
|
|
433
|
+
|
|
434
|
+
const labeled = computeAttentionLabels(items, {});
|
|
435
|
+
|
|
436
|
+
assert.strictEqual(labeled[0]._attention_label, "Conflicts+Feedback");
|
|
437
|
+
assert.strictEqual(labeled[0]._has_attention, true);
|
|
438
|
+
|
|
439
|
+
const config = {
|
|
440
|
+
readiness: {
|
|
441
|
+
require_attention: true,
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
const result = evaluateReadiness(labeled[0], config);
|
|
445
|
+
|
|
446
|
+
assert.strictEqual(result.ready, true);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("PR without conflicts or feedback is NOT ready when require_attention is set", () => {
|
|
450
|
+
const items = [{
|
|
451
|
+
number: 999,
|
|
452
|
+
title: "Clean PR",
|
|
453
|
+
user: { login: "author" },
|
|
454
|
+
_mergeable: "MERGEABLE",
|
|
455
|
+
_comments: [
|
|
456
|
+
{ user: { login: "github-actions[bot]", type: "Bot" }, body: "CI passed" },
|
|
457
|
+
],
|
|
458
|
+
}];
|
|
459
|
+
|
|
460
|
+
const labeled = computeAttentionLabels(items, {});
|
|
461
|
+
|
|
462
|
+
assert.strictEqual(labeled[0]._attention_label, "PR");
|
|
463
|
+
assert.strictEqual(labeled[0]._has_attention, false);
|
|
464
|
+
|
|
465
|
+
const config = {
|
|
466
|
+
readiness: {
|
|
467
|
+
require_attention: true,
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
const result = evaluateReadiness(labeled[0], config);
|
|
471
|
+
|
|
472
|
+
assert.strictEqual(result.ready, false, "PR without attention conditions should NOT be ready");
|
|
473
|
+
assert.ok(result.reason.includes("no attention needed"), "Should have appropriate reason");
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("PR with only bot comments is NOT ready when require_attention is NOT set", () => {
|
|
477
|
+
// Without require_attention, the strict bot check applies
|
|
478
|
+
const pr = {
|
|
479
|
+
number: 111,
|
|
480
|
+
title: "Test",
|
|
481
|
+
user: { login: "author" },
|
|
482
|
+
_comments: [
|
|
483
|
+
{ user: { login: "github-actions[bot]", type: "Bot" }, body: "CI passed" },
|
|
484
|
+
],
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const config = {}; // No require_attention
|
|
488
|
+
|
|
489
|
+
const result = evaluateReadiness(pr, config);
|
|
490
|
+
|
|
491
|
+
assert.strictEqual(result.ready, false, "Without require_attention, bot-only comments should fail");
|
|
492
|
+
assert.ok(result.reason.includes("bot"), "Should mention bot in reason");
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe("integration: worktree creation with worktree_name", () => {
|
|
497
|
+
let mockServer;
|
|
498
|
+
|
|
499
|
+
afterEach(async () => {
|
|
500
|
+
if (mockServer) {
|
|
501
|
+
await mockServer.close();
|
|
502
|
+
mockServer = null;
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("executeAction creates worktree when worktree_name is set but project has no sandboxes", async () => {
|
|
507
|
+
let worktreeListCalled = false;
|
|
508
|
+
let worktreeCreateCalled = false;
|
|
509
|
+
let createdWorktreeName = null;
|
|
510
|
+
let sessionCreated = false;
|
|
511
|
+
let sessionDirectory = null;
|
|
512
|
+
|
|
513
|
+
mockServer = await createMockServer({
|
|
514
|
+
// Project has no sandboxes
|
|
515
|
+
"GET /project": () => ({
|
|
516
|
+
body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
|
|
517
|
+
}),
|
|
518
|
+
"GET /project/current": () => ({
|
|
519
|
+
body: { id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } },
|
|
520
|
+
}),
|
|
521
|
+
// No existing worktrees
|
|
522
|
+
"GET /experimental/worktree": () => {
|
|
523
|
+
worktreeListCalled = true;
|
|
524
|
+
return { body: [] };
|
|
525
|
+
},
|
|
526
|
+
// Worktree creation
|
|
527
|
+
"POST /experimental/worktree": (req) => {
|
|
528
|
+
worktreeCreateCalled = true;
|
|
529
|
+
createdWorktreeName = req.body?.name;
|
|
530
|
+
return {
|
|
531
|
+
body: {
|
|
532
|
+
name: req.body?.name || "new-wt",
|
|
533
|
+
directory: `/worktree/${req.body?.name || "new-wt"}`,
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
},
|
|
537
|
+
// No existing sessions
|
|
538
|
+
"GET /session": () => ({ body: [] }),
|
|
539
|
+
"GET /session/status": () => ({ body: {} }),
|
|
540
|
+
// Session creation
|
|
541
|
+
"POST /session": (req) => {
|
|
542
|
+
sessionCreated = true;
|
|
543
|
+
// Extract directory from URL
|
|
544
|
+
const url = new URL(req.path, "http://localhost");
|
|
545
|
+
sessionDirectory = req.query?.directory;
|
|
546
|
+
return { body: { id: "ses_new" } };
|
|
547
|
+
},
|
|
548
|
+
"PATCH /session/ses_new": () => ({ body: {} }),
|
|
549
|
+
"POST /session/ses_new/message": () => ({ body: { success: true } }),
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const result = await executeAction(
|
|
553
|
+
{ number: 42, title: "Review PR" },
|
|
554
|
+
{
|
|
555
|
+
path: "/proj",
|
|
556
|
+
prompt: "review",
|
|
557
|
+
worktree_name: "pr-{number}", // This should trigger worktree creation
|
|
558
|
+
},
|
|
559
|
+
{ discoverServer: async () => mockServer.url }
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
assert.ok(result.success, "Action should succeed");
|
|
563
|
+
assert.ok(worktreeListCalled, "Should check for existing worktrees");
|
|
564
|
+
assert.ok(worktreeCreateCalled, "Should create worktree when worktree_name is configured");
|
|
565
|
+
assert.strictEqual(createdWorktreeName, "pr-42", "Should expand worktree_name template");
|
|
566
|
+
assert.ok(sessionCreated, "Should create session");
|
|
567
|
+
assert.strictEqual(sessionDirectory, "/worktree/pr-42", "Session should be in worktree directory");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("reuses stored directory when reprocessing same item", async () => {
|
|
571
|
+
// This tests the scenario where:
|
|
572
|
+
// 1. Item was processed before, worktree created with random name (e.g., "calm-wizard")
|
|
573
|
+
// 2. Item triggers again (e.g., new feedback)
|
|
574
|
+
// 3. We should reuse the stored directory, not create a new worktree
|
|
575
|
+
|
|
576
|
+
let worktreeListCalled = false;
|
|
577
|
+
let worktreeCreateCalled = false;
|
|
578
|
+
let sessionDirectory = null;
|
|
579
|
+
|
|
580
|
+
// Existing worktree has a random name, not "pr-42"
|
|
581
|
+
const existingWorktreeDir = "/worktree/calm-wizard";
|
|
582
|
+
|
|
583
|
+
const mockServer = await createMockServer({
|
|
584
|
+
"GET /experimental/worktree": () => {
|
|
585
|
+
worktreeListCalled = true;
|
|
586
|
+
// Return existing worktree with random name
|
|
587
|
+
return { body: [existingWorktreeDir] };
|
|
588
|
+
},
|
|
589
|
+
"POST /experimental/worktree": () => {
|
|
590
|
+
worktreeCreateCalled = true;
|
|
591
|
+
return { body: { name: "pr-42", directory: "/worktree/pr-42" } };
|
|
592
|
+
},
|
|
593
|
+
"GET /session": () => ({ body: [] }),
|
|
594
|
+
"GET /session/status": () => ({ body: {} }),
|
|
595
|
+
"POST /session": (req) => {
|
|
596
|
+
sessionDirectory = req.query?.directory;
|
|
597
|
+
return { body: { id: "ses_reprocess" } };
|
|
598
|
+
},
|
|
599
|
+
"PATCH /session/ses_reprocess": () => ({ body: {} }),
|
|
600
|
+
"POST /session/ses_reprocess/message": () => ({ body: { success: true } }),
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// Simulate reprocessing with a stored directory from previous run
|
|
604
|
+
const result = await executeAction(
|
|
605
|
+
{ number: 42, title: "Review PR" },
|
|
606
|
+
{
|
|
607
|
+
path: "/proj",
|
|
608
|
+
prompt: "review",
|
|
609
|
+
worktree_name: "pr-{number}",
|
|
610
|
+
// This is the key: pass the directory we used last time
|
|
611
|
+
existing_directory: existingWorktreeDir,
|
|
612
|
+
},
|
|
613
|
+
{ discoverServer: async () => mockServer.url }
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
assert.ok(result.success, "Action should succeed");
|
|
617
|
+
// Should NOT create a new worktree since we have existing_directory
|
|
618
|
+
assert.strictEqual(worktreeCreateCalled, false, "Should NOT create new worktree when existing_directory provided");
|
|
619
|
+
// Session should be created in the existing directory
|
|
620
|
+
assert.strictEqual(sessionDirectory, existingWorktreeDir, "Session should use existing directory");
|
|
621
|
+
});
|
|
622
|
+
});
|
|
@@ -830,6 +830,133 @@ Check for bugs and security issues.`;
|
|
|
830
830
|
assert.ok(result.command.includes(tempDir),
|
|
831
831
|
'Should use base directory when no worktree workflow detected');
|
|
832
832
|
});
|
|
833
|
+
|
|
834
|
+
test('creates worktree when worktree_name configured but no existing sandboxes (dry run)', async () => {
|
|
835
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
836
|
+
|
|
837
|
+
const item = { number: 123, title: 'Review PR' };
|
|
838
|
+
const config = {
|
|
839
|
+
path: tempDir,
|
|
840
|
+
prompt: 'review',
|
|
841
|
+
// worktree_name without worktree: 'new' - preset pattern
|
|
842
|
+
worktree_name: 'pr-{number}'
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
// Mock server discovery
|
|
846
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
847
|
+
|
|
848
|
+
let worktreeListCalled = false;
|
|
849
|
+
let worktreeCreateCalled = false;
|
|
850
|
+
let createdWorktreeName = null;
|
|
851
|
+
|
|
852
|
+
const mockFetch = async (url, opts) => {
|
|
853
|
+
// Worktree list endpoint - no existing worktrees
|
|
854
|
+
if (url.startsWith('http://localhost:4096/experimental/worktree') && !opts?.method) {
|
|
855
|
+
worktreeListCalled = true;
|
|
856
|
+
return {
|
|
857
|
+
ok: true,
|
|
858
|
+
json: async () => []
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
// Worktree creation endpoint
|
|
862
|
+
if (url.startsWith('http://localhost:4096/experimental/worktree') && opts?.method === 'POST') {
|
|
863
|
+
worktreeCreateCalled = true;
|
|
864
|
+
const body = JSON.parse(opts.body);
|
|
865
|
+
createdWorktreeName = body.name;
|
|
866
|
+
return {
|
|
867
|
+
ok: true,
|
|
868
|
+
json: async () => ({
|
|
869
|
+
name: body.name,
|
|
870
|
+
branch: `opencode/${body.name}`,
|
|
871
|
+
directory: `/data/worktree/proj/${body.name}`
|
|
872
|
+
})
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
return { ok: false, text: async () => 'Not found' };
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
const result = await executeAction(item, config, {
|
|
879
|
+
dryRun: true,
|
|
880
|
+
discoverServer: mockDiscoverServer,
|
|
881
|
+
fetch: mockFetch
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
assert.ok(result.dryRun);
|
|
885
|
+
assert.ok(worktreeListCalled, 'Should check for existing worktrees');
|
|
886
|
+
assert.ok(worktreeCreateCalled, 'Should create worktree when worktree_name is configured');
|
|
887
|
+
assert.strictEqual(createdWorktreeName, 'pr-123', 'Should expand worktree_name template');
|
|
888
|
+
assert.ok(result.command.includes('/data/worktree/proj/pr-123'),
|
|
889
|
+
'Should use new worktree directory');
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test('uses existing_directory without creating new worktree', async () => {
|
|
893
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
894
|
+
|
|
895
|
+
const item = { number: 123, title: 'Review PR #123' };
|
|
896
|
+
const config = {
|
|
897
|
+
path: '/data/proj',
|
|
898
|
+
prompt: 'review',
|
|
899
|
+
worktree_name: 'pr-{number}',
|
|
900
|
+
// This is the key - pass an existing directory from a previous run
|
|
901
|
+
existing_directory: '/data/worktree/calm-wizard',
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
905
|
+
|
|
906
|
+
let worktreeListCalled = false;
|
|
907
|
+
let worktreeCreateCalled = false;
|
|
908
|
+
let sessionDirectory = null;
|
|
909
|
+
|
|
910
|
+
const mockFetch = async (url, opts) => {
|
|
911
|
+
const urlObj = new URL(url);
|
|
912
|
+
|
|
913
|
+
// Track if worktree endpoints are called (they shouldn't be)
|
|
914
|
+
if (urlObj.pathname === '/experimental/worktree') {
|
|
915
|
+
if (opts?.method === 'POST') {
|
|
916
|
+
worktreeCreateCalled = true;
|
|
917
|
+
} else {
|
|
918
|
+
worktreeListCalled = true;
|
|
919
|
+
}
|
|
920
|
+
return { ok: true, json: async () => [] };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// No existing sessions
|
|
924
|
+
if (urlObj.pathname === '/session' && !opts?.method) {
|
|
925
|
+
return { ok: true, json: async () => [] };
|
|
926
|
+
}
|
|
927
|
+
if (urlObj.pathname === '/session/status') {
|
|
928
|
+
return { ok: true, json: async () => ({}) };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Session creation - capture the directory
|
|
932
|
+
if (urlObj.pathname === '/session' && opts?.method === 'POST') {
|
|
933
|
+
sessionDirectory = urlObj.searchParams.get('directory');
|
|
934
|
+
return { ok: true, json: async () => ({ id: 'ses_test' }) };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Other session endpoints
|
|
938
|
+
if (urlObj.pathname.includes('/session/')) {
|
|
939
|
+
return { ok: true, json: async () => ({}) };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return { ok: false, text: async () => 'Not found' };
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
const result = await executeAction(item, config, {
|
|
946
|
+
discoverServer: mockDiscoverServer,
|
|
947
|
+
fetch: mockFetch
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
assert.ok(result.success);
|
|
951
|
+
// Should NOT call worktree endpoints when existing_directory is provided
|
|
952
|
+
assert.strictEqual(worktreeListCalled, false, 'Should NOT list worktrees');
|
|
953
|
+
assert.strictEqual(worktreeCreateCalled, false, 'Should NOT create worktree');
|
|
954
|
+
// Should use the existing directory
|
|
955
|
+
assert.strictEqual(sessionDirectory, '/data/worktree/calm-wizard',
|
|
956
|
+
'Should use existing_directory for session');
|
|
957
|
+
assert.strictEqual(result.directory, '/data/worktree/calm-wizard',
|
|
958
|
+
'Result should include directory');
|
|
959
|
+
});
|
|
833
960
|
});
|
|
834
961
|
|
|
835
962
|
describe('createSessionViaApi', () => {
|
|
@@ -1534,4 +1661,276 @@ Check for bugs and security issues.`;
|
|
|
1534
1661
|
assert.strictEqual(sessionCreated, true, 'Should create new session directly');
|
|
1535
1662
|
});
|
|
1536
1663
|
});
|
|
1664
|
+
|
|
1665
|
+
describe('buildSessionName', () => {
|
|
1666
|
+
test('expands template with item fields', async () => {
|
|
1667
|
+
const { buildSessionName } = await import('../../service/actions.js');
|
|
1668
|
+
|
|
1669
|
+
const template = 'Review: {title}';
|
|
1670
|
+
const item = { title: 'Fix mobile overflow', number: 123 };
|
|
1671
|
+
|
|
1672
|
+
const result = buildSessionName(template, item);
|
|
1673
|
+
|
|
1674
|
+
assert.strictEqual(result, 'Review: Fix mobile overflow');
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
test('handles nested field references', async () => {
|
|
1678
|
+
const { buildSessionName } = await import('../../service/actions.js');
|
|
1679
|
+
|
|
1680
|
+
const template = '{repository.name} #{number}';
|
|
1681
|
+
const item = { repository: { name: 'my-repo' }, number: 456 };
|
|
1682
|
+
|
|
1683
|
+
const result = buildSessionName(template, item);
|
|
1684
|
+
|
|
1685
|
+
assert.strictEqual(result, 'my-repo #456');
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test('preserves placeholders for missing fields', async () => {
|
|
1689
|
+
const { buildSessionName } = await import('../../service/actions.js');
|
|
1690
|
+
|
|
1691
|
+
const template = '{label}: {title}';
|
|
1692
|
+
const item = { title: 'Fix bug' }; // no label field
|
|
1693
|
+
|
|
1694
|
+
const result = buildSessionName(template, item);
|
|
1695
|
+
|
|
1696
|
+
assert.strictEqual(result, '{label}: Fix bug');
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
test('handles attention label pattern', async () => {
|
|
1700
|
+
const { buildSessionName } = await import('../../service/actions.js');
|
|
1701
|
+
|
|
1702
|
+
const template = '{_attention_label}: {title}';
|
|
1703
|
+
const item = { _attention_label: 'Conflicts + Feedback', title: 'Update API' };
|
|
1704
|
+
|
|
1705
|
+
const result = buildSessionName(template, item);
|
|
1706
|
+
|
|
1707
|
+
assert.strictEqual(result, 'Conflicts + Feedback: Update API');
|
|
1708
|
+
});
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
describe('listSessions', () => {
|
|
1712
|
+
test('fetches sessions from server with directory filter', async () => {
|
|
1713
|
+
const { listSessions } = await import('../../service/actions.js');
|
|
1714
|
+
|
|
1715
|
+
let capturedUrl = '';
|
|
1716
|
+
const mockFetch = async (url) => {
|
|
1717
|
+
capturedUrl = url;
|
|
1718
|
+
return {
|
|
1719
|
+
ok: true,
|
|
1720
|
+
json: async () => [
|
|
1721
|
+
{ id: 'ses_1', title: 'Session 1' },
|
|
1722
|
+
{ id: 'ses_2', title: 'Session 2' },
|
|
1723
|
+
],
|
|
1724
|
+
};
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
const result = await listSessions('http://localhost:4096', {
|
|
1728
|
+
directory: '/Users/test/code/project',
|
|
1729
|
+
fetch: mockFetch,
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
assert.ok(capturedUrl.includes('directory='));
|
|
1733
|
+
assert.ok(capturedUrl.includes(encodeURIComponent('/Users/test/code/project')));
|
|
1734
|
+
assert.ok(capturedUrl.includes('roots=true'));
|
|
1735
|
+
assert.strictEqual(result.length, 2);
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
test('returns empty array on server error', async () => {
|
|
1739
|
+
const { listSessions } = await import('../../service/actions.js');
|
|
1740
|
+
|
|
1741
|
+
const mockFetch = async () => ({
|
|
1742
|
+
ok: false,
|
|
1743
|
+
status: 500,
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
const result = await listSessions('http://localhost:4096', { fetch: mockFetch });
|
|
1747
|
+
|
|
1748
|
+
assert.deepStrictEqual(result, []);
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
test('returns empty array on network error', async () => {
|
|
1752
|
+
const { listSessions } = await import('../../service/actions.js');
|
|
1753
|
+
|
|
1754
|
+
const mockFetch = async () => {
|
|
1755
|
+
throw new Error('Network error');
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
const result = await listSessions('http://localhost:4096', { fetch: mockFetch });
|
|
1759
|
+
|
|
1760
|
+
assert.deepStrictEqual(result, []);
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
test('returns empty array when response is not an array', async () => {
|
|
1764
|
+
const { listSessions } = await import('../../service/actions.js');
|
|
1765
|
+
|
|
1766
|
+
const mockFetch = async () => ({
|
|
1767
|
+
ok: true,
|
|
1768
|
+
json: async () => ({ error: 'unexpected format' }),
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
const result = await listSessions('http://localhost:4096', { fetch: mockFetch });
|
|
1772
|
+
|
|
1773
|
+
assert.deepStrictEqual(result, []);
|
|
1774
|
+
});
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
describe('getSessionStatuses', () => {
|
|
1778
|
+
test('fetches session statuses from server', async () => {
|
|
1779
|
+
const { getSessionStatuses } = await import('../../service/actions.js');
|
|
1780
|
+
|
|
1781
|
+
const mockFetch = async () => ({
|
|
1782
|
+
ok: true,
|
|
1783
|
+
json: async () => ({
|
|
1784
|
+
'ses_1': { type: 'busy' },
|
|
1785
|
+
'ses_2': { type: 'idle' },
|
|
1786
|
+
}),
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
const result = await getSessionStatuses('http://localhost:4096', { fetch: mockFetch });
|
|
1790
|
+
|
|
1791
|
+
assert.strictEqual(result['ses_1'].type, 'busy');
|
|
1792
|
+
assert.strictEqual(result['ses_2'].type, 'idle');
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
test('returns empty object on server error', async () => {
|
|
1796
|
+
const { getSessionStatuses } = await import('../../service/actions.js');
|
|
1797
|
+
|
|
1798
|
+
const mockFetch = async () => ({
|
|
1799
|
+
ok: false,
|
|
1800
|
+
status: 500,
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
const result = await getSessionStatuses('http://localhost:4096', { fetch: mockFetch });
|
|
1804
|
+
|
|
1805
|
+
assert.deepStrictEqual(result, {});
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
test('returns empty object on network error', async () => {
|
|
1809
|
+
const { getSessionStatuses } = await import('../../service/actions.js');
|
|
1810
|
+
|
|
1811
|
+
const mockFetch = async () => {
|
|
1812
|
+
throw new Error('Connection refused');
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
const result = await getSessionStatuses('http://localhost:4096', { fetch: mockFetch });
|
|
1816
|
+
|
|
1817
|
+
assert.deepStrictEqual(result, {});
|
|
1818
|
+
});
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
describe('findReusableSession', () => {
|
|
1822
|
+
test('finds best idle session from list', async () => {
|
|
1823
|
+
const { findReusableSession } = await import('../../service/actions.js');
|
|
1824
|
+
|
|
1825
|
+
const mockFetch = async (url) => {
|
|
1826
|
+
if (url.includes('/session/status')) {
|
|
1827
|
+
return {
|
|
1828
|
+
ok: true,
|
|
1829
|
+
json: async () => ({
|
|
1830
|
+
'ses_busy': { type: 'busy' },
|
|
1831
|
+
// ses_idle not in status map = idle
|
|
1832
|
+
}),
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
// GET /session
|
|
1836
|
+
return {
|
|
1837
|
+
ok: true,
|
|
1838
|
+
json: async () => [
|
|
1839
|
+
{ id: 'ses_busy', time: { created: 1000, updated: 3000 } },
|
|
1840
|
+
{ id: 'ses_idle', time: { created: 1000, updated: 2000 } },
|
|
1841
|
+
],
|
|
1842
|
+
};
|
|
1843
|
+
};
|
|
1844
|
+
|
|
1845
|
+
const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
|
|
1846
|
+
|
|
1847
|
+
assert.strictEqual(result.id, 'ses_idle');
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
test('returns null when all sessions are archived', async () => {
|
|
1851
|
+
const { findReusableSession } = await import('../../service/actions.js');
|
|
1852
|
+
|
|
1853
|
+
const mockFetch = async (url) => {
|
|
1854
|
+
if (url.includes('/session/status')) {
|
|
1855
|
+
return { ok: true, json: async () => ({}) };
|
|
1856
|
+
}
|
|
1857
|
+
return {
|
|
1858
|
+
ok: true,
|
|
1859
|
+
json: async () => [
|
|
1860
|
+
{ id: 'ses_1', time: { created: 1000, updated: 2000, archived: 3000 } },
|
|
1861
|
+
{ id: 'ses_2', time: { created: 1000, updated: 2000, archived: 3000 } },
|
|
1862
|
+
],
|
|
1863
|
+
};
|
|
1864
|
+
};
|
|
1865
|
+
|
|
1866
|
+
const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
|
|
1867
|
+
|
|
1868
|
+
assert.strictEqual(result, null);
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
test('returns null when no sessions exist', async () => {
|
|
1872
|
+
const { findReusableSession } = await import('../../service/actions.js');
|
|
1873
|
+
|
|
1874
|
+
const mockFetch = async (url) => {
|
|
1875
|
+
if (url.includes('/session/status')) {
|
|
1876
|
+
return { ok: true, json: async () => ({}) };
|
|
1877
|
+
}
|
|
1878
|
+
return { ok: true, json: async () => [] };
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
|
|
1882
|
+
|
|
1883
|
+
assert.strictEqual(result, null);
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
test('prefers most recently updated idle session', async () => {
|
|
1887
|
+
const { findReusableSession } = await import('../../service/actions.js');
|
|
1888
|
+
|
|
1889
|
+
const mockFetch = async (url) => {
|
|
1890
|
+
if (url.includes('/session/status')) {
|
|
1891
|
+
return { ok: true, json: async () => ({}) }; // all idle
|
|
1892
|
+
}
|
|
1893
|
+
return {
|
|
1894
|
+
ok: true,
|
|
1895
|
+
json: async () => [
|
|
1896
|
+
{ id: 'ses_old', time: { created: 1000, updated: 2000 } },
|
|
1897
|
+
{ id: 'ses_new', time: { created: 1000, updated: 5000 } },
|
|
1898
|
+
{ id: 'ses_mid', time: { created: 1000, updated: 3000 } },
|
|
1899
|
+
],
|
|
1900
|
+
};
|
|
1901
|
+
};
|
|
1902
|
+
|
|
1903
|
+
const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
|
|
1904
|
+
|
|
1905
|
+
assert.strictEqual(result.id, 'ses_new');
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
test('falls back to busy session when no idle available', async () => {
|
|
1909
|
+
const { findReusableSession } = await import('../../service/actions.js');
|
|
1910
|
+
|
|
1911
|
+
const mockFetch = async (url) => {
|
|
1912
|
+
if (url.includes('/session/status')) {
|
|
1913
|
+
return {
|
|
1914
|
+
ok: true,
|
|
1915
|
+
json: async () => ({
|
|
1916
|
+
'ses_busy1': { type: 'busy' },
|
|
1917
|
+
'ses_busy2': { type: 'retry' },
|
|
1918
|
+
}),
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
return {
|
|
1922
|
+
ok: true,
|
|
1923
|
+
json: async () => [
|
|
1924
|
+
{ id: 'ses_busy1', time: { created: 1000, updated: 2000 } },
|
|
1925
|
+
{ id: 'ses_busy2', time: { created: 1000, updated: 4000 } },
|
|
1926
|
+
],
|
|
1927
|
+
};
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1930
|
+
const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
|
|
1931
|
+
|
|
1932
|
+
// Should return most recently updated busy session
|
|
1933
|
+
assert.strictEqual(result.id, 'ses_busy2');
|
|
1934
|
+
});
|
|
1935
|
+
});
|
|
1537
1936
|
});
|
package/test/unit/poller.test.js
CHANGED
|
@@ -136,6 +136,29 @@ describe('poller.js', () => {
|
|
|
136
136
|
assert.strictEqual(poller.getProcessedIds().length, 1);
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
+
test('getProcessedMeta returns stored metadata', async () => {
|
|
140
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
141
|
+
|
|
142
|
+
const poller = createPoller({ stateFile });
|
|
143
|
+
|
|
144
|
+
// Not processed yet
|
|
145
|
+
assert.strictEqual(poller.getProcessedMeta('item-1'), null);
|
|
146
|
+
|
|
147
|
+
// Mark as processed with metadata including directory
|
|
148
|
+
poller.markProcessed('item-1', {
|
|
149
|
+
source: 'test',
|
|
150
|
+
directory: '/worktree/pr-123',
|
|
151
|
+
itemState: 'open',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const meta = poller.getProcessedMeta('item-1');
|
|
155
|
+
assert.ok(meta);
|
|
156
|
+
assert.strictEqual(meta.source, 'test');
|
|
157
|
+
assert.strictEqual(meta.directory, '/worktree/pr-123');
|
|
158
|
+
assert.strictEqual(meta.itemState, 'open');
|
|
159
|
+
assert.ok(meta.processedAt); // Should have timestamp
|
|
160
|
+
});
|
|
161
|
+
|
|
139
162
|
test('persists state across instances', async () => {
|
|
140
163
|
const { createPoller } = await import('../../service/poller.js');
|
|
141
164
|
|
|
@@ -1059,4 +1082,165 @@ describe('poller.js', () => {
|
|
|
1059
1082
|
assert.strictEqual(result[0]._has_attention, false);
|
|
1060
1083
|
});
|
|
1061
1084
|
});
|
|
1085
|
+
|
|
1086
|
+
describe('enrichItemsWithComments', () => {
|
|
1087
|
+
test('skips enrichment when filter_bot_comments is not set', async () => {
|
|
1088
|
+
const { enrichItemsWithComments } = await import('../../service/poller.js');
|
|
1089
|
+
|
|
1090
|
+
const items = [{ number: 1, comments: 5, repository_full_name: 'org/repo' }];
|
|
1091
|
+
const source = { tool: { command: ['gh', 'search', 'prs'] } }; // no filter_bot_comments
|
|
1092
|
+
|
|
1093
|
+
const result = await enrichItemsWithComments(items, source);
|
|
1094
|
+
|
|
1095
|
+
// Items returned unchanged, no _comments added
|
|
1096
|
+
assert.strictEqual(result.length, 1);
|
|
1097
|
+
assert.strictEqual(result[0]._comments, undefined);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
test('skips enrichment for non-GitHub sources', async () => {
|
|
1101
|
+
const { enrichItemsWithComments } = await import('../../service/poller.js');
|
|
1102
|
+
|
|
1103
|
+
const items = [{ number: 1, comments: 5, repository_full_name: 'org/repo' }];
|
|
1104
|
+
const source = {
|
|
1105
|
+
filter_bot_comments: true,
|
|
1106
|
+
tool: { mcp: 'linear', name: 'list_issues' } // not GitHub
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
const result = await enrichItemsWithComments(items, source);
|
|
1110
|
+
|
|
1111
|
+
// Items returned unchanged
|
|
1112
|
+
assert.strictEqual(result.length, 1);
|
|
1113
|
+
assert.strictEqual(result[0]._comments, undefined);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
test('skips items with zero comments', async () => {
|
|
1117
|
+
const { enrichItemsWithComments } = await import('../../service/poller.js');
|
|
1118
|
+
|
|
1119
|
+
const items = [
|
|
1120
|
+
{ number: 1, comments: 0, repository_full_name: 'org/repo' },
|
|
1121
|
+
{ number: 2, comments: 0, repository_full_name: 'org/repo' }
|
|
1122
|
+
];
|
|
1123
|
+
const source = {
|
|
1124
|
+
filter_bot_comments: true,
|
|
1125
|
+
tool: { command: ['gh', 'search', 'prs'] }
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
const result = await enrichItemsWithComments(items, source);
|
|
1129
|
+
|
|
1130
|
+
// Items returned unchanged (no API calls made)
|
|
1131
|
+
assert.strictEqual(result.length, 2);
|
|
1132
|
+
assert.strictEqual(result[0]._comments, undefined);
|
|
1133
|
+
assert.strictEqual(result[1]._comments, undefined);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
test('identifies GitHub MCP source correctly', async () => {
|
|
1137
|
+
const { enrichItemsWithComments } = await import('../../service/poller.js');
|
|
1138
|
+
|
|
1139
|
+
const items = [{ number: 1, comments: 0, repository_full_name: 'org/repo' }];
|
|
1140
|
+
const source = {
|
|
1141
|
+
filter_bot_comments: true,
|
|
1142
|
+
tool: { mcp: 'github', name: 'search_issues' }
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
// Should not throw, just skip due to 0 comments
|
|
1146
|
+
const result = await enrichItemsWithComments(items, source);
|
|
1147
|
+
assert.strictEqual(result.length, 1);
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
test('identifies GitHub CLI source correctly', async () => {
|
|
1151
|
+
const { enrichItemsWithComments } = await import('../../service/poller.js');
|
|
1152
|
+
|
|
1153
|
+
const items = [{ number: 1, comments: 0, repository_full_name: 'org/repo' }];
|
|
1154
|
+
const source = {
|
|
1155
|
+
filter_bot_comments: true,
|
|
1156
|
+
tool: { command: ['gh', 'search', 'issues', '--json', 'number'] }
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
// Should not throw, just skip due to 0 comments
|
|
1160
|
+
const result = await enrichItemsWithComments(items, source);
|
|
1161
|
+
assert.strictEqual(result.length, 1);
|
|
1162
|
+
});
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
describe('enrichItemsWithMergeable', () => {
|
|
1166
|
+
test('skips enrichment when enrich_mergeable is not set', async () => {
|
|
1167
|
+
const { enrichItemsWithMergeable } = await import('../../service/poller.js');
|
|
1168
|
+
|
|
1169
|
+
const items = [{ number: 1, repository_full_name: 'org/repo' }];
|
|
1170
|
+
const source = {}; // no enrich_mergeable
|
|
1171
|
+
|
|
1172
|
+
const result = await enrichItemsWithMergeable(items, source);
|
|
1173
|
+
|
|
1174
|
+
// Items returned unchanged
|
|
1175
|
+
assert.strictEqual(result.length, 1);
|
|
1176
|
+
assert.strictEqual(result[0]._mergeable, undefined);
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
test('skips items without repository info', async () => {
|
|
1180
|
+
const { enrichItemsWithMergeable } = await import('../../service/poller.js');
|
|
1181
|
+
|
|
1182
|
+
const items = [
|
|
1183
|
+
{ number: 1 }, // no repository_full_name
|
|
1184
|
+
{ repository_full_name: 'org/repo' } // no number
|
|
1185
|
+
];
|
|
1186
|
+
const source = { enrich_mergeable: true };
|
|
1187
|
+
|
|
1188
|
+
const result = await enrichItemsWithMergeable(items, source);
|
|
1189
|
+
|
|
1190
|
+
// Items returned unchanged (no API calls made for invalid items)
|
|
1191
|
+
assert.strictEqual(result.length, 2);
|
|
1192
|
+
assert.strictEqual(result[0]._mergeable, undefined);
|
|
1193
|
+
assert.strictEqual(result[1]._mergeable, undefined);
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
test('accepts repository.nameWithOwner as alternative field', async () => {
|
|
1197
|
+
const { enrichItemsWithMergeable } = await import('../../service/poller.js');
|
|
1198
|
+
|
|
1199
|
+
const items = [
|
|
1200
|
+
{ number: 1, repository: { nameWithOwner: 'org/repo' } }
|
|
1201
|
+
];
|
|
1202
|
+
const source = { enrich_mergeable: true };
|
|
1203
|
+
|
|
1204
|
+
// This will attempt the API call (which may fail in test env)
|
|
1205
|
+
// but it should not skip due to missing repo info
|
|
1206
|
+
const result = await enrichItemsWithMergeable(items, source);
|
|
1207
|
+
|
|
1208
|
+
// Should have attempted enrichment (result may have _mergeable: null on CLI error)
|
|
1209
|
+
assert.strictEqual(result.length, 1);
|
|
1210
|
+
// The item should have been processed (not skipped)
|
|
1211
|
+
assert.ok('_mergeable' in result[0] || result[0]._mergeable === undefined);
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
describe('fetchGitHubComments', () => {
|
|
1216
|
+
test('returns empty array when repository_full_name is missing', async () => {
|
|
1217
|
+
const { fetchGitHubComments } = await import('../../service/poller.js');
|
|
1218
|
+
|
|
1219
|
+
const item = { number: 123 }; // no repository_full_name
|
|
1220
|
+
|
|
1221
|
+
const result = await fetchGitHubComments(item);
|
|
1222
|
+
|
|
1223
|
+
assert.deepStrictEqual(result, []);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test('returns empty array when number is missing', async () => {
|
|
1227
|
+
const { fetchGitHubComments } = await import('../../service/poller.js');
|
|
1228
|
+
|
|
1229
|
+
const item = { repository_full_name: 'org/repo' }; // no number
|
|
1230
|
+
|
|
1231
|
+
const result = await fetchGitHubComments(item);
|
|
1232
|
+
|
|
1233
|
+
assert.deepStrictEqual(result, []);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test('returns empty array when owner/repo cannot be parsed', async () => {
|
|
1237
|
+
const { fetchGitHubComments } = await import('../../service/poller.js');
|
|
1238
|
+
|
|
1239
|
+
const item = { repository_full_name: 'invalid', number: 123 }; // no slash
|
|
1240
|
+
|
|
1241
|
+
const result = await fetchGitHubComments(item);
|
|
1242
|
+
|
|
1243
|
+
assert.deepStrictEqual(result, []);
|
|
1244
|
+
});
|
|
1245
|
+
});
|
|
1062
1246
|
});
|
|
@@ -675,5 +675,56 @@ describe('readiness.js', () => {
|
|
|
675
675
|
|
|
676
676
|
assert.strictEqual(result.ready, true);
|
|
677
677
|
});
|
|
678
|
+
|
|
679
|
+
test('passes when PR has conflicts but only bot comments (require_attention skips bot check)', async () => {
|
|
680
|
+
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
681
|
+
|
|
682
|
+
// This is the key scenario: PR has merge conflicts but no human feedback
|
|
683
|
+
// With require_attention, the checkBotComments check should be skipped
|
|
684
|
+
// because checkAttention handles the combined logic via _has_attention
|
|
685
|
+
const pr = {
|
|
686
|
+
number: 123,
|
|
687
|
+
title: 'Test PR',
|
|
688
|
+
user: { login: 'author' },
|
|
689
|
+
_mergeable: 'CONFLICTING',
|
|
690
|
+
_comments: [
|
|
691
|
+
{ user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
|
|
692
|
+
],
|
|
693
|
+
_has_attention: true,
|
|
694
|
+
_attention_label: 'Conflicts'
|
|
695
|
+
};
|
|
696
|
+
const config = {
|
|
697
|
+
readiness: {
|
|
698
|
+
require_attention: true
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const result = evaluateReadiness(pr, config);
|
|
703
|
+
|
|
704
|
+
// Should be ready because it has conflicts (via _has_attention)
|
|
705
|
+
// even though it only has bot comments
|
|
706
|
+
assert.strictEqual(result.ready, true);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test('still fails bot check when require_attention is NOT configured', async () => {
|
|
710
|
+
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
711
|
+
|
|
712
|
+
// Without require_attention, the strict bot check applies
|
|
713
|
+
const pr = {
|
|
714
|
+
number: 123,
|
|
715
|
+
title: 'Test PR',
|
|
716
|
+
user: { login: 'author' },
|
|
717
|
+
_comments: [
|
|
718
|
+
{ user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
|
|
719
|
+
]
|
|
720
|
+
};
|
|
721
|
+
const config = {};
|
|
722
|
+
|
|
723
|
+
const result = evaluateReadiness(pr, config);
|
|
724
|
+
|
|
725
|
+
// Should fail because no human feedback and no require_attention bypass
|
|
726
|
+
assert.strictEqual(result.ready, false);
|
|
727
|
+
assert.ok(result.reason.includes('bot'));
|
|
728
|
+
});
|
|
678
729
|
});
|
|
679
730
|
});
|
package/test/unit/server.test.js
CHANGED
|
@@ -86,5 +86,101 @@ describe('service/server.js', () => {
|
|
|
86
86
|
|
|
87
87
|
assert.strictEqual(res.status, 404);
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
test('returns 404 for POST to health', async () => {
|
|
91
|
+
const { startService } = await import('../../service/server.js');
|
|
92
|
+
|
|
93
|
+
service = await startService({
|
|
94
|
+
httpPort: 0,
|
|
95
|
+
enablePolling: false
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const port = service.httpServer.address().port;
|
|
99
|
+
const res = await fetch(`http://localhost:${port}/health`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
body: '{}'
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.strictEqual(res.status, 404);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('startService and stopService', () => {
|
|
109
|
+
test('starts and stops cleanly', async () => {
|
|
110
|
+
const { startService, stopService } = await import('../../service/server.js');
|
|
111
|
+
|
|
112
|
+
const localService = await startService({
|
|
113
|
+
httpPort: 0,
|
|
114
|
+
enablePolling: false
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
assert.ok(localService.httpServer, 'Should have httpServer');
|
|
118
|
+
assert.strictEqual(localService.pollingState, null, 'Should not have pollingState when disabled');
|
|
119
|
+
|
|
120
|
+
const port = localService.httpServer.address().port;
|
|
121
|
+
assert.ok(port > 0, 'Should have valid port');
|
|
122
|
+
|
|
123
|
+
await stopService(localService);
|
|
124
|
+
|
|
125
|
+
// Server should be closed - attempting to fetch should fail
|
|
126
|
+
try {
|
|
127
|
+
await fetch(`http://localhost:${port}/health`, { signal: AbortSignal.timeout(500) });
|
|
128
|
+
assert.fail('Fetch should have failed after stop');
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Expected - connection refused or timeout
|
|
131
|
+
assert.ok(err.message.includes('fetch failed') || err.name === 'TimeoutError' || err.name === 'AbortError');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('handles stopService on already stopped service', async () => {
|
|
136
|
+
const { startService, stopService } = await import('../../service/server.js');
|
|
137
|
+
|
|
138
|
+
const localService = await startService({
|
|
139
|
+
httpPort: 0,
|
|
140
|
+
enablePolling: false
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Stop twice - should not throw
|
|
144
|
+
await stopService(localService);
|
|
145
|
+
await stopService(localService);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('starts with enablePolling=false when no config exists', async () => {
|
|
149
|
+
const { startService, stopService } = await import('../../service/server.js');
|
|
150
|
+
|
|
151
|
+
const localService = await startService({
|
|
152
|
+
httpPort: 0,
|
|
153
|
+
enablePolling: true, // enabled but config doesn't exist
|
|
154
|
+
reposConfig: '/nonexistent/config.yaml'
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Should start without polling since config doesn't exist
|
|
158
|
+
assert.ok(localService.httpServer);
|
|
159
|
+
assert.strictEqual(localService.pollingState, null);
|
|
160
|
+
|
|
161
|
+
await stopService(localService);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('CORS headers', () => {
|
|
166
|
+
test('OPTIONS includes all required headers', async () => {
|
|
167
|
+
const { startService } = await import('../../service/server.js');
|
|
168
|
+
|
|
169
|
+
service = await startService({
|
|
170
|
+
httpPort: 0,
|
|
171
|
+
enablePolling: false
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const port = service.httpServer.address().port;
|
|
175
|
+
const res = await fetch(`http://localhost:${port}/anything`, {
|
|
176
|
+
method: 'OPTIONS'
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assert.strictEqual(res.status, 204);
|
|
180
|
+
assert.strictEqual(res.headers.get('access-control-allow-origin'), '*');
|
|
181
|
+
assert.strictEqual(res.headers.get('access-control-allow-methods'), 'GET, OPTIONS');
|
|
182
|
+
assert.ok(res.headers.get('access-control-allow-headers').includes('Content-Type'));
|
|
183
|
+
assert.strictEqual(res.headers.get('access-control-max-age'), '86400');
|
|
184
|
+
});
|
|
89
185
|
});
|
|
90
186
|
});
|