opencode-pilot 0.23.0 → 0.24.1
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/.devcontainer/devcontainer.json +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/CONTRIBUTING.md +43 -54
- package/Formula/opencode-pilot.rb +2 -2
- package/README.md +12 -0
- package/examples/config.yaml +2 -0
- package/package.json +1 -1
- package/service/actions.js +31 -0
- package/service/poll-service.js +32 -1
- package/service/poller.js +173 -0
- package/service/presets/github.yaml +6 -1
- package/test/integration/session-reuse.test.js +174 -0
- package/test/unit/poller.test.js +377 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"ghcr.io/devcontainers/features/git:1": {},
|
|
6
6
|
"ghcr.io/devcontainers/features/github-cli:1": {}
|
|
7
7
|
},
|
|
8
|
-
"postCreateCommand": "sudo apt-get update && sudo apt-get install -y
|
|
8
|
+
"postCreateCommand": "sudo apt-get update && sudo apt-get install -y ripgrep && npm install -g opencode-ai@latest",
|
|
9
9
|
"customizations": {
|
|
10
10
|
"vscode": {
|
|
11
11
|
"extensions": [
|
package/.github/workflows/ci.yml
CHANGED
package/CONTRIBUTING.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Contributing to opencode-
|
|
1
|
+
# Contributing to opencode-pilot
|
|
2
2
|
|
|
3
3
|
Thanks for your interest in contributing!
|
|
4
4
|
|
|
@@ -6,84 +6,75 @@ Thanks for your interest in contributing!
|
|
|
6
6
|
|
|
7
7
|
1. Clone the repository:
|
|
8
8
|
```bash
|
|
9
|
-
git clone https://github.com/athal7/opencode-
|
|
10
|
-
cd opencode-
|
|
9
|
+
git clone https://github.com/athal7/opencode-pilot.git
|
|
10
|
+
cd opencode-pilot
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
2. Install
|
|
13
|
+
2. Install dependencies:
|
|
14
14
|
```bash
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
3. Set required environment variables:
|
|
19
|
-
```bash
|
|
20
|
-
export NTFY_TOPIC=your-test-topic
|
|
15
|
+
npm install
|
|
21
16
|
```
|
|
22
17
|
|
|
23
18
|
## Running Tests
|
|
24
19
|
|
|
25
|
-
Run the full test suite:
|
|
26
20
|
```bash
|
|
27
|
-
|
|
21
|
+
npm test # Unit tests
|
|
22
|
+
npm run test:integration # Integration tests
|
|
23
|
+
npm run test:all # All tests
|
|
28
24
|
```
|
|
29
25
|
|
|
30
|
-
|
|
31
|
-
- **File structure tests** - Verify all plugin files exist
|
|
32
|
-
- **Syntax validation** - Run `node --check` on all JS files
|
|
33
|
-
- **Export structure tests** - Verify expected functions are exported
|
|
34
|
-
- **Integration tests** - Test plugin loads in OpenCode without hanging (requires opencode CLI)
|
|
26
|
+
Tests use the Node.js built-in test runner (`node:test`) with `node:assert`.
|
|
35
27
|
|
|
36
28
|
## Writing Tests
|
|
37
29
|
|
|
38
|
-
Tests live in `test/`
|
|
30
|
+
Tests live in `test/unit/` and `test/integration/`. Each test file follows this pattern:
|
|
39
31
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
# Use assertions from test_helper.bash
|
|
44
|
-
assert_file_exists "$PLUGIN_DIR/myfile.js"
|
|
45
|
-
assert_contains "$output" "expected string"
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
# Register and run
|
|
49
|
-
run_test "my_feature" "test_my_feature"
|
|
50
|
-
```
|
|
32
|
+
```js
|
|
33
|
+
import { test, describe } from 'node:test';
|
|
34
|
+
import assert from 'node:assert';
|
|
51
35
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (myFunction() !== 'expected') throw new Error('Failed');
|
|
58
|
-
console.log('PASS');
|
|
59
|
-
" || return 1
|
|
60
|
-
}
|
|
36
|
+
describe('myModule', () => {
|
|
37
|
+
test('does the thing', () => {
|
|
38
|
+
assert.strictEqual(actual, expected);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
61
41
|
```
|
|
62
42
|
|
|
63
43
|
## Code Style
|
|
64
44
|
|
|
65
45
|
- Use ES modules (`import`/`export`)
|
|
66
46
|
- Use `async`/`await` for async operations
|
|
67
|
-
- Log with `[opencode-
|
|
47
|
+
- Log with `[opencode-pilot]` prefix
|
|
68
48
|
- Handle errors gracefully (log, don't crash OpenCode)
|
|
69
|
-
- No external dependencies
|
|
49
|
+
- No external dependencies beyond what's in `package.json`
|
|
70
50
|
|
|
71
|
-
##
|
|
51
|
+
## Project Architecture
|
|
72
52
|
|
|
73
53
|
```
|
|
74
54
|
plugin/
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
├──
|
|
78
|
-
├──
|
|
79
|
-
|
|
55
|
+
└── index.js # OpenCode plugin entry point (auto-starts daemon)
|
|
56
|
+
service/
|
|
57
|
+
├── server.js # HTTP server and polling orchestration
|
|
58
|
+
├── poll-service.js # Polling lifecycle management
|
|
59
|
+
├── poller.js # MCP tool polling
|
|
60
|
+
├── actions.js # Session creation and template expansion
|
|
61
|
+
├── readiness.js # Evaluate item readiness (labels, deps, priority)
|
|
62
|
+
├── worktree.js # Git worktree management
|
|
63
|
+
├── repo-config.js # Repository discovery and config
|
|
64
|
+
├── logger.js # Debug logging
|
|
65
|
+
├── utils.js # Shared utilities
|
|
66
|
+
├── version.js # Package version detection
|
|
67
|
+
└── presets/
|
|
68
|
+
├── index.js # Preset loader
|
|
69
|
+
├── github.yaml # GitHub source presets
|
|
70
|
+
└── linear.yaml # Linear source presets
|
|
80
71
|
```
|
|
81
72
|
|
|
82
73
|
## Submitting Changes
|
|
83
74
|
|
|
84
75
|
1. Create a feature branch: `git checkout -b my-feature`
|
|
85
76
|
2. Make your changes
|
|
86
|
-
3. Run tests:
|
|
77
|
+
3. Run tests: `npm test`
|
|
87
78
|
4. Commit with a clear message following conventional commits:
|
|
88
79
|
- `feat(#1): add idle notifications`
|
|
89
80
|
- `fix(#3): handle network timeout`
|
|
@@ -91,12 +82,10 @@ plugin/
|
|
|
91
82
|
|
|
92
83
|
## Releasing
|
|
93
84
|
|
|
94
|
-
Releases are automated via
|
|
95
|
-
|
|
96
|
-
1. Tag the commit: `git tag v1.0.0`
|
|
97
|
-
2. Push the tag: `git push origin v1.0.0`
|
|
85
|
+
Releases are automated via [semantic-release](https://github.com/semantic-release/semantic-release) on merge to `main`. The CI pipeline will:
|
|
98
86
|
|
|
99
|
-
The release workflow will:
|
|
100
87
|
1. Run tests
|
|
101
|
-
2.
|
|
102
|
-
3.
|
|
88
|
+
2. Determine the next version from commit messages
|
|
89
|
+
3. Publish to npm
|
|
90
|
+
4. Create a GitHub release
|
|
91
|
+
5. Update the Homebrew formula
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
class OpencodePilot < Formula
|
|
2
2
|
desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
|
|
3
3
|
homepage "https://github.com/athal7/opencode-pilot"
|
|
4
|
-
url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.
|
|
5
|
-
sha256 "
|
|
4
|
+
url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.23.1.tar.gz"
|
|
5
|
+
sha256 "106172281e27a0847f1c457837c503aa64058908ab4ac5df6fb7326db4c07079"
|
|
6
6
|
license "MIT"
|
|
7
7
|
|
|
8
8
|
depends_on "node"
|
package/README.md
CHANGED
|
@@ -140,6 +140,18 @@ sources:
|
|
|
140
140
|
- `worktree_name` - Template for naming new worktrees (only with `worktree: "new"`)
|
|
141
141
|
- `prefer_existing_sandbox: false` - Disable sandbox reuse for this source
|
|
142
142
|
|
|
143
|
+
### Stacked PR Support
|
|
144
|
+
|
|
145
|
+
When `detect_stacks: true` is set on a source, pilot detects stacked PRs (where one PR's head branch is another PR's base branch) and reuses the existing session from a stack sibling. This gives the agent full context about the entire stack without redundant context-gathering.
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
sources:
|
|
149
|
+
- preset: github/review-requests
|
|
150
|
+
detect_stacks: true # Enabled by default in this preset
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The `github/review-requests` and `github/my-prs-attention` presets enable stack detection by default.
|
|
154
|
+
|
|
143
155
|
## CLI Commands
|
|
144
156
|
|
|
145
157
|
```bash
|
package/examples/config.yaml
CHANGED
|
@@ -35,6 +35,8 @@ sources:
|
|
|
35
35
|
- preset: github/my-issues
|
|
36
36
|
prompt: worktree
|
|
37
37
|
|
|
38
|
+
# PR presets have detect_stacks: true by default, enabling session reuse
|
|
39
|
+
# across stacked PRs (where one PR's head branch = another's base branch)
|
|
38
40
|
- preset: github/review-requests
|
|
39
41
|
# Per-source model override (takes precedence over defaults.model)
|
|
40
42
|
# model: anthropic/claude-haiku-3.5
|
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -819,6 +819,37 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}) {
|
|
|
819
819
|
? buildSessionName(config.session.name, item)
|
|
820
820
|
: (item.title || `session-${Date.now()}`);
|
|
821
821
|
|
|
822
|
+
// Check if we should reuse a stack sibling's session
|
|
823
|
+
// This is set when another PR in the same stack was already processed
|
|
824
|
+
if (config.reuse_stack_session && !options.dryRun) {
|
|
825
|
+
try {
|
|
826
|
+
debug(`executeInDirectory: trying stack session reuse ${config.reuse_stack_session} for ${cwd}`);
|
|
827
|
+
|
|
828
|
+
const stackResult = await sendMessageToSession(serverUrl, config.reuse_stack_session, cwd, prompt, {
|
|
829
|
+
title: sessionTitle,
|
|
830
|
+
agent: config.agent,
|
|
831
|
+
model: config.model,
|
|
832
|
+
fetch: options.fetch,
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
if (stackResult.success) {
|
|
836
|
+
const stackCommand = `[API] POST ${serverUrl}/session/${config.reuse_stack_session}/message (stack reuse)`;
|
|
837
|
+
return {
|
|
838
|
+
command: stackCommand,
|
|
839
|
+
success: true,
|
|
840
|
+
sessionId: stackResult.sessionId,
|
|
841
|
+
directory: cwd,
|
|
842
|
+
sessionReused: true,
|
|
843
|
+
error: stackResult.error,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
debug(`executeInDirectory: stack session reuse failed, falling through`);
|
|
848
|
+
} catch (err) {
|
|
849
|
+
debug(`executeInDirectory: stack session reuse error, falling through: ${err.message}`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
822
853
|
// Check if we should try to reuse an existing session
|
|
823
854
|
const reuseActiveSession = config.reuse_active_session !== false; // default true
|
|
824
855
|
|
package/service/poll-service.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig, resolveRepoForItem, getCleanupTtlDays, getStartupDelay } from "./repo-config.js";
|
|
13
|
-
import { createPoller, pollGenericSource, enrichItemsWithComments, enrichItemsWithMergeable, computeAttentionLabels, computeDedupKeys } from "./poller.js";
|
|
13
|
+
import { createPoller, pollGenericSource, enrichItemsWithComments, enrichItemsWithMergeable, enrichItemsWithBranchRefs, computeAttentionLabels, computeDedupKeys, detectStacks } from "./poller.js";
|
|
14
14
|
import { evaluateReadiness, sortByPriority } from "./readiness.js";
|
|
15
15
|
import { executeAction, buildCommand } from "./actions.js";
|
|
16
16
|
import { debug } from "./logger.js";
|
|
@@ -146,6 +146,12 @@ export async function pollOnce(options = {}) {
|
|
|
146
146
|
debug(`Enriched ${items.length} items with mergeable status`);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// Enrich items with branch refs for stack detection if configured
|
|
150
|
+
if (source.detect_stacks) {
|
|
151
|
+
items = await enrichItemsWithBranchRefs(items, source);
|
|
152
|
+
debug(`Enriched ${items.length} items with branch refs for stack detection`);
|
|
153
|
+
}
|
|
154
|
+
|
|
149
155
|
// Compute attention labels if both enrichments are present (for my-prs-attention)
|
|
150
156
|
if (source.enrich_mergeable && source.filter_bot_comments) {
|
|
151
157
|
items = computeAttentionLabels(items, source);
|
|
@@ -193,6 +199,12 @@ export async function pollOnce(options = {}) {
|
|
|
193
199
|
const sortConfig = readyItems.length > 0 ? readyItems[0]._repoConfig : {};
|
|
194
200
|
const sortedItems = sortByPriority(readyItems, sortConfig);
|
|
195
201
|
|
|
202
|
+
// Detect PR stacks for session reuse (only when detect_stacks is enabled)
|
|
203
|
+
const stackMap = source.detect_stacks ? detectStacks(sortedItems) : new Map();
|
|
204
|
+
if (stackMap.size > 0) {
|
|
205
|
+
debug(`Detected ${stackMap.size} items in PR stacks`);
|
|
206
|
+
}
|
|
207
|
+
|
|
196
208
|
// Process ready items
|
|
197
209
|
// Get reprocess_on config: source-level overrides provider-level
|
|
198
210
|
const reprocessOn = source.reprocess_on || toolProviderConfig?.reprocess_on;
|
|
@@ -241,6 +253,21 @@ export async function pollOnce(options = {}) {
|
|
|
241
253
|
debug(`Reusing existing directory: ${existingDirectory}`);
|
|
242
254
|
}
|
|
243
255
|
|
|
256
|
+
// Check if a stack sibling was already processed (for session reuse across stacked PRs)
|
|
257
|
+
if (stackMap.has(item.id) && pollerInstance) {
|
|
258
|
+
const siblings = stackMap.get(item.id);
|
|
259
|
+
for (const siblingId of siblings) {
|
|
260
|
+
const siblingMeta = pollerInstance.getProcessedMeta(siblingId);
|
|
261
|
+
if (siblingMeta?.sessionId && siblingMeta?.directory) {
|
|
262
|
+
actionConfig.existing_directory = siblingMeta.directory;
|
|
263
|
+
actionConfig.reuse_stack_session = siblingMeta.sessionId;
|
|
264
|
+
debug(`Stack reuse: ${item.id} reusing session ${siblingMeta.sessionId} from sibling ${siblingId}`);
|
|
265
|
+
console.log(`[poll] Stack reuse: ${item.id} reusing session from stack sibling ${siblingId}`);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
244
271
|
// Skip items with no valid local path (prevents sessions in home directory)
|
|
245
272
|
const hasLocalPath = actionConfig.working_dir || actionConfig.path || actionConfig.repo_path;
|
|
246
273
|
if (!hasLocalPath) {
|
|
@@ -277,8 +304,12 @@ export async function pollOnce(options = {}) {
|
|
|
277
304
|
command: result.command,
|
|
278
305
|
source: sourceName,
|
|
279
306
|
directory: result.directory || null,
|
|
307
|
+
sessionId: result.sessionId || null,
|
|
280
308
|
itemState: item.state || item.status || null,
|
|
281
309
|
itemUpdatedAt: item.updated_at || null,
|
|
310
|
+
// Store attention state for detecting new feedback on PRs
|
|
311
|
+
// _has_attention is boolean for enriched items, undefined for non-PR sources
|
|
312
|
+
hasAttention: item._has_attention ?? null,
|
|
282
313
|
dedupKeys: dedupKeys.length > 0 ? dedupKeys : undefined,
|
|
283
314
|
});
|
|
284
315
|
}
|
package/service/poller.js
CHANGED
|
@@ -635,6 +635,167 @@ export async function enrichItemsWithMergeable(items, source, options = {}) {
|
|
|
635
635
|
return enrichedItems;
|
|
636
636
|
}
|
|
637
637
|
|
|
638
|
+
/**
|
|
639
|
+
* Fetch branch ref names for a PR via gh CLI
|
|
640
|
+
*
|
|
641
|
+
* @param {string} owner - Repository owner
|
|
642
|
+
* @param {string} repo - Repository name
|
|
643
|
+
* @param {number} number - PR number
|
|
644
|
+
* @param {number} timeout - Timeout in ms
|
|
645
|
+
* @returns {Promise<object|null>} { headRefName, baseRefName } or null on error
|
|
646
|
+
*/
|
|
647
|
+
async function fetchBranchRefs(owner, repo, number, timeout) {
|
|
648
|
+
const { exec } = await import('child_process');
|
|
649
|
+
const { promisify } = await import('util');
|
|
650
|
+
const execAsync = promisify(exec);
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
const { stdout } = await Promise.race([
|
|
654
|
+
execAsync(`gh pr view ${number} -R ${owner}/${repo} --json headRefName,baseRefName`),
|
|
655
|
+
createTimeout(timeout, "gh pr view branch refs"),
|
|
656
|
+
]);
|
|
657
|
+
|
|
658
|
+
const data = JSON.parse(stdout.trim());
|
|
659
|
+
return data && data.headRefName && data.baseRefName ? data : null;
|
|
660
|
+
} catch (err) {
|
|
661
|
+
console.error(`[poller] Error fetching branch refs for ${owner}/${repo}#${number}: ${err.message}`);
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Enrich items with branch ref names for stack detection
|
|
668
|
+
*
|
|
669
|
+
* For items from sources with detect_stacks: true, fetches headRefName and
|
|
670
|
+
* baseRefName via gh CLI and attaches them as _headRefName and _baseRefName
|
|
671
|
+
* fields for stack detection.
|
|
672
|
+
*
|
|
673
|
+
* @param {Array} items - Items to enrich
|
|
674
|
+
* @param {object} source - Source configuration with optional detect_stacks
|
|
675
|
+
* @param {object} [options] - Options
|
|
676
|
+
* @param {number} [options.timeout] - Timeout in ms (default: 30000)
|
|
677
|
+
* @returns {Promise<Array>} Items with _headRefName and _baseRefName fields added
|
|
678
|
+
*/
|
|
679
|
+
export async function enrichItemsWithBranchRefs(items, source, options = {}) {
|
|
680
|
+
// Skip if not configured or not a GitHub source
|
|
681
|
+
if (!source.detect_stacks || !isGitHubSource(source)) {
|
|
682
|
+
return items;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
|
|
686
|
+
|
|
687
|
+
// Fetch branch refs for each item
|
|
688
|
+
const enrichedItems = [];
|
|
689
|
+
for (const item of items) {
|
|
690
|
+
// Extract owner/repo from item
|
|
691
|
+
const fullName = item.repository_full_name || item.repository?.nameWithOwner;
|
|
692
|
+
if (!fullName || !item.number) {
|
|
693
|
+
enrichedItems.push(item);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const [owner, repo] = fullName.split("/");
|
|
698
|
+
const refs = await fetchBranchRefs(owner, repo, item.number, timeout);
|
|
699
|
+
if (refs) {
|
|
700
|
+
enrichedItems.push({ ...item, _headRefName: refs.headRefName, _baseRefName: refs.baseRefName });
|
|
701
|
+
} else {
|
|
702
|
+
enrichedItems.push(item);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return enrichedItems;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Detect PR stacks from enriched items
|
|
711
|
+
*
|
|
712
|
+
* Groups items by repo and finds stacks by matching headRefName/baseRefName:
|
|
713
|
+
* if PR A's headRefName equals PR B's baseRefName, they're in the same stack.
|
|
714
|
+
* Walks chains to handle 3+ PR stacks.
|
|
715
|
+
*
|
|
716
|
+
* @param {Array} items - Items enriched with _headRefName and _baseRefName
|
|
717
|
+
* @returns {Map<string, string[]>} Map of itemId -> sibling itemIds (only stacked items included)
|
|
718
|
+
*/
|
|
719
|
+
export function detectStacks(items) {
|
|
720
|
+
const stacks = new Map();
|
|
721
|
+
|
|
722
|
+
if (!items || items.length === 0) return stacks;
|
|
723
|
+
|
|
724
|
+
// Group items by repo (stacks only make sense within same repo)
|
|
725
|
+
const byRepo = new Map();
|
|
726
|
+
for (const item of items) {
|
|
727
|
+
if (!item._headRefName || !item._baseRefName) continue;
|
|
728
|
+
|
|
729
|
+
const repo = item.repository_full_name || item.repository?.nameWithOwner;
|
|
730
|
+
if (!repo) continue;
|
|
731
|
+
|
|
732
|
+
if (!byRepo.has(repo)) {
|
|
733
|
+
byRepo.set(repo, []);
|
|
734
|
+
}
|
|
735
|
+
byRepo.get(repo).push(item);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// For each repo group, find stacks
|
|
739
|
+
for (const [, repoItems] of byRepo) {
|
|
740
|
+
if (repoItems.length < 2) continue;
|
|
741
|
+
|
|
742
|
+
// Build lookup: headRefName -> item
|
|
743
|
+
const headToItem = new Map();
|
|
744
|
+
for (const item of repoItems) {
|
|
745
|
+
headToItem.set(item._headRefName, item);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Find connected components (stacks) using union-find approach
|
|
749
|
+
// Two items are connected if one's headRefName equals the other's baseRefName
|
|
750
|
+
const parent = new Map(); // itemId -> root itemId
|
|
751
|
+
|
|
752
|
+
function find(id) {
|
|
753
|
+
if (!parent.has(id)) parent.set(id, id);
|
|
754
|
+
if (parent.get(id) !== id) {
|
|
755
|
+
parent.set(id, find(parent.get(id)));
|
|
756
|
+
}
|
|
757
|
+
return parent.get(id);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function union(a, b) {
|
|
761
|
+
const rootA = find(a);
|
|
762
|
+
const rootB = find(b);
|
|
763
|
+
if (rootA !== rootB) {
|
|
764
|
+
parent.set(rootA, rootB);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Connect items that form a stack
|
|
769
|
+
for (const item of repoItems) {
|
|
770
|
+
const baseMatch = headToItem.get(item._baseRefName);
|
|
771
|
+
if (baseMatch && baseMatch.id !== item.id) {
|
|
772
|
+
union(item.id, baseMatch.id);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Group items by their root to find stack members
|
|
777
|
+
const groups = new Map(); // root -> [itemIds]
|
|
778
|
+
for (const item of repoItems) {
|
|
779
|
+
if (!parent.has(item.id)) continue;
|
|
780
|
+
const root = find(item.id);
|
|
781
|
+
if (!groups.has(root)) {
|
|
782
|
+
groups.set(root, []);
|
|
783
|
+
}
|
|
784
|
+
groups.get(root).push(item.id);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Build sibling map for groups with 2+ members
|
|
788
|
+
for (const [, members] of groups) {
|
|
789
|
+
if (members.length < 2) continue;
|
|
790
|
+
for (const id of members) {
|
|
791
|
+
stacks.set(id, members.filter(m => m !== id));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return stacks;
|
|
797
|
+
}
|
|
798
|
+
|
|
638
799
|
/**
|
|
639
800
|
* Compute attention label from enriched item conditions
|
|
640
801
|
*
|
|
@@ -1090,6 +1251,18 @@ export function createPoller(options = {}) {
|
|
|
1090
1251
|
}
|
|
1091
1252
|
}
|
|
1092
1253
|
}
|
|
1254
|
+
|
|
1255
|
+
// Handle attention field (detect new feedback on PRs)
|
|
1256
|
+
// Only reprocess when attention changes from false to true
|
|
1257
|
+
if (field === 'attention') {
|
|
1258
|
+
const storedHasAttention = meta.hasAttention;
|
|
1259
|
+
const currentHasAttention = item._has_attention;
|
|
1260
|
+
|
|
1261
|
+
// Only trigger if we have stored state (not legacy items) and attention changed false -> true
|
|
1262
|
+
if (storedHasAttention === false && currentHasAttention === true) {
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1093
1266
|
}
|
|
1094
1267
|
|
|
1095
1268
|
return false;
|
|
@@ -39,6 +39,8 @@ review-requests:
|
|
|
39
39
|
worktree_name: "pr-{number}"
|
|
40
40
|
session:
|
|
41
41
|
name: "Review: {title}"
|
|
42
|
+
# Detect stacked PRs and reuse sessions across the stack
|
|
43
|
+
detect_stacks: true
|
|
42
44
|
|
|
43
45
|
my-prs-attention:
|
|
44
46
|
name: my-prs-attention
|
|
@@ -56,10 +58,13 @@ my-prs-attention:
|
|
|
56
58
|
# Enrich with both mergeable status and comments to detect all attention conditions
|
|
57
59
|
enrich_mergeable: true
|
|
58
60
|
filter_bot_comments: true
|
|
61
|
+
# Detect stacked PRs and reuse sessions across the stack
|
|
62
|
+
detect_stacks: true
|
|
59
63
|
readiness:
|
|
60
64
|
# Require at least one attention condition (conflicts or human feedback)
|
|
61
65
|
require_attention: true
|
|
62
|
-
#
|
|
66
|
+
# Reprocess when state changes (reopened) or new feedback received
|
|
63
67
|
# Note: updatedAt is NOT included - CI status changes would trigger reprocessing
|
|
64
68
|
reprocess_on:
|
|
65
69
|
- state
|
|
70
|
+
- attention
|
|
@@ -733,3 +733,177 @@ describe("integration: cross-source deduplication", () => {
|
|
|
733
733
|
}
|
|
734
734
|
});
|
|
735
735
|
});
|
|
736
|
+
|
|
737
|
+
describe("integration: stacked PR session reuse", () => {
|
|
738
|
+
let mockServer;
|
|
739
|
+
|
|
740
|
+
afterEach(async () => {
|
|
741
|
+
if (mockServer) {
|
|
742
|
+
await mockServer.close();
|
|
743
|
+
mockServer = null;
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("reuses stack sibling's session when reuse_stack_session is set", async () => {
|
|
748
|
+
let sessionCreated = false;
|
|
749
|
+
let messageSessionId = null;
|
|
750
|
+
let messageTitleUpdated = null;
|
|
751
|
+
|
|
752
|
+
mockServer = await createMockServer({
|
|
753
|
+
"GET /session": () => ({
|
|
754
|
+
// Return the stack sibling's session
|
|
755
|
+
body: [{ id: "ses_stack_sibling", directory: "/wt/pr-101", time: { created: 1000, updated: 2000 } }],
|
|
756
|
+
}),
|
|
757
|
+
"GET /session/status": () => ({ body: {} }),
|
|
758
|
+
"POST /session": () => {
|
|
759
|
+
sessionCreated = true;
|
|
760
|
+
return { body: { id: "ses_new" } };
|
|
761
|
+
},
|
|
762
|
+
"PATCH /session/ses_stack_sibling": (req) => {
|
|
763
|
+
messageTitleUpdated = req.body?.title;
|
|
764
|
+
return { body: {} };
|
|
765
|
+
},
|
|
766
|
+
"POST /session/ses_stack_sibling/message": (req) => {
|
|
767
|
+
messageSessionId = "ses_stack_sibling";
|
|
768
|
+
return { body: { success: true } };
|
|
769
|
+
},
|
|
770
|
+
"GET /project/current": () => ({
|
|
771
|
+
body: { id: "proj_1", worktree: "/proj", time: { created: 1000, updated: 2000 }, sandboxes: [] },
|
|
772
|
+
}),
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
const result = await executeAction(
|
|
776
|
+
{ number: 102, title: "Part 2 of feature" },
|
|
777
|
+
{
|
|
778
|
+
path: "/proj",
|
|
779
|
+
prompt: "default",
|
|
780
|
+
// These are set by poll-service when a stack sibling is found
|
|
781
|
+
existing_directory: "/wt/pr-101",
|
|
782
|
+
reuse_stack_session: "ses_stack_sibling",
|
|
783
|
+
},
|
|
784
|
+
{ discoverServer: async () => mockServer.url }
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
assert.ok(result.success, "Action should succeed");
|
|
788
|
+
assert.strictEqual(result.sessionReused, true, "Should indicate session was reused");
|
|
789
|
+
assert.strictEqual(sessionCreated, false, "Should NOT create new session");
|
|
790
|
+
assert.strictEqual(messageSessionId, "ses_stack_sibling", "Should post to stack sibling's session");
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("falls back to normal flow when stack session is gone", async () => {
|
|
794
|
+
let sessionCreated = false;
|
|
795
|
+
let newSessionMessageId = null;
|
|
796
|
+
|
|
797
|
+
mockServer = await createMockServer({
|
|
798
|
+
"GET /session": () => ({
|
|
799
|
+
// No sessions exist (sibling's session was archived/gone)
|
|
800
|
+
body: [],
|
|
801
|
+
}),
|
|
802
|
+
"GET /session/status": () => ({ body: {} }),
|
|
803
|
+
// Stack session reuse will try this and fail
|
|
804
|
+
"PATCH /session/ses_gone": () => ({
|
|
805
|
+
status: 404,
|
|
806
|
+
body: { error: "Session not found" },
|
|
807
|
+
}),
|
|
808
|
+
"POST /session/ses_gone/message": () => ({
|
|
809
|
+
status: 404,
|
|
810
|
+
body: { error: "Session not found" },
|
|
811
|
+
}),
|
|
812
|
+
// Falls through to creating a new session
|
|
813
|
+
"POST /session": () => {
|
|
814
|
+
sessionCreated = true;
|
|
815
|
+
return { body: { id: "ses_new_fallback" } };
|
|
816
|
+
},
|
|
817
|
+
"PATCH /session/ses_new_fallback": () => ({ body: {} }),
|
|
818
|
+
"POST /session/ses_new_fallback/message": (req) => {
|
|
819
|
+
newSessionMessageId = "ses_new_fallback";
|
|
820
|
+
return { body: { success: true } };
|
|
821
|
+
},
|
|
822
|
+
"GET /project/current": () => ({
|
|
823
|
+
body: { id: "proj_1", worktree: "/proj", time: { created: 1000, updated: 2000 }, sandboxes: [] },
|
|
824
|
+
}),
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
const result = await executeAction(
|
|
828
|
+
{ number: 102, title: "Part 2 of feature" },
|
|
829
|
+
{
|
|
830
|
+
path: "/proj",
|
|
831
|
+
prompt: "default",
|
|
832
|
+
existing_directory: "/wt/pr-101",
|
|
833
|
+
reuse_stack_session: "ses_gone",
|
|
834
|
+
},
|
|
835
|
+
{ discoverServer: async () => mockServer.url }
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
assert.ok(result.success, "Action should succeed via fallback");
|
|
839
|
+
assert.ok(sessionCreated, "Should create new session when stack session is gone");
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("detectStacks + poller metadata enables stack reuse across poll cycles", async () => {
|
|
843
|
+
// This test verifies the full flow:
|
|
844
|
+
// 1. PR #101 was processed in a previous poll cycle (has sessionId + directory in metadata)
|
|
845
|
+
// 2. PR #102 is in the same stack (detected via detectStacks)
|
|
846
|
+
// 3. poll-service should set reuse_stack_session from sibling metadata
|
|
847
|
+
|
|
848
|
+
const { createPoller, detectStacks } = await import("../../service/poller.js");
|
|
849
|
+
const { mkdtempSync, rmSync: rmSyncFs } = await import("fs");
|
|
850
|
+
const { join } = await import("path");
|
|
851
|
+
const { tmpdir } = await import("os");
|
|
852
|
+
|
|
853
|
+
const tempDir = mkdtempSync(join(tmpdir(), "stack-reuse-test-"));
|
|
854
|
+
const stateFile = join(tempDir, "poll-state.json");
|
|
855
|
+
|
|
856
|
+
try {
|
|
857
|
+
const poller = createPoller({ stateFile });
|
|
858
|
+
|
|
859
|
+
// Simulate: PR #101 was processed in a previous poll cycle
|
|
860
|
+
poller.markProcessed("https://github.com/myorg/app/pull/101", {
|
|
861
|
+
source: "review-requests",
|
|
862
|
+
directory: "/wt/pr-101",
|
|
863
|
+
sessionId: "ses_pr101",
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// Current poll returns both PRs with branch refs
|
|
867
|
+
const items = [
|
|
868
|
+
{
|
|
869
|
+
id: "https://github.com/myorg/app/pull/101",
|
|
870
|
+
number: 101,
|
|
871
|
+
repository_full_name: "myorg/app",
|
|
872
|
+
_baseRefName: "main",
|
|
873
|
+
_headRefName: "feature-part-1",
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
id: "https://github.com/myorg/app/pull/102",
|
|
877
|
+
number: 102,
|
|
878
|
+
repository_full_name: "myorg/app",
|
|
879
|
+
_baseRefName: "feature-part-1",
|
|
880
|
+
_headRefName: "feature-part-2",
|
|
881
|
+
},
|
|
882
|
+
];
|
|
883
|
+
|
|
884
|
+
// Detect stacks
|
|
885
|
+
const stackMap = detectStacks(items);
|
|
886
|
+
|
|
887
|
+
assert.ok(stackMap.has(items[1].id), "PR #102 should be in a stack");
|
|
888
|
+
|
|
889
|
+
// Simulate what poll-service does: look up sibling metadata
|
|
890
|
+
const siblings = stackMap.get(items[1].id);
|
|
891
|
+
let foundSessionId = null;
|
|
892
|
+
let foundDirectory = null;
|
|
893
|
+
|
|
894
|
+
for (const siblingId of siblings) {
|
|
895
|
+
const meta = poller.getProcessedMeta(siblingId);
|
|
896
|
+
if (meta?.sessionId && meta?.directory) {
|
|
897
|
+
foundSessionId = meta.sessionId;
|
|
898
|
+
foundDirectory = meta.directory;
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
assert.strictEqual(foundSessionId, "ses_pr101", "Should find PR #101's session ID");
|
|
904
|
+
assert.strictEqual(foundDirectory, "/wt/pr-101", "Should find PR #101's directory");
|
|
905
|
+
} finally {
|
|
906
|
+
rmSyncFs(tempDir, { recursive: true, force: true });
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
});
|
package/test/unit/poller.test.js
CHANGED
|
@@ -792,6 +792,126 @@ describe('poller.js', () => {
|
|
|
792
792
|
// Poll 3: item-2 reappears - wasUnseen flag should still be true until cleared
|
|
793
793
|
// (The flag gets cleared when shouldReprocess triggers reprocessing)
|
|
794
794
|
});
|
|
795
|
+
|
|
796
|
+
test('shouldReprocess returns true when attention changes from false to true', async () => {
|
|
797
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
798
|
+
|
|
799
|
+
const poller = createPoller({ stateFile });
|
|
800
|
+
// Item was processed without attention (no feedback)
|
|
801
|
+
poller.markProcessed('pr-1', {
|
|
802
|
+
source: 'my-prs-attention',
|
|
803
|
+
itemState: 'open',
|
|
804
|
+
hasAttention: false
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Item now has attention (received feedback)
|
|
808
|
+
const item = { id: 'pr-1', state: 'open', _has_attention: true };
|
|
809
|
+
assert.strictEqual(
|
|
810
|
+
poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
|
|
811
|
+
true,
|
|
812
|
+
'Should reprocess when attention changes false -> true'
|
|
813
|
+
);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test('shouldReprocess returns false when attention stays true', async () => {
|
|
817
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
818
|
+
|
|
819
|
+
const poller = createPoller({ stateFile });
|
|
820
|
+
// Item was processed with attention
|
|
821
|
+
poller.markProcessed('pr-1', {
|
|
822
|
+
source: 'my-prs-attention',
|
|
823
|
+
itemState: 'open',
|
|
824
|
+
hasAttention: true
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// Item still has attention
|
|
828
|
+
const item = { id: 'pr-1', state: 'open', _has_attention: true };
|
|
829
|
+
assert.strictEqual(
|
|
830
|
+
poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
|
|
831
|
+
false,
|
|
832
|
+
'Should NOT reprocess when attention stays true'
|
|
833
|
+
);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test('shouldReprocess returns false when attention stays false', async () => {
|
|
837
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
838
|
+
|
|
839
|
+
const poller = createPoller({ stateFile });
|
|
840
|
+
// Item was processed without attention
|
|
841
|
+
poller.markProcessed('pr-1', {
|
|
842
|
+
source: 'my-prs-attention',
|
|
843
|
+
itemState: 'open',
|
|
844
|
+
hasAttention: false
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
// Item still has no attention
|
|
848
|
+
const item = { id: 'pr-1', state: 'open', _has_attention: false };
|
|
849
|
+
assert.strictEqual(
|
|
850
|
+
poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
|
|
851
|
+
false,
|
|
852
|
+
'Should NOT reprocess when attention stays false'
|
|
853
|
+
);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test('shouldReprocess returns false when attention changes from true to false', async () => {
|
|
857
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
858
|
+
|
|
859
|
+
const poller = createPoller({ stateFile });
|
|
860
|
+
// Item was processed with attention
|
|
861
|
+
poller.markProcessed('pr-1', {
|
|
862
|
+
source: 'my-prs-attention',
|
|
863
|
+
itemState: 'open',
|
|
864
|
+
hasAttention: true
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// Attention was addressed (no longer needs attention)
|
|
868
|
+
const item = { id: 'pr-1', state: 'open', _has_attention: false };
|
|
869
|
+
assert.strictEqual(
|
|
870
|
+
poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
|
|
871
|
+
false,
|
|
872
|
+
'Should NOT reprocess when attention changes true -> false'
|
|
873
|
+
);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test('shouldReprocess handles attention with no stored hasAttention (legacy)', async () => {
|
|
877
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
878
|
+
|
|
879
|
+
const poller = createPoller({ stateFile });
|
|
880
|
+
// Legacy item without hasAttention stored
|
|
881
|
+
poller.markProcessed('pr-1', {
|
|
882
|
+
source: 'my-prs-attention',
|
|
883
|
+
itemState: 'open'
|
|
884
|
+
// Note: no hasAttention
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// Item now has attention
|
|
888
|
+
const item = { id: 'pr-1', state: 'open', _has_attention: true };
|
|
889
|
+
// Should NOT reprocess - we don't know previous state, assume it was handled
|
|
890
|
+
assert.strictEqual(
|
|
891
|
+
poller.shouldReprocess(item, { reprocessOn: ['attention'] }),
|
|
892
|
+
false,
|
|
893
|
+
'Should NOT reprocess legacy items without stored hasAttention'
|
|
894
|
+
);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test('shouldReprocess handles attention combined with state changes', async () => {
|
|
898
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
899
|
+
|
|
900
|
+
const poller = createPoller({ stateFile });
|
|
901
|
+
poller.markProcessed('pr-1', {
|
|
902
|
+
source: 'my-prs-attention',
|
|
903
|
+
itemState: 'closed', // Was closed
|
|
904
|
+
hasAttention: false
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// Reopened but no attention - state change should trigger
|
|
908
|
+
const item = { id: 'pr-1', state: 'open', _has_attention: false };
|
|
909
|
+
assert.strictEqual(
|
|
910
|
+
poller.shouldReprocess(item, { reprocessOn: ['state', 'attention'] }),
|
|
911
|
+
true,
|
|
912
|
+
'Should reprocess when state changes even if attention unchanged'
|
|
913
|
+
);
|
|
914
|
+
});
|
|
795
915
|
});
|
|
796
916
|
|
|
797
917
|
describe('pollGenericSource', () => {
|
|
@@ -1438,4 +1558,261 @@ describe('poller.js', () => {
|
|
|
1438
1558
|
assert.deepStrictEqual(result, []);
|
|
1439
1559
|
});
|
|
1440
1560
|
});
|
|
1561
|
+
|
|
1562
|
+
describe('detectStacks', () => {
|
|
1563
|
+
test('detects a simple 2-PR stack', async () => {
|
|
1564
|
+
const { detectStacks } = await import('../../service/poller.js');
|
|
1565
|
+
|
|
1566
|
+
const items = [
|
|
1567
|
+
{
|
|
1568
|
+
id: 'https://github.com/myorg/app/pull/101',
|
|
1569
|
+
number: 101,
|
|
1570
|
+
repository_full_name: 'myorg/app',
|
|
1571
|
+
_baseRefName: 'main',
|
|
1572
|
+
_headRefName: 'feature-part-1',
|
|
1573
|
+
},
|
|
1574
|
+
{
|
|
1575
|
+
id: 'https://github.com/myorg/app/pull/102',
|
|
1576
|
+
number: 102,
|
|
1577
|
+
repository_full_name: 'myorg/app',
|
|
1578
|
+
_baseRefName: 'feature-part-1',
|
|
1579
|
+
_headRefName: 'feature-part-2',
|
|
1580
|
+
},
|
|
1581
|
+
];
|
|
1582
|
+
|
|
1583
|
+
const stacks = detectStacks(items);
|
|
1584
|
+
|
|
1585
|
+
// Both PRs should be in the map as siblings of each other
|
|
1586
|
+
assert.ok(stacks.has(items[0].id), 'PR #101 should be in stacks map');
|
|
1587
|
+
assert.ok(stacks.has(items[1].id), 'PR #102 should be in stacks map');
|
|
1588
|
+
assert.deepStrictEqual(stacks.get(items[0].id), [items[1].id]);
|
|
1589
|
+
assert.deepStrictEqual(stacks.get(items[1].id), [items[0].id]);
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
test('detects a 3-PR chain', async () => {
|
|
1593
|
+
const { detectStacks } = await import('../../service/poller.js');
|
|
1594
|
+
|
|
1595
|
+
const items = [
|
|
1596
|
+
{
|
|
1597
|
+
id: 'https://github.com/myorg/app/pull/101',
|
|
1598
|
+
number: 101,
|
|
1599
|
+
repository_full_name: 'myorg/app',
|
|
1600
|
+
_baseRefName: 'main',
|
|
1601
|
+
_headRefName: 'feature-part-1',
|
|
1602
|
+
},
|
|
1603
|
+
{
|
|
1604
|
+
id: 'https://github.com/myorg/app/pull/102',
|
|
1605
|
+
number: 102,
|
|
1606
|
+
repository_full_name: 'myorg/app',
|
|
1607
|
+
_baseRefName: 'feature-part-1',
|
|
1608
|
+
_headRefName: 'feature-part-2',
|
|
1609
|
+
},
|
|
1610
|
+
{
|
|
1611
|
+
id: 'https://github.com/myorg/app/pull/103',
|
|
1612
|
+
number: 103,
|
|
1613
|
+
repository_full_name: 'myorg/app',
|
|
1614
|
+
_baseRefName: 'feature-part-2',
|
|
1615
|
+
_headRefName: 'feature-part-3',
|
|
1616
|
+
},
|
|
1617
|
+
];
|
|
1618
|
+
|
|
1619
|
+
const stacks = detectStacks(items);
|
|
1620
|
+
|
|
1621
|
+
// All three should be siblings of each other
|
|
1622
|
+
assert.ok(stacks.has(items[0].id));
|
|
1623
|
+
assert.ok(stacks.has(items[1].id));
|
|
1624
|
+
assert.ok(stacks.has(items[2].id));
|
|
1625
|
+
|
|
1626
|
+
// PR #101 should have #102 and #103 as siblings
|
|
1627
|
+
const siblings101 = stacks.get(items[0].id);
|
|
1628
|
+
assert.ok(siblings101.includes(items[1].id));
|
|
1629
|
+
assert.ok(siblings101.includes(items[2].id));
|
|
1630
|
+
assert.strictEqual(siblings101.length, 2);
|
|
1631
|
+
|
|
1632
|
+
// PR #102 should have #101 and #103 as siblings
|
|
1633
|
+
const siblings102 = stacks.get(items[1].id);
|
|
1634
|
+
assert.ok(siblings102.includes(items[0].id));
|
|
1635
|
+
assert.ok(siblings102.includes(items[2].id));
|
|
1636
|
+
assert.strictEqual(siblings102.length, 2);
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
test('returns empty map when no stacks exist', async () => {
|
|
1640
|
+
const { detectStacks } = await import('../../service/poller.js');
|
|
1641
|
+
|
|
1642
|
+
const items = [
|
|
1643
|
+
{
|
|
1644
|
+
id: 'https://github.com/myorg/app/pull/101',
|
|
1645
|
+
number: 101,
|
|
1646
|
+
repository_full_name: 'myorg/app',
|
|
1647
|
+
_baseRefName: 'main',
|
|
1648
|
+
_headRefName: 'feature-a',
|
|
1649
|
+
},
|
|
1650
|
+
{
|
|
1651
|
+
id: 'https://github.com/myorg/app/pull/102',
|
|
1652
|
+
number: 102,
|
|
1653
|
+
repository_full_name: 'myorg/app',
|
|
1654
|
+
_baseRefName: 'main',
|
|
1655
|
+
_headRefName: 'feature-b',
|
|
1656
|
+
},
|
|
1657
|
+
];
|
|
1658
|
+
|
|
1659
|
+
const stacks = detectStacks(items);
|
|
1660
|
+
|
|
1661
|
+
assert.strictEqual(stacks.size, 0, 'No stacks should be detected when all PRs are based on main');
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
test('handles PRs from different repos independently', async () => {
|
|
1665
|
+
const { detectStacks } = await import('../../service/poller.js');
|
|
1666
|
+
|
|
1667
|
+
const items = [
|
|
1668
|
+
{
|
|
1669
|
+
id: 'https://github.com/myorg/app-a/pull/1',
|
|
1670
|
+
number: 1,
|
|
1671
|
+
repository_full_name: 'myorg/app-a',
|
|
1672
|
+
_baseRefName: 'main',
|
|
1673
|
+
_headRefName: 'feature-x',
|
|
1674
|
+
},
|
|
1675
|
+
{
|
|
1676
|
+
id: 'https://github.com/myorg/app-b/pull/2',
|
|
1677
|
+
number: 2,
|
|
1678
|
+
repository_full_name: 'myorg/app-b',
|
|
1679
|
+
_baseRefName: 'feature-x',
|
|
1680
|
+
_headRefName: 'feature-y',
|
|
1681
|
+
},
|
|
1682
|
+
];
|
|
1683
|
+
|
|
1684
|
+
const stacks = detectStacks(items);
|
|
1685
|
+
|
|
1686
|
+
// Even though app-b PR #2's base matches app-a PR #1's head,
|
|
1687
|
+
// they're in different repos so should NOT be stacked
|
|
1688
|
+
assert.strictEqual(stacks.size, 0, 'Should not match branches across different repos');
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
test('handles items missing branch refs gracefully', async () => {
|
|
1692
|
+
const { detectStacks } = await import('../../service/poller.js');
|
|
1693
|
+
|
|
1694
|
+
const items = [
|
|
1695
|
+
{
|
|
1696
|
+
id: 'https://github.com/myorg/app/pull/101',
|
|
1697
|
+
number: 101,
|
|
1698
|
+
repository_full_name: 'myorg/app',
|
|
1699
|
+
_baseRefName: 'main',
|
|
1700
|
+
_headRefName: 'feature-part-1',
|
|
1701
|
+
},
|
|
1702
|
+
{
|
|
1703
|
+
id: 'https://github.com/myorg/app/pull/102',
|
|
1704
|
+
number: 102,
|
|
1705
|
+
repository_full_name: 'myorg/app',
|
|
1706
|
+
// Missing _baseRefName and _headRefName (enrichment failed)
|
|
1707
|
+
},
|
|
1708
|
+
];
|
|
1709
|
+
|
|
1710
|
+
const stacks = detectStacks(items);
|
|
1711
|
+
|
|
1712
|
+
assert.strictEqual(stacks.size, 0, 'Should not crash on items without branch refs');
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
test('handles single item gracefully', async () => {
|
|
1716
|
+
const { detectStacks } = await import('../../service/poller.js');
|
|
1717
|
+
|
|
1718
|
+
const items = [
|
|
1719
|
+
{
|
|
1720
|
+
id: 'https://github.com/myorg/app/pull/101',
|
|
1721
|
+
number: 101,
|
|
1722
|
+
repository_full_name: 'myorg/app',
|
|
1723
|
+
_baseRefName: 'main',
|
|
1724
|
+
_headRefName: 'feature-1',
|
|
1725
|
+
},
|
|
1726
|
+
];
|
|
1727
|
+
|
|
1728
|
+
const stacks = detectStacks(items);
|
|
1729
|
+
|
|
1730
|
+
assert.strictEqual(stacks.size, 0, 'Single item cannot form a stack');
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
test('handles empty items array', async () => {
|
|
1734
|
+
const { detectStacks } = await import('../../service/poller.js');
|
|
1735
|
+
|
|
1736
|
+
const stacks = detectStacks([]);
|
|
1737
|
+
|
|
1738
|
+
assert.strictEqual(stacks.size, 0);
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
test('uses repository.nameWithOwner as fallback for repo grouping', async () => {
|
|
1742
|
+
const { detectStacks } = await import('../../service/poller.js');
|
|
1743
|
+
|
|
1744
|
+
const items = [
|
|
1745
|
+
{
|
|
1746
|
+
id: 'https://github.com/myorg/app/pull/101',
|
|
1747
|
+
number: 101,
|
|
1748
|
+
repository: { nameWithOwner: 'myorg/app' },
|
|
1749
|
+
_baseRefName: 'main',
|
|
1750
|
+
_headRefName: 'feature-part-1',
|
|
1751
|
+
},
|
|
1752
|
+
{
|
|
1753
|
+
id: 'https://github.com/myorg/app/pull/102',
|
|
1754
|
+
number: 102,
|
|
1755
|
+
repository: { nameWithOwner: 'myorg/app' },
|
|
1756
|
+
_baseRefName: 'feature-part-1',
|
|
1757
|
+
_headRefName: 'feature-part-2',
|
|
1758
|
+
},
|
|
1759
|
+
];
|
|
1760
|
+
|
|
1761
|
+
const stacks = detectStacks(items);
|
|
1762
|
+
|
|
1763
|
+
assert.ok(stacks.has(items[0].id));
|
|
1764
|
+
assert.ok(stacks.has(items[1].id));
|
|
1765
|
+
});
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
describe('enrichItemsWithBranchRefs', () => {
|
|
1769
|
+
test('skips enrichment when detect_stacks is not set', async () => {
|
|
1770
|
+
const { enrichItemsWithBranchRefs } = await import('../../service/poller.js');
|
|
1771
|
+
|
|
1772
|
+
const items = [{ number: 1, repository_full_name: 'org/repo' }];
|
|
1773
|
+
const source = { tool: { command: ['gh', 'search', 'prs'] } }; // no detect_stacks
|
|
1774
|
+
|
|
1775
|
+
const result = await enrichItemsWithBranchRefs(items, source);
|
|
1776
|
+
|
|
1777
|
+
assert.strictEqual(result.length, 1);
|
|
1778
|
+
assert.strictEqual(result[0]._headRefName, undefined);
|
|
1779
|
+
assert.strictEqual(result[0]._baseRefName, undefined);
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
test('skips enrichment for non-GitHub sources', async () => {
|
|
1783
|
+
const { enrichItemsWithBranchRefs } = await import('../../service/poller.js');
|
|
1784
|
+
|
|
1785
|
+
const items = [{ number: 1, repository_full_name: 'org/repo' }];
|
|
1786
|
+
const source = {
|
|
1787
|
+
detect_stacks: true,
|
|
1788
|
+
tool: { mcp: 'linear', name: 'list_issues' }
|
|
1789
|
+
};
|
|
1790
|
+
|
|
1791
|
+
const result = await enrichItemsWithBranchRefs(items, source);
|
|
1792
|
+
|
|
1793
|
+
assert.strictEqual(result.length, 1);
|
|
1794
|
+
assert.strictEqual(result[0]._headRefName, undefined);
|
|
1795
|
+
assert.strictEqual(result[0]._baseRefName, undefined);
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
test('skips items without repository info', async () => {
|
|
1799
|
+
const { enrichItemsWithBranchRefs } = await import('../../service/poller.js');
|
|
1800
|
+
|
|
1801
|
+
const items = [
|
|
1802
|
+
{ number: 1 }, // no repository_full_name
|
|
1803
|
+
{ repository_full_name: 'org/repo' } // no number
|
|
1804
|
+
];
|
|
1805
|
+
const source = {
|
|
1806
|
+
detect_stacks: true,
|
|
1807
|
+
tool: { command: ['gh', 'search', 'prs'] }
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
const result = await enrichItemsWithBranchRefs(items, source);
|
|
1811
|
+
|
|
1812
|
+
// Items returned unchanged (no API calls for invalid items)
|
|
1813
|
+
assert.strictEqual(result.length, 2);
|
|
1814
|
+
assert.strictEqual(result[0]._headRefName, undefined);
|
|
1815
|
+
assert.strictEqual(result[1]._headRefName, undefined);
|
|
1816
|
+
});
|
|
1817
|
+
});
|
|
1441
1818
|
});
|