@kud/gh-ink 0.23.3 → 0.25.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
@@ -199,8 +199,8 @@ declare const sortItems: (items: GHItem[]) => GHItem[];
199
199
  declare const sortByRecency: (items: GHItem[]) => GHItem[];
200
200
  declare const insertRepoHeaders: (items: GHItem[]) => AnyItem[];
201
201
  type Standing = "authored" | "queued" | "spoken";
202
- declare const whoseMove: (health: Health, sectionId: string, standing?: Standing) => "you" | "them";
203
- declare const layoutGHItems: (items: GHItem[], sectionId: string) => AnyItem[];
202
+ declare const whoseMove: (health: Health, sectionId: string, standing?: Standing, theySpokeLast?: boolean) => "you" | "them";
203
+ declare const layoutGHItems: (items: GHItem[], sectionId: string, login?: string) => AnyItem[];
204
204
  /**
205
205
  * A two-way repo split, when a host has one. `undefined` — the ordinary case —
206
206
  * means no split at all: every row stands.
@@ -270,6 +270,7 @@ declare const runHere: (cmd: string) => void;
270
270
  declare const COLS: number;
271
271
  declare const topLevelCount: (s: Section) => number;
272
272
  declare const drillCmd: (item: AnyItem) => string | null;
273
+ declare const explainGhAction: (e: unknown) => string;
273
274
  declare const buildActions: (item: AnyItem, login: string, showFlash: (msg: string) => void, jiraBase?: string, jiraKeyRe?: RegExp, jiraTransitions?: JiraTransition[], onRefresh?: () => void, onRemove?: (item: GHItem) => void, onOpenView?: (item: AnyItem) => boolean, ext?: {
274
275
  extensions?: InboxExtension[];
275
276
  onOpenExt?: (id: string, target: ExtensionTarget) => void;
@@ -314,7 +315,7 @@ declare const ActionMenu: ({ item, actions, cursor, }: {
314
315
  cursor: number;
315
316
  }) => React.JSX.Element;
316
317
  declare const signatureOf: (sections: Section[]) => string;
317
- declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jiraKeyRe, jiraTransitions, hasCiStatus, ciJob, ciFetcher, ciPollMs, watchPath, watchDebounceMs, extensions, tabHelp, emptyHint, }: {
318
+ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jiraKeyRe, jiraTransitions, hasCiStatus, ciJob, ciFetcher, ciPollMs, watchPath, watchDebounceMs, watchJitterMs, extensions, tabHelp, emptyHint, }: {
318
319
  fetcher: () => Promise<{
319
320
  sections: Section[];
320
321
  login: string;
@@ -342,6 +343,15 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
342
343
  watchPath?: string;
343
344
  /** Bursts of writes to collapse into one refetch. */
344
345
  watchDebounceMs?: number;
346
+ /**
347
+ * Random spread added to `watchDebounceMs`, so several cockpits woken by one
348
+ * signal take turns instead of stampeding. The first to wake fetches and
349
+ * writes the shared cache; the others adopt it and pay nothing.
350
+ *
351
+ * Defaults to 0 — no randomness unless a host asks for it. Set it to comfortably
352
+ * more than one round trip when you expect several instances to be open.
353
+ */
354
+ watchJitterMs?: number;
345
355
  extensions?: InboxExtension[];
346
356
  }) => React.JSX.Element;
347
357
 
@@ -443,4 +453,4 @@ declare const matchesFilter: (repo: string, filter: RepoFilter) => boolean;
443
453
  */
444
454
  declare const parsePatterns: (value: string) => string[];
445
455
 
446
- export { type Action, ActionMenu, type AnyItem, App, COLS, type CiStatus, CiStatusLine, type CiStatusState, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, type GHDetail, type GHItem, HealthPanel, type HealthPanelProps, type InboxConfig, type InboxExtension, type JiraTransition, type OriginSplit, type RepoFilter, type RepoHeader, type RepoProfile, type Section, type ShowLess, type ShowMore, type Standing, type SubgroupHeader, type TaskRow, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
456
+ export { type Action, ActionMenu, type AnyItem, App, COLS, type CiStatus, CiStatusLine, type CiStatusState, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, type GHDetail, type GHItem, HealthPanel, type HealthPanelProps, type InboxConfig, type InboxExtension, type JiraTransition, type OriginSplit, type RepoFilter, type RepoHeader, type RepoProfile, type Section, type ShowLess, type ShowMore, type Standing, type SubgroupHeader, type TaskRow, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainGhAction, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
package/dist/index.js CHANGED
@@ -919,18 +919,38 @@ var YOURS = {
919
919
  queued: ["waiting", "pending", "threads", "approved"],
920
920
  spoken: ["threads"]
921
921
  };
922
- var whoseMove = (health, sectionId, standing) => YOURS[standing ?? STANDING[sectionId] ?? "queued"].includes(health) ? "you" : "them";
922
+ var whoseMove = (health, sectionId, standing, theySpokeLast) => (
923
+ // Somebody else having the last word outranks every review state below. It is
924
+ // the plainest claim on you there is — a question asked, an objection raised,
925
+ // a "can you rebase" — and none of it shows up as a health, because a bare
926
+ // comment approves nothing, fails nothing and opens no thread.
927
+ //
928
+ // The row already SAID this and the band disagreed with it: the turn arrow
929
+ // reads lastActor and drew `←`, and the explain panel spelled out "X spoke
930
+ // last, your reply is owed", while the band filed it under Their move. Two
931
+ // signals on one row, pointing opposite ways, until 2026-08-27.
932
+ //
933
+ // Only in that direction. YOU having spoken last does not hand the row over —
934
+ // red CI on your own PR is yours whether or not you commented after it — so
935
+ // that case falls through to the table.
936
+ theySpokeLast ? "you" : YOURS[standing ?? STANDING[sectionId] ?? "queued"].includes(health) ? "you" : "them"
937
+ );
923
938
  var BAND_LABEL = {
924
939
  you: "Your move",
925
940
  them: "Their move"
926
941
  };
927
- var layoutGHItems = (items, sectionId) => {
942
+ var layoutGHItems = (items, sectionId, login) => {
928
943
  if (sectionId === "done") return insertRepoHeaders(sortByRecency(items));
929
944
  const sorted = sortItems(items);
930
945
  const bands = ["you", "them"].map((side) => ({
931
946
  side,
932
947
  rows: sorted.filter(
933
- (i) => whoseMove(i.health, sectionId, i.standing) === side
948
+ (i) => whoseMove(
949
+ i.health,
950
+ sectionId,
951
+ i.standing,
952
+ !!login && !!i.lastActor && i.lastActor !== login
953
+ ) === side
934
954
  )
935
955
  }));
936
956
  const filled = bands.filter((b) => b.rows.length > 0);
@@ -1297,9 +1317,22 @@ var UNSUBSCRIBE_MUTATION = `
1297
1317
  }
1298
1318
  }
1299
1319
  `;
1320
+ var GH_ACTION_FAILURES = [
1321
+ [/rate limit|RATE_LIMIT/i, "GitHub rate limit spent \u2014 it resets within the hour"],
1322
+ [/\b401\b|not logged in|authentication/i, "gh is not authenticated"],
1323
+ [/\b403\b|must have admin|not authorized|permission/i, "not permitted on this repo"],
1324
+ [/\b50[0234]\b/, "GitHub's API is failing (5xx)"],
1325
+ [/ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNREFUSED/i, "no route to GitHub"]
1326
+ ];
1327
+ var explainGhAction = (e) => {
1328
+ const err = e;
1329
+ const raw = (err.stderr ?? "").trim().split("\n").filter(Boolean).pop() ?? err.message ?? "no reason reported";
1330
+ const human = GH_ACTION_FAILURES.find(([re]) => re.test(raw))?.[1];
1331
+ return human ? `${human} (${raw})` : raw;
1332
+ };
1300
1333
  var unsubscribeFrom = async (item) => {
1301
- const view = item.kind === "pr" ? "pr" : "issue";
1302
- const found = await quietly`gh ${view} view ${item.number} --repo ${item.repo} --json id --jq .id`;
1334
+ const route = item.kind === "pr" ? "pulls" : "issues";
1335
+ const found = await quietly`gh api repos/${item.repo}/${route}/${item.number} --jq .node_id`;
1303
1336
  const id = found.stdout.trim();
1304
1337
  if (!id) throw new Error(`no node id for ${item.repo}#${item.number}`);
1305
1338
  await quietly`gh api graphql -f query=${UNSUBSCRIBE_MUTATION} -f id=${id}`;
@@ -1399,8 +1432,8 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
1399
1432
  void quietly`gh pr edit ${item.number} --repo ${item.repo} --remove-reviewer ${login}`.then(() => {
1400
1433
  showFlash(`\u2713 Removed you as reviewer on #${item.number}`);
1401
1434
  ext?.onActed?.();
1402
- }).catch(() => {
1403
- showFlash(`\u2717 Could not remove you from #${item.number}`);
1435
+ }).catch((e) => {
1436
+ showFlash(`\u2717 #${item.number}: ${explainGhAction(e)}`);
1404
1437
  onRefresh?.();
1405
1438
  });
1406
1439
  }
@@ -1411,7 +1444,9 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
1411
1444
  hint: "u",
1412
1445
  run: () => {
1413
1446
  showFlash(`\u22EF Unsubscribing from #${item.number}\u2026`);
1414
- void unsubscribeFrom(item).then(() => showFlash(`\u2713 Unsubscribed from #${item.number}`)).catch(() => showFlash(`\u2717 Unsubscribe failed for #${item.number}`));
1447
+ void unsubscribeFrom(item).then(() => showFlash(`\u2713 Unsubscribed from #${item.number}`)).catch(
1448
+ (e) => showFlash(`\u2717 #${item.number}: ${explainGhAction(e)}`)
1449
+ );
1415
1450
  }
1416
1451
  });
1417
1452
  if (item.kind === "pr" && item.branch) {
@@ -2446,7 +2481,9 @@ var BrowseScreen = ({
2446
2481
  }
2447
2482
  if (input === "u" && (activeItem.kind === "pr" || activeItem.kind === "issue")) {
2448
2483
  showFlash(`\u22EF Unsubscribing from #${activeItem.number}\u2026`);
2449
- void unsubscribeFrom(activeItem).then(() => showFlash(`\u2713 Unsubscribed from #${activeItem.number}`)).catch(() => showFlash(`\u2717 Unsubscribe failed for #${activeItem.number}`));
2484
+ void unsubscribeFrom(activeItem).then(() => showFlash(`\u2713 Unsubscribed from #${activeItem.number}`)).catch(
2485
+ (e) => showFlash(`\u2717 #${activeItem.number}: ${explainGhAction(e)}`)
2486
+ );
2450
2487
  return;
2451
2488
  }
2452
2489
  if (input === "x" && activeItem.kind === "pr" && activeItem.standing === "queued" && login) {
@@ -2455,8 +2492,8 @@ var BrowseScreen = ({
2455
2492
  void quietly`gh pr edit ${activeItem.number} --repo ${activeItem.repo} --remove-reviewer ${login}`.then(() => {
2456
2493
  showFlash(`\u2713 Removed you as reviewer on #${activeItem.number}`);
2457
2494
  onActed?.();
2458
- }).catch(() => {
2459
- showFlash(`\u2717 Could not remove you from #${activeItem.number}`);
2495
+ }).catch((e) => {
2496
+ showFlash(`\u2717 #${activeItem.number}: ${explainGhAction(e)}`);
2460
2497
  onRefresh?.();
2461
2498
  });
2462
2499
  return;
@@ -2685,6 +2722,11 @@ var App = ({
2685
2722
  ciPollMs = 6e4,
2686
2723
  watchPath,
2687
2724
  watchDebounceMs = 400,
2725
+ // Zero by default, deliberately. A package that adds randomness to its own
2726
+ // timing makes every host's tests flaky — this one's went green locally on a
2727
+ // lucky draw and red in CI. It is also a host concern: only somebody running
2728
+ // several cockpits at once needs the stagger, and only they know how many.
2729
+ watchJitterMs = 0,
2688
2730
  extensions,
2689
2731
  tabHelp,
2690
2732
  emptyHint
@@ -2782,24 +2824,7 @@ var App = ({
2782
2824
  setFetchedAt(Date.now());
2783
2825
  setRefreshError(null);
2784
2826
  if (hasCiStatus) applyCiStatus(fresh.ciStatus ?? null);
2785
- const freshKey = signatureOf(fresh.sections);
2786
- if (!displayedKey.current) {
2787
- if (fresh.sections.length === 0) {
2788
- setState({ phase: "empty" });
2789
- return;
2790
- }
2791
- showData(fresh.sections, fresh.login);
2792
- } else if (freshKey !== displayedKey.current) {
2793
- const { counts } = diffSections(
2794
- displayedSections.current,
2795
- fresh.sections
2796
- );
2797
- if (counts.added + counts.removed + counts.changed === 0)
2798
- showData(fresh.sections, fresh.login);
2799
- else setPending(fresh);
2800
- } else {
2801
- setPending(null);
2802
- }
2827
+ receive(fresh);
2803
2828
  }).catch((err) => {
2804
2829
  setRefreshing(false);
2805
2830
  const message = err.message;
@@ -2810,6 +2835,26 @@ var App = ({
2810
2835
  setRefreshError({ message, at: Date.now() });
2811
2836
  });
2812
2837
  };
2838
+ const receive = (fresh) => {
2839
+ const freshKey = signatureOf(fresh.sections);
2840
+ if (!displayedKey.current) {
2841
+ if (fresh.sections.length === 0) {
2842
+ setState({ phase: "empty" });
2843
+ return;
2844
+ }
2845
+ showData(fresh.sections, fresh.login);
2846
+ } else if (freshKey !== displayedKey.current) {
2847
+ const { counts } = diffSections(
2848
+ displayedSections.current,
2849
+ fresh.sections
2850
+ );
2851
+ if (counts.added + counts.removed + counts.changed === 0)
2852
+ showData(fresh.sections, fresh.login);
2853
+ else setPending(fresh);
2854
+ } else {
2855
+ setPending(null);
2856
+ }
2857
+ };
2813
2858
  const applyOrRefresh = () => {
2814
2859
  if (pending) showData(pending.sections, pending.login);
2815
2860
  else revalidate(true);
@@ -2862,7 +2907,18 @@ var App = ({
2862
2907
  watcher = watch(dir, (_event, changed) => {
2863
2908
  if (!live || changed && changed !== name) return;
2864
2909
  if (timer) clearTimeout(timer);
2865
- timer = setTimeout(() => live && revalidate(), watchDebounceMs);
2910
+ const firedAt = Date.now();
2911
+ const delay = watchDebounceMs + Math.floor(Math.random() * watchJitterMs);
2912
+ timer = setTimeout(() => {
2913
+ if (!live) return;
2914
+ const shared = cacheKey ? readCache(cacheKey) : null;
2915
+ if (shared && shared.at > firedAt) {
2916
+ receive({ sections: shared.sections, login: shared.login });
2917
+ setFetchedAt(shared.at);
2918
+ return;
2919
+ }
2920
+ revalidate();
2921
+ }, delay);
2866
2922
  });
2867
2923
  } catch {
2868
2924
  return;
@@ -3009,4 +3065,4 @@ var matchesFilter = (repo, filter) => {
3009
3065
  };
3010
3066
  var parsePatterns = (value) => value.split(",").map((p) => p.trim()).filter(Boolean);
3011
3067
 
3012
- export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
3068
+ export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainGhAction, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kud/gh-ink",
3
- "version": "0.23.3",
3
+ "version": "0.25.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",