github-issue-tower-defence-management 1.116.9 → 1.117.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -3
  3. package/bin/adapter/entry-points/console/consoleReadApi.js +13 -1
  4. package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -1
  5. package/bin/adapter/entry-points/console/ui-dist/assets/index-CJJsRF8i.js +101 -0
  6. package/bin/adapter/entry-points/console/ui-dist/assets/{index-D1V2fi29.css → index-DGVKvwux.css} +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  8. package/bin/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.js +9 -2
  9. package/bin/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.js.map +1 -1
  10. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +3 -2
  11. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  12. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +1 -0
  13. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  14. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +13 -3
  15. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/adapter/entry-points/console/consoleReadApi.test.ts +4 -0
  18. package/src/adapter/entry-points/console/consoleReadApi.ts +18 -0
  19. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.stories.tsx +2 -0
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.test.tsx +3 -1
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +1 -1
  23. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.test.tsx +1 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +1 -1
  25. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestStatusBadges.stories.tsx +15 -5
  26. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestStatusBadges.test.tsx +38 -8
  27. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestStatusBadges.tsx +35 -12
  28. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleDetailPrefetch.test.ts +117 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleDetailPrefetch.ts +83 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.test.ts +2 -0
  31. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.ts +1 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +67 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +26 -6
  34. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +21 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +14 -0
  36. package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.test.ts +48 -0
  37. package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.ts +24 -0
  38. package/src/adapter/entry-points/console/ui/src/features/console/logic/types.ts +4 -0
  39. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +1 -0
  40. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +9 -1
  41. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +1 -0
  42. package/src/adapter/entry-points/console/ui/src/index.css +10 -1
  43. package/src/adapter/entry-points/console/ui-dist/assets/index-CJJsRF8i.js +101 -0
  44. package/src/adapter/entry-points/console/ui-dist/assets/{index-D1V2fi29.css → index-DGVKvwux.css} +1 -1
  45. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  46. package/src/adapter/entry-points/console/webServer.test.ts +2 -0
  47. package/src/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.test.ts +40 -4
  48. package/src/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.ts +12 -6
  49. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +107 -29
  50. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +7 -2
  51. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +1 -0
  52. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +1 -0
  53. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +1 -0
  54. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +84 -4
  55. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +16 -3
  56. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +1 -0
  57. package/src/domain/usecases/StartPreparationUseCase.test.ts +9 -0
  58. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +1 -0
  59. package/src/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.ts +1 -0
  60. package/src/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.ts +4 -1
  61. package/types/adapter/entry-points/console/consoleReadApi.d.ts +4 -0
  62. package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -1
  63. package/types/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.d.ts +1 -1
  64. package/types/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.d.ts.map +1 -1
  65. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts +1 -1
  66. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  67. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  68. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  69. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -0
  70. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  71. package/types/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.d.ts +1 -1
  72. package/types/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.d.ts.map +1 -1
  73. package/types/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.d.ts +1 -1
  74. package/types/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.d.ts.map +1 -1
  75. package/bin/adapter/entry-points/console/ui-dist/assets/index-Bm0wt3Mn.js +0 -101
  76. package/src/adapter/entry-points/console/ui-dist/assets/index-Bm0wt3Mn.js +0 -101
@@ -130,6 +130,7 @@ describe('ConsoleItemDetail', () => {
130
130
  pullRequestStatus={{
131
131
  found: true,
132
132
  isConflicted: true,
133
+ mergeableStatus: 'CONFLICTING',
133
134
  isPassedAllCiJob: false,
134
135
  isCiStateSuccess: false,
135
136
  isBranchOutOfDate: true,
@@ -161,6 +162,7 @@ describe('ConsoleItemDetail', () => {
161
162
  pullRequestStatus={{
162
163
  found: true,
163
164
  isConflicted: false,
165
+ mergeableStatus: 'MERGEABLE',
164
166
  isPassedAllCiJob: true,
165
167
  isCiStateSuccess: true,
166
168
  isBranchOutOfDate: false,
@@ -169,7 +171,7 @@ describe('ConsoleItemDetail', () => {
169
171
  />,
170
172
  );
171
173
  expect(getByText('CI passing')).toBeInTheDocument();
172
- expect(queryByText('Conflict')).toBeNull();
174
+ expect(getByText('No conflict')).toBeInTheDocument();
173
175
  expect(queryByText('Out of date')).toBeNull();
174
176
  });
175
177
 
@@ -158,7 +158,7 @@ export const ConsoleItemDetail = ({
158
158
  {item.isPr && pullRequestStatus?.found && (
159
159
  <div className="console-detail-pr-status-row">
160
160
  <ConsolePullRequestStatusBadges
161
- isConflicted={pullRequestStatus.isConflicted}
161
+ mergeableStatus={pullRequestStatus.mergeableStatus}
162
162
  isPassedAllCiJob={pullRequestStatus.isPassedAllCiJob}
163
163
  isCiStateSuccess={pullRequestStatus.isCiStateSuccess}
164
164
  isBranchOutOfDate={pullRequestStatus.isBranchOutOfDate}
@@ -62,6 +62,7 @@ describe('ConsolePullRequestDetail', () => {
62
62
  pullRequest={{
63
63
  ...pullRequest,
64
64
  isConflicted: true,
65
+ mergeableStatus: 'CONFLICTING',
65
66
  isPassedAllCiJob: false,
66
67
  isCiStateSuccess: false,
67
68
  isBranchOutOfDate: true,
@@ -64,7 +64,7 @@ export const ConsolePullRequestDetail = ({
64
64
  <span className="console-pr-section-state">draft</span>
65
65
  )}
66
66
  <ConsolePullRequestStatusBadges
67
- isConflicted={pullRequest.isConflicted}
67
+ mergeableStatus={pullRequest.mergeableStatus}
68
68
  isPassedAllCiJob={pullRequest.isPassedAllCiJob}
69
69
  isCiStateSuccess={pullRequest.isCiStateSuccess}
70
70
  isBranchOutOfDate={pullRequest.isBranchOutOfDate}
@@ -10,9 +10,9 @@ export default meta;
10
10
 
11
11
  type Story = StoryObj<typeof ConsolePullRequestStatusBadges>;
12
12
 
13
- export const Passing: Story = {
13
+ export const PassingAndMergeable: Story = {
14
14
  args: {
15
- isConflicted: false,
15
+ mergeableStatus: 'MERGEABLE',
16
16
  isPassedAllCiJob: true,
17
17
  isCiStateSuccess: true,
18
18
  isBranchOutOfDate: false,
@@ -22,7 +22,7 @@ export const Passing: Story = {
22
22
 
23
23
  export const FailingWithMissingChecks: Story = {
24
24
  args: {
25
- isConflicted: false,
25
+ mergeableStatus: 'MERGEABLE',
26
26
  isPassedAllCiJob: false,
27
27
  isCiStateSuccess: false,
28
28
  isBranchOutOfDate: false,
@@ -30,12 +30,22 @@ export const FailingWithMissingChecks: Story = {
30
30
  },
31
31
  };
32
32
 
33
- export const ConflictedAndOutOfDate: Story = {
33
+ export const ConflictingAndOutOfDate: Story = {
34
34
  args: {
35
- isConflicted: true,
35
+ mergeableStatus: 'CONFLICTING',
36
36
  isPassedAllCiJob: false,
37
37
  isCiStateSuccess: false,
38
38
  isBranchOutOfDate: true,
39
39
  missingRequiredCheckNames: ['build', 'test'],
40
40
  },
41
41
  };
42
+
43
+ export const CheckingMergeStatus: Story = {
44
+ args: {
45
+ mergeableStatus: 'UNKNOWN',
46
+ isPassedAllCiJob: true,
47
+ isCiStateSuccess: true,
48
+ isBranchOutOfDate: false,
49
+ missingRequiredCheckNames: [],
50
+ },
51
+ };
@@ -5,7 +5,7 @@ describe('ConsolePullRequestStatusBadges', () => {
5
5
  it('renders a passing CI badge when all CI jobs passed and the CI state is success', () => {
6
6
  const { getByText, queryByText } = render(
7
7
  <ConsolePullRequestStatusBadges
8
- isConflicted={false}
8
+ mergeableStatus="MERGEABLE"
9
9
  isPassedAllCiJob={true}
10
10
  isCiStateSuccess={true}
11
11
  isBranchOutOfDate={false}
@@ -13,14 +13,13 @@ describe('ConsolePullRequestStatusBadges', () => {
13
13
  />,
14
14
  );
15
15
  expect(getByText('CI passing')).toBeInTheDocument();
16
- expect(queryByText('Conflict')).toBeNull();
17
16
  expect(queryByText('Out of date')).toBeNull();
18
17
  });
19
18
 
20
19
  it('renders a failing CI badge with missing required checks', () => {
21
20
  const { getByText } = render(
22
21
  <ConsolePullRequestStatusBadges
23
- isConflicted={false}
22
+ mergeableStatus="MERGEABLE"
24
23
  isPassedAllCiJob={false}
25
24
  isCiStateSuccess={true}
26
25
  isBranchOutOfDate={false}
@@ -31,10 +30,25 @@ describe('ConsolePullRequestStatusBadges', () => {
31
30
  expect(getByText(/missing: build, test/)).toBeInTheDocument();
32
31
  });
33
32
 
34
- it('renders the conflict badge when the pull request is conflicted', () => {
35
- const { getByText } = render(
33
+ it('renders the green no-conflict badge when the pull request is mergeable', () => {
34
+ const { getByText, queryByText } = render(
36
35
  <ConsolePullRequestStatusBadges
37
- isConflicted={true}
36
+ mergeableStatus="MERGEABLE"
37
+ isPassedAllCiJob={true}
38
+ isCiStateSuccess={true}
39
+ isBranchOutOfDate={false}
40
+ missingRequiredCheckNames={[]}
41
+ />,
42
+ );
43
+ expect(getByText('No conflict')).toBeInTheDocument();
44
+ expect(queryByText('Conflict')).toBeNull();
45
+ expect(queryByText('Checking merge status')).toBeNull();
46
+ });
47
+
48
+ it('renders the red conflict badge when the pull request is conflicting', () => {
49
+ const { getByText, queryByText } = render(
50
+ <ConsolePullRequestStatusBadges
51
+ mergeableStatus="CONFLICTING"
38
52
  isPassedAllCiJob={true}
39
53
  isCiStateSuccess={true}
40
54
  isBranchOutOfDate={false}
@@ -42,12 +56,28 @@ describe('ConsolePullRequestStatusBadges', () => {
42
56
  />,
43
57
  );
44
58
  expect(getByText('Conflict')).toBeInTheDocument();
59
+ expect(queryByText('No conflict')).toBeNull();
60
+ });
61
+
62
+ it('renders the gray checking badge when the merge status is unknown', () => {
63
+ const { getByText, queryByText } = render(
64
+ <ConsolePullRequestStatusBadges
65
+ mergeableStatus="UNKNOWN"
66
+ isPassedAllCiJob={true}
67
+ isCiStateSuccess={true}
68
+ isBranchOutOfDate={false}
69
+ missingRequiredCheckNames={[]}
70
+ />,
71
+ );
72
+ expect(getByText('Checking merge status')).toBeInTheDocument();
73
+ expect(queryByText('Conflict')).toBeNull();
74
+ expect(queryByText('No conflict')).toBeNull();
45
75
  });
46
76
 
47
77
  it('renders the out-of-date badge when the branch is behind the base branch', () => {
48
78
  const { getByText } = render(
49
79
  <ConsolePullRequestStatusBadges
50
- isConflicted={false}
80
+ mergeableStatus="MERGEABLE"
51
81
  isPassedAllCiJob={true}
52
82
  isCiStateSuccess={true}
53
83
  isBranchOutOfDate={true}
@@ -60,7 +90,7 @@ describe('ConsolePullRequestStatusBadges', () => {
60
90
  it('treats a successful CI state but unfinished jobs as failing', () => {
61
91
  const { getByText } = render(
62
92
  <ConsolePullRequestStatusBadges
63
- isConflicted={false}
93
+ mergeableStatus="MERGEABLE"
64
94
  isPassedAllCiJob={false}
65
95
  isCiStateSuccess={true}
66
96
  isBranchOutOfDate={false}
@@ -1,8 +1,8 @@
1
1
  import { colorFromEnum } from '../../logic/colors';
2
- import type { ConsoleColor } from '../../logic/types';
2
+ import type { ConsoleColor, ConsoleMergeableStatus } from '../../logic/types';
3
3
 
4
4
  export type ConsolePullRequestStatusBadgesProps = {
5
- isConflicted: boolean;
5
+ mergeableStatus: ConsoleMergeableStatus;
6
6
  isPassedAllCiJob: boolean;
7
7
  isCiStateSuccess: boolean;
8
8
  isBranchOutOfDate: boolean;
@@ -18,8 +18,32 @@ const badgeStyle = (color: ConsoleColor) => {
18
18
  };
19
19
  };
20
20
 
21
+ const MERGEABLE_BADGE: Record<
22
+ ConsoleMergeableStatus,
23
+ { color: ConsoleColor; label: string; title: string; modifier: string }
24
+ > = {
25
+ MERGEABLE: {
26
+ color: 'GREEN',
27
+ label: 'No conflict',
28
+ title: 'This pull request has no merge conflicts',
29
+ modifier: 'console-detail-mergeable-chip-ok',
30
+ },
31
+ CONFLICTING: {
32
+ color: 'RED',
33
+ label: 'Conflict',
34
+ title: 'This pull request has merge conflicts',
35
+ modifier: 'console-detail-mergeable-chip-conflict',
36
+ },
37
+ UNKNOWN: {
38
+ color: 'GRAY',
39
+ label: 'Checking merge status',
40
+ title: 'GitHub has not finished computing the merge status yet',
41
+ modifier: 'console-detail-mergeable-chip-unknown',
42
+ },
43
+ };
44
+
21
45
  export const ConsolePullRequestStatusBadges = ({
22
- isConflicted,
46
+ mergeableStatus,
23
47
  isPassedAllCiJob,
24
48
  isCiStateSuccess,
25
49
  isBranchOutOfDate,
@@ -32,6 +56,7 @@ export const ConsolePullRequestStatusBadges = ({
32
56
  !ciPassing && missingRequiredCheckNames.length > 0
33
57
  ? `missing: ${missingRequiredCheckNames.join(', ')}`
34
58
  : null;
59
+ const mergeableBadge = MERGEABLE_BADGE[mergeableStatus];
35
60
 
36
61
  return (
37
62
  <span className="console-detail-pr-status">
@@ -48,15 +73,13 @@ export const ConsolePullRequestStatusBadges = ({
48
73
  </span>
49
74
  )}
50
75
  </span>
51
- {isConflicted && (
52
- <span
53
- className="console-detail-status-chip console-detail-conflict-chip"
54
- style={badgeStyle('RED')}
55
- title="This pull request has merge conflicts"
56
- >
57
- Conflict
58
- </span>
59
- )}
76
+ <span
77
+ className={`console-detail-status-chip console-detail-mergeable-chip ${mergeableBadge.modifier}`}
78
+ style={badgeStyle(mergeableBadge.color)}
79
+ title={mergeableBadge.title}
80
+ >
81
+ {mergeableBadge.label}
82
+ </span>
60
83
  {isBranchOutOfDate && (
61
84
  <span
62
85
  className="console-detail-status-chip console-detail-outofdate-chip"
@@ -0,0 +1,117 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { ResourceCache } from '../lib/resourceCache';
3
+ import type { ConsoleListItem } from '../logic/types';
4
+ import type { ConsoleCaches } from './useConsoleCaches';
5
+ import { useConsoleDetailPrefetch } from './useConsoleDetailPrefetch';
6
+
7
+ const makeItem = (
8
+ number: number,
9
+ overrides: Partial<ConsoleListItem> = {},
10
+ ): ConsoleListItem => ({
11
+ number,
12
+ title: `Item ${number}`,
13
+ url: `https://github.com/o/r/issues/${number}`,
14
+ repo: 'o/r',
15
+ nameWithOwner: 'o/r',
16
+ projectItemId: `PVTI_${number}`,
17
+ itemId: `PVTI_${number}`,
18
+ isPr: false,
19
+ story: 'Story',
20
+ status: null,
21
+ nextActionDate: null,
22
+ nextActionHour: null,
23
+ dependedIssueUrls: [],
24
+ labels: [],
25
+ createdAt: '2026-06-10T00:00:00.000Z',
26
+ ...overrides,
27
+ });
28
+
29
+ const buildCaches = (): ConsoleCaches => {
30
+ const never = () => new Promise<never>(() => {});
31
+ return {
32
+ client: {} as ConsoleCaches['client'],
33
+ body: new ResourceCache<string>(never),
34
+ comments: new ResourceCache(never),
35
+ files: new ResourceCache(never),
36
+ commits: new ResourceCache(never),
37
+ relatedPrs: new ResourceCache(never),
38
+ state: new ResourceCache(never),
39
+ prStatus: new ResourceCache(never),
40
+ };
41
+ };
42
+
43
+ describe('useConsoleDetailPrefetch', () => {
44
+ beforeEach(() => {
45
+ jest.useFakeTimers();
46
+ const idleWindow = window as unknown as Record<string, unknown>;
47
+ idleWindow.requestIdleCallback = undefined;
48
+ idleWindow.cancelIdleCallback = undefined;
49
+ });
50
+
51
+ afterEach(() => {
52
+ jest.runAllTimers();
53
+ jest.useRealTimers();
54
+ });
55
+
56
+ it('prefetches the next items after the idle defer elapses', () => {
57
+ const caches = buildCaches();
58
+ const bodyPrefetch = jest.spyOn(caches.body, 'prefetch');
59
+ const relatedPrefetch = jest.spyOn(caches.relatedPrs, 'prefetch');
60
+ const items = [makeItem(1), makeItem(2), makeItem(3)];
61
+ renderHook(() => useConsoleDetailPrefetch(caches, items[0], items));
62
+
63
+ expect(bodyPrefetch).not.toHaveBeenCalled();
64
+ jest.runAllTimers();
65
+
66
+ expect(bodyPrefetch).toHaveBeenCalledWith('o/r#2', items[1].url);
67
+ expect(bodyPrefetch).toHaveBeenCalledWith('o/r#3', items[2].url);
68
+ expect(relatedPrefetch).toHaveBeenCalledWith('o/r#2', items[1].url);
69
+ });
70
+
71
+ it('prefetches PR resources for pull request items', () => {
72
+ const caches = buildCaches();
73
+ const filesPrefetch = jest.spyOn(caches.files, 'prefetch');
74
+ const prStatusPrefetch = jest.spyOn(caches.prStatus, 'prefetch');
75
+ const items = [
76
+ makeItem(1),
77
+ makeItem(2, { isPr: true, url: 'https://github.com/o/r/pull/2' }),
78
+ ];
79
+ renderHook(() => useConsoleDetailPrefetch(caches, items[0], items));
80
+ jest.runAllTimers();
81
+
82
+ expect(filesPrefetch).toHaveBeenCalledWith('o/r#2', items[1].url);
83
+ expect(prStatusPrefetch).toHaveBeenCalledWith('o/r#2', items[1].url);
84
+ });
85
+
86
+ it('limits prefetch to the next ten items', () => {
87
+ const caches = buildCaches();
88
+ const bodyPrefetch = jest.spyOn(caches.body, 'prefetch');
89
+ const items = Array.from({ length: 15 }, (_unused, index) =>
90
+ makeItem(index + 1),
91
+ );
92
+ renderHook(() => useConsoleDetailPrefetch(caches, items[0], items));
93
+ jest.runAllTimers();
94
+
95
+ expect(bodyPrefetch).toHaveBeenCalledTimes(10);
96
+ });
97
+
98
+ it('does nothing when no item is selected', () => {
99
+ const caches = buildCaches();
100
+ const bodyPrefetch = jest.spyOn(caches.body, 'prefetch');
101
+ const items = [makeItem(1), makeItem(2)];
102
+ renderHook(() => useConsoleDetailPrefetch(caches, null, items));
103
+ jest.runAllTimers();
104
+
105
+ expect(bodyPrefetch).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it('does not prefetch when the selected item is the last in the list', () => {
109
+ const caches = buildCaches();
110
+ const bodyPrefetch = jest.spyOn(caches.body, 'prefetch');
111
+ const items = [makeItem(1), makeItem(2)];
112
+ renderHook(() => useConsoleDetailPrefetch(caches, items[1], items));
113
+ jest.runAllTimers();
114
+
115
+ expect(bodyPrefetch).not.toHaveBeenCalled();
116
+ });
117
+ });
@@ -0,0 +1,83 @@
1
+ import { useEffect } from 'react';
2
+ import type { ConsoleListItem } from '../logic/types';
3
+ import type { ConsoleCaches } from './useConsoleCaches';
4
+
5
+ const PREFETCH_AHEAD_COUNT = 10;
6
+ const PREFETCH_DEFER_MS = 500;
7
+ const PREFETCH_IDLE_TIMEOUT_MS = 2000;
8
+
9
+ type IdleHandle = { cancel: () => void };
10
+
11
+ const scheduleOnIdle = (task: () => void): IdleHandle => {
12
+ const idleWindow = window as Window & {
13
+ requestIdleCallback?: (
14
+ callback: () => void,
15
+ options?: { timeout: number },
16
+ ) => number;
17
+ cancelIdleCallback?: (handle: number) => void;
18
+ };
19
+ if (
20
+ typeof idleWindow.requestIdleCallback === 'function' &&
21
+ typeof idleWindow.cancelIdleCallback === 'function'
22
+ ) {
23
+ const handle = idleWindow.requestIdleCallback(task, {
24
+ timeout: PREFETCH_IDLE_TIMEOUT_MS,
25
+ });
26
+ return { cancel: () => idleWindow.cancelIdleCallback?.(handle) };
27
+ }
28
+ const handle = window.setTimeout(task, 0);
29
+ return { cancel: () => window.clearTimeout(handle) };
30
+ };
31
+
32
+ const prefetchItem = (caches: ConsoleCaches, item: ConsoleListItem): void => {
33
+ const key = `${item.repo}#${item.number}`;
34
+ caches.body.prefetch(key, item.url);
35
+ caches.state.prefetch(key, item.url);
36
+ caches.comments.prefetch(key, item.url);
37
+ if (item.isPr) {
38
+ caches.files.prefetch(key, item.url);
39
+ caches.commits.prefetch(key, item.url);
40
+ caches.prStatus.prefetch(key, item.url);
41
+ } else {
42
+ caches.relatedPrs.prefetch(key, item.url);
43
+ }
44
+ };
45
+
46
+ export const useConsoleDetailPrefetch = (
47
+ caches: ConsoleCaches,
48
+ selectedItem: ConsoleListItem | null,
49
+ orderedPendingItems: ConsoleListItem[],
50
+ ): void => {
51
+ useEffect(() => {
52
+ if (selectedItem === null) {
53
+ return;
54
+ }
55
+ const selectedIndex = orderedPendingItems.findIndex(
56
+ (item) => item.projectItemId === selectedItem.projectItemId,
57
+ );
58
+ if (selectedIndex === -1) {
59
+ return;
60
+ }
61
+ const upcoming = orderedPendingItems.slice(
62
+ selectedIndex + 1,
63
+ selectedIndex + 1 + PREFETCH_AHEAD_COUNT,
64
+ );
65
+ if (upcoming.length === 0) {
66
+ return;
67
+ }
68
+
69
+ let idleHandle: IdleHandle | null = null;
70
+ const deferHandle = window.setTimeout(() => {
71
+ idleHandle = scheduleOnIdle(() => {
72
+ for (const item of upcoming) {
73
+ prefetchItem(caches, item);
74
+ }
75
+ });
76
+ }, PREFETCH_DEFER_MS);
77
+
78
+ return () => {
79
+ window.clearTimeout(deferHandle);
80
+ idleHandle?.cancel();
81
+ };
82
+ }, [caches, selectedItem, orderedPendingItems]);
83
+ };
@@ -68,6 +68,7 @@ const buildCaches = (related: ConsoleRelatedPullRequest[]): ConsoleCaches => {
68
68
  fetchPullRequestStatus: async (): Promise<ConsolePullRequestStatus> => ({
69
69
  found: true,
70
70
  isConflicted: true,
71
+ mergeableStatus: 'CONFLICTING',
71
72
  isPassedAllCiJob: false,
72
73
  isCiStateSuccess: false,
73
74
  isBranchOutOfDate: true,
@@ -117,6 +118,7 @@ describe('useConsoleItemDetailData', () => {
117
118
  createdAt: '2026-06-10T00:00:00.000Z',
118
119
  isDraft: false,
119
120
  isConflicted: false,
121
+ mergeableStatus: 'MERGEABLE',
120
122
  isPassedAllCiJob: true,
121
123
  isCiStateSuccess: true,
122
124
  isResolvedAllReviewComments: true,
@@ -25,6 +25,7 @@ const DEFAULT_STATE: ConsoleIssueState = {
25
25
  const DEFAULT_PR_STATUS: ConsolePullRequestStatus = {
26
26
  found: false,
27
27
  isConflicted: false,
28
+ mergeableStatus: 'UNKNOWN',
28
29
  isPassedAllCiJob: false,
29
30
  isCiStateSuccess: false,
30
31
  isBranchOutOfDate: false,
@@ -1,15 +1,31 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
+ import { ResourceCache } from '../lib/resourceCache';
2
3
  import { overlayStorageKey } from '../logic/overlay';
3
4
  import {
4
5
  consoleListItemsFixture,
5
6
  consoleStatusOptionsFixture,
6
7
  } from '../testing/fixtures';
8
+ import type { ConsoleCaches } from './useConsoleCaches';
7
9
  import { useConsoleOperations } from './useConsoleOperations';
8
10
  import { useConsoleOverlay } from './useConsoleOverlay';
9
11
 
10
12
  const prItem = consoleListItemsFixture[0];
11
13
  const issueItem = consoleListItemsFixture[2];
12
14
 
15
+ const buildOperationCaches = (): ConsoleCaches => {
16
+ const never = () => new Promise<never>(() => {});
17
+ return {
18
+ client: {} as ConsoleCaches['client'],
19
+ body: new ResourceCache<string>(never),
20
+ comments: new ResourceCache(never),
21
+ files: new ResourceCache(never),
22
+ commits: new ResourceCache(never),
23
+ relatedPrs: new ResourceCache(never),
24
+ state: new ResourceCache(never),
25
+ prStatus: new ResourceCache(never),
26
+ };
27
+ };
28
+
13
29
  const captureFetch = (): jest.Mock => {
14
30
  const fetchMock = jest.fn(async () => ({
15
31
  ok: true,
@@ -256,6 +272,57 @@ describe('useConsoleOperations', () => {
256
272
  });
257
273
  });
258
274
 
275
+ it('invalidates the operated item body and comments cache on a review', async () => {
276
+ captureFetch();
277
+ localStorage.clear();
278
+ window.history.replaceState({}, '', '/projects/umino/prs?k=token');
279
+ const caches = buildOperationCaches();
280
+ const bodyInvalidate = jest.spyOn(caches.body, 'invalidate');
281
+ const commentsInvalidate = jest.spyOn(caches.comments, 'invalidate');
282
+ const { result } = renderHook(() => {
283
+ const overlay = useConsoleOverlay('umino');
284
+ const operations = useConsoleOperations('umino', 'prs', overlay, caches);
285
+ return { overlay, operations };
286
+ });
287
+ await act(async () => {
288
+ await result.current.operations.reviewPullRequest(
289
+ prItem,
290
+ prItem.url,
291
+ 'approve',
292
+ );
293
+ });
294
+ const key = `${prItem.repo}#${prItem.number}`;
295
+ expect(bodyInvalidate).toHaveBeenCalledWith(key);
296
+ expect(commentsInvalidate).toHaveBeenCalledWith(key);
297
+ });
298
+
299
+ it('invalidates the operated item cache after posting a comment', async () => {
300
+ const fetchMock: jest.Mock = jest.fn(async () => ({
301
+ ok: true,
302
+ status: 200,
303
+ json: async () => ({
304
+ ok: true,
305
+ comment: { author: 'a', body: 'b', createdAt: 'c' },
306
+ }),
307
+ }));
308
+ global.fetch = fetchMock as unknown as typeof fetch;
309
+ localStorage.clear();
310
+ window.history.replaceState({}, '', '/projects/umino/prs?k=token');
311
+ const caches = buildOperationCaches();
312
+ const commentsInvalidate = jest.spyOn(caches.comments, 'invalidate');
313
+ const { result } = renderHook(() => {
314
+ const overlay = useConsoleOverlay('umino');
315
+ const operations = useConsoleOperations('umino', 'prs', overlay, caches);
316
+ return { overlay, operations };
317
+ });
318
+ await act(async () => {
319
+ await result.current.operations.addComment(issueItem, 'hello');
320
+ });
321
+ expect(commentsInvalidate).toHaveBeenCalledWith(
322
+ `${issueItem.repo}#${issueItem.number}`,
323
+ );
324
+ });
325
+
259
326
  it('rejects an operation and posts nothing when no pjcode is available', async () => {
260
327
  const fetchMock = captureFetch();
261
328
  localStorage.clear();