opencode-forgekeeper 2.1.0 → 2.1.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 CHANGED
@@ -148,6 +148,11 @@ reuses a branch's PR/MR number for up to 10 minutes.
148
148
  Explicit targets that aren't PRs/MRs, such as issues, show no status. Unresolved explicit
149
149
  PRs/MRs never fall back to the branch.
150
150
 
151
+ Without a PR/MR to show, the footer stays empty, even when a lookup fails, such as in a new
152
+ session before the server answers. It shows `PR/MR status unavailable` only when none of
153
+ the session's explicit PR/MR targets can be looked up or found. `/forge-status` explains
154
+ why.
155
+
151
156
  The server runs at most four `glab` or `gh` commands at once for status and review
152
157
  feedback, across all sessions. When some target lookups fail, the footer shows the others
153
158
  and counts the failures as `N unavailable`. A target whose lookup failed with an error
package/dist/index.js CHANGED
@@ -1255,6 +1255,175 @@ function createFeedbackWatcher(options) {
1255
1255
  };
1256
1256
  }
1257
1257
 
1258
+ // packages/forgekeeper/src/store.ts
1259
+ class TargetLookupError extends Error {
1260
+ cause;
1261
+ constructor(cause) {
1262
+ super(cause instanceof Error ? cause.message : String(cause));
1263
+ this.cause = cause;
1264
+ }
1265
+ }
1266
+ function createStatusStore(options) {
1267
+ const now = options.now ?? Date.now;
1268
+ const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay));
1269
+ const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
1270
+ const interval = options.interval ?? 120000;
1271
+ const maxBackoff = options.maxBackoff ?? 900000;
1272
+ const minEventInterval = options.minEventInterval ?? 15000;
1273
+ const entries = new Map;
1274
+ let disposed = false;
1275
+ let revision = now() * 1000;
1276
+ const entry = (key2) => {
1277
+ let value = entries.get(key2);
1278
+ if (!value) {
1279
+ value = { snapshot: { loading: false }, refs: 0, failures: 0 };
1280
+ entries.set(key2, value);
1281
+ }
1282
+ return value;
1283
+ };
1284
+ const update = (key2, value, patch) => {
1285
+ value.snapshot = { ...value.snapshot, ...patch, revision: ++revision };
1286
+ if (!disposed)
1287
+ options.onChange(key2);
1288
+ };
1289
+ const cancelTimer = (value) => {
1290
+ if (value.timer === undefined)
1291
+ return;
1292
+ clearTimer(value.timer);
1293
+ value.timer = undefined;
1294
+ };
1295
+ const baseInterval = (value) => options.active?.(value.snapshot.lookup) ? options.activeInterval ?? interval : interval;
1296
+ const pollDelay = (value) => Math.min(baseInterval(value) * 2 ** value.failures, maxBackoff);
1297
+ const lastAttempt = (value) => Math.max(value.snapshot.fetchedAt ?? -Infinity, value.snapshot.error?.at ?? -Infinity);
1298
+ const schedule = (key2, value, delay = pollDelay(value)) => {
1299
+ cancelTimer(value);
1300
+ if (disposed || value.refs === 0)
1301
+ return;
1302
+ const lookup = value.snapshot.lookup;
1303
+ if (!value.snapshot.error && (lookup?.kind !== "found" || lookup.requests.length === 0))
1304
+ return;
1305
+ value.timer = setTimer(() => {
1306
+ value.timer = undefined;
1307
+ refresh(key2);
1308
+ }, delay);
1309
+ };
1310
+ function refresh(key2) {
1311
+ if (disposed)
1312
+ return Promise.resolve({ loading: false });
1313
+ const value = entry(key2);
1314
+ if (value.inflight)
1315
+ return value.inflight;
1316
+ value.stale = false;
1317
+ update(key2, value, { loading: true });
1318
+ value.inflight = options.load(key2).then((lookup) => {
1319
+ if (disposed || value.invalidated)
1320
+ return;
1321
+ const previous = value.snapshot.lookup;
1322
+ value.failures = 0;
1323
+ update(key2, value, {
1324
+ lookup,
1325
+ fetchedAt: now(),
1326
+ error: undefined,
1327
+ targeted: lookup.explicitTarget === true ? true : undefined,
1328
+ loading: false
1329
+ });
1330
+ try {
1331
+ if (!disposed)
1332
+ options.onLoad?.(key2, previous, lookup);
1333
+ } catch (error) {
1334
+ try {
1335
+ options.onLoadError?.(error);
1336
+ } catch {}
1337
+ }
1338
+ }, (error) => {
1339
+ if (disposed || value.invalidated)
1340
+ return;
1341
+ value.failures++;
1342
+ const target2 = error instanceof TargetLookupError;
1343
+ const cause = target2 ? error.cause : error;
1344
+ update(key2, value, {
1345
+ loading: false,
1346
+ error: {
1347
+ kind: cause instanceof ForgeError ? cause.kind : "request",
1348
+ message: cause instanceof Error ? cause.message : String(cause),
1349
+ at: now()
1350
+ },
1351
+ ...target2 ? { targeted: true } : {}
1352
+ });
1353
+ }).then(() => {
1354
+ value.inflight = undefined;
1355
+ if (value.invalidated && !disposed) {
1356
+ value.invalidated = false;
1357
+ return refresh(key2);
1358
+ }
1359
+ schedule(key2, value);
1360
+ return value.snapshot;
1361
+ });
1362
+ return value.inflight;
1363
+ }
1364
+ return {
1365
+ get(key2) {
1366
+ return entries.get(key2)?.snapshot ?? { loading: false };
1367
+ },
1368
+ acquire(key2) {
1369
+ const value = entry(key2);
1370
+ value.refs++;
1371
+ if (value.refs === 1 && !value.inflight) {
1372
+ const remaining = lastAttempt(value) + pollDelay(value) - now();
1373
+ if (value.stale || remaining <= 0)
1374
+ refresh(key2);
1375
+ else
1376
+ schedule(key2, value, remaining);
1377
+ }
1378
+ let released = false;
1379
+ return () => {
1380
+ if (released)
1381
+ return;
1382
+ released = true;
1383
+ value.refs--;
1384
+ if (value.refs === 0)
1385
+ cancelTimer(value);
1386
+ };
1387
+ },
1388
+ refresh,
1389
+ keys() {
1390
+ return [...entries.keys()];
1391
+ },
1392
+ notify(key2) {
1393
+ const value = entries.get(key2);
1394
+ if (!value || value.refs === 0 || value.inflight)
1395
+ return;
1396
+ const last = Math.max(value.snapshot.fetchedAt ?? 0, value.snapshot.error?.at ?? 0);
1397
+ if (now() - last < minEventInterval)
1398
+ return;
1399
+ refresh(key2);
1400
+ },
1401
+ invalidate(key2) {
1402
+ const value = entries.get(key2);
1403
+ if (!value)
1404
+ return;
1405
+ if (value.inflight)
1406
+ value.invalidated = true;
1407
+ else if (value.refs > 0)
1408
+ refresh(key2);
1409
+ else
1410
+ value.stale = true;
1411
+ },
1412
+ setTargeted(key2, targeted) {
1413
+ const value = entries.get(key2);
1414
+ if (!value || disposed || value.snapshot.targeted === true === targeted)
1415
+ return;
1416
+ update(key2, value, { targeted: targeted ? true : undefined });
1417
+ },
1418
+ dispose() {
1419
+ disposed = true;
1420
+ for (const value of entries.values())
1421
+ cancelTimer(value);
1422
+ entries.clear();
1423
+ }
1424
+ };
1425
+ }
1426
+
1258
1427
  // packages/forgekeeper/src/target.ts
1259
1428
  var MAX_TARGETS = 50;
1260
1429
  var PREFIX_REFERENCES = 4;
@@ -1466,11 +1635,12 @@ async function locateTargets(forges, directory, targets, previous) {
1466
1635
  });
1467
1636
  });
1468
1637
  if (error !== undefined && !outcomes.some((outcome) => ("request" in outcome)))
1469
- throw error;
1638
+ throw new TargetLookupError(error);
1470
1639
  if (found.length === 0)
1471
1640
  return {
1472
1641
  kind: "none",
1473
- reason: requests2.length === 1 ? failed[0].reason : "No session target PR/MR could be resolved"
1642
+ reason: requests2.length === 1 ? failed[0].reason : "No session target PR/MR could be resolved",
1643
+ explicitTarget: true
1474
1644
  };
1475
1645
  return {
1476
1646
  kind: "found",
@@ -1516,154 +1686,6 @@ async function locate(forges, hints) {
1516
1686
  };
1517
1687
  }
1518
1688
 
1519
- // packages/forgekeeper/src/store.ts
1520
- function createStatusStore(options) {
1521
- const now = options.now ?? Date.now;
1522
- const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay));
1523
- const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
1524
- const interval = options.interval ?? 120000;
1525
- const maxBackoff = options.maxBackoff ?? 900000;
1526
- const minEventInterval = options.minEventInterval ?? 15000;
1527
- const entries = new Map;
1528
- let disposed = false;
1529
- let revision = now() * 1000;
1530
- const entry = (key2) => {
1531
- let value = entries.get(key2);
1532
- if (!value) {
1533
- value = { snapshot: { loading: false }, refs: 0, failures: 0 };
1534
- entries.set(key2, value);
1535
- }
1536
- return value;
1537
- };
1538
- const update = (key2, value, patch) => {
1539
- value.snapshot = { ...value.snapshot, ...patch, revision: ++revision };
1540
- if (!disposed)
1541
- options.onChange(key2);
1542
- };
1543
- const cancelTimer = (value) => {
1544
- if (value.timer === undefined)
1545
- return;
1546
- clearTimer(value.timer);
1547
- value.timer = undefined;
1548
- };
1549
- const baseInterval = (value) => options.active?.(value.snapshot.lookup) ? options.activeInterval ?? interval : interval;
1550
- const pollDelay = (value) => Math.min(baseInterval(value) * 2 ** value.failures, maxBackoff);
1551
- const lastAttempt = (value) => Math.max(value.snapshot.fetchedAt ?? -Infinity, value.snapshot.error?.at ?? -Infinity);
1552
- const schedule = (key2, value, delay = pollDelay(value)) => {
1553
- cancelTimer(value);
1554
- if (disposed || value.refs === 0)
1555
- return;
1556
- const lookup = value.snapshot.lookup;
1557
- if (!value.snapshot.error && (lookup?.kind !== "found" || lookup.requests.length === 0))
1558
- return;
1559
- value.timer = setTimer(() => {
1560
- value.timer = undefined;
1561
- refresh(key2);
1562
- }, delay);
1563
- };
1564
- function refresh(key2) {
1565
- if (disposed)
1566
- return Promise.resolve({ loading: false });
1567
- const value = entry(key2);
1568
- if (value.inflight)
1569
- return value.inflight;
1570
- value.stale = false;
1571
- update(key2, value, { loading: true });
1572
- value.inflight = options.load(key2).then((lookup) => {
1573
- if (disposed || value.invalidated)
1574
- return;
1575
- const previous = value.snapshot.lookup;
1576
- value.failures = 0;
1577
- update(key2, value, { lookup, fetchedAt: now(), error: undefined, loading: false });
1578
- try {
1579
- if (!disposed)
1580
- options.onLoad?.(key2, previous, lookup);
1581
- } catch (error) {
1582
- try {
1583
- options.onLoadError?.(error);
1584
- } catch {}
1585
- }
1586
- }, (error) => {
1587
- if (disposed || value.invalidated)
1588
- return;
1589
- value.failures++;
1590
- const known = error instanceof ForgeError;
1591
- update(key2, value, {
1592
- loading: false,
1593
- error: {
1594
- kind: known ? error.kind : "request",
1595
- message: error instanceof Error ? error.message : String(error),
1596
- at: now()
1597
- }
1598
- });
1599
- }).then(() => {
1600
- value.inflight = undefined;
1601
- if (value.invalidated && !disposed) {
1602
- value.invalidated = false;
1603
- return refresh(key2);
1604
- }
1605
- schedule(key2, value);
1606
- return value.snapshot;
1607
- });
1608
- return value.inflight;
1609
- }
1610
- return {
1611
- get(key2) {
1612
- return entries.get(key2)?.snapshot ?? { loading: false };
1613
- },
1614
- acquire(key2) {
1615
- const value = entry(key2);
1616
- value.refs++;
1617
- if (value.refs === 1 && !value.inflight) {
1618
- const remaining = lastAttempt(value) + pollDelay(value) - now();
1619
- if (value.stale || remaining <= 0)
1620
- refresh(key2);
1621
- else
1622
- schedule(key2, value, remaining);
1623
- }
1624
- let released = false;
1625
- return () => {
1626
- if (released)
1627
- return;
1628
- released = true;
1629
- value.refs--;
1630
- if (value.refs === 0)
1631
- cancelTimer(value);
1632
- };
1633
- },
1634
- refresh,
1635
- keys() {
1636
- return [...entries.keys()];
1637
- },
1638
- notify(key2) {
1639
- const value = entries.get(key2);
1640
- if (!value || value.refs === 0 || value.inflight)
1641
- return;
1642
- const last = Math.max(value.snapshot.fetchedAt ?? 0, value.snapshot.error?.at ?? 0);
1643
- if (now() - last < minEventInterval)
1644
- return;
1645
- refresh(key2);
1646
- },
1647
- invalidate(key2) {
1648
- const value = entries.get(key2);
1649
- if (!value)
1650
- return;
1651
- if (value.inflight)
1652
- value.invalidated = true;
1653
- else if (value.refs > 0)
1654
- refresh(key2);
1655
- else
1656
- value.stale = true;
1657
- },
1658
- dispose() {
1659
- disposed = true;
1660
- for (const value of entries.values())
1661
- cancelTimer(value);
1662
- entries.clear();
1663
- }
1664
- };
1665
- }
1666
-
1667
1689
  // packages/forgekeeper/src/status-service.ts
1668
1690
  function createStatusService(options) {
1669
1691
  const now = options.now ?? Date.now;
@@ -1791,6 +1813,7 @@ function createStatusService(options) {
1791
1813
  if (!disposed && !holds.has(key2) && liveLeases(key2).length > 0)
1792
1814
  store.refresh(key2);
1793
1815
  },
1816
+ setTargeted: (key2, targeted) => store.setTargeted(key2, targeted),
1794
1817
  notify: (key2) => store.notify(key2),
1795
1818
  keys: () => store.keys(),
1796
1819
  polling: (key2) => holds.has(key2),
@@ -1995,7 +2018,11 @@ async function setupStatus(ctx, settings, deps, host) {
1995
2018
  });
1996
2019
  return {
1997
2020
  service,
1998
- targetsChanged: (sessionID) => service.invalidate(sessionID),
2021
+ targetsChanged(sessionID, targets) {
2022
+ const classified = classifyTargets(targetOutput(targets)) ?? [];
2023
+ service.setTargeted(sessionID, classified.some((target2) => target2.kind === "merge-request"));
2024
+ service.invalidate(sessionID);
2025
+ },
1999
2026
  async titleChanged(sessionID) {
2000
2027
  if (!service.keys().includes(sessionID))
2001
2028
  return;
@@ -2243,7 +2270,7 @@ async function setupServer(ctx, lookups, status2) {
2243
2270
  targets: result.targets,
2244
2271
  ...state.prefix ? { prefix: state.prefix } : {}
2245
2272
  });
2246
- watcher?.targetsChanged(sessionID);
2273
+ watcher?.targetsChanged(sessionID, result.targets);
2247
2274
  await rpc.events.emit("targetChanged", { sessionID, ...targetOutput(result.targets) });
2248
2275
  await update(sessionID);
2249
2276
  });
package/dist/tui.js CHANGED
@@ -731,7 +731,7 @@ function footerSegments(snapshot) {
731
731
  const { lookup, error } = snapshot;
732
732
  const stale = error ? [{ text: "stale", tone: "warning", essential: true }] : [];
733
733
  if (!lookup || lookup.kind === "none" || lookup.requests.length === 0) {
734
- return error ? [{ text: "PR/MR status unavailable", tone: "warning" }] : [];
734
+ return snapshot.targeted && (error || lookup?.explicitTarget) ? [{ text: "PR/MR status unavailable", tone: "warning" }] : [];
735
735
  }
736
736
  if (lookup.failed?.length) {
737
737
  stale.unshift({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-forgekeeper",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "OpenCode plugin that tracks each session's GitHub and GitLab issues and PRs/MRs: title prefixes, a PR/MR status footer, and review notifications",
5
5
  "author": {
6
6
  "name": "Pedro Pombeiro",