opencode-pilot 0.17.1 → 0.18.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/README.md +4 -1
- package/examples/config.yaml +7 -2
- package/package.json +1 -1
- package/service/poll-service.js +19 -2
- package/service/poller.js +123 -2
- package/service/presets/github.yaml +24 -1
- package/service/readiness.js +91 -0
- package/test/unit/poller.test.js +132 -0
- package/test/unit/readiness.test.js +289 -0
- package/test/unit/repo-config.test.js +27 -0
package/README.md
CHANGED
|
@@ -65,9 +65,12 @@ Three ways to configure sources, from simplest to most flexible:
|
|
|
65
65
|
|
|
66
66
|
- `github/my-issues` - Issues assigned to me
|
|
67
67
|
- `github/review-requests` - PRs needing my review
|
|
68
|
-
- `github/my-prs-feedback` - My PRs with
|
|
68
|
+
- `github/my-prs-feedback` - My PRs with comments/feedback (simple trigger)
|
|
69
|
+
- `github/my-prs-attention` - My PRs needing attention (conflicts OR feedback, with dynamic labeling)
|
|
69
70
|
- `linear/my-issues` - Linear tickets (requires `teamId`, `assigneeId`)
|
|
70
71
|
|
|
72
|
+
**Tip:** Use `my-prs-attention` instead of `my-prs-feedback` if you also want to detect merge conflicts. The session name will indicate which condition(s) triggered it: "Conflicts: {title}", "Feedback: {title}", or "Conflicts+Feedback: {title}".
|
|
73
|
+
|
|
71
74
|
### Prompt Templates
|
|
72
75
|
|
|
73
76
|
Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
|
package/examples/config.yaml
CHANGED
|
@@ -27,11 +27,16 @@ sources:
|
|
|
27
27
|
|
|
28
28
|
- preset: github/review-requests
|
|
29
29
|
|
|
30
|
-
-
|
|
30
|
+
# PRs needing attention (conflicts OR feedback) - recommended over my-prs-feedback
|
|
31
|
+
# Session names dynamically indicate the condition: "Conflicts: ...", "Feedback: ...", or "Conflicts+Feedback: ..."
|
|
32
|
+
- preset: github/my-prs-attention
|
|
31
33
|
repos:
|
|
32
34
|
- myorg/backend
|
|
33
35
|
- myorg/frontend
|
|
34
36
|
|
|
37
|
+
# Alternative: Simple feedback-only trigger (use my-prs-attention for conflicts too)
|
|
38
|
+
# - preset: github/my-prs-feedback
|
|
39
|
+
|
|
35
40
|
# Linear (requires teamId and assigneeId)
|
|
36
41
|
- preset: linear/my-issues
|
|
37
42
|
args:
|
|
@@ -87,4 +92,4 @@ sources:
|
|
|
87
92
|
# ttl_days: 30
|
|
88
93
|
|
|
89
94
|
# Available presets: github/my-issues, github/review-requests,
|
|
90
|
-
# github/my-prs-feedback, linear/my-issues
|
|
95
|
+
# github/my-prs-feedback, github/my-prs-attention, linear/my-issues
|
package/package.json
CHANGED
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 } from "./poller.js";
|
|
13
|
+
import { createPoller, pollGenericSource, enrichItemsWithComments, enrichItemsWithMergeable, computeAttentionLabels } 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";
|
|
@@ -124,7 +124,12 @@ export async function pollOnce(options = {}) {
|
|
|
124
124
|
// Fetch items from source
|
|
125
125
|
if (!skipMcp) {
|
|
126
126
|
try {
|
|
127
|
-
|
|
127
|
+
// Get provider config - for MCP sources use source.tool.mcp, for CLI sources detect provider
|
|
128
|
+
let provider = source.tool.mcp;
|
|
129
|
+
if (!provider && Array.isArray(source.tool?.command) && source.tool.command[0] === 'gh') {
|
|
130
|
+
provider = 'github'; // CLI-based GitHub source
|
|
131
|
+
}
|
|
132
|
+
toolProviderConfig = getToolProviderConfig(provider);
|
|
128
133
|
items = await pollGenericSource(source, { toolProviderConfig });
|
|
129
134
|
debug(`Fetched ${items.length} items from ${sourceName}`);
|
|
130
135
|
|
|
@@ -133,6 +138,18 @@ export async function pollOnce(options = {}) {
|
|
|
133
138
|
items = await enrichItemsWithComments(items, source);
|
|
134
139
|
debug(`Enriched ${items.length} items with comments for bot filtering`);
|
|
135
140
|
}
|
|
141
|
+
|
|
142
|
+
// Enrich items with mergeable status for conflict detection if configured
|
|
143
|
+
if (source.enrich_mergeable) {
|
|
144
|
+
items = await enrichItemsWithMergeable(items, source);
|
|
145
|
+
debug(`Enriched ${items.length} items with mergeable status`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Compute attention labels if both enrichments are present (for my-prs-attention)
|
|
149
|
+
if (source.enrich_mergeable && source.filter_bot_comments) {
|
|
150
|
+
items = computeAttentionLabels(items, source);
|
|
151
|
+
debug(`Computed attention labels for ${items.length} items`);
|
|
152
|
+
}
|
|
136
153
|
} catch (err) {
|
|
137
154
|
console.error(`[poll] Error fetching from ${sourceName}: ${err.message}`);
|
|
138
155
|
continue;
|
package/service/poller.js
CHANGED
|
@@ -12,7 +12,7 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
|
12
12
|
import fs from "fs";
|
|
13
13
|
import path from "path";
|
|
14
14
|
import os from "os";
|
|
15
|
-
import { getNestedValue } from "./utils.js";
|
|
15
|
+
import { getNestedValue, hasNonBotFeedback } from "./utils.js";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Expand template string with item fields
|
|
@@ -501,6 +501,22 @@ export async function fetchGitHubComments(item, options = {}) {
|
|
|
501
501
|
}
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
/**
|
|
505
|
+
* Check if a source is a GitHub source (MCP or CLI-based)
|
|
506
|
+
* @param {object} source - Source configuration
|
|
507
|
+
* @returns {boolean} True if this is a GitHub source
|
|
508
|
+
*/
|
|
509
|
+
function isGitHubSource(source) {
|
|
510
|
+
// MCP-based GitHub source
|
|
511
|
+
if (source.tool?.mcp === "github") return true;
|
|
512
|
+
|
|
513
|
+
// CLI-based GitHub source (uses gh command)
|
|
514
|
+
const command = source.tool?.command;
|
|
515
|
+
if (Array.isArray(command) && command[0] === "gh") return true;
|
|
516
|
+
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
|
|
504
520
|
/**
|
|
505
521
|
* Enrich items with comments for bot filtering
|
|
506
522
|
*
|
|
@@ -514,7 +530,7 @@ export async function fetchGitHubComments(item, options = {}) {
|
|
|
514
530
|
*/
|
|
515
531
|
export async function enrichItemsWithComments(items, source, options = {}) {
|
|
516
532
|
// Skip if not configured or not a GitHub source
|
|
517
|
-
if (!source.filter_bot_comments || source
|
|
533
|
+
if (!source.filter_bot_comments || !isGitHubSource(source)) {
|
|
518
534
|
return items;
|
|
519
535
|
}
|
|
520
536
|
|
|
@@ -533,6 +549,111 @@ export async function enrichItemsWithComments(items, source, options = {}) {
|
|
|
533
549
|
return enrichedItems;
|
|
534
550
|
}
|
|
535
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Fetch mergeable status for a PR via gh CLI
|
|
554
|
+
*
|
|
555
|
+
* @param {string} owner - Repository owner
|
|
556
|
+
* @param {string} repo - Repository name
|
|
557
|
+
* @param {number} number - PR number
|
|
558
|
+
* @param {number} timeout - Timeout in ms
|
|
559
|
+
* @returns {Promise<string|null>} Mergeable status ("MERGEABLE", "CONFLICTING", "UNKNOWN") or null on error
|
|
560
|
+
*/
|
|
561
|
+
async function fetchMergeableStatus(owner, repo, number, timeout) {
|
|
562
|
+
const { exec } = await import('child_process');
|
|
563
|
+
const { promisify } = await import('util');
|
|
564
|
+
const execAsync = promisify(exec);
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
const { stdout } = await Promise.race([
|
|
568
|
+
execAsync(`gh pr view ${number} -R ${owner}/${repo} --json mergeable --jq .mergeable`),
|
|
569
|
+
createTimeout(timeout, "gh pr view"),
|
|
570
|
+
]);
|
|
571
|
+
|
|
572
|
+
const status = stdout.trim();
|
|
573
|
+
return status || null;
|
|
574
|
+
} catch (err) {
|
|
575
|
+
console.error(`[poller] Error fetching mergeable status for ${owner}/${repo}#${number}: ${err.message}`);
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Enrich items with mergeable status for conflict detection
|
|
582
|
+
*
|
|
583
|
+
* For items from sources with enrich_mergeable: true, fetches mergeable status
|
|
584
|
+
* via gh CLI and attaches it as _mergeable field for readiness evaluation.
|
|
585
|
+
*
|
|
586
|
+
* @param {Array} items - Items to enrich
|
|
587
|
+
* @param {object} source - Source configuration with optional enrich_mergeable
|
|
588
|
+
* @param {object} [options] - Options
|
|
589
|
+
* @param {number} [options.timeout] - Timeout in ms (default: 30000)
|
|
590
|
+
* @returns {Promise<Array>} Items with _mergeable field added
|
|
591
|
+
*/
|
|
592
|
+
export async function enrichItemsWithMergeable(items, source, options = {}) {
|
|
593
|
+
// Skip if not configured
|
|
594
|
+
if (!source.enrich_mergeable) {
|
|
595
|
+
return items;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
|
|
599
|
+
|
|
600
|
+
// Fetch mergeable status for each item
|
|
601
|
+
const enrichedItems = [];
|
|
602
|
+
for (const item of items) {
|
|
603
|
+
// Extract owner/repo from item
|
|
604
|
+
const fullName = item.repository_full_name || item.repository?.nameWithOwner;
|
|
605
|
+
if (!fullName || !item.number) {
|
|
606
|
+
enrichedItems.push(item);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const [owner, repo] = fullName.split("/");
|
|
611
|
+
const mergeable = await fetchMergeableStatus(owner, repo, item.number, timeout);
|
|
612
|
+
enrichedItems.push({ ...item, _mergeable: mergeable });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return enrichedItems;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Compute attention label from enriched item conditions
|
|
620
|
+
*
|
|
621
|
+
* Examines _mergeable and _comments fields to determine what needs attention.
|
|
622
|
+
* Sets _attention_label (for session name) and _has_attention (for readiness).
|
|
623
|
+
*
|
|
624
|
+
* @param {Array} items - Items enriched with _mergeable and/or _comments
|
|
625
|
+
* @param {object} source - Source configuration
|
|
626
|
+
* @returns {Array} Items with _attention_label and _has_attention added
|
|
627
|
+
*/
|
|
628
|
+
export function computeAttentionLabels(items, source) {
|
|
629
|
+
return items.map(item => {
|
|
630
|
+
const reasons = [];
|
|
631
|
+
|
|
632
|
+
// Check for merge conflicts
|
|
633
|
+
if (item._mergeable === 'CONFLICTING') {
|
|
634
|
+
reasons.push('Conflicts');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Check for human feedback using the shared hasNonBotFeedback utility
|
|
638
|
+
// This properly handles known bots like 'linear' that don't have [bot] suffix
|
|
639
|
+
if (item._comments && item._comments.length > 0) {
|
|
640
|
+
const authorUsername = item.user?.login || item.author?.login;
|
|
641
|
+
if (hasNonBotFeedback(item._comments, authorUsername)) {
|
|
642
|
+
reasons.push('Feedback');
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Build label: "Conflicts", "Feedback", or "Conflicts+Feedback"
|
|
647
|
+
const label = reasons.length > 0 ? reasons.join('+') : 'PR';
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
...item,
|
|
651
|
+
_attention_label: label,
|
|
652
|
+
_has_attention: reasons.length > 0,
|
|
653
|
+
};
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
536
657
|
/**
|
|
537
658
|
* Create a poller instance with state tracking
|
|
538
659
|
*
|
|
@@ -41,7 +41,7 @@ my-prs-feedback:
|
|
|
41
41
|
name: my-prs-feedback
|
|
42
42
|
tool:
|
|
43
43
|
# comments:>0 filter ensures only PRs with feedback are returned
|
|
44
|
-
command: ["gh", "search", "prs", "--author=@me", "--state=open", "comments:>0", "--json", "number,title,url,repository,state,body,updatedAt"]
|
|
44
|
+
command: ["gh", "search", "prs", "--author=@me", "--state=open", "comments:>0", "--json", "number,title,url,repository,state,body,updatedAt,commentsCount"]
|
|
45
45
|
item:
|
|
46
46
|
id: "{url}"
|
|
47
47
|
repo: "{repository.nameWithOwner}"
|
|
@@ -53,3 +53,26 @@ my-prs-feedback:
|
|
|
53
53
|
reprocess_on:
|
|
54
54
|
- state
|
|
55
55
|
- updatedAt
|
|
56
|
+
|
|
57
|
+
my-prs-attention:
|
|
58
|
+
name: my-prs-attention
|
|
59
|
+
tool:
|
|
60
|
+
# Get all open PRs authored by me - we filter by conditions after enrichment
|
|
61
|
+
command: ["gh", "search", "prs", "--author=@me", "--state=open", "--json", "number,title,url,repository,state,body,updatedAt,commentsCount"]
|
|
62
|
+
item:
|
|
63
|
+
id: "{url}"
|
|
64
|
+
repo: "{repository.nameWithOwner}"
|
|
65
|
+
prompt: review-feedback
|
|
66
|
+
session:
|
|
67
|
+
# Dynamic name showing which conditions triggered (set by enrichment)
|
|
68
|
+
name: "{_attention_label}: {title}"
|
|
69
|
+
# Enrich with both mergeable status and comments to detect all attention conditions
|
|
70
|
+
enrich_mergeable: true
|
|
71
|
+
filter_bot_comments: true
|
|
72
|
+
readiness:
|
|
73
|
+
# Require at least one attention condition (conflicts or human feedback)
|
|
74
|
+
require_attention: true
|
|
75
|
+
# Reprocess when PR is updated
|
|
76
|
+
reprocess_on:
|
|
77
|
+
- state
|
|
78
|
+
- updatedAt
|
package/service/readiness.js
CHANGED
|
@@ -213,6 +213,77 @@ export function checkBotComments(item, config) {
|
|
|
213
213
|
};
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Check if a PR has merge conflicts
|
|
218
|
+
*
|
|
219
|
+
* This check is only applied when the item has been enriched with `_mergeable`
|
|
220
|
+
* (the mergeable status from GitHub: "MERGEABLE", "CONFLICTING", or "UNKNOWN").
|
|
221
|
+
* Items without `_mergeable` are considered ready (check is skipped).
|
|
222
|
+
*
|
|
223
|
+
* Used by the my-prs-conflicts source to filter to only PRs with conflicts.
|
|
224
|
+
*
|
|
225
|
+
* @param {object} item - Item with optional _mergeable field
|
|
226
|
+
* @param {object} config - Repo config with optional readiness.require_conflicts
|
|
227
|
+
* @returns {object} { ready: boolean, reason?: string }
|
|
228
|
+
*/
|
|
229
|
+
export function checkMergeable(item, config) {
|
|
230
|
+
const readinessConfig = config.readiness || {};
|
|
231
|
+
|
|
232
|
+
// Skip check if no _mergeable field (item not enriched)
|
|
233
|
+
if (!item._mergeable) {
|
|
234
|
+
return { ready: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Check if we require conflicts (for conflict-detection sources)
|
|
238
|
+
if (readinessConfig.require_conflicts) {
|
|
239
|
+
if (item._mergeable === "CONFLICTING") {
|
|
240
|
+
return { ready: true };
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
ready: false,
|
|
244
|
+
reason: `PR is ${item._mergeable}, not CONFLICTING`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Default: allow any mergeable status
|
|
249
|
+
return { ready: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Check if a PR needs attention (has conflicts OR human feedback)
|
|
254
|
+
*
|
|
255
|
+
* This check uses the _has_attention field computed by computeAttentionLabels().
|
|
256
|
+
* Items without _has_attention are considered ready (check is skipped).
|
|
257
|
+
*
|
|
258
|
+
* Used by the my-prs-attention source to filter to PRs needing action.
|
|
259
|
+
*
|
|
260
|
+
* @param {object} item - Item with optional _has_attention field
|
|
261
|
+
* @param {object} config - Repo config with optional readiness.require_attention
|
|
262
|
+
* @returns {object} { ready: boolean, reason?: string }
|
|
263
|
+
*/
|
|
264
|
+
export function checkAttention(item, config) {
|
|
265
|
+
const readinessConfig = config.readiness || {};
|
|
266
|
+
|
|
267
|
+
// Skip check if require_attention not configured
|
|
268
|
+
if (!readinessConfig.require_attention) {
|
|
269
|
+
return { ready: true };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Skip check if _has_attention not computed (item not enriched)
|
|
273
|
+
if (item._has_attention === undefined) {
|
|
274
|
+
return { ready: true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (item._has_attention) {
|
|
278
|
+
return { ready: true };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
ready: false,
|
|
283
|
+
reason: "PR has no conflicts and no human feedback - no attention needed",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
216
287
|
/**
|
|
217
288
|
* Calculate priority score for an issue
|
|
218
289
|
* @param {object} issue - Issue with labels and created_at
|
|
@@ -286,6 +357,26 @@ export function evaluateReadiness(issue, config) {
|
|
|
286
357
|
};
|
|
287
358
|
}
|
|
288
359
|
|
|
360
|
+
// Check mergeable status (for PRs enriched with _mergeable)
|
|
361
|
+
const mergeableResult = checkMergeable(issue, config);
|
|
362
|
+
if (!mergeableResult.ready) {
|
|
363
|
+
return {
|
|
364
|
+
ready: false,
|
|
365
|
+
reason: mergeableResult.reason,
|
|
366
|
+
priority: 0,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Check attention status (for PRs needing conflicts OR feedback)
|
|
371
|
+
const attentionResult = checkAttention(issue, config);
|
|
372
|
+
if (!attentionResult.ready) {
|
|
373
|
+
return {
|
|
374
|
+
ready: false,
|
|
375
|
+
reason: attentionResult.reason,
|
|
376
|
+
priority: 0,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
289
380
|
// Check required field values
|
|
290
381
|
const fieldsResult = checkFields(issue, config);
|
|
291
382
|
if (!fieldsResult.ready) {
|
package/test/unit/poller.test.js
CHANGED
|
@@ -902,4 +902,136 @@ describe('poller.js', () => {
|
|
|
902
902
|
assert.strictEqual(transformed[0].title, 'First');
|
|
903
903
|
});
|
|
904
904
|
});
|
|
905
|
+
|
|
906
|
+
describe('computeAttentionLabels', () => {
|
|
907
|
+
test('labels PR with conflicts only', async () => {
|
|
908
|
+
const { computeAttentionLabels } = await import('../../service/poller.js');
|
|
909
|
+
|
|
910
|
+
const items = [{
|
|
911
|
+
number: 123,
|
|
912
|
+
title: 'Test PR',
|
|
913
|
+
_mergeable: 'CONFLICTING',
|
|
914
|
+
_comments: []
|
|
915
|
+
}];
|
|
916
|
+
|
|
917
|
+
const result = computeAttentionLabels(items, {});
|
|
918
|
+
|
|
919
|
+
assert.strictEqual(result[0]._attention_label, 'Conflicts');
|
|
920
|
+
assert.strictEqual(result[0]._has_attention, true);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
test('labels PR with human feedback only', async () => {
|
|
924
|
+
const { computeAttentionLabels } = await import('../../service/poller.js');
|
|
925
|
+
|
|
926
|
+
const items = [{
|
|
927
|
+
number: 123,
|
|
928
|
+
title: 'Test PR',
|
|
929
|
+
user: { login: 'author' },
|
|
930
|
+
_mergeable: 'MERGEABLE',
|
|
931
|
+
_comments: [
|
|
932
|
+
{ user: { login: 'reviewer', type: 'User' }, body: 'Please fix' }
|
|
933
|
+
]
|
|
934
|
+
}];
|
|
935
|
+
|
|
936
|
+
const result = computeAttentionLabels(items, {});
|
|
937
|
+
|
|
938
|
+
assert.strictEqual(result[0]._attention_label, 'Feedback');
|
|
939
|
+
assert.strictEqual(result[0]._has_attention, true);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test('labels PR with both conflicts and feedback', async () => {
|
|
943
|
+
const { computeAttentionLabels } = await import('../../service/poller.js');
|
|
944
|
+
|
|
945
|
+
const items = [{
|
|
946
|
+
number: 123,
|
|
947
|
+
title: 'Test PR',
|
|
948
|
+
user: { login: 'author' },
|
|
949
|
+
_mergeable: 'CONFLICTING',
|
|
950
|
+
_comments: [
|
|
951
|
+
{ user: { login: 'reviewer', type: 'User' }, body: 'Please fix' }
|
|
952
|
+
]
|
|
953
|
+
}];
|
|
954
|
+
|
|
955
|
+
const result = computeAttentionLabels(items, {});
|
|
956
|
+
|
|
957
|
+
assert.strictEqual(result[0]._attention_label, 'Conflicts+Feedback');
|
|
958
|
+
assert.strictEqual(result[0]._has_attention, true);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test('labels PR with no attention conditions', async () => {
|
|
962
|
+
const { computeAttentionLabels } = await import('../../service/poller.js');
|
|
963
|
+
|
|
964
|
+
const items = [{
|
|
965
|
+
number: 123,
|
|
966
|
+
title: 'Test PR',
|
|
967
|
+
user: { login: 'author' },
|
|
968
|
+
_mergeable: 'MERGEABLE',
|
|
969
|
+
_comments: []
|
|
970
|
+
}];
|
|
971
|
+
|
|
972
|
+
const result = computeAttentionLabels(items, {});
|
|
973
|
+
|
|
974
|
+
assert.strictEqual(result[0]._attention_label, 'PR');
|
|
975
|
+
assert.strictEqual(result[0]._has_attention, false);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test('ignores bot comments when computing feedback', async () => {
|
|
979
|
+
const { computeAttentionLabels } = await import('../../service/poller.js');
|
|
980
|
+
|
|
981
|
+
const items = [{
|
|
982
|
+
number: 123,
|
|
983
|
+
title: 'Test PR',
|
|
984
|
+
user: { login: 'author' },
|
|
985
|
+
_mergeable: 'MERGEABLE',
|
|
986
|
+
_comments: [
|
|
987
|
+
{ user: { login: 'github-actions[bot]', type: 'Bot' }, body: 'CI passed' },
|
|
988
|
+
{ user: { login: 'codecov[bot]', type: 'Bot' }, body: 'Coverage report' }
|
|
989
|
+
]
|
|
990
|
+
}];
|
|
991
|
+
|
|
992
|
+
const result = computeAttentionLabels(items, {});
|
|
993
|
+
|
|
994
|
+
assert.strictEqual(result[0]._attention_label, 'PR');
|
|
995
|
+
assert.strictEqual(result[0]._has_attention, false);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test('ignores author comments when computing feedback', async () => {
|
|
999
|
+
const { computeAttentionLabels } = await import('../../service/poller.js');
|
|
1000
|
+
|
|
1001
|
+
const items = [{
|
|
1002
|
+
number: 123,
|
|
1003
|
+
title: 'Test PR',
|
|
1004
|
+
user: { login: 'author' },
|
|
1005
|
+
_mergeable: 'MERGEABLE',
|
|
1006
|
+
_comments: [
|
|
1007
|
+
{ user: { login: 'author', type: 'User' }, body: 'Added screenshots' }
|
|
1008
|
+
]
|
|
1009
|
+
}];
|
|
1010
|
+
|
|
1011
|
+
const result = computeAttentionLabels(items, {});
|
|
1012
|
+
|
|
1013
|
+
assert.strictEqual(result[0]._attention_label, 'PR');
|
|
1014
|
+
assert.strictEqual(result[0]._has_attention, false);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
test('ignores known bots without [bot] suffix (e.g., linear)', async () => {
|
|
1018
|
+
const { computeAttentionLabels } = await import('../../service/poller.js');
|
|
1019
|
+
|
|
1020
|
+
const items = [{
|
|
1021
|
+
number: 123,
|
|
1022
|
+
title: 'Test PR',
|
|
1023
|
+
user: { login: 'author' },
|
|
1024
|
+
_mergeable: 'MERGEABLE',
|
|
1025
|
+
_comments: [
|
|
1026
|
+
// Linear bot posts linkback comments without [bot] suffix
|
|
1027
|
+
{ user: { login: 'linear', type: 'User' }, body: '<!-- linear-linkback -->' }
|
|
1028
|
+
]
|
|
1029
|
+
}];
|
|
1030
|
+
|
|
1031
|
+
const result = computeAttentionLabels(items, {});
|
|
1032
|
+
|
|
1033
|
+
assert.strictEqual(result[0]._attention_label, 'PR');
|
|
1034
|
+
assert.strictEqual(result[0]._has_attention, false);
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
905
1037
|
});
|
|
@@ -387,4 +387,293 @@ describe('readiness.js', () => {
|
|
|
387
387
|
assert.strictEqual(result.ready, true);
|
|
388
388
|
});
|
|
389
389
|
});
|
|
390
|
+
|
|
391
|
+
describe('checkMergeable', () => {
|
|
392
|
+
test('returns ready when no _mergeable field (skip check)', async () => {
|
|
393
|
+
const { checkMergeable } = await import('../../service/readiness.js');
|
|
394
|
+
|
|
395
|
+
const pr = {
|
|
396
|
+
number: 123,
|
|
397
|
+
title: 'Test PR'
|
|
398
|
+
};
|
|
399
|
+
const config = {};
|
|
400
|
+
|
|
401
|
+
const result = checkMergeable(pr, config);
|
|
402
|
+
|
|
403
|
+
assert.strictEqual(result.ready, true);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('returns ready when PR has conflicts and require_conflicts is true', async () => {
|
|
407
|
+
const { checkMergeable } = await import('../../service/readiness.js');
|
|
408
|
+
|
|
409
|
+
const pr = {
|
|
410
|
+
number: 123,
|
|
411
|
+
title: 'Test PR',
|
|
412
|
+
_mergeable: 'CONFLICTING'
|
|
413
|
+
};
|
|
414
|
+
const config = {
|
|
415
|
+
readiness: {
|
|
416
|
+
require_conflicts: true
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const result = checkMergeable(pr, config);
|
|
421
|
+
|
|
422
|
+
assert.strictEqual(result.ready, true);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('returns not ready when PR is mergeable and require_conflicts is true', async () => {
|
|
426
|
+
const { checkMergeable } = await import('../../service/readiness.js');
|
|
427
|
+
|
|
428
|
+
const pr = {
|
|
429
|
+
number: 123,
|
|
430
|
+
title: 'Test PR',
|
|
431
|
+
_mergeable: 'MERGEABLE'
|
|
432
|
+
};
|
|
433
|
+
const config = {
|
|
434
|
+
readiness: {
|
|
435
|
+
require_conflicts: true
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const result = checkMergeable(pr, config);
|
|
440
|
+
|
|
441
|
+
assert.strictEqual(result.ready, false);
|
|
442
|
+
assert.ok(result.reason.includes('MERGEABLE'));
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('returns not ready when PR status is UNKNOWN and require_conflicts is true', async () => {
|
|
446
|
+
const { checkMergeable } = await import('../../service/readiness.js');
|
|
447
|
+
|
|
448
|
+
const pr = {
|
|
449
|
+
number: 123,
|
|
450
|
+
title: 'Test PR',
|
|
451
|
+
_mergeable: 'UNKNOWN'
|
|
452
|
+
};
|
|
453
|
+
const config = {
|
|
454
|
+
readiness: {
|
|
455
|
+
require_conflicts: true
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const result = checkMergeable(pr, config);
|
|
460
|
+
|
|
461
|
+
assert.strictEqual(result.ready, false);
|
|
462
|
+
assert.ok(result.reason.includes('UNKNOWN'));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('returns ready for any mergeable status when require_conflicts is not set', async () => {
|
|
466
|
+
const { checkMergeable } = await import('../../service/readiness.js');
|
|
467
|
+
|
|
468
|
+
const pr = {
|
|
469
|
+
number: 123,
|
|
470
|
+
title: 'Test PR',
|
|
471
|
+
_mergeable: 'CONFLICTING'
|
|
472
|
+
};
|
|
473
|
+
const config = {};
|
|
474
|
+
|
|
475
|
+
const result = checkMergeable(pr, config);
|
|
476
|
+
|
|
477
|
+
assert.strictEqual(result.ready, true);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe('evaluateReadiness with mergeable', () => {
|
|
482
|
+
test('checks mergeable when _mergeable is present with require_conflicts', async () => {
|
|
483
|
+
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
484
|
+
|
|
485
|
+
const pr = {
|
|
486
|
+
number: 123,
|
|
487
|
+
title: 'Test PR',
|
|
488
|
+
_mergeable: 'MERGEABLE'
|
|
489
|
+
};
|
|
490
|
+
const config = {
|
|
491
|
+
readiness: {
|
|
492
|
+
require_conflicts: true
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const result = evaluateReadiness(pr, config);
|
|
497
|
+
|
|
498
|
+
assert.strictEqual(result.ready, false);
|
|
499
|
+
assert.ok(result.reason.includes('MERGEABLE'));
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('passes when PR has conflicts and require_conflicts is configured', async () => {
|
|
503
|
+
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
504
|
+
|
|
505
|
+
const pr = {
|
|
506
|
+
number: 123,
|
|
507
|
+
title: 'Test PR',
|
|
508
|
+
_mergeable: 'CONFLICTING'
|
|
509
|
+
};
|
|
510
|
+
const config = {
|
|
511
|
+
readiness: {
|
|
512
|
+
require_conflicts: true
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const result = evaluateReadiness(pr, config);
|
|
517
|
+
|
|
518
|
+
assert.strictEqual(result.ready, true);
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
describe('checkAttention', () => {
|
|
523
|
+
test('returns ready when require_attention is not configured', async () => {
|
|
524
|
+
const { checkAttention } = await import('../../service/readiness.js');
|
|
525
|
+
|
|
526
|
+
const pr = {
|
|
527
|
+
number: 123,
|
|
528
|
+
title: 'Test PR',
|
|
529
|
+
_has_attention: false
|
|
530
|
+
};
|
|
531
|
+
const config = {};
|
|
532
|
+
|
|
533
|
+
const result = checkAttention(pr, config);
|
|
534
|
+
|
|
535
|
+
assert.strictEqual(result.ready, true);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test('returns ready when _has_attention is not computed', async () => {
|
|
539
|
+
const { checkAttention } = await import('../../service/readiness.js');
|
|
540
|
+
|
|
541
|
+
const pr = {
|
|
542
|
+
number: 123,
|
|
543
|
+
title: 'Test PR'
|
|
544
|
+
};
|
|
545
|
+
const config = {
|
|
546
|
+
readiness: {
|
|
547
|
+
require_attention: true
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const result = checkAttention(pr, config);
|
|
552
|
+
|
|
553
|
+
assert.strictEqual(result.ready, true);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('returns ready when PR has attention (conflicts)', async () => {
|
|
557
|
+
const { checkAttention } = await import('../../service/readiness.js');
|
|
558
|
+
|
|
559
|
+
const pr = {
|
|
560
|
+
number: 123,
|
|
561
|
+
title: 'Test PR',
|
|
562
|
+
_has_attention: true,
|
|
563
|
+
_attention_label: 'Conflicts'
|
|
564
|
+
};
|
|
565
|
+
const config = {
|
|
566
|
+
readiness: {
|
|
567
|
+
require_attention: true
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const result = checkAttention(pr, config);
|
|
572
|
+
|
|
573
|
+
assert.strictEqual(result.ready, true);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('returns ready when PR has attention (feedback)', async () => {
|
|
577
|
+
const { checkAttention } = await import('../../service/readiness.js');
|
|
578
|
+
|
|
579
|
+
const pr = {
|
|
580
|
+
number: 123,
|
|
581
|
+
title: 'Test PR',
|
|
582
|
+
_has_attention: true,
|
|
583
|
+
_attention_label: 'Feedback'
|
|
584
|
+
};
|
|
585
|
+
const config = {
|
|
586
|
+
readiness: {
|
|
587
|
+
require_attention: true
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const result = checkAttention(pr, config);
|
|
592
|
+
|
|
593
|
+
assert.strictEqual(result.ready, true);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('returns ready when PR has both conditions', async () => {
|
|
597
|
+
const { checkAttention } = await import('../../service/readiness.js');
|
|
598
|
+
|
|
599
|
+
const pr = {
|
|
600
|
+
number: 123,
|
|
601
|
+
title: 'Test PR',
|
|
602
|
+
_has_attention: true,
|
|
603
|
+
_attention_label: 'Conflicts+Feedback'
|
|
604
|
+
};
|
|
605
|
+
const config = {
|
|
606
|
+
readiness: {
|
|
607
|
+
require_attention: true
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const result = checkAttention(pr, config);
|
|
612
|
+
|
|
613
|
+
assert.strictEqual(result.ready, true);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test('returns not ready when PR has no attention conditions', async () => {
|
|
617
|
+
const { checkAttention } = await import('../../service/readiness.js');
|
|
618
|
+
|
|
619
|
+
const pr = {
|
|
620
|
+
number: 123,
|
|
621
|
+
title: 'Test PR',
|
|
622
|
+
_has_attention: false,
|
|
623
|
+
_attention_label: 'PR'
|
|
624
|
+
};
|
|
625
|
+
const config = {
|
|
626
|
+
readiness: {
|
|
627
|
+
require_attention: true
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const result = checkAttention(pr, config);
|
|
632
|
+
|
|
633
|
+
assert.strictEqual(result.ready, false);
|
|
634
|
+
assert.ok(result.reason.includes('no conflicts'));
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
describe('evaluateReadiness with attention', () => {
|
|
639
|
+
test('checks attention when require_attention is configured', async () => {
|
|
640
|
+
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
641
|
+
|
|
642
|
+
const pr = {
|
|
643
|
+
number: 123,
|
|
644
|
+
title: 'Test PR',
|
|
645
|
+
_has_attention: false
|
|
646
|
+
};
|
|
647
|
+
const config = {
|
|
648
|
+
readiness: {
|
|
649
|
+
require_attention: true
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const result = evaluateReadiness(pr, config);
|
|
654
|
+
|
|
655
|
+
assert.strictEqual(result.ready, false);
|
|
656
|
+
assert.ok(result.reason.includes('no attention needed'));
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test('passes when PR has attention', async () => {
|
|
660
|
+
const { evaluateReadiness } = await import('../../service/readiness.js');
|
|
661
|
+
|
|
662
|
+
const pr = {
|
|
663
|
+
number: 123,
|
|
664
|
+
title: 'Test PR',
|
|
665
|
+
_has_attention: true,
|
|
666
|
+
_attention_label: 'Conflicts'
|
|
667
|
+
};
|
|
668
|
+
const config = {
|
|
669
|
+
readiness: {
|
|
670
|
+
require_attention: true
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const result = evaluateReadiness(pr, config);
|
|
675
|
+
|
|
676
|
+
assert.strictEqual(result.ready, true);
|
|
677
|
+
});
|
|
678
|
+
});
|
|
390
679
|
});
|
|
@@ -697,6 +697,28 @@ sources:
|
|
|
697
697
|
assert.deepStrictEqual(sources[0].reprocess_on, ['state', 'updatedAt']);
|
|
698
698
|
});
|
|
699
699
|
|
|
700
|
+
test('expands github/my-prs-attention preset', async () => {
|
|
701
|
+
writeFileSync(configPath, `
|
|
702
|
+
sources:
|
|
703
|
+
- preset: github/my-prs-attention
|
|
704
|
+
`);
|
|
705
|
+
|
|
706
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
707
|
+
loadRepoConfig(configPath);
|
|
708
|
+
const sources = getSources();
|
|
709
|
+
|
|
710
|
+
assert.strictEqual(sources[0].name, 'my-prs-attention');
|
|
711
|
+
// GitHub presets now use gh CLI instead of MCP
|
|
712
|
+
assert.ok(sources[0].tool.command.includes('--author=@me'), 'command should include author filter');
|
|
713
|
+
// This preset enables both mergeable and comments enrichment
|
|
714
|
+
assert.strictEqual(sources[0].enrich_mergeable, true);
|
|
715
|
+
assert.strictEqual(sources[0].filter_bot_comments, true);
|
|
716
|
+
// This preset requires attention (conflicts OR human feedback)
|
|
717
|
+
assert.strictEqual(sources[0].readiness.require_attention, true);
|
|
718
|
+
// Session name uses dynamic attention label
|
|
719
|
+
assert.ok(sources[0].session.name.includes('_attention_label'), 'session name should use dynamic label');
|
|
720
|
+
});
|
|
721
|
+
|
|
700
722
|
test('expands linear/my-issues preset with required args', async () => {
|
|
701
723
|
writeFileSync(configPath, `
|
|
702
724
|
sources:
|
|
@@ -753,6 +775,7 @@ sources:
|
|
|
753
775
|
- preset: github/my-issues
|
|
754
776
|
- preset: github/review-requests
|
|
755
777
|
- preset: github/my-prs-feedback
|
|
778
|
+
- preset: github/my-prs-attention
|
|
756
779
|
`);
|
|
757
780
|
|
|
758
781
|
const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
|
|
@@ -838,6 +861,7 @@ sources:
|
|
|
838
861
|
- preset: github/my-issues
|
|
839
862
|
- preset: github/review-requests
|
|
840
863
|
- preset: github/my-prs-feedback
|
|
864
|
+
- preset: github/my-prs-attention
|
|
841
865
|
`);
|
|
842
866
|
|
|
843
867
|
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
@@ -852,6 +876,9 @@ sources:
|
|
|
852
876
|
|
|
853
877
|
// my-prs-feedback: "Feedback: {title}"
|
|
854
878
|
assert.strictEqual(sources[2].session.name, 'Feedback: {title}', 'my-prs-feedback should prefix with Feedback:');
|
|
879
|
+
|
|
880
|
+
// my-prs-attention: "{_attention_label}: {title}" (dynamic based on detected conditions)
|
|
881
|
+
assert.ok(sources[3].session.name.includes('_attention_label'), 'my-prs-attention should use dynamic label');
|
|
855
882
|
});
|
|
856
883
|
|
|
857
884
|
test('linear preset includes session name', async () => {
|