github-issue-tower-defence-management 1.116.9 → 1.117.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/bin/adapter/entry-points/console/consoleReadApi.js +13 -1
  3. package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -1
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-CJJsRF8i.js +101 -0
  5. package/bin/adapter/entry-points/console/ui-dist/assets/{index-D1V2fi29.css → index-DGVKvwux.css} +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  7. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +1 -0
  8. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/adapter/entry-points/console/consoleReadApi.test.ts +4 -0
  11. package/src/adapter/entry-points/console/consoleReadApi.ts +18 -0
  12. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -0
  13. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.stories.tsx +2 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.test.tsx +3 -1
  15. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +1 -1
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.test.tsx +1 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +1 -1
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestStatusBadges.stories.tsx +15 -5
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestStatusBadges.test.tsx +38 -8
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestStatusBadges.tsx +35 -12
  21. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleDetailPrefetch.test.ts +117 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleDetailPrefetch.ts +83 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.test.ts +2 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.ts +1 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +67 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +26 -6
  27. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +21 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +14 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.test.ts +48 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.ts +24 -0
  31. package/src/adapter/entry-points/console/ui/src/features/console/logic/types.ts +4 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +1 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +9 -1
  34. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +1 -0
  35. package/src/adapter/entry-points/console/ui/src/index.css +10 -1
  36. package/src/adapter/entry-points/console/ui-dist/assets/index-CJJsRF8i.js +101 -0
  37. package/src/adapter/entry-points/console/ui-dist/assets/{index-D1V2fi29.css → index-DGVKvwux.css} +1 -1
  38. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  39. package/src/adapter/entry-points/console/webServer.test.ts +2 -0
  40. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +1 -0
  41. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +1 -0
  42. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +1 -0
  43. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +1 -0
  44. package/src/domain/usecases/StartPreparationUseCase.test.ts +9 -0
  45. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +1 -0
  46. package/types/adapter/entry-points/console/consoleReadApi.d.ts +4 -0
  47. package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -1
  48. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  49. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -0
  50. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  51. package/bin/adapter/entry-points/console/ui-dist/assets/index-Bm0wt3Mn.js +0 -101
  52. package/src/adapter/entry-points/console/ui-dist/assets/index-Bm0wt3Mn.js +0 -101
@@ -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();
@@ -22,6 +22,7 @@ import type {
22
22
  ConsoleListItem,
23
23
  ConsoleTabName,
24
24
  } from '../logic/types';
25
+ import type { ConsoleCaches } from './useConsoleCaches';
25
26
  import type { ConsoleOverlayState } from './useConsoleOverlay';
26
27
  import { useConsoleToken } from './useConsoleToken';
27
28
 
@@ -113,15 +114,29 @@ export const useConsoleOperations = (
113
114
  pjcode: string | null,
114
115
  mode: ConsoleTabName,
115
116
  overlayState: ConsoleOverlayState,
117
+ caches?: ConsoleCaches,
116
118
  ): ConsoleOperationsApi => {
117
119
  const { appendToken } = useConsoleToken();
118
120
  const { patchOverlay } = overlayState;
119
121
 
122
+ const invalidateItemContent = useCallback(
123
+ (item: ConsoleListItem) => {
124
+ if (caches === undefined) {
125
+ return;
126
+ }
127
+ const key = `${item.repo}#${item.number}`;
128
+ caches.body.invalidate(key);
129
+ caches.comments.invalidate(key);
130
+ },
131
+ [caches],
132
+ );
133
+
120
134
  const markDone = useCallback(
121
135
  (item: ConsoleListItem) => {
136
+ invalidateItemContent(item);
122
137
  patchOverlay(overlayKeyForItem(item), { done: true }, mode);
123
138
  },
124
- [patchOverlay, mode],
139
+ [invalidateItemContent, patchOverlay, mode],
125
140
  );
126
141
 
127
142
  const reviewPullRequest = useCallback(
@@ -173,13 +188,14 @@ export const useConsoleOperations = (
173
188
  storyOptionId: option.id,
174
189
  };
175
190
  await postConsoleOperation(appendToken, TRIAGE_OPERATION_PATH, request);
191
+ invalidateItemContent(item);
176
192
  patchOverlay(
177
193
  overlayKeyForItem(item),
178
194
  { done: true, story: { name: option.name, color: option.color } },
179
195
  mode,
180
196
  );
181
197
  },
182
- [pjcode, appendToken, patchOverlay, mode],
198
+ [pjcode, appendToken, invalidateItemContent, patchOverlay, mode],
183
199
  );
184
200
 
185
201
  const setStatus = useCallback(
@@ -195,13 +211,14 @@ export const useConsoleOperations = (
195
211
  statusName: option.name,
196
212
  };
197
213
  await postConsoleOperation(appendToken, TRIAGE_OPERATION_PATH, request);
214
+ invalidateItemContent(item);
198
215
  patchOverlay(
199
216
  overlayKeyForItem(item),
200
217
  { done: true, status: { name: option.name, color: option.color } },
201
218
  mode,
202
219
  );
203
220
  },
204
- [pjcode, appendToken, patchOverlay, mode],
221
+ [pjcode, appendToken, invalidateItemContent, patchOverlay, mode],
205
222
  );
206
223
 
207
224
  const setInTmuxByHuman = useCallback(
@@ -216,13 +233,14 @@ export const useConsoleOperations = (
216
233
  projectItemId: item.projectItemId,
217
234
  };
218
235
  await postConsoleOperation(appendToken, INTMUX_OPERATION_PATH, request);
236
+ invalidateItemContent(item);
219
237
  patchOverlay(
220
238
  overlayKeyForItem(item),
221
239
  { done: true, status: { name: option.name, color: option.color } },
222
240
  mode,
223
241
  );
224
242
  },
225
- [pjcode, appendToken, patchOverlay, mode],
243
+ [pjcode, appendToken, invalidateItemContent, patchOverlay, mode],
226
244
  );
227
245
 
228
246
  const closeIssue = useCallback(
@@ -247,13 +265,15 @@ export const useConsoleOperations = (
247
265
  if (pjcode === null) {
248
266
  throw missingPjcodeError();
249
267
  }
250
- return postConsoleComment(appendToken, {
268
+ const comment = await postConsoleComment(appendToken, {
251
269
  pjcode,
252
270
  url: item.url,
253
271
  body,
254
272
  });
273
+ invalidateItemContent(item);
274
+ return comment;
255
275
  },
256
- [pjcode, appendToken],
276
+ [pjcode, appendToken, invalidateItemContent],
257
277
  );
258
278
 
259
279
  const addInlineReviewComment = useCallback(
@@ -191,6 +191,7 @@ describe('createConsoleApiClient', () => {
191
191
  found: true,
192
192
  status: {
193
193
  isConflicted: true,
194
+ mergeableStatus: 'CONFLICTING',
194
195
  isPassedAllCiJob: false,
195
196
  isCiStateSuccess: false,
196
197
  isBranchOutOfDate: true,
@@ -203,6 +204,7 @@ describe('createConsoleApiClient', () => {
203
204
  ).toEqual({
204
205
  found: true,
205
206
  isConflicted: true,
207
+ mergeableStatus: 'CONFLICTING',
206
208
  isPassedAllCiJob: false,
207
209
  isCiStateSuccess: false,
208
210
  isBranchOutOfDate: true,
@@ -210,6 +212,24 @@ describe('createConsoleApiClient', () => {
210
212
  });
211
213
  });
212
214
 
215
+ it('defaults the mergeable status to unknown when the server omits it', async () => {
216
+ mockFetchOnce({
217
+ found: true,
218
+ status: {
219
+ isConflicted: false,
220
+ isPassedAllCiJob: true,
221
+ isCiStateSuccess: true,
222
+ isBranchOutOfDate: false,
223
+ missingRequiredCheckNames: [],
224
+ },
225
+ });
226
+ const client = createConsoleApiClient(appendToken);
227
+ const status = await client.fetchPullRequestStatus(
228
+ 'https://github.com/o/r/pull/1',
229
+ );
230
+ expect(status.mergeableStatus).toBe('UNKNOWN');
231
+ });
232
+
213
233
  it('returns a not-found pull request status when the server reports none', async () => {
214
234
  mockFetchOnce({ found: false, status: null });
215
235
  const client = createConsoleApiClient(appendToken);
@@ -218,6 +238,7 @@ describe('createConsoleApiClient', () => {
218
238
  ).toEqual({
219
239
  found: false,
220
240
  isConflicted: false,
241
+ mergeableStatus: 'UNKNOWN',
221
242
  isPassedAllCiJob: false,
222
243
  isCiStateSuccess: false,
223
244
  isBranchOutOfDate: false,
@@ -3,6 +3,7 @@ import type {
3
3
  ConsoleComment,
4
4
  ConsoleCommit,
5
5
  ConsoleIssueState,
6
+ ConsoleMergeableStatus,
6
7
  ConsolePullRequestStatus,
7
8
  ConsoleRelatedPullRequest,
8
9
  } from '../logic/types';
@@ -136,6 +137,16 @@ const parseStringArray = (value: unknown): string[] =>
136
137
  ? value.filter((name): name is string => typeof name === 'string')
137
138
  : [];
138
139
 
140
+ const parseMergeableStatus = (value: unknown): ConsoleMergeableStatus => {
141
+ if (value === 'MERGEABLE') {
142
+ return 'MERGEABLE';
143
+ }
144
+ if (value === 'CONFLICTING') {
145
+ return 'CONFLICTING';
146
+ }
147
+ return 'UNKNOWN';
148
+ };
149
+
139
150
  const parseRelatedPrs = (payload: unknown): ConsoleRelatedPullRequest[] => {
140
151
  if (!isRecord(payload) || !Array.isArray(payload.relatedPullRequests)) {
141
152
  return [];
@@ -146,6 +157,7 @@ const parseRelatedPrs = (payload: unknown): ConsoleRelatedPullRequest[] => {
146
157
  createdAt: getString(pr.createdAt),
147
158
  isDraft: getBoolean(pr.isDraft),
148
159
  isConflicted: getBoolean(pr.isConflicted),
160
+ mergeableStatus: parseMergeableStatus(pr.mergeableStatus),
149
161
  isPassedAllCiJob: getBoolean(pr.isPassedAllCiJob),
150
162
  isCiStateSuccess: getBoolean(pr.isCiStateSuccess),
151
163
  isResolvedAllReviewComments: getBoolean(pr.isResolvedAllReviewComments),
@@ -160,6 +172,7 @@ const parsePullRequestStatus = (payload: unknown): ConsolePullRequestStatus => {
160
172
  return {
161
173
  found: false,
162
174
  isConflicted: false,
175
+ mergeableStatus: 'UNKNOWN',
163
176
  isPassedAllCiJob: false,
164
177
  isCiStateSuccess: false,
165
178
  isBranchOutOfDate: false,
@@ -170,6 +183,7 @@ const parsePullRequestStatus = (payload: unknown): ConsolePullRequestStatus => {
170
183
  return {
171
184
  found: true,
172
185
  isConflicted: getBoolean(status.isConflicted),
186
+ mergeableStatus: parseMergeableStatus(status.mergeableStatus),
173
187
  isPassedAllCiJob: getBoolean(status.isPassedAllCiJob),
174
188
  isCiStateSuccess: getBoolean(status.isCiStateSuccess),
175
189
  isBranchOutOfDate: getBoolean(status.isBranchOutOfDate),