@kud/gh-ink 0.29.1 → 0.31.0

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/dist/index.d.ts CHANGED
@@ -94,6 +94,7 @@ type GHDetail = {
94
94
  checksPass: number;
95
95
  checksFail: number;
96
96
  checksPending: number;
97
+ checksStale?: number;
97
98
  threadsTotal: number;
98
99
  lastCommitAt?: string;
99
100
  lastEventAt?: string;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import React2, { createContext, useState, useRef, useEffect, useMemo, useContext } from 'react';
2
2
  import { useWindowSize, useInput, Text, Box } from 'ink';
3
3
  import { colors, ScrollView, TextInput, LoadingScreen, StatusMessage, FooterHints, useListCursor, Tabs } from '@kud/ink-ui';
4
- import { isPassCheck, isFailCheck, resolveThread, unresolveThread, replyToThread, rerunFailedRun, mergePr, reRequestReviewer } from '@kud/gh';
4
+ import { isPassCheck, isFailCheck, isInconclusiveCheck, resolveThread, unresolveThread, replyToThread, rerunFailedRun, mergePr, reRequestReviewer } from '@kud/gh';
5
5
  import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
6
6
  import { $ } from 'zx';
7
7
  import { spawn } from 'child_process';
@@ -417,7 +417,7 @@ var CommentsPanel = ({
417
417
  ) : null
418
418
  ] });
419
419
  };
420
- var checkIcon = (c) => isPassCheck(c) ? ["\u2713", colors.success] : isFailCheck(c) ? ["\u2717", colors.error] : ["\xB7", colors.warning];
420
+ var checkIcon = (c) => isPassCheck(c) ? ["\u2713", colors.success] : isFailCheck(c) ? ["\u2717", colors.error] : isInconclusiveCheck(c) ? ["-", colors.warning] : ["\xB7", colors.warning];
421
421
  var checkLabel = (c) => {
422
422
  const name = c.context ?? c.name ?? "";
423
423
  return c.workflowName ? `${c.workflowName} / ${name}` : name;
@@ -491,9 +491,11 @@ var HealthPanel = ({
491
491
  }
492
492
  };
493
493
  const retrigger = () => {
494
- const failing = checks.find((c) => isFailCheck(c) && runIdOf(checkUrl(c)));
495
- const runId = failing && runIdOf(checkUrl(failing));
496
- if (!runId) return flash("No failed Actions run to retrigger");
494
+ const stuck = checks.find(
495
+ (c) => (isFailCheck(c) || isInconclusiveCheck(c)) && runIdOf(checkUrl(c))
496
+ );
497
+ const runId = stuck && runIdOf(checkUrl(stuck));
498
+ if (!runId) return flash("No Actions run to retrigger");
497
499
  void act("retrigger CI", () => rerunFailedRun(repo, runId));
498
500
  };
499
501
  useInput((input, key) => {
@@ -528,7 +530,8 @@ var HealthPanel = ({
528
530
  const reviewerMap = Object.fromEntries(reviewers);
529
531
  const passed = checks.filter(isPassCheck).length;
530
532
  const failed = checks.filter(isFailCheck).length;
531
- const pending = checks.length - passed - failed;
533
+ const stale = checks.filter(isInconclusiveCheck).length;
534
+ const pending = checks.length - passed - failed - stale;
532
535
  const approved = Object.values(reviewerMap).filter(
533
536
  (s) => s === "APPROVED"
534
537
  ).length;
@@ -537,6 +540,7 @@ var HealthPanel = ({
537
540
  ).length;
538
541
  const checkParts = [[`${passed} passed`, colors.success]];
539
542
  if (failed > 0) checkParts.push([`${failed} failed`, colors.error]);
543
+ if (stale > 0) checkParts.push([`${stale} cancelled`, colors.warning]);
540
544
  if (pending > 0) checkParts.push([`${pending} pending`, colors.warning]);
541
545
  const reviewParts = [
542
546
  approved > 0 ? [`${approved} approved`, colors.success] : ["0 approved", "white"]
@@ -823,11 +827,13 @@ var healthSentence = (item) => {
823
827
  };
824
828
  var checksSentence = (d) => {
825
829
  if (!d) return null;
826
- const total = d.checksPass + d.checksFail + d.checksPending;
830
+ const stale = d.checksStale ?? 0;
831
+ const total = d.checksPass + d.checksFail + d.checksPending + stale;
827
832
  if (total === 0) return "No CI checks run on it.";
828
833
  const parts = [];
829
834
  if (d.checksPass) parts.push(`${d.checksPass} passing`);
830
835
  if (d.checksFail) parts.push(`${d.checksFail} failing`);
836
+ if (stale) parts.push(`${stale} cancelled`);
831
837
  if (d.checksPending) parts.push(`${d.checksPending} running`);
832
838
  return `Checks: ${parts.join(", ")}.`;
833
839
  };
@@ -920,7 +926,14 @@ var STANDING = {
920
926
  reviewed: "spoken"
921
927
  };
922
928
  var YOURS = {
923
- authored: ["ci-fail", "conflict", "changes-req", "threads", "approved", "draft"],
929
+ authored: [
930
+ "ci-fail",
931
+ "conflict",
932
+ "changes-req",
933
+ "threads",
934
+ "approved",
935
+ "draft"
936
+ ],
924
937
  queued: ["waiting", "pending", "threads", "approved"],
925
938
  spoken: ["threads"]
926
939
  };
@@ -1052,7 +1065,10 @@ var budgetNotice = (budget, now = Date.now()) => {
1052
1065
  1,
1053
1066
  Math.round((new Date(budget.resetAt).getTime() - now) / 6e4)
1054
1067
  );
1055
- return fetches <= 0 ? { label: `\u26A1 API budget spent \xB7 ${mins}m`, critical: true } : { label: `\u26A1 ${fetches} fetch${fetches === 1 ? "" : "es"} left`, critical: false };
1068
+ return fetches <= 0 ? { label: `\u26A1 API budget spent \xB7 ${mins}m`, critical: true } : {
1069
+ label: `\u26A1 ${fetches} fetch${fetches === 1 ? "" : "es"} left`,
1070
+ critical: false
1071
+ };
1056
1072
  };
1057
1073
  var isChildRow = (item) => !!item && (item.kind === "show-more" || item.kind === "show-less" || "indent" in item && item.indent === true);
1058
1074
  var treeUrls = (items, i) => {
@@ -1074,7 +1090,8 @@ var treeUrls = (items, i) => {
1074
1090
  var gapsAbove = (items, i) => {
1075
1091
  const item = items[i];
1076
1092
  if (!item || i === 0) return false;
1077
- if (item.kind === "repo-header" || item.kind === "subgroup-header") return true;
1093
+ if (item.kind === "repo-header" || item.kind === "subgroup-header")
1094
+ return true;
1078
1095
  if (item.kind !== "task") return false;
1079
1096
  return isChildRow(items[i + 1]) || isChildRow(items[i - 1]);
1080
1097
  };
@@ -1342,9 +1359,15 @@ var UNSUBSCRIBE_MUTATION = `
1342
1359
  }
1343
1360
  `;
1344
1361
  var GH_ACTION_FAILURES = [
1345
- [/rate limit|RATE_LIMIT/i, "GitHub rate limit spent \u2014 it resets within the hour"],
1362
+ [
1363
+ /rate limit|RATE_LIMIT/i,
1364
+ "GitHub rate limit spent \u2014 it resets within the hour"
1365
+ ],
1346
1366
  [/\b401\b|not logged in|authentication/i, "gh is not authenticated"],
1347
- [/\b403\b|must have admin|not authorized|permission/i, "not permitted on this repo"],
1367
+ [
1368
+ /\b403\b|must have admin|not authorized|permission/i,
1369
+ "not permitted on this repo"
1370
+ ],
1348
1371
  [/\b50[0234]\b/, "GitHub's API is failing (5xx)"],
1349
1372
  [/ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNREFUSED/i, "no route to GitHub"]
1350
1373
  ];
@@ -1468,9 +1491,7 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
1468
1491
  hint: "u",
1469
1492
  run: () => {
1470
1493
  showFlash(`\u22EF Unsubscribing from #${item.number}\u2026`);
1471
- void unsubscribeFrom(item).then(() => showFlash(`\u2713 Unsubscribed from #${item.number}`)).catch(
1472
- (e) => showFlash(`\u2717 #${item.number}: ${explainGhAction(e)}`)
1473
- );
1494
+ void unsubscribeFrom(item).then(() => showFlash(`\u2713 Unsubscribed from #${item.number}`)).catch((e) => showFlash(`\u2717 #${item.number}: ${explainGhAction(e)}`));
1474
1495
  }
1475
1496
  });
1476
1497
  if (item.kind === "pr" && item.branch) {
@@ -1744,6 +1765,7 @@ var MERGED_FRAMES = ["\u2726", "\u2727", "\u2736", "\u2727"];
1744
1765
  var MERGED_COLOUR = "#A371F7";
1745
1766
  var TRANSIT_HOLD_MS = 7e3;
1746
1767
  var NO_TRANSIENTS = /* @__PURE__ */ new Map();
1768
+ var NO_HOLDS = /* @__PURE__ */ new Map();
1747
1769
  var TRANSIT_OUT_FRAMES = ["\u25C9", "\u25CE", "\u25CB", "\xB7"];
1748
1770
  var TRANSIT_IN_FRAMES = ["\xB7", "\u25CB", "\u25CE", "\u25C9"];
1749
1771
  var TRANSIT_COLOUR = {
@@ -1756,6 +1778,7 @@ var TRANSIT_LABEL = {
1756
1778
  out: "GONE",
1757
1779
  changed: "UPDATED"
1758
1780
  };
1781
+ var LEAVING_HOLD_MS = 2500;
1759
1782
  var TAB_MARK = "\u25CF";
1760
1783
  var tabLabel = (label, marked, id) => marked.size === 0 ? label : `${marked.has(id) ? TAB_MARK : " "} ${label}`;
1761
1784
  var ItemRow = ({
@@ -1764,11 +1787,13 @@ var ItemRow = ({
1764
1787
  gap,
1765
1788
  login,
1766
1789
  merged,
1767
- transient,
1790
+ transient: refreshMark,
1791
+ leaving,
1768
1792
  lastChild,
1769
1793
  parent,
1770
1794
  sparkFrame = 0
1771
1795
  }) => {
1796
+ const transient = leaving ? "out" : refreshMark;
1772
1797
  if (item.kind === "repo-header")
1773
1798
  return /* @__PURE__ */ jsx(RepoHeaderRow, { repo: item.repo, gap: gap ?? false });
1774
1799
  if (item.kind === "subgroup-header")
@@ -2190,6 +2215,8 @@ var BrowseScreen = ({
2190
2215
  skippedForBudget,
2191
2216
  brand,
2192
2217
  mergedUrls,
2218
+ leavingUrls,
2219
+ onLeave,
2193
2220
  transients,
2194
2221
  onTabChange
2195
2222
  }) => {
@@ -2259,7 +2286,7 @@ var BrowseScreen = ({
2259
2286
  )
2260
2287
  );
2261
2288
  }, [localSections, listHeight]);
2262
- const removeItemFromSections = (target) => setLocalSections((prev) => withoutItem(prev, target));
2289
+ const dismissItem = (target) => onLeave ? onLeave(target) : setLocalSections((prev) => withoutItem(prev, target));
2263
2290
  const rawSection = localSections[safeTabIdx] ?? {
2264
2291
  id: "empty",
2265
2292
  label: "",
@@ -2291,7 +2318,7 @@ var BrowseScreen = ({
2291
2318
  return out;
2292
2319
  }, [localSections, transients]);
2293
2320
  const [sparkFrame, setSparkFrame] = useState(0);
2294
- const sparkling = (mergedUrls?.length ?? 0) > 0 || !!transients?.size && section.items.some((item) => transientOf(transients, item));
2321
+ const sparkling = (mergedUrls?.length ?? 0) > 0 || (leavingUrls?.length ?? 0) > 0 || !!transients?.size && section.items.some((item) => transientOf(transients, item));
2295
2322
  useEffect(() => {
2296
2323
  if (!sparkling) return;
2297
2324
  const id = setInterval(() => setSparkFrame((f) => f + 1), MERGED_FRAME_MS);
@@ -2324,7 +2351,7 @@ var BrowseScreen = ({
2324
2351
  jiraKeyRe,
2325
2352
  jiraTransitions,
2326
2353
  onRefresh,
2327
- removeItemFromSections,
2354
+ dismissItem,
2328
2355
  openDrillView,
2329
2356
  { extensions, onOpenExt, onActed }
2330
2357
  );
@@ -2431,7 +2458,7 @@ var BrowseScreen = ({
2431
2458
  item: activeItem ?? void 0,
2432
2459
  ciJob: ciStatus?.job,
2433
2460
  login,
2434
- onRemove: (row) => removeItemFromSections(row),
2461
+ onRemove: (row) => dismissItem(row),
2435
2462
  showFlash
2436
2463
  });
2437
2464
  return;
@@ -2540,7 +2567,7 @@ var BrowseScreen = ({
2540
2567
  return;
2541
2568
  }
2542
2569
  if (input === "x" && activeItem.kind === "pr" && activeItem.standing === "queued" && login) {
2543
- removeItemFromSections(activeItem);
2570
+ dismissItem(activeItem);
2544
2571
  showFlash(`\u22EF Removing you from #${activeItem.number}\u2026`);
2545
2572
  void quietly`gh pr edit ${activeItem.number} --repo ${activeItem.repo} --remove-reviewer ${login}`.then(() => {
2546
2573
  showFlash(`\u2713 Removed you as reviewer on #${activeItem.number}`);
@@ -2707,6 +2734,7 @@ var BrowseScreen = ({
2707
2734
  active: viewStart + i === cursor,
2708
2735
  login,
2709
2736
  merged: (item.kind === "pr" || item.kind === "issue") && !!mergedUrls?.includes(item.url),
2737
+ leaving: (item.kind === "pr" || item.kind === "issue") && !!leavingUrls?.includes(item.url),
2710
2738
  transient: transientOf(transients, item),
2711
2739
  lastChild: "indent" in item && item.indent ? !isChildRow(section.items[viewStart + i + 1]) : void 0,
2712
2740
  parent: item.kind === "task" && isChildRow(section.items[viewStart + i + 1]),
@@ -2807,6 +2835,7 @@ var App = ({
2807
2835
  const displayedSections = useRef([]);
2808
2836
  const [transients, setTransients] = useState(NO_TRANSIENTS);
2809
2837
  const transitTabs = useRef(/* @__PURE__ */ new Map());
2838
+ const [heldSince, setHeldSince] = useState(NO_HOLDS);
2810
2839
  const [visibleTab, setVisibleTab] = useState("");
2811
2840
  const showData = (sections, login) => {
2812
2841
  const before = displayedSections.current;
@@ -2861,15 +2890,31 @@ var App = ({
2861
2890
  );
2862
2891
  };
2863
2892
  useEffect(() => {
2864
- if (transients.size === 0) return;
2865
- if (state.phase !== "browse" || !visibleTab) return;
2866
- if (![...transitTabs.current.values()].includes(visibleTab)) return;
2893
+ const held = new Set(transitTabs.current.values());
2894
+ const arrived = state.phase === "browse" && !!visibleTab && held.has(visibleTab);
2895
+ setHeldSince((prev) => {
2896
+ const next = new Map([...prev].filter(([tab]) => held.has(tab)));
2897
+ if (arrived && !next.has(visibleTab)) next.set(visibleTab, Date.now());
2898
+ const same = next.size === prev.size && [...next.keys()].every((t) => prev.has(t));
2899
+ return same ? prev : next.size === 0 ? NO_HOLDS : next;
2900
+ });
2901
+ }, [transients, visibleTab, state.phase]);
2902
+ useEffect(() => {
2903
+ if (transients.size === 0 || heldSince.size === 0) return;
2904
+ if (state.phase !== "browse") return;
2905
+ const held = new Set(transitTabs.current.values());
2906
+ const running = [...heldSince].filter(([tab]) => held.has(tab));
2907
+ if (running.length === 0) return;
2908
+ const remaining = (at) => at + TRANSIT_HOLD_MS - Date.now();
2867
2909
  const timer = setTimeout(
2868
- () => settleTab(visibleTab, transients),
2869
- TRANSIT_HOLD_MS
2910
+ () => {
2911
+ for (const [tab, at] of running)
2912
+ if (remaining(at) <= 0) settleTab(tab, transients);
2913
+ },
2914
+ Math.max(0, Math.min(...running.map(([, at]) => remaining(at))))
2870
2915
  );
2871
2916
  return () => clearTimeout(timer);
2872
- }, [transients, visibleTab, state.phase]);
2917
+ }, [transients, heldSince, state.phase]);
2873
2918
  const pendingSummary = useMemo(
2874
2919
  () => pending ? summariseDiff(
2875
2920
  diffSections(displayedSections.current, pending.sections).counts
@@ -2917,10 +2962,7 @@ var App = ({
2917
2962
  }
2918
2963
  showData(fresh.sections, fresh.login);
2919
2964
  } else if (freshKey !== displayedKey.current) {
2920
- const { counts } = diffSections(
2921
- displayedSections.current,
2922
- fresh.sections
2923
- );
2965
+ const { counts } = diffSections(displayedSections.current, fresh.sections);
2924
2966
  if (counts.added + counts.removed + counts.changed === 0)
2925
2967
  showData(fresh.sections, fresh.login);
2926
2968
  else setPending(fresh);
@@ -3007,8 +3049,9 @@ var App = ({
3007
3049
  );
3008
3050
  const [skippedForBudget, setSkippedForBudget] = useState(false);
3009
3051
  const [mergedUrls, setMergedUrls] = useState([]);
3010
- const mergeTimers = useRef([]);
3011
- useEffect(() => () => mergeTimers.current.forEach(clearTimeout), []);
3052
+ const [leavingUrls, setLeavingUrls] = useState([]);
3053
+ const holdTimers = useRef([]);
3054
+ useEffect(() => () => holdTimers.current.forEach(clearTimeout), []);
3012
3055
  if (state.phase === "loading" || state.phase === "empty" || state.phase === "failed")
3013
3056
  return /* @__PURE__ */ jsxs(
3014
3057
  Box,
@@ -3056,17 +3099,26 @@ var App = ({
3056
3099
  target: state.target
3057
3100
  } : null;
3058
3101
  const toBrowse = () => setState({ phase: "browse", sections: state.sections, login: state.login });
3059
- const removeAndReturn = (target) => setState({
3060
- phase: "browse",
3061
- sections: withoutItem(state.sections, target),
3062
- login: state.login
3063
- });
3102
+ const markLeaving = (target) => {
3103
+ if (state.phase !== "browse") toBrowse();
3104
+ setLeavingUrls(
3105
+ (prev) => prev.includes(target.url) ? prev : [...prev, target.url]
3106
+ );
3107
+ holdTimers.current.push(
3108
+ setTimeout(() => {
3109
+ setLeavingUrls((prev) => prev.filter((u) => u !== target.url));
3110
+ setState(
3111
+ (s) => s.phase === "browse" ? { ...s, sections: withoutItem(s.sections, target) } : s
3112
+ );
3113
+ }, LEAVING_HOLD_MS)
3114
+ );
3115
+ };
3064
3116
  const markMerged = (target) => {
3065
3117
  toBrowse();
3066
3118
  setMergedUrls(
3067
3119
  (prev) => prev.includes(target.url) ? prev : [...prev, target.url]
3068
3120
  );
3069
- mergeTimers.current.push(
3121
+ holdTimers.current.push(
3070
3122
  setTimeout(() => {
3071
3123
  setMergedUrls((prev) => prev.filter((u) => u !== target.url));
3072
3124
  setState(
@@ -3097,6 +3149,8 @@ var App = ({
3097
3149
  skippedForBudget,
3098
3150
  hidden: overlay !== null,
3099
3151
  mergedUrls,
3152
+ leavingUrls,
3153
+ onLeave: markLeaving,
3100
3154
  transients,
3101
3155
  onTabChange: setVisibleTab,
3102
3156
  ciStatusState: hasCiStatus ? ciStatusState : void 0,
@@ -3130,7 +3184,7 @@ var App = ({
3130
3184
  login: state.login,
3131
3185
  onBack: toBrowse,
3132
3186
  onRefresh: applyOrRefresh,
3133
- onRemove: removeAndReturn,
3187
+ onRemove: markLeaving,
3134
3188
  onMerged: markMerged
3135
3189
  }),
3136
3190
  overlay?.kind === "ext" && extensions?.find((e) => e.id === overlay.extId)?.body(toBrowse, overlay.target)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kud/gh-ink",
3
- "version": "0.29.1",
3
+ "version": "0.31.0",
4
4
  "description": "Ink components for rendering GitHub PR review comments and health — controlled, presentation-only, built on @kud/ink-ui and fed by @kud/gh.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -48,7 +48,7 @@
48
48
  "react": ">=19"
49
49
  },
50
50
  "dependencies": {
51
- "@kud/gh": "0.7.1",
51
+ "@kud/gh": "0.8.0",
52
52
  "@kud/ink-ui": "0.14.0",
53
53
  "zx": "8.8.5"
54
54
  },