bosun 0.36.0 → 0.36.2
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/.env.example +98 -16
- package/README.md +27 -0
- package/agent-event-bus.mjs +5 -5
- package/agent-pool.mjs +129 -12
- package/agent-prompts.mjs +7 -1
- package/agent-sdk.mjs +13 -2
- package/agent-supervisor.mjs +2 -2
- package/agent-work-report.mjs +1 -1
- package/anomaly-detector.mjs +6 -6
- package/autofix.mjs +15 -15
- package/bosun-skills.mjs +4 -4
- package/bosun.schema.json +160 -4
- package/claude-shell.mjs +11 -11
- package/cli.mjs +21 -21
- package/codex-config.mjs +19 -19
- package/codex-shell.mjs +180 -29
- package/config-doctor.mjs +27 -2
- package/config.mjs +60 -7
- package/copilot-shell.mjs +4 -4
- package/error-detector.mjs +1 -1
- package/fleet-coordinator.mjs +2 -2
- package/gemini-shell.mjs +692 -0
- package/github-oauth-portal.mjs +1 -1
- package/github-reconciler.mjs +2 -2
- package/kanban-adapter.mjs +741 -168
- package/merge-strategy.mjs +25 -25
- package/monitor.mjs +123 -105
- package/opencode-shell.mjs +22 -22
- package/package.json +7 -1
- package/postinstall.mjs +22 -22
- package/pr-cleanup-daemon.mjs +6 -6
- package/prepublish-check.mjs +4 -4
- package/presence.mjs +2 -2
- package/primary-agent.mjs +85 -7
- package/publish.mjs +1 -1
- package/review-agent.mjs +1 -1
- package/session-tracker.mjs +11 -0
- package/setup-web-server.mjs +429 -21
- package/setup.mjs +367 -12
- package/shared-knowledge.mjs +1 -1
- package/startup-service.mjs +9 -9
- package/stream-resilience.mjs +58 -4
- package/sync-engine.mjs +2 -2
- package/task-assessment.mjs +9 -9
- package/task-cli.mjs +1 -1
- package/task-complexity.mjs +71 -2
- package/task-context.mjs +1 -2
- package/task-executor.mjs +104 -41
- package/telegram-bot.mjs +825 -494
- package/telegram-sentinel.mjs +28 -28
- package/ui/app.js +256 -23
- package/ui/app.monolith.js +1 -1
- package/ui/components/agent-selector.js +4 -3
- package/ui/components/chat-view.js +101 -28
- package/ui/components/diff-viewer.js +3 -3
- package/ui/components/kanban-board.js +3 -3
- package/ui/components/session-list.js +255 -35
- package/ui/components/workspace-switcher.js +3 -3
- package/ui/demo.html +209 -194
- package/ui/index.html +3 -3
- package/ui/modules/icon-utils.js +206 -142
- package/ui/modules/icons.js +2 -27
- package/ui/modules/settings-schema.js +29 -5
- package/ui/modules/streaming.js +30 -2
- package/ui/modules/vision-stream.js +275 -0
- package/ui/modules/voice-client.js +102 -9
- package/ui/modules/voice-fallback.js +62 -6
- package/ui/modules/voice-overlay.js +594 -59
- package/ui/modules/voice.js +31 -38
- package/ui/setup.html +284 -34
- package/ui/styles/components.css +47 -0
- package/ui/styles/sessions.css +75 -0
- package/ui/tabs/agents.js +73 -43
- package/ui/tabs/chat.js +37 -40
- package/ui/tabs/control.js +2 -2
- package/ui/tabs/dashboard.js +1 -1
- package/ui/tabs/infra.js +10 -10
- package/ui/tabs/library.js +8 -8
- package/ui/tabs/logs.js +10 -10
- package/ui/tabs/settings.js +20 -20
- package/ui/tabs/tasks.js +76 -47
- package/ui-server.mjs +1761 -124
- package/update-check.mjs +13 -13
- package/ve-kanban.mjs +1 -1
- package/whatsapp-channel.mjs +5 -5
- package/workflow-engine.mjs +20 -1
- package/workflow-nodes.mjs +904 -4
- package/workflow-templates/agents.mjs +321 -7
- package/workflow-templates/ci-cd.mjs +6 -6
- package/workflow-templates/github.mjs +156 -84
- package/workflow-templates/planning.mjs +8 -8
- package/workflow-templates/reliability.mjs +8 -8
- package/workflow-templates/security.mjs +3 -3
- package/workflow-templates.mjs +15 -9
- package/workspace-manager.mjs +85 -1
- package/workspace-monitor.mjs +2 -2
- package/workspace-registry.mjs +2 -2
- package/worktree-manager.mjs +1 -1
package/kanban-adapter.mjs
CHANGED
|
@@ -287,9 +287,62 @@ const GH_ISSUE_LIST_CACHE_TTL_MS =
|
|
|
287
287
|
const GH_SHARED_STATE_CACHE_TTL_MS =
|
|
288
288
|
Number(process.env.GITHUB_SHARED_STATE_CACHE_TTL_MS) || 5 * 60 * 1000; // 5 min
|
|
289
289
|
|
|
290
|
+
const GH_ISSUE_VIEW_CACHE_TTL_MS =
|
|
291
|
+
Number(process.env.GH_ISSUE_VIEW_CACHE_TTL_MS) || 60 * 1000; // 1 min
|
|
292
|
+
|
|
293
|
+
const GH_ISSUE_VIEW_NEGATIVE_CACHE_TTL_MS =
|
|
294
|
+
Number(process.env.GH_ISSUE_VIEW_NEGATIVE_CACHE_TTL_MS) || 5 * 60 * 1000; // 5 min
|
|
295
|
+
|
|
296
|
+
const GH_ISSUE_LOCATOR_CACHE_TTL_MS =
|
|
297
|
+
Number(process.env.GH_ISSUE_LOCATOR_CACHE_TTL_MS) || 30 * 60 * 1000; // 30 min
|
|
298
|
+
|
|
299
|
+
const GH_READ_CACHE_TTL_MS =
|
|
300
|
+
Number(process.env.GH_READ_CACHE_TTL_MS) || 30 * 1000; // 30 sec
|
|
301
|
+
|
|
302
|
+
const GH_ERROR_COOLDOWN_MS =
|
|
303
|
+
Number(process.env.GH_ERROR_COOLDOWN_MS) || 60 * 1000; // 1 min
|
|
304
|
+
|
|
305
|
+
const GH_NOT_FOUND_COOLDOWN_MS =
|
|
306
|
+
Number(process.env.GH_NOT_FOUND_COOLDOWN_MS) || 5 * 60 * 1000; // 5 min
|
|
307
|
+
|
|
308
|
+
function buildGitHubAdapterCacheNamespace(owner, repo) {
|
|
309
|
+
const normalizedOwner = String(owner || "").trim().toLowerCase();
|
|
310
|
+
const normalizedRepo = String(repo || "").trim().toLowerCase();
|
|
311
|
+
if (normalizedOwner && normalizedRepo) return `${normalizedOwner}/${normalizedRepo}`;
|
|
312
|
+
if (normalizedRepo) return normalizedRepo;
|
|
313
|
+
return normalizedOwner || "unknown";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const GH_ADAPTER_CACHE_BUCKETS = new Map();
|
|
317
|
+
|
|
318
|
+
function createGitHubAdapterCacheBucket() {
|
|
319
|
+
return {
|
|
320
|
+
issueList: new Map(),
|
|
321
|
+
sharedState: new Map(),
|
|
322
|
+
issueLocator: new Map(),
|
|
323
|
+
missingIssue: new Map(),
|
|
324
|
+
ghRead: new Map(),
|
|
325
|
+
ghInflight: new Map(),
|
|
326
|
+
ghErrorCooldown: new Map(),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function getGitHubAdapterCacheBucket(owner, repo) {
|
|
331
|
+
const namespace = buildGitHubAdapterCacheNamespace(owner, repo);
|
|
332
|
+
if (!GH_ADAPTER_CACHE_BUCKETS.has(namespace)) {
|
|
333
|
+
GH_ADAPTER_CACHE_BUCKETS.set(namespace, createGitHubAdapterCacheBucket());
|
|
334
|
+
}
|
|
335
|
+
return GH_ADAPTER_CACHE_BUCKETS.get(namespace);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function clearGitHubAdapterCacheBuckets() {
|
|
339
|
+
GH_ADAPTER_CACHE_BUCKETS.clear();
|
|
340
|
+
}
|
|
341
|
+
|
|
290
342
|
/** Build a cache key for the issue-list cache (per adapter instance). */
|
|
291
|
-
function _issueListCacheKey(state, limit) {
|
|
292
|
-
|
|
343
|
+
function _issueListCacheKey(repoKey, state, limit) {
|
|
344
|
+
const normalizedRepo = String(repoKey || "").trim().toLowerCase();
|
|
345
|
+
return `${normalizedRepo}:${state}:${limit}`;
|
|
293
346
|
}
|
|
294
347
|
|
|
295
348
|
/** Build a cache key for the shared-state cache (per adapter instance). */
|
|
@@ -299,6 +352,18 @@ function _sharedStateCacheKey(num, repoKey = "") {
|
|
|
299
352
|
return normalizedRepo ? `${normalizedRepo}#${normalizedNum}` : normalizedNum;
|
|
300
353
|
}
|
|
301
354
|
|
|
355
|
+
function _issueLocatorCacheKey(num, repoKey = "", owner = "", repo = "") {
|
|
356
|
+
const normalizedNum = String(num || "")
|
|
357
|
+
.trim()
|
|
358
|
+
.replace(/^#/, "");
|
|
359
|
+
const normalizedRepoKey =
|
|
360
|
+
String(repoKey || "")
|
|
361
|
+
.trim()
|
|
362
|
+
.toLowerCase() ||
|
|
363
|
+
buildGitHubAdapterCacheNamespace(owner, repo);
|
|
364
|
+
return _sharedStateCacheKey(normalizedNum, normalizedRepoKey);
|
|
365
|
+
}
|
|
366
|
+
|
|
302
367
|
function parseIssueLocator(issueNumber, defaultOwner, defaultRepo, issueUrl = "") {
|
|
303
368
|
const urlText = String(issueUrl || issueNumber || "").trim();
|
|
304
369
|
const urlMatch = urlText.match(
|
|
@@ -330,6 +395,25 @@ function parseIssueLocator(issueNumber, defaultOwner, defaultRepo, issueUrl = ""
|
|
|
330
395
|
};
|
|
331
396
|
}
|
|
332
397
|
|
|
398
|
+
function parseIssueRefLocator(issueRef, defaultOwner, defaultRepo, issueUrl = "") {
|
|
399
|
+
const raw = String(issueRef || "").trim();
|
|
400
|
+
const explicit = raw.match(/^([^/\s]+)\/([^#\s]+)#(\d+)$/);
|
|
401
|
+
if (explicit) {
|
|
402
|
+
const owner = String(explicit[1] || "").trim();
|
|
403
|
+
const repo = String(explicit[2] || "")
|
|
404
|
+
.trim()
|
|
405
|
+
.replace(/\.git$/i, "");
|
|
406
|
+
const number = String(explicit[3] || "").trim();
|
|
407
|
+
return {
|
|
408
|
+
owner,
|
|
409
|
+
repo,
|
|
410
|
+
number,
|
|
411
|
+
repoKey: `${owner}/${repo}`.toLowerCase(),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return parseIssueLocator(issueRef, defaultOwner, defaultRepo, issueUrl);
|
|
415
|
+
}
|
|
416
|
+
|
|
333
417
|
function isGhRateLimitError(text) {
|
|
334
418
|
const errText = String(text || "").toLowerCase();
|
|
335
419
|
if (!errText) return false;
|
|
@@ -347,6 +431,95 @@ function isGhTransientError(text) {
|
|
|
347
431
|
return GH_TRANSIENT_ERROR_PATTERNS.some((pattern) => pattern.test(errText));
|
|
348
432
|
}
|
|
349
433
|
|
|
434
|
+
const GH_NOT_FOUND_ERROR_PATTERNS = [
|
|
435
|
+
/could not resolve to an issue or pull request with the number/i,
|
|
436
|
+
/repository\.issue/i,
|
|
437
|
+
/\bnot found\b/i,
|
|
438
|
+
/http\s*404/i,
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
const GH_AUTH_ERROR_PATTERNS = [
|
|
442
|
+
/bad credentials/i,
|
|
443
|
+
/requires authentication/i,
|
|
444
|
+
/must authenticate/i,
|
|
445
|
+
/authentication failed/i,
|
|
446
|
+
/resource not accessible by integration/i,
|
|
447
|
+
/http\s*401/i,
|
|
448
|
+
];
|
|
449
|
+
|
|
450
|
+
function buildGhRequestKey(args, parseJson = true) {
|
|
451
|
+
const parts = Array.isArray(args) ? args : [args];
|
|
452
|
+
return `${parseJson ? "json" : "text"}:${parts
|
|
453
|
+
.map((entry) => String(entry ?? ""))
|
|
454
|
+
.join("\u001f")}`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isGhGraphqlMutation(args) {
|
|
458
|
+
if (!Array.isArray(args)) return false;
|
|
459
|
+
if (String(args[0] || "").toLowerCase() !== "api") return false;
|
|
460
|
+
const queryArg = args.find(
|
|
461
|
+
(entry) =>
|
|
462
|
+
typeof entry === "string" &&
|
|
463
|
+
(entry.startsWith("query=") || entry.startsWith("-fquery=")),
|
|
464
|
+
);
|
|
465
|
+
if (!queryArg) return false;
|
|
466
|
+
return /\bmutation\b/i.test(queryArg);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isGhReadOnlyCommand(args) {
|
|
470
|
+
if (!Array.isArray(args) || args.length === 0) return false;
|
|
471
|
+
const cmd = String(args[0] || "").toLowerCase();
|
|
472
|
+
const sub = String(args[1] || "").toLowerCase();
|
|
473
|
+
|
|
474
|
+
if (cmd === "issue") {
|
|
475
|
+
return sub === "view" || sub === "list";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (cmd === "project") {
|
|
479
|
+
return ["list", "view", "field-list", "item-list"].includes(sub);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (cmd === "api") {
|
|
483
|
+
const methodFlagIndex = args.findIndex(
|
|
484
|
+
(entry) => String(entry || "").toLowerCase() === "-x",
|
|
485
|
+
);
|
|
486
|
+
if (methodFlagIndex >= 0) {
|
|
487
|
+
const method = String(args[methodFlagIndex + 1] || "GET")
|
|
488
|
+
.trim()
|
|
489
|
+
.toUpperCase();
|
|
490
|
+
if (method && method !== "GET") return false;
|
|
491
|
+
}
|
|
492
|
+
if (isGhGraphqlMutation(args)) return false;
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function classifyGhDeterministicError(text) {
|
|
500
|
+
const raw = String(text || "");
|
|
501
|
+
if (!raw) return null;
|
|
502
|
+
if (isGhRateLimitError(raw) || isGhTransientError(raw)) return null;
|
|
503
|
+
if (GH_NOT_FOUND_ERROR_PATTERNS.some((pattern) => pattern.test(raw))) {
|
|
504
|
+
return "not_found";
|
|
505
|
+
}
|
|
506
|
+
if (GH_AUTH_ERROR_PATTERNS.some((pattern) => pattern.test(raw))) {
|
|
507
|
+
return "auth";
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function isGhNotFoundError(text) {
|
|
513
|
+
const errText = String(text || "").toLowerCase();
|
|
514
|
+
if (!errText) return false;
|
|
515
|
+
return (
|
|
516
|
+
errText.includes("http 404") ||
|
|
517
|
+
errText.includes("not found (http 404)") ||
|
|
518
|
+
errText.includes("could not resolve to an issue or pull request with the number") ||
|
|
519
|
+
errText.includes("(repository.issue)")
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
350
523
|
function parseRepoSlug(raw) {
|
|
351
524
|
const text = String(raw || "").trim().replace(/^https?:\/\/github\.com\//i, "");
|
|
352
525
|
if (!text) return null;
|
|
@@ -1343,11 +1516,57 @@ class GitHubIssuesAdapter {
|
|
|
1343
1516
|
Number(process.env.GH_TRANSIENT_RETRY_MAX) || 2,
|
|
1344
1517
|
);
|
|
1345
1518
|
|
|
1346
|
-
// Issue-list and shared-state caches (
|
|
1519
|
+
// Issue-list and shared-state caches (shared per repo namespace)
|
|
1520
|
+
const cacheBucket = getGitHubAdapterCacheBucket(this._owner, this._repo);
|
|
1347
1521
|
/** @type {Map<string, {data: any, ts: number}>} state:limit → {data, ts} */
|
|
1348
|
-
this._issueListCache =
|
|
1522
|
+
this._issueListCache = cacheBucket.issueList;
|
|
1349
1523
|
/** @type {Map<string, {data: object|null, ts: number}>} issueNum → {data, ts} */
|
|
1350
|
-
this._sharedStateCache =
|
|
1524
|
+
this._sharedStateCache = cacheBucket.sharedState;
|
|
1525
|
+
/** @type {Map<string, {owner:string, repo:string, repoKey:string, number:string, issueUrl:string, ts:number}>} */
|
|
1526
|
+
this._issueLocatorCache = cacheBucket.issueLocator;
|
|
1527
|
+
this._issueLocatorCacheTtlMs = parseDelayMs(
|
|
1528
|
+
process.env.GH_ISSUE_LOCATOR_CACHE_TTL_MS,
|
|
1529
|
+
GH_ISSUE_LOCATOR_CACHE_TTL_MS,
|
|
1530
|
+
0,
|
|
1531
|
+
);
|
|
1532
|
+
/** @type {Map<string, number>} repo#issueNum → last-seen-missing timestamp */
|
|
1533
|
+
this._missingIssueCache = cacheBucket.missingIssue;
|
|
1534
|
+
this._missingIssueCacheTtlMs = parseDelayMs(
|
|
1535
|
+
process.env.GH_MISSING_ISSUE_CACHE_TTL_MS,
|
|
1536
|
+
10 * 60 * 1000,
|
|
1537
|
+
5_000,
|
|
1538
|
+
);
|
|
1539
|
+
/** @type {Map<string, {data:any, ts:number}>} gh request key → parsed payload */
|
|
1540
|
+
this._ghReadCache = cacheBucket.ghRead;
|
|
1541
|
+
/** @type {Map<string, Promise<any>>} gh request key → in-flight promise */
|
|
1542
|
+
this._ghInflight = cacheBucket.ghInflight;
|
|
1543
|
+
/** @type {Map<string, {until:number, message:string, type:string|null}>} gh request key → cooldown metadata */
|
|
1544
|
+
this._ghErrorCooldown = cacheBucket.ghErrorCooldown;
|
|
1545
|
+
this._ghReadCacheTtlMs = parseDelayMs(
|
|
1546
|
+
process.env.GH_READ_CACHE_TTL_MS,
|
|
1547
|
+
GH_READ_CACHE_TTL_MS,
|
|
1548
|
+
0,
|
|
1549
|
+
);
|
|
1550
|
+
this._ghErrorCooldownMs = parseDelayMs(
|
|
1551
|
+
process.env.GH_ERROR_COOLDOWN_MS,
|
|
1552
|
+
GH_ERROR_COOLDOWN_MS,
|
|
1553
|
+
0,
|
|
1554
|
+
);
|
|
1555
|
+
this._ghNotFoundCooldownMs = parseDelayMs(
|
|
1556
|
+
process.env.GH_NOT_FOUND_COOLDOWN_MS,
|
|
1557
|
+
GH_NOT_FOUND_COOLDOWN_MS,
|
|
1558
|
+
0,
|
|
1559
|
+
);
|
|
1560
|
+
this._issueViewCacheTtlMs = parseDelayMs(
|
|
1561
|
+
process.env.GH_ISSUE_VIEW_CACHE_TTL_MS,
|
|
1562
|
+
GH_ISSUE_VIEW_CACHE_TTL_MS,
|
|
1563
|
+
0,
|
|
1564
|
+
);
|
|
1565
|
+
this._issueViewNegativeCacheTtlMs = parseDelayMs(
|
|
1566
|
+
process.env.GH_ISSUE_VIEW_NEGATIVE_CACHE_TTL_MS,
|
|
1567
|
+
GH_ISSUE_VIEW_NEGATIVE_CACHE_TTL_MS,
|
|
1568
|
+
5_000,
|
|
1569
|
+
);
|
|
1351
1570
|
this._lastKnownTasks = [];
|
|
1352
1571
|
this._taskListBackoffUntil = 0;
|
|
1353
1572
|
this._taskListBackoffMs = parseDelayMs(
|
|
@@ -1364,6 +1583,11 @@ class GitHubIssuesAdapter {
|
|
|
1364
1583
|
...task,
|
|
1365
1584
|
meta: task?.meta ? { ...task.meta } : {},
|
|
1366
1585
|
}));
|
|
1586
|
+
for (const task of this._lastKnownTasks) {
|
|
1587
|
+
const issueUrl = String(task?.taskUrl || task?.meta?.url || "").trim();
|
|
1588
|
+
if (!issueUrl) continue;
|
|
1589
|
+
this._rememberIssueLocator(task.id, issueUrl);
|
|
1590
|
+
}
|
|
1367
1591
|
this._taskListBackoffUntil = 0;
|
|
1368
1592
|
this._taskListBackoffWarnAt = 0;
|
|
1369
1593
|
}
|
|
@@ -1390,6 +1614,142 @@ class GitHubIssuesAdapter {
|
|
|
1390
1614
|
return fallback;
|
|
1391
1615
|
}
|
|
1392
1616
|
|
|
1617
|
+
_clearGhReadCaches() {
|
|
1618
|
+
this._ghReadCache.clear();
|
|
1619
|
+
this._ghErrorCooldown.clear();
|
|
1620
|
+
this._ghInflight.clear();
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
_rememberIssueLocator(issueNumber, issueUrl = "") {
|
|
1624
|
+
const locator = parseIssueRefLocator(
|
|
1625
|
+
issueNumber,
|
|
1626
|
+
this._owner,
|
|
1627
|
+
this._repo,
|
|
1628
|
+
issueUrl,
|
|
1629
|
+
);
|
|
1630
|
+
if (!/^\d+$/.test(locator.number) || !locator.owner || !locator.repo) return;
|
|
1631
|
+
const key = _issueLocatorCacheKey(
|
|
1632
|
+
locator.number,
|
|
1633
|
+
locator.repoKey,
|
|
1634
|
+
this._owner,
|
|
1635
|
+
this._repo,
|
|
1636
|
+
);
|
|
1637
|
+
const canonicalUrl =
|
|
1638
|
+
issueUrl ||
|
|
1639
|
+
`https://github.com/${locator.owner}/${locator.repo}/issues/${locator.number}`;
|
|
1640
|
+
this._issueLocatorCache.set(key, {
|
|
1641
|
+
...locator,
|
|
1642
|
+
issueUrl: canonicalUrl,
|
|
1643
|
+
ts: Date.now(),
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
_resolveIssueLocator(issueNumber, options = {}) {
|
|
1648
|
+
const issueUrl = String(options?.issueUrl || "").trim();
|
|
1649
|
+
const direct = parseIssueRefLocator(
|
|
1650
|
+
issueNumber,
|
|
1651
|
+
this._owner,
|
|
1652
|
+
this._repo,
|
|
1653
|
+
issueUrl,
|
|
1654
|
+
);
|
|
1655
|
+
if (!/^\d+$/.test(direct.number)) return direct;
|
|
1656
|
+
|
|
1657
|
+
// Explicit URL/ref always wins and refreshes cache.
|
|
1658
|
+
if (issueUrl || String(issueNumber || "").includes("/")) {
|
|
1659
|
+
this._rememberIssueLocator(direct.number, issueUrl || String(issueNumber || ""));
|
|
1660
|
+
return {
|
|
1661
|
+
...direct,
|
|
1662
|
+
issueUrl:
|
|
1663
|
+
issueUrl ||
|
|
1664
|
+
`https://github.com/${direct.owner}/${direct.repo}/issues/${direct.number}`,
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
const key = _issueLocatorCacheKey(
|
|
1669
|
+
direct.number,
|
|
1670
|
+
direct.repoKey,
|
|
1671
|
+
this._owner,
|
|
1672
|
+
this._repo,
|
|
1673
|
+
);
|
|
1674
|
+
const cached = this._issueLocatorCache.get(key);
|
|
1675
|
+
if (cached) {
|
|
1676
|
+
if (Date.now() - cached.ts <= this._issueLocatorCacheTtlMs) {
|
|
1677
|
+
return { ...cached };
|
|
1678
|
+
}
|
|
1679
|
+
this._issueLocatorCache.delete(key);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// Cross-repo fallback: if an issue number was previously observed with a
|
|
1683
|
+
// different repo slug (e.g. project board rows), reuse that locator.
|
|
1684
|
+
const suffix = `#${direct.number}`;
|
|
1685
|
+
for (const [cacheKey, value] of this._issueLocatorCache.entries()) {
|
|
1686
|
+
if (!cacheKey.endsWith(suffix)) continue;
|
|
1687
|
+
if (Date.now() - value.ts > this._issueLocatorCacheTtlMs) {
|
|
1688
|
+
this._issueLocatorCache.delete(cacheKey);
|
|
1689
|
+
continue;
|
|
1690
|
+
}
|
|
1691
|
+
return { ...value };
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Fall back to last-known tasks (e.g., project item-list rows from other repos).
|
|
1695
|
+
const match = this._lastKnownTasks.find(
|
|
1696
|
+
(task) => String(task?.id || "") === String(direct.number),
|
|
1697
|
+
);
|
|
1698
|
+
const taskUrl = String(match?.taskUrl || match?.meta?.url || "").trim();
|
|
1699
|
+
if (taskUrl) {
|
|
1700
|
+
const resolved = parseIssueRefLocator(
|
|
1701
|
+
key,
|
|
1702
|
+
this._owner,
|
|
1703
|
+
this._repo,
|
|
1704
|
+
taskUrl,
|
|
1705
|
+
);
|
|
1706
|
+
if (/^\d+$/.test(resolved.number)) {
|
|
1707
|
+
const resolvedKey = _issueLocatorCacheKey(
|
|
1708
|
+
resolved.number,
|
|
1709
|
+
resolved.repoKey,
|
|
1710
|
+
this._owner,
|
|
1711
|
+
this._repo,
|
|
1712
|
+
);
|
|
1713
|
+
const value = {
|
|
1714
|
+
...resolved,
|
|
1715
|
+
issueUrl: taskUrl,
|
|
1716
|
+
ts: Date.now(),
|
|
1717
|
+
};
|
|
1718
|
+
this._issueLocatorCache.set(resolvedKey, value);
|
|
1719
|
+
return value;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
return {
|
|
1724
|
+
...direct,
|
|
1725
|
+
issueUrl: `https://github.com/${direct.owner}/${direct.repo}/issues/${direct.number}`,
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
_isIssueKnownMissing(issueNumber, options = {}) {
|
|
1730
|
+
const locator = this._resolveIssueLocator(issueNumber, options);
|
|
1731
|
+
const key = _sharedStateCacheKey(locator.number, locator.repoKey);
|
|
1732
|
+
const seenAt = this._missingIssueCache.get(key);
|
|
1733
|
+
if (!seenAt) return false;
|
|
1734
|
+
if (Date.now() - seenAt > this._missingIssueCacheTtlMs) {
|
|
1735
|
+
this._missingIssueCache.delete(key);
|
|
1736
|
+
return false;
|
|
1737
|
+
}
|
|
1738
|
+
return true;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
_markIssueMissing(issueNumber, options = {}) {
|
|
1742
|
+
const locator = this._resolveIssueLocator(issueNumber, options);
|
|
1743
|
+
const key = _sharedStateCacheKey(locator.number, locator.repoKey);
|
|
1744
|
+
this._missingIssueCache.set(key, Date.now());
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
_clearIssueMissing(issueNumber, options = {}) {
|
|
1748
|
+
const locator = this._resolveIssueLocator(issueNumber, options);
|
|
1749
|
+
const key = _sharedStateCacheKey(locator.number, locator.repoKey);
|
|
1750
|
+
this._missingIssueCache.delete(key);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1393
1753
|
/**
|
|
1394
1754
|
* Get project fields with caching (private — returns legacy format for _syncStatusToProject).
|
|
1395
1755
|
* Returns status field ID and options for project board.
|
|
@@ -1674,9 +2034,20 @@ class GitHubIssuesAdapter {
|
|
|
1674
2034
|
const issueUrl =
|
|
1675
2035
|
content.url ||
|
|
1676
2036
|
`https://github.com/${this._owner}/${this._repo}/issues/${num}`;
|
|
2037
|
+
const locator = parseIssueRefLocator(
|
|
2038
|
+
num,
|
|
2039
|
+
this._owner,
|
|
2040
|
+
this._repo,
|
|
2041
|
+
issueUrl,
|
|
2042
|
+
);
|
|
2043
|
+
const normalizedNum = /^\d+$/.test(locator.number)
|
|
2044
|
+
? locator.number
|
|
2045
|
+
: String(num);
|
|
2046
|
+
const projectId = `${locator.owner}/${locator.repo}`;
|
|
2047
|
+
this._rememberIssueLocator(normalizedNum, issueUrl);
|
|
1677
2048
|
|
|
1678
2049
|
return {
|
|
1679
|
-
id: String(
|
|
2050
|
+
id: String(normalizedNum),
|
|
1680
2051
|
title: content.title || projectItem.title || "",
|
|
1681
2052
|
description: body,
|
|
1682
2053
|
status,
|
|
@@ -1688,12 +2059,12 @@ class GitHubIssuesAdapter {
|
|
|
1688
2059
|
: null,
|
|
1689
2060
|
tags,
|
|
1690
2061
|
draft: labelSet.has("draft") || status === "draft",
|
|
1691
|
-
projectId
|
|
2062
|
+
projectId,
|
|
1692
2063
|
baseBranch,
|
|
1693
2064
|
branchName: branchMatch?.[1] || null,
|
|
1694
2065
|
prNumber: prMatch?.[1] || null,
|
|
1695
2066
|
meta: {
|
|
1696
|
-
number: Number(
|
|
2067
|
+
number: Number(normalizedNum),
|
|
1697
2068
|
title: content.title || projectItem.title || "",
|
|
1698
2069
|
body,
|
|
1699
2070
|
state: content.state || null,
|
|
@@ -2182,120 +2553,221 @@ class GitHubIssuesAdapter {
|
|
|
2182
2553
|
/** Execute a gh CLI command and return parsed JSON (with rate limit retry) */
|
|
2183
2554
|
async _gh(args, options = {}) {
|
|
2184
2555
|
const { parseJson = true } = options;
|
|
2185
|
-
const
|
|
2556
|
+
const normalizedArgs = Array.isArray(args)
|
|
2557
|
+
? args.map((entry) => String(entry ?? ""))
|
|
2558
|
+
: [String(args ?? "")];
|
|
2559
|
+
const readOnly = isGhReadOnlyCommand(normalizedArgs);
|
|
2560
|
+
const isIssueViewRead =
|
|
2561
|
+
readOnly &&
|
|
2562
|
+
String(normalizedArgs[0] || "").toLowerCase() === "issue" &&
|
|
2563
|
+
String(normalizedArgs[1] || "").toLowerCase() === "view";
|
|
2564
|
+
const readCacheTtlMs = isIssueViewRead
|
|
2565
|
+
? this._issueViewCacheTtlMs
|
|
2566
|
+
: this._ghReadCacheTtlMs;
|
|
2567
|
+
const requestKey = buildGhRequestKey(normalizedArgs, parseJson);
|
|
2568
|
+
const now = Date.now();
|
|
2186
2569
|
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
2195
|
-
timeout: 30_000,
|
|
2196
|
-
},
|
|
2197
|
-
(err, stdout, stderr) => {
|
|
2198
|
-
const normalizeOutput = (value, fallback = "") => {
|
|
2199
|
-
if (value == null) return String(fallback || "");
|
|
2200
|
-
if (typeof value === "string") return value;
|
|
2201
|
-
return String(value);
|
|
2202
|
-
};
|
|
2203
|
-
|
|
2204
|
-
const outputFromStdoutObject =
|
|
2205
|
-
stdout && typeof stdout === "object"
|
|
2206
|
-
? {
|
|
2207
|
-
stdout: normalizeOutput(stdout.stdout),
|
|
2208
|
-
stderr: normalizeOutput(stdout.stderr, stderr),
|
|
2209
|
-
}
|
|
2210
|
-
: null;
|
|
2211
|
-
|
|
2212
|
-
const normalized = outputFromStdoutObject || {
|
|
2213
|
-
stdout: normalizeOutput(stdout),
|
|
2214
|
-
stderr: normalizeOutput(stderr),
|
|
2215
|
-
};
|
|
2216
|
-
|
|
2217
|
-
if (err) {
|
|
2218
|
-
err.stdout = normalizeOutput(err.stdout, normalized.stdout);
|
|
2219
|
-
err.stderr = normalizeOutput(err.stderr, normalized.stderr);
|
|
2220
|
-
reject(err);
|
|
2221
|
-
return;
|
|
2222
|
-
}
|
|
2570
|
+
if (readOnly && readCacheTtlMs > 0) {
|
|
2571
|
+
const cached = this._ghReadCache.get(requestKey);
|
|
2572
|
+
if (cached && now - cached.ts < readCacheTtlMs) {
|
|
2573
|
+
return cached.data;
|
|
2574
|
+
}
|
|
2575
|
+
if (cached) this._ghReadCache.delete(requestKey);
|
|
2576
|
+
}
|
|
2223
2577
|
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
const ghError = new Error(message);
|
|
2234
|
-
ghError.stdout = stdout;
|
|
2235
|
-
ghError.stderr = stderr;
|
|
2236
|
-
ghError.fullText = [message, stderr, stdout].filter(Boolean).join("\n");
|
|
2237
|
-
ghError.isRateLimit = isGhRateLimitError([message, stderr].join("\n"));
|
|
2238
|
-
ghError.isTransient = isGhTransientError([message, stderr, stdout].join("\n"));
|
|
2239
|
-
throw ghError;
|
|
2578
|
+
if (readOnly && this._ghErrorCooldownMs > 0) {
|
|
2579
|
+
const cooldown = this._ghErrorCooldown.get(requestKey);
|
|
2580
|
+
if (cooldown && cooldown.until > now) {
|
|
2581
|
+
const cooldownError = new Error(
|
|
2582
|
+
`gh CLI request skipped during cooldown: ${cooldown.message}`,
|
|
2583
|
+
);
|
|
2584
|
+
cooldownError.isCooldown = true;
|
|
2585
|
+
cooldownError.isNotFound = cooldown.type === "not_found";
|
|
2586
|
+
throw cooldownError;
|
|
2240
2587
|
}
|
|
2241
|
-
|
|
2588
|
+
if (cooldown) this._ghErrorCooldown.delete(requestKey);
|
|
2589
|
+
}
|
|
2242
2590
|
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2591
|
+
if (readOnly && this._ghInflight.has(requestKey)) {
|
|
2592
|
+
return this._ghInflight.get(requestKey);
|
|
2593
|
+
}
|
|
2246
2594
|
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2595
|
+
const { execFile } = await import("node:child_process");
|
|
2596
|
+
|
|
2597
|
+
const run = async () => {
|
|
2598
|
+
const attempt = async () => {
|
|
2599
|
+
try {
|
|
2600
|
+
const result = await new Promise((resolve, reject) => {
|
|
2601
|
+
execFile(
|
|
2602
|
+
"gh",
|
|
2603
|
+
normalizedArgs,
|
|
2604
|
+
{
|
|
2605
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
2606
|
+
timeout: 30_000,
|
|
2607
|
+
},
|
|
2608
|
+
(err, stdout, stderr) => {
|
|
2609
|
+
const normalizeOutput = (value, fallback = "") => {
|
|
2610
|
+
if (value == null) return String(fallback || "");
|
|
2611
|
+
if (typeof value === "string") return value;
|
|
2612
|
+
return String(value);
|
|
2613
|
+
};
|
|
2614
|
+
|
|
2615
|
+
const outputFromStdoutObject =
|
|
2616
|
+
stdout && typeof stdout === "object"
|
|
2617
|
+
? {
|
|
2618
|
+
stdout: normalizeOutput(stdout.stdout),
|
|
2619
|
+
stderr: normalizeOutput(stdout.stderr, stderr),
|
|
2620
|
+
}
|
|
2621
|
+
: null;
|
|
2622
|
+
|
|
2623
|
+
const normalized = outputFromStdoutObject || {
|
|
2624
|
+
stdout: normalizeOutput(stdout),
|
|
2625
|
+
stderr: normalizeOutput(stderr),
|
|
2626
|
+
};
|
|
2627
|
+
|
|
2628
|
+
if (err) {
|
|
2629
|
+
err.stdout = normalizeOutput(err.stdout, normalized.stdout);
|
|
2630
|
+
err.stderr = normalizeOutput(err.stderr, normalized.stderr);
|
|
2631
|
+
reject(err);
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
resolve(normalized);
|
|
2636
|
+
},
|
|
2637
|
+
);
|
|
2638
|
+
});
|
|
2639
|
+
return result;
|
|
2640
|
+
} catch (err) {
|
|
2641
|
+
const message = String(err?.message || err);
|
|
2642
|
+
const stdout = String(err?.stdout || "");
|
|
2643
|
+
const stderr = String(err?.stderr || "");
|
|
2644
|
+
const ghError = new Error(message);
|
|
2645
|
+
ghError.stdout = stdout;
|
|
2646
|
+
ghError.stderr = stderr;
|
|
2647
|
+
ghError.fullText = [message, stderr, stdout].filter(Boolean).join("\n");
|
|
2648
|
+
ghError.isRateLimit = isGhRateLimitError([message, stderr].join("\n"));
|
|
2649
|
+
ghError.isTransient = isGhTransientError([message, stderr, stdout].join("\n"));
|
|
2650
|
+
ghError.isNotFound = isGhNotFoundError([message, stderr, stdout].join("\n"));
|
|
2651
|
+
throw ghError;
|
|
2260
2652
|
}
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2653
|
+
};
|
|
2654
|
+
|
|
2655
|
+
let usedRateLimitRetry = false;
|
|
2656
|
+
let transientRetries = 0;
|
|
2657
|
+
const maxTransientRetries = Math.max(0, Number(this._transientRetryMax) || 0);
|
|
2658
|
+
|
|
2659
|
+
while (true) {
|
|
2660
|
+
let result;
|
|
2661
|
+
try {
|
|
2662
|
+
result = await attempt();
|
|
2663
|
+
} catch (err) {
|
|
2664
|
+
const message = String(err?.message || err);
|
|
2665
|
+
if (err?.isRateLimit && !usedRateLimitRetry) {
|
|
2666
|
+
usedRateLimitRetry = true;
|
|
2667
|
+
console.warn(
|
|
2668
|
+
`${TAG} rate limit detected, waiting ${this._rateLimitRetryDelayMs}ms before retry...`,
|
|
2669
|
+
);
|
|
2670
|
+
await sleepMs(this._rateLimitRetryDelayMs);
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2673
|
+
if (err?.isTransient && transientRetries < maxTransientRetries) {
|
|
2674
|
+
transientRetries += 1;
|
|
2675
|
+
console.warn(
|
|
2676
|
+
`${TAG} transient gh failure (attempt ${transientRetries}/${maxTransientRetries}), retrying in ${this._transientRetryDelayMs}ms...`,
|
|
2677
|
+
);
|
|
2678
|
+
await sleepMs(this._transientRetryDelayMs);
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
const fullText = String(err?.fullText || message);
|
|
2683
|
+
const deterministicClass = classifyGhDeterministicError(fullText);
|
|
2684
|
+
if (readOnly && deterministicClass) {
|
|
2685
|
+
const cooldownMs =
|
|
2686
|
+
deterministicClass === "not_found"
|
|
2687
|
+
? isIssueViewRead
|
|
2688
|
+
? this._issueViewNegativeCacheTtlMs
|
|
2689
|
+
: this._ghNotFoundCooldownMs
|
|
2690
|
+
: this._ghErrorCooldownMs;
|
|
2691
|
+
if (cooldownMs > 0) {
|
|
2692
|
+
this._ghErrorCooldown.set(requestKey, {
|
|
2693
|
+
until: Date.now() + cooldownMs,
|
|
2694
|
+
message,
|
|
2695
|
+
type: deterministicClass,
|
|
2696
|
+
});
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
if (err?.isRateLimit && usedRateLimitRetry) {
|
|
2701
|
+
const wrapped = new Error(
|
|
2702
|
+
`gh CLI failed (after rate limit retry): ${message}`,
|
|
2703
|
+
);
|
|
2704
|
+
wrapped.cause = err;
|
|
2705
|
+
wrapped.isRateLimit = Boolean(err?.isRateLimit);
|
|
2706
|
+
wrapped.isTransient = Boolean(err?.isTransient);
|
|
2707
|
+
wrapped.isNotFound = Boolean(
|
|
2708
|
+
err?.isNotFound || deterministicClass === "not_found",
|
|
2709
|
+
);
|
|
2710
|
+
wrapped.isDeterministic = Boolean(deterministicClass);
|
|
2711
|
+
wrapped.deterministicClass = deterministicClass;
|
|
2712
|
+
throw wrapped;
|
|
2713
|
+
}
|
|
2714
|
+
const wrapped = new Error(`gh CLI failed: ${message}`);
|
|
2715
|
+
wrapped.cause = err;
|
|
2716
|
+
wrapped.isRateLimit = Boolean(err?.isRateLimit);
|
|
2717
|
+
wrapped.isTransient = Boolean(err?.isTransient);
|
|
2718
|
+
wrapped.isNotFound = Boolean(
|
|
2719
|
+
err?.isNotFound || deterministicClass === "not_found",
|
|
2265
2720
|
);
|
|
2266
|
-
|
|
2267
|
-
|
|
2721
|
+
wrapped.isDeterministic = Boolean(deterministicClass);
|
|
2722
|
+
wrapped.deterministicClass = deterministicClass;
|
|
2723
|
+
throw wrapped;
|
|
2268
2724
|
}
|
|
2269
|
-
if (err?.isRateLimit && usedRateLimitRetry) {
|
|
2270
|
-
throw new Error(`gh CLI failed (after rate limit retry): ${message}`);
|
|
2271
|
-
}
|
|
2272
|
-
throw new Error(`gh CLI failed: ${message}`);
|
|
2273
|
-
}
|
|
2274
2725
|
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2726
|
+
const text = String(result?.stdout || "").trim();
|
|
2727
|
+
let parsed;
|
|
2728
|
+
if (!parseJson) {
|
|
2729
|
+
parsed = text;
|
|
2730
|
+
} else if (!text) {
|
|
2731
|
+
parsed = null;
|
|
2732
|
+
} else {
|
|
2733
|
+
try {
|
|
2734
|
+
parsed = JSON.parse(text);
|
|
2735
|
+
} catch (err) {
|
|
2736
|
+
const parseMessage = String(err?.message || err);
|
|
2737
|
+
const parseContext = [parseMessage, result?.stderr || "", text.slice(0, 512)]
|
|
2738
|
+
.filter(Boolean)
|
|
2739
|
+
.join("\n");
|
|
2740
|
+
if (
|
|
2741
|
+
isGhTransientError(parseContext) &&
|
|
2742
|
+
transientRetries < maxTransientRetries
|
|
2743
|
+
) {
|
|
2744
|
+
transientRetries += 1;
|
|
2745
|
+
console.warn(
|
|
2746
|
+
`${TAG} transient gh JSON parse failure (attempt ${transientRetries}/${maxTransientRetries}), retrying in ${this._transientRetryDelayMs}ms...`,
|
|
2747
|
+
);
|
|
2748
|
+
await sleepMs(this._transientRetryDelayMs);
|
|
2749
|
+
continue;
|
|
2750
|
+
}
|
|
2751
|
+
throw new Error(`gh CLI returned invalid JSON: ${parseMessage}`);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2278
2754
|
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
.filter(Boolean)
|
|
2285
|
-
.join("\n");
|
|
2286
|
-
if (
|
|
2287
|
-
isGhTransientError(parseContext) &&
|
|
2288
|
-
transientRetries < maxTransientRetries
|
|
2289
|
-
) {
|
|
2290
|
-
transientRetries += 1;
|
|
2291
|
-
console.warn(
|
|
2292
|
-
`${TAG} transient gh JSON parse failure (attempt ${transientRetries}/${maxTransientRetries}), retrying in ${this._transientRetryDelayMs}ms...`,
|
|
2293
|
-
);
|
|
2294
|
-
await sleepMs(this._transientRetryDelayMs);
|
|
2295
|
-
continue;
|
|
2755
|
+
if (readOnly && readCacheTtlMs > 0) {
|
|
2756
|
+
this._ghReadCache.set(requestKey, { data: parsed, ts: Date.now() });
|
|
2757
|
+
} else if (!readOnly) {
|
|
2758
|
+
// A successful mutation invalidates all request-level read caches.
|
|
2759
|
+
this._clearGhReadCaches();
|
|
2296
2760
|
}
|
|
2297
|
-
|
|
2761
|
+
return parsed;
|
|
2298
2762
|
}
|
|
2763
|
+
};
|
|
2764
|
+
|
|
2765
|
+
const promise = run();
|
|
2766
|
+
if (readOnly) this._ghInflight.set(requestKey, promise);
|
|
2767
|
+
try {
|
|
2768
|
+
return await promise;
|
|
2769
|
+
} finally {
|
|
2770
|
+
if (readOnly) this._ghInflight.delete(requestKey);
|
|
2299
2771
|
}
|
|
2300
2772
|
}
|
|
2301
2773
|
async _ensureLabelExists(label) {
|
|
@@ -2434,7 +2906,11 @@ class GitHubIssuesAdapter {
|
|
|
2434
2906
|
}
|
|
2435
2907
|
|
|
2436
2908
|
// Check instance-level issue-list cache to avoid redundant gh API calls
|
|
2437
|
-
const listCacheKey = _issueListCacheKey(
|
|
2909
|
+
const listCacheKey = _issueListCacheKey(
|
|
2910
|
+
`${this._owner}/${this._repo}`,
|
|
2911
|
+
stateFilter,
|
|
2912
|
+
limit,
|
|
2913
|
+
);
|
|
2438
2914
|
const nowMs = Date.now();
|
|
2439
2915
|
const cachedList = this._issueListCache.get(listCacheKey);
|
|
2440
2916
|
let rawIssues;
|
|
@@ -2520,13 +2996,33 @@ class GitHubIssuesAdapter {
|
|
|
2520
2996
|
return normalized;
|
|
2521
2997
|
}
|
|
2522
2998
|
|
|
2523
|
-
async getTask(issueNumber) {
|
|
2524
|
-
const
|
|
2999
|
+
async getTask(issueNumber, options = {}) {
|
|
3000
|
+
const locator = this._resolveIssueLocator(issueNumber, options);
|
|
3001
|
+
const num = String(locator.number || "").replace(/^#/, "");
|
|
2525
3002
|
if (!/^\d+$/.test(num)) {
|
|
2526
3003
|
throw new Error(
|
|
2527
3004
|
`GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
|
|
2528
3005
|
);
|
|
2529
3006
|
}
|
|
3007
|
+
const repoRef = `${locator.owner}/${locator.repo}`;
|
|
3008
|
+
const issueUrl =
|
|
3009
|
+
locator.issueUrl || `https://github.com/${repoRef}/issues/${num}`;
|
|
3010
|
+
if (this._isIssueKnownMissing(num, { issueUrl })) {
|
|
3011
|
+
return {
|
|
3012
|
+
id: String(num),
|
|
3013
|
+
title: "",
|
|
3014
|
+
description: "",
|
|
3015
|
+
status: "todo",
|
|
3016
|
+
assignee: null,
|
|
3017
|
+
priority: null,
|
|
3018
|
+
projectId: repoRef,
|
|
3019
|
+
branchName: null,
|
|
3020
|
+
prNumber: null,
|
|
3021
|
+
meta: { externalMissing: true, externalMissingReason: "issue_not_found" },
|
|
3022
|
+
taskUrl: issueUrl,
|
|
3023
|
+
backend: "github",
|
|
3024
|
+
};
|
|
3025
|
+
}
|
|
2530
3026
|
let issue = null;
|
|
2531
3027
|
try {
|
|
2532
3028
|
issue = await this._gh([
|
|
@@ -2534,14 +3030,20 @@ class GitHubIssuesAdapter {
|
|
|
2534
3030
|
"view",
|
|
2535
3031
|
num,
|
|
2536
3032
|
"--repo",
|
|
2537
|
-
|
|
3033
|
+
repoRef,
|
|
2538
3034
|
"--json",
|
|
2539
3035
|
"number,title,body,state,url,assignees,labels,milestone,comments",
|
|
2540
3036
|
]);
|
|
3037
|
+
this._rememberIssueLocator(num, issue?.url || issueUrl);
|
|
3038
|
+
this._clearIssueMissing(num, { issueUrl: issue?.url || issueUrl });
|
|
2541
3039
|
} catch (err) {
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
)
|
|
3040
|
+
if (err?.isNotFound) {
|
|
3041
|
+
this._markIssueMissing(num, { issueUrl });
|
|
3042
|
+
} else if (!err?.isCooldown) {
|
|
3043
|
+
console.warn(
|
|
3044
|
+
`${TAG} failed to fetch issue #${num}: ${err.message || err}`,
|
|
3045
|
+
);
|
|
3046
|
+
}
|
|
2545
3047
|
}
|
|
2546
3048
|
const task = issue
|
|
2547
3049
|
? this._normaliseIssue(issue)
|
|
@@ -2552,13 +3054,19 @@ class GitHubIssuesAdapter {
|
|
|
2552
3054
|
status: "todo",
|
|
2553
3055
|
assignee: null,
|
|
2554
3056
|
priority: null,
|
|
2555
|
-
projectId:
|
|
3057
|
+
projectId: repoRef,
|
|
2556
3058
|
branchName: null,
|
|
2557
3059
|
prNumber: null,
|
|
2558
3060
|
meta: {},
|
|
2559
|
-
taskUrl:
|
|
3061
|
+
taskUrl: issueUrl,
|
|
2560
3062
|
backend: "github",
|
|
2561
3063
|
};
|
|
3064
|
+
if (!issue) {
|
|
3065
|
+
task.meta.externalMissing = this._isIssueKnownMissing(num, { issueUrl });
|
|
3066
|
+
if (task.meta.externalMissing) {
|
|
3067
|
+
task.meta.externalMissingReason = "issue_not_found";
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
2562
3070
|
|
|
2563
3071
|
if (issue && (!task.branchName || !task.prNumber)) {
|
|
2564
3072
|
const comments = Array.isArray(issue?.comments) ? issue.comments : [];
|
|
@@ -2577,31 +3085,40 @@ class GitHubIssuesAdapter {
|
|
|
2577
3085
|
}
|
|
2578
3086
|
|
|
2579
3087
|
// Enrich with shared state from comments
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
3088
|
+
if (issue) {
|
|
3089
|
+
try {
|
|
3090
|
+
const embeddedComments = Array.isArray(issue?.comments)
|
|
3091
|
+
? issue.comments
|
|
3092
|
+
: null;
|
|
3093
|
+
const sharedState = normalizeSharedStatePayload(
|
|
3094
|
+
await this.readSharedStateFromIssue(num, embeddedComments, {
|
|
3095
|
+
issueUrl: issue?.url || issueUrl,
|
|
3096
|
+
}),
|
|
3097
|
+
);
|
|
3098
|
+
if (sharedState) {
|
|
3099
|
+
task.meta.sharedState = sharedState;
|
|
3100
|
+
task.sharedState = sharedState;
|
|
3101
|
+
}
|
|
3102
|
+
} catch (err) {
|
|
3103
|
+
// Non-critical - continue without shared state
|
|
3104
|
+
console.warn(
|
|
3105
|
+
`[kanban] failed to read shared state for #${num}: ${err.message}`,
|
|
3106
|
+
);
|
|
2587
3107
|
}
|
|
2588
|
-
} catch (err) {
|
|
2589
|
-
// Non-critical - continue without shared state
|
|
2590
|
-
console.warn(
|
|
2591
|
-
`[kanban] failed to read shared state for #${num}: ${err.message}`,
|
|
2592
|
-
);
|
|
2593
3108
|
}
|
|
2594
3109
|
|
|
2595
3110
|
return task;
|
|
2596
3111
|
}
|
|
2597
3112
|
|
|
2598
3113
|
async updateTaskStatus(issueNumber, status, options = {}) {
|
|
2599
|
-
const
|
|
3114
|
+
const locator = this._resolveIssueLocator(issueNumber, options);
|
|
3115
|
+
const num = String(locator.number || "").replace(/^#/, "");
|
|
2600
3116
|
if (!/^\d+$/.test(num)) {
|
|
2601
3117
|
throw new Error(
|
|
2602
3118
|
`GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
|
|
2603
3119
|
);
|
|
2604
3120
|
}
|
|
3121
|
+
const repoRef = `${locator.owner}/${locator.repo}`;
|
|
2605
3122
|
|
|
2606
3123
|
// Invalidate the instance issue-list cache so the next listTasks() poll
|
|
2607
3124
|
// sees fresh data rather than stale cache.
|
|
@@ -2613,7 +3130,7 @@ class GitHubIssuesAdapter {
|
|
|
2613
3130
|
"close",
|
|
2614
3131
|
num,
|
|
2615
3132
|
"--repo",
|
|
2616
|
-
|
|
3133
|
+
repoRef,
|
|
2617
3134
|
];
|
|
2618
3135
|
if (normalised === "cancelled") {
|
|
2619
3136
|
closeArgs.push("--reason", "not planned");
|
|
@@ -2621,7 +3138,7 @@ class GitHubIssuesAdapter {
|
|
|
2621
3138
|
await this._gh(closeArgs, { parseJson: false });
|
|
2622
3139
|
} else {
|
|
2623
3140
|
await this._gh(
|
|
2624
|
-
["issue", "reopen", num, "--repo",
|
|
3141
|
+
["issue", "reopen", num, "--repo", repoRef],
|
|
2625
3142
|
{ parseJson: false },
|
|
2626
3143
|
);
|
|
2627
3144
|
|
|
@@ -2647,7 +3164,7 @@ class GitHubIssuesAdapter {
|
|
|
2647
3164
|
"edit",
|
|
2648
3165
|
num,
|
|
2649
3166
|
"--repo",
|
|
2650
|
-
|
|
3167
|
+
repoRef,
|
|
2651
3168
|
];
|
|
2652
3169
|
if (nextLabel) {
|
|
2653
3170
|
editArgs.push("--add-label", nextLabel);
|
|
@@ -2690,7 +3207,7 @@ class GitHubIssuesAdapter {
|
|
|
2690
3207
|
) {
|
|
2691
3208
|
const projectNumber = await this._resolveProjectNumber();
|
|
2692
3209
|
if (projectNumber) {
|
|
2693
|
-
const task = await this.getTask(num);
|
|
3210
|
+
const task = await this.getTask(num, { issueUrl: locator.issueUrl });
|
|
2694
3211
|
if (task?.taskUrl) {
|
|
2695
3212
|
try {
|
|
2696
3213
|
await this._syncStatusToProject(
|
|
@@ -2710,7 +3227,7 @@ class GitHubIssuesAdapter {
|
|
|
2710
3227
|
}
|
|
2711
3228
|
|
|
2712
3229
|
try {
|
|
2713
|
-
return await this.getTask(issueNumber);
|
|
3230
|
+
return await this.getTask(issueNumber, { issueUrl: locator.issueUrl });
|
|
2714
3231
|
} catch (err) {
|
|
2715
3232
|
console.warn(
|
|
2716
3233
|
`${TAG} failed to fetch updated issue #${num} after status change: ${err.message}`,
|
|
@@ -2722,29 +3239,31 @@ class GitHubIssuesAdapter {
|
|
|
2722
3239
|
status: normalised,
|
|
2723
3240
|
assignee: null,
|
|
2724
3241
|
priority: null,
|
|
2725
|
-
projectId:
|
|
3242
|
+
projectId: repoRef,
|
|
2726
3243
|
branchName: null,
|
|
2727
3244
|
prNumber: null,
|
|
2728
3245
|
meta: {},
|
|
2729
|
-
taskUrl:
|
|
3246
|
+
taskUrl: locator.issueUrl || `https://github.com/${repoRef}/issues/${num}`,
|
|
2730
3247
|
backend: "github",
|
|
2731
3248
|
};
|
|
2732
3249
|
}
|
|
2733
3250
|
}
|
|
2734
3251
|
|
|
2735
3252
|
async updateTask(issueNumber, patch = {}) {
|
|
2736
|
-
const
|
|
3253
|
+
const locator = this._resolveIssueLocator(issueNumber);
|
|
3254
|
+
const num = String(locator.number || "").replace(/^#/, "");
|
|
2737
3255
|
if (!/^\d+$/.test(num)) {
|
|
2738
3256
|
throw new Error(
|
|
2739
3257
|
`GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID, got a UUID or non-numeric string`,
|
|
2740
3258
|
);
|
|
2741
3259
|
}
|
|
3260
|
+
const repoRef = `${locator.owner}/${locator.repo}`;
|
|
2742
3261
|
const editArgs = [
|
|
2743
3262
|
"issue",
|
|
2744
3263
|
"edit",
|
|
2745
3264
|
num,
|
|
2746
3265
|
"--repo",
|
|
2747
|
-
|
|
3266
|
+
repoRef,
|
|
2748
3267
|
];
|
|
2749
3268
|
let hasEditArgs = false;
|
|
2750
3269
|
if (typeof patch.title === "string") {
|
|
@@ -2769,7 +3288,7 @@ class GitHubIssuesAdapter {
|
|
|
2769
3288
|
"view",
|
|
2770
3289
|
num,
|
|
2771
3290
|
"--repo",
|
|
2772
|
-
|
|
3291
|
+
repoRef,
|
|
2773
3292
|
"--json",
|
|
2774
3293
|
"labels",
|
|
2775
3294
|
]);
|
|
@@ -2811,7 +3330,7 @@ class GitHubIssuesAdapter {
|
|
|
2811
3330
|
"edit",
|
|
2812
3331
|
num,
|
|
2813
3332
|
"--repo",
|
|
2814
|
-
|
|
3333
|
+
repoRef,
|
|
2815
3334
|
];
|
|
2816
3335
|
for (const label of toAdd) {
|
|
2817
3336
|
labelArgs.push("--add-label", label);
|
|
@@ -3059,18 +3578,20 @@ class GitHubIssuesAdapter {
|
|
|
3059
3578
|
|
|
3060
3579
|
async deleteTask(issueNumber) {
|
|
3061
3580
|
// GitHub issues can't be deleted — close with "not planned"
|
|
3062
|
-
const
|
|
3581
|
+
const locator = this._resolveIssueLocator(issueNumber);
|
|
3582
|
+
const num = String(locator.number || "").replace(/^#/, "");
|
|
3063
3583
|
if (!/^\d+$/.test(num)) {
|
|
3064
3584
|
throw new Error(
|
|
3065
3585
|
`GitHub Issues: invalid issue number "${issueNumber}" — expected a numeric ID`,
|
|
3066
3586
|
);
|
|
3067
3587
|
}
|
|
3588
|
+
const repoRef = `${locator.owner}/${locator.repo}`;
|
|
3068
3589
|
await this._gh([
|
|
3069
3590
|
"issue",
|
|
3070
3591
|
"close",
|
|
3071
3592
|
num,
|
|
3072
3593
|
"--repo",
|
|
3073
|
-
|
|
3594
|
+
repoRef,
|
|
3074
3595
|
"--reason",
|
|
3075
3596
|
"not planned",
|
|
3076
3597
|
]);
|
|
@@ -3078,8 +3599,10 @@ class GitHubIssuesAdapter {
|
|
|
3078
3599
|
}
|
|
3079
3600
|
|
|
3080
3601
|
async addComment(issueNumber, body) {
|
|
3081
|
-
const
|
|
3602
|
+
const locator = this._resolveIssueLocator(issueNumber);
|
|
3603
|
+
const num = String(locator.number || "").replace(/^#/, "");
|
|
3082
3604
|
if (!/^\d+$/.test(num) || !body) return false;
|
|
3605
|
+
const repoRef = `${locator.owner}/${locator.repo}`;
|
|
3083
3606
|
try {
|
|
3084
3607
|
await this._gh(
|
|
3085
3608
|
[
|
|
@@ -3087,7 +3610,7 @@ class GitHubIssuesAdapter {
|
|
|
3087
3610
|
"comment",
|
|
3088
3611
|
num,
|
|
3089
3612
|
"--repo",
|
|
3090
|
-
|
|
3613
|
+
repoRef,
|
|
3091
3614
|
"--body",
|
|
3092
3615
|
String(body).slice(0, 65536),
|
|
3093
3616
|
],
|
|
@@ -3125,10 +3648,14 @@ class GitHubIssuesAdapter {
|
|
|
3125
3648
|
* });
|
|
3126
3649
|
*/
|
|
3127
3650
|
async persistSharedStateToIssue(issueNumber, sharedState) {
|
|
3128
|
-
const
|
|
3651
|
+
const locator = this._resolveIssueLocator(issueNumber);
|
|
3652
|
+
const num = String(locator.number || "").replace(/^#/, "");
|
|
3129
3653
|
if (!/^\d+$/.test(num)) {
|
|
3130
3654
|
throw new Error(`Invalid issue number: ${issueNumber}`);
|
|
3131
3655
|
}
|
|
3656
|
+
const repoRef = `${locator.owner}/${locator.repo}`;
|
|
3657
|
+
const issueUrl =
|
|
3658
|
+
locator.issueUrl || `https://github.com/${repoRef}/issues/${num}`;
|
|
3132
3659
|
const normalizedState = normalizeSharedStatePayload(sharedState);
|
|
3133
3660
|
if (!normalizedState) {
|
|
3134
3661
|
throw new Error(`Invalid shared state payload for issue #${num}`);
|
|
@@ -3183,7 +3710,7 @@ class GitHubIssuesAdapter {
|
|
|
3183
3710
|
"edit",
|
|
3184
3711
|
num,
|
|
3185
3712
|
"--repo",
|
|
3186
|
-
|
|
3713
|
+
repoRef,
|
|
3187
3714
|
];
|
|
3188
3715
|
|
|
3189
3716
|
// Remove old codex labels
|
|
@@ -3210,7 +3737,7 @@ class GitHubIssuesAdapter {
|
|
|
3210
3737
|
|
|
3211
3738
|
// 2. Create/update structured comment
|
|
3212
3739
|
const commentSuccess = await attemptWithRetry(async () => {
|
|
3213
|
-
const comments = await this._getIssueComments(num);
|
|
3740
|
+
const comments = await this._getIssueComments(num, { issueUrl });
|
|
3214
3741
|
const stateCommentIndex = comments.findIndex((c) =>
|
|
3215
3742
|
c.body?.includes("<!-- bosun-state"),
|
|
3216
3743
|
);
|
|
@@ -3231,7 +3758,7 @@ ${stateJson}
|
|
|
3231
3758
|
await this._gh(
|
|
3232
3759
|
[
|
|
3233
3760
|
"api",
|
|
3234
|
-
`/repos/${
|
|
3761
|
+
`/repos/${locator.owner}/${locator.repo}/issues/comments/${commentId}`,
|
|
3235
3762
|
"-X",
|
|
3236
3763
|
"PATCH",
|
|
3237
3764
|
"-f",
|
|
@@ -3248,7 +3775,7 @@ ${stateJson}
|
|
|
3248
3775
|
|
|
3249
3776
|
// Invalidate the shared-state cache so the next read fetches fresh data
|
|
3250
3777
|
if (commentSuccess) {
|
|
3251
|
-
this._sharedStateCache.delete(_sharedStateCacheKey(num));
|
|
3778
|
+
this._sharedStateCache.delete(_sharedStateCacheKey(num, locator.repoKey));
|
|
3252
3779
|
}
|
|
3253
3780
|
|
|
3254
3781
|
return commentSuccess;
|
|
@@ -3271,7 +3798,7 @@ ${stateJson}
|
|
|
3271
3798
|
*/
|
|
3272
3799
|
async readSharedStateFromIssue(issueNumber, cachedComments = null, options = {}) {
|
|
3273
3800
|
const issueUrl = String(options?.issueUrl || "").trim();
|
|
3274
|
-
const locator =
|
|
3801
|
+
const locator = parseIssueRefLocator(
|
|
3275
3802
|
issueNumber,
|
|
3276
3803
|
this._owner,
|
|
3277
3804
|
this._repo,
|
|
@@ -3281,6 +3808,9 @@ ${stateJson}
|
|
|
3281
3808
|
if (!/^\d+$/.test(num)) {
|
|
3282
3809
|
throw new Error(`Invalid issue number: ${issueNumber}`);
|
|
3283
3810
|
}
|
|
3811
|
+
if (issueUrl) {
|
|
3812
|
+
this._rememberIssueLocator(num, issueUrl);
|
|
3813
|
+
}
|
|
3284
3814
|
|
|
3285
3815
|
// If no pre-fetched comments, check the instance-level shared-state cache
|
|
3286
3816
|
// to avoid a separate API call per issue during bulk listTasks cycles.
|
|
@@ -3370,10 +3900,12 @@ ${stateJson}
|
|
|
3370
3900
|
* await adapter.markTaskIgnored(123, "Task requires manual security review");
|
|
3371
3901
|
*/
|
|
3372
3902
|
async markTaskIgnored(issueNumber, reason) {
|
|
3373
|
-
const
|
|
3903
|
+
const locator = this._resolveIssueLocator(issueNumber);
|
|
3904
|
+
const num = String(locator.number || "").replace(/^#/, "");
|
|
3374
3905
|
if (!/^\d+$/.test(num)) {
|
|
3375
3906
|
throw new Error(`Invalid issue number: ${issueNumber}`);
|
|
3376
3907
|
}
|
|
3908
|
+
const repoRef = `${locator.owner}/${locator.repo}`;
|
|
3377
3909
|
|
|
3378
3910
|
try {
|
|
3379
3911
|
// Add codex:ignore label
|
|
@@ -3383,7 +3915,7 @@ ${stateJson}
|
|
|
3383
3915
|
"edit",
|
|
3384
3916
|
num,
|
|
3385
3917
|
"--repo",
|
|
3386
|
-
|
|
3918
|
+
repoRef,
|
|
3387
3919
|
"--add-label",
|
|
3388
3920
|
this._codexLabels.ignore,
|
|
3389
3921
|
],
|
|
@@ -3417,10 +3949,12 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
|
|
|
3417
3949
|
* @returns {Promise<boolean>} Success status
|
|
3418
3950
|
*/
|
|
3419
3951
|
async unmarkTaskIgnored(issueNumber) {
|
|
3420
|
-
const
|
|
3952
|
+
const locator = this._resolveIssueLocator(issueNumber);
|
|
3953
|
+
const num = String(locator.number || "").replace(/^#/, "");
|
|
3421
3954
|
if (!/^\d+$/.test(num)) {
|
|
3422
3955
|
throw new Error(`Invalid issue number: ${issueNumber}`);
|
|
3423
3956
|
}
|
|
3957
|
+
const repoRef = `${locator.owner}/${locator.repo}`;
|
|
3424
3958
|
|
|
3425
3959
|
try {
|
|
3426
3960
|
await this._gh(
|
|
@@ -3429,7 +3963,7 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
|
|
|
3429
3963
|
"edit",
|
|
3430
3964
|
num,
|
|
3431
3965
|
"--repo",
|
|
3432
|
-
|
|
3966
|
+
repoRef,
|
|
3433
3967
|
"--remove-label",
|
|
3434
3968
|
this._codexLabels.ignore,
|
|
3435
3969
|
],
|
|
@@ -3449,12 +3983,15 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
|
|
|
3449
3983
|
* @private
|
|
3450
3984
|
*/
|
|
3451
3985
|
async _getIssueLabels(issueNumber) {
|
|
3986
|
+
const locator = this._resolveIssueLocator(issueNumber);
|
|
3987
|
+
const num = String(locator.number || "").replace(/^#/, "");
|
|
3988
|
+
const repoRef = `${locator.owner}/${locator.repo}`;
|
|
3452
3989
|
const issue = await this._gh([
|
|
3453
3990
|
"issue",
|
|
3454
3991
|
"view",
|
|
3455
|
-
|
|
3992
|
+
num,
|
|
3456
3993
|
"--repo",
|
|
3457
|
-
|
|
3994
|
+
repoRef,
|
|
3458
3995
|
"--json",
|
|
3459
3996
|
"labels",
|
|
3460
3997
|
]);
|
|
@@ -3469,12 +4006,21 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
|
|
|
3469
4006
|
*/
|
|
3470
4007
|
async _getIssueComments(issueNumber, options = {}) {
|
|
3471
4008
|
const issueUrl = String(options?.issueUrl || "").trim();
|
|
3472
|
-
const locator =
|
|
4009
|
+
const locator = parseIssueRefLocator(
|
|
3473
4010
|
issueNumber,
|
|
3474
4011
|
this._owner,
|
|
3475
4012
|
this._repo,
|
|
3476
4013
|
issueUrl,
|
|
3477
4014
|
);
|
|
4015
|
+
const resolvedIssueUrl =
|
|
4016
|
+
issueUrl ||
|
|
4017
|
+
`https://github.com/${locator.owner}/${locator.repo}/issues/${locator.number}`;
|
|
4018
|
+
if (/^\d+$/.test(locator.number)) {
|
|
4019
|
+
this._rememberIssueLocator(locator.number, resolvedIssueUrl);
|
|
4020
|
+
}
|
|
4021
|
+
if (this._isIssueKnownMissing(locator.number, { issueUrl: resolvedIssueUrl })) {
|
|
4022
|
+
return [];
|
|
4023
|
+
}
|
|
3478
4024
|
try {
|
|
3479
4025
|
const result = await this._gh([
|
|
3480
4026
|
"api",
|
|
@@ -3482,11 +4028,16 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
|
|
|
3482
4028
|
"--jq",
|
|
3483
4029
|
".",
|
|
3484
4030
|
]);
|
|
4031
|
+
this._clearIssueMissing(locator.number, { issueUrl: resolvedIssueUrl });
|
|
3485
4032
|
return Array.isArray(result) ? result : [];
|
|
3486
4033
|
} catch (err) {
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
4034
|
+
if (err?.isNotFound) {
|
|
4035
|
+
this._markIssueMissing(locator.number, { issueUrl: resolvedIssueUrl });
|
|
4036
|
+
} else {
|
|
4037
|
+
console.warn(
|
|
4038
|
+
`[kanban] failed to fetch comments for ${locator.owner}/${locator.repo}#${locator.number}: ${err.message}`,
|
|
4039
|
+
);
|
|
4040
|
+
}
|
|
3490
4041
|
return [];
|
|
3491
4042
|
}
|
|
3492
4043
|
}
|
|
@@ -3643,6 +4194,24 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
|
|
|
3643
4194
|
|
|
3644
4195
|
_normaliseIssue(issue) {
|
|
3645
4196
|
if (!issue) return null;
|
|
4197
|
+
const fallbackNum = String(issue.number || "").trim();
|
|
4198
|
+
const fallbackUrl = fallbackNum
|
|
4199
|
+
? `https://github.com/${this._owner}/${this._repo}/issues/${fallbackNum}`
|
|
4200
|
+
: null;
|
|
4201
|
+
const locator = parseIssueRefLocator(
|
|
4202
|
+
fallbackNum,
|
|
4203
|
+
this._owner,
|
|
4204
|
+
this._repo,
|
|
4205
|
+
issue.url || fallbackUrl || "",
|
|
4206
|
+
);
|
|
4207
|
+
const issueNum = /^\d+$/.test(locator.number)
|
|
4208
|
+
? locator.number
|
|
4209
|
+
: fallbackNum;
|
|
4210
|
+
const issueUrl = issue.url || fallbackUrl || null;
|
|
4211
|
+
const projectId = `${locator.owner}/${locator.repo}`;
|
|
4212
|
+
if (issueNum && issueUrl) {
|
|
4213
|
+
this._rememberIssueLocator(issueNum, issueUrl);
|
|
4214
|
+
}
|
|
3646
4215
|
const labels = (issue.labels || []).map((l) =>
|
|
3647
4216
|
typeof l === "string" ? l : l.name,
|
|
3648
4217
|
);
|
|
@@ -3695,14 +4264,14 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
|
|
|
3695
4264
|
createdAt: comment.createdAt,
|
|
3696
4265
|
}),
|
|
3697
4266
|
);
|
|
3698
|
-
const localAttachments = listTaskAttachments(
|
|
4267
|
+
const localAttachments = listTaskAttachments(issueNum, "github");
|
|
3699
4268
|
const mergedAttachments = mergeTaskAttachments(
|
|
3700
4269
|
mergeTaskAttachments(descriptionAttachments, commentAttachments),
|
|
3701
4270
|
localAttachments,
|
|
3702
4271
|
);
|
|
3703
4272
|
|
|
3704
4273
|
return {
|
|
3705
|
-
id: String(
|
|
4274
|
+
id: String(issueNum || ""),
|
|
3706
4275
|
title: issue.title || "",
|
|
3707
4276
|
description: issue.body || "",
|
|
3708
4277
|
status,
|
|
@@ -3714,7 +4283,7 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
|
|
|
3714
4283
|
: null,
|
|
3715
4284
|
tags,
|
|
3716
4285
|
draft: labelSet.has("draft") || status === "draft",
|
|
3717
|
-
projectId
|
|
4286
|
+
projectId,
|
|
3718
4287
|
baseBranch,
|
|
3719
4288
|
branchName: branchMatch?.[1] || null,
|
|
3720
4289
|
prNumber: prMatch?.[1] || null,
|
|
@@ -3722,14 +4291,14 @@ To re-enable bosun for this task, remove the \`${this._codexLabels.ignore}\` lab
|
|
|
3722
4291
|
comments,
|
|
3723
4292
|
meta: {
|
|
3724
4293
|
...issue,
|
|
3725
|
-
task_url:
|
|
4294
|
+
task_url: issueUrl,
|
|
3726
4295
|
tags,
|
|
3727
4296
|
comments,
|
|
3728
4297
|
attachments: mergedAttachments,
|
|
3729
4298
|
...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
|
|
3730
4299
|
codex: codexMeta,
|
|
3731
4300
|
},
|
|
3732
|
-
taskUrl:
|
|
4301
|
+
taskUrl: issueUrl,
|
|
3733
4302
|
backend: "github",
|
|
3734
4303
|
};
|
|
3735
4304
|
}
|
|
@@ -5225,6 +5794,10 @@ export function setKanbanBackend(name) {
|
|
|
5225
5794
|
`${TAG} unknown kanban backend: "${name}". Valid: ${Object.keys(ADAPTERS).join(", ")}`,
|
|
5226
5795
|
);
|
|
5227
5796
|
}
|
|
5797
|
+
if (normalised === "github") {
|
|
5798
|
+
// Avoid carrying stale GH cache state across explicit backend switches.
|
|
5799
|
+
clearGitHubAdapterCacheBuckets();
|
|
5800
|
+
}
|
|
5228
5801
|
activeBackendName = normalised;
|
|
5229
5802
|
activeAdapter = null; // Force re-create on next getKanbanAdapter()
|
|
5230
5803
|
console.log(`${TAG} switched to ${normalised} backend`);
|