github-issue-tower-defence-management 1.94.3 → 1.95.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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/cli/index.js +1 -0
  3. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  4. package/bin/adapter/entry-points/console/consoleImageProxy.js +79 -0
  5. package/bin/adapter/entry-points/console/consoleImageProxy.js.map +1 -0
  6. package/bin/adapter/entry-points/console/consoleServer.js +29 -1
  7. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  8. package/bin/adapter/entry-points/console/ui-dist/assets/index-BeJzGnfH.js +101 -0
  9. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  10. package/package.json +1 -1
  11. package/src/adapter/entry-points/cli/index.ts +1 -0
  12. package/src/adapter/entry-points/console/consoleImageProxy.test.ts +140 -0
  13. package/src/adapter/entry-points/console/consoleImageProxy.ts +111 -0
  14. package/src/adapter/entry-points/console/consoleServer.test.ts +182 -0
  15. package/src/adapter/entry-points/console/consoleServer.ts +45 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.stories.tsx +10 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.tsx +23 -3
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentList.tsx +7 -1
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +9 -1
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +7 -1
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +14 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/lib/imageProxy.test.ts +78 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/lib/imageProxy.ts +41 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.test.ts +48 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.ts +19 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +9 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +39 -4
  28. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +29 -1
  29. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +11 -0
  30. package/src/adapter/entry-points/console/ui-dist/assets/index-BeJzGnfH.js +101 -0
  31. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  32. package/types/adapter/entry-points/console/consoleImageProxy.d.ts +19 -0
  33. package/types/adapter/entry-points/console/consoleImageProxy.d.ts.map +1 -0
  34. package/types/adapter/entry-points/console/consoleServer.d.ts +4 -0
  35. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  36. package/bin/adapter/entry-points/console/ui-dist/assets/index-eSyo3r3C.js +0 -101
  37. package/src/adapter/entry-points/console/ui-dist/assets/index-eSyo3r3C.js +0 -101
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { ImageProxyUrlBuilder } from '../../lib/imageProxy';
2
3
  import { colorFromEnum } from '../../logic/colors';
3
4
  import {
4
5
  formatFullTimestamp,
@@ -54,6 +55,7 @@ export type ConsoleItemDetailProps = {
54
55
  now: number;
55
56
  commentComposer: ReactNode;
56
57
  operationBar: ReactNode;
58
+ buildImageProxyUrl?: ImageProxyUrlBuilder;
57
59
  };
58
60
 
59
61
  export const ConsoleItemDetail = ({
@@ -78,6 +80,7 @@ export const ConsoleItemDetail = ({
78
80
  now,
79
81
  commentComposer,
80
82
  operationBar,
83
+ buildImageProxyUrl,
81
84
  }: ConsoleItemDetailProps) => {
82
85
  const resolvedState = state?.state ?? 'open';
83
86
  const merged = state?.merged ?? false;
@@ -192,7 +195,10 @@ export const ConsoleItemDetail = ({
192
195
  ) : bodyIsLoading ? (
193
196
  <p className="console-detail-body-loading">Loading description...</p>
194
197
  ) : (
195
- <ConsoleMarkdownContent body={body} />
198
+ <ConsoleMarkdownContent
199
+ body={body}
200
+ buildImageProxyUrl={buildImageProxyUrl}
201
+ />
196
202
  )}
197
203
  </ConsolePanel>
198
204
 
@@ -216,6 +222,7 @@ export const ConsoleItemDetail = ({
216
222
  isLoading={commentsAreLoading}
217
223
  error={commentsError}
218
224
  now={now}
225
+ buildImageProxyUrl={buildImageProxyUrl}
219
226
  />
220
227
  </ConsolePanel>
221
228
 
@@ -244,6 +251,7 @@ export const ConsoleItemDetail = ({
244
251
  commitsAreLoading={related.commitsAreLoading}
245
252
  commitsError={related.commitsError}
246
253
  now={now}
254
+ buildImageProxyUrl={buildImageProxyUrl}
247
255
  />
248
256
  ))}
249
257
 
@@ -1,3 +1,4 @@
1
+ import type { ImageProxyUrlBuilder } from '../../lib/imageProxy';
1
2
  import type {
2
3
  ConsoleChangedFile,
3
4
  ConsoleCommit,
@@ -19,6 +20,7 @@ export type ConsolePullRequestSectionProps = {
19
20
  commitsAreLoading: boolean;
20
21
  commitsError: string | null;
21
22
  now: number;
23
+ buildImageProxyUrl?: ImageProxyUrlBuilder;
22
24
  };
23
25
 
24
26
  export const ConsolePullRequestDetail = ({
@@ -32,6 +34,7 @@ export const ConsolePullRequestDetail = ({
32
34
  commitsAreLoading,
33
35
  commitsError,
34
36
  now,
37
+ buildImageProxyUrl,
35
38
  }: ConsolePullRequestSectionProps) => {
36
39
  const summary = pullRequest.summary;
37
40
  const filesCount =
@@ -71,7 +74,10 @@ export const ConsolePullRequestDetail = ({
71
74
  {bodyIsLoading ? (
72
75
  <p className="console-pr-body-loading">Loading description...</p>
73
76
  ) : (
74
- <ConsoleMarkdownContent body={summary?.body ?? body} />
77
+ <ConsoleMarkdownContent
78
+ body={summary?.body ?? body}
79
+ buildImageProxyUrl={buildImageProxyUrl}
80
+ />
75
81
  )}
76
82
  </ConsolePanel>
77
83
  <ConsolePanel title="Changed files" count={filesCount}>
@@ -59,6 +59,20 @@ export const ZeroCountActiveTabStaysVisible: Story = {
59
59
  },
60
60
  };
61
61
 
62
+ export const AfterAutoAdvanceToNextTab: Story = {
63
+ args: {
64
+ activeTab: 'unread',
65
+ counts: {
66
+ prs: 0,
67
+ triage: 0,
68
+ unread: 7,
69
+ 'failed-preparation': 2,
70
+ 'todo-by-human': 4,
71
+ },
72
+ onSelectTab: () => {},
73
+ },
74
+ };
75
+
62
76
  export const Interactive: Story = {
63
77
  render: (args) => {
64
78
  const [activeTab, setActiveTab] = useState<ConsoleTabName>('prs');
@@ -0,0 +1,78 @@
1
+ import {
2
+ buildImageProxyUrl,
3
+ isProxyableImageUrl,
4
+ rewriteGitHubImageSources,
5
+ } from './imageProxy';
6
+
7
+ describe('isProxyableImageUrl', () => {
8
+ it('matches github user-attachments urls', () => {
9
+ expect(
10
+ isProxyableImageUrl(
11
+ 'https://github.com/user-attachments/assets/0a1b2c3d',
12
+ ),
13
+ ).toBe(true);
14
+ });
15
+
16
+ it('matches githubusercontent subdomain urls', () => {
17
+ expect(
18
+ isProxyableImageUrl(
19
+ 'https://private-user-images.githubusercontent.com/1/2.png',
20
+ ),
21
+ ).toBe(true);
22
+ });
23
+
24
+ it('does not match arbitrary external hosts', () => {
25
+ expect(isProxyableImageUrl('https://example.com/avatar.png')).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe('buildImageProxyUrl', () => {
30
+ it('encodes the source url and appends the token', () => {
31
+ const src = 'https://github.com/user-attachments/assets/abc?x=1';
32
+ expect(buildImageProxyUrl(src, 'tk')).toBe(
33
+ `/api/img?url=${encodeURIComponent(src)}&k=tk`,
34
+ );
35
+ });
36
+
37
+ it('omits the token when it is null', () => {
38
+ const src = 'https://github.com/user-attachments/assets/abc';
39
+ expect(buildImageProxyUrl(src, null)).toBe(
40
+ `/api/img?url=${encodeURIComponent(src)}`,
41
+ );
42
+ });
43
+ });
44
+
45
+ describe('rewriteGitHubImageSources', () => {
46
+ const buildProxyUrl = (src: string): string =>
47
+ buildImageProxyUrl(src, 'console-token');
48
+
49
+ it('rewrites an allow-listed github image src to the proxy url', () => {
50
+ const githubSrc = 'https://github.com/user-attachments/assets/abc';
51
+ const html = `<p><img src="${githubSrc}" alt="shot" /></p>`;
52
+ const result = rewriteGitHubImageSources(html, buildProxyUrl);
53
+ expect(result).toContain(`/api/img?url=${encodeURIComponent(githubSrc)}`);
54
+ expect(result).toContain('k=console-token');
55
+ expect(result).not.toContain(`src="${githubSrc}"`);
56
+ });
57
+
58
+ it('rewrites a githubusercontent image src to the proxy url', () => {
59
+ const githubSrc =
60
+ 'https://private-user-images.githubusercontent.com/1/2.png';
61
+ const html = `<img src="${githubSrc}" />`;
62
+ const result = rewriteGitHubImageSources(html, buildProxyUrl);
63
+ expect(result).toContain(`/api/img?url=${encodeURIComponent(githubSrc)}`);
64
+ });
65
+
66
+ it('leaves a non-matching image src unchanged', () => {
67
+ const externalSrc = 'https://example.com/avatar.png';
68
+ const html = `<img src="${externalSrc}" />`;
69
+ const result = rewriteGitHubImageSources(html, buildProxyUrl);
70
+ expect(result).toContain(`src="${externalSrc}"`);
71
+ expect(result).not.toContain('/api/img');
72
+ });
73
+
74
+ it('returns the original html when there is no image to rewrite', () => {
75
+ const html = '<p>no images here</p>';
76
+ expect(rewriteGitHubImageSources(html, buildProxyUrl)).toBe(html);
77
+ });
78
+ });
@@ -0,0 +1,41 @@
1
+ const ALLOWED_IMAGE_URL =
2
+ /^https:\/\/github\.com\/user-attachments\/|^https:\/\/[a-z0-9][a-z0-9.-]*\.githubusercontent\.com\//;
3
+
4
+ export const isProxyableImageUrl = (src: string): boolean =>
5
+ ALLOWED_IMAGE_URL.test(src);
6
+
7
+ export type ImageProxyUrlBuilder = (src: string) => string;
8
+
9
+ export const buildImageProxyUrl = (
10
+ src: string,
11
+ token: string | null,
12
+ ): string => {
13
+ if (token === null) {
14
+ return `/api/img?url=${encodeURIComponent(src)}`;
15
+ }
16
+ return `/api/img?url=${encodeURIComponent(src)}&k=${encodeURIComponent(token)}`;
17
+ };
18
+
19
+ export const rewriteGitHubImageSources = (
20
+ html: string,
21
+ buildProxyUrl: ImageProxyUrlBuilder,
22
+ ): string => {
23
+ if (typeof document === 'undefined') {
24
+ return html;
25
+ }
26
+ const template = document.createElement('template');
27
+ template.innerHTML = html;
28
+ const images = template.content.querySelectorAll('img[src]');
29
+ let rewrote = false;
30
+ images.forEach((image) => {
31
+ const src = image.getAttribute('src') ?? '';
32
+ if (isProxyableImageUrl(src)) {
33
+ image.setAttribute('src', buildProxyUrl(src));
34
+ rewrote = true;
35
+ }
36
+ });
37
+ if (!rewrote) {
38
+ return html;
39
+ }
40
+ return template.innerHTML;
41
+ };
@@ -0,0 +1,48 @@
1
+ import { findNextNonEmptyTabToRight } from './tabAdvance';
2
+ import type { ConsoleTabName } from './types';
3
+
4
+ const counts = (
5
+ overrides: Partial<Record<ConsoleTabName, number>>,
6
+ ): Record<ConsoleTabName, number> => ({
7
+ prs: 0,
8
+ triage: 0,
9
+ unread: 0,
10
+ 'failed-preparation': 0,
11
+ 'todo-by-human': 0,
12
+ ...overrides,
13
+ });
14
+
15
+ describe('findNextNonEmptyTabToRight', () => {
16
+ it('returns the first non-empty tab to the right of the active tab', () => {
17
+ expect(findNextNonEmptyTabToRight('prs', counts({ unread: 7 }))).toBe(
18
+ 'unread',
19
+ );
20
+ });
21
+
22
+ it('skips empty tabs and returns the next non-empty tab further right', () => {
23
+ expect(
24
+ findNextNonEmptyTabToRight(
25
+ 'prs',
26
+ counts({ triage: 0, unread: 0, 'todo-by-human': 4 }),
27
+ ),
28
+ ).toBe('todo-by-human');
29
+ });
30
+
31
+ it('returns the immediately adjacent tab when it is non-empty', () => {
32
+ expect(
33
+ findNextNonEmptyTabToRight('prs', counts({ triage: 12, unread: 7 })),
34
+ ).toBe('triage');
35
+ });
36
+
37
+ it('returns null when no tab to the right has any items', () => {
38
+ expect(
39
+ findNextNonEmptyTabToRight('unread', counts({ prs: 35 })),
40
+ ).toBeNull();
41
+ });
42
+
43
+ it('returns null when the active tab is the last tab', () => {
44
+ expect(
45
+ findNextNonEmptyTabToRight('todo-by-human', counts({ prs: 35 })),
46
+ ).toBeNull();
47
+ });
48
+ });
@@ -0,0 +1,19 @@
1
+ import type { ConsoleTabName } from './types';
2
+ import { CONSOLE_TABS } from './types';
3
+
4
+ export const findNextNonEmptyTabToRight = (
5
+ activeTab: ConsoleTabName,
6
+ counts: Record<ConsoleTabName, number>,
7
+ ): ConsoleTabName | null => {
8
+ const activeIndex = CONSOLE_TABS.findIndex((tab) => tab.name === activeTab);
9
+ if (activeIndex === -1) {
10
+ return null;
11
+ }
12
+ for (let index = activeIndex + 1; index < CONSOLE_TABS.length; index += 1) {
13
+ const candidate = CONSOLE_TABS[index].name;
14
+ if ((counts[candidate] ?? 0) > 0) {
15
+ return candidate;
16
+ }
17
+ }
18
+ return null;
19
+ };
@@ -1,9 +1,12 @@
1
+ import { useCallback } from 'react';
1
2
  import { ConsoleCommentComposer } from '../components/detail/ConsoleCommentComposer';
2
3
  import { ConsoleItemDetail } from '../components/detail/ConsoleItemDetail';
3
4
  import { ConsoleOperationMenu } from '../components/operations/ConsoleOperationMenu';
4
5
  import type { ConsoleCaches } from '../hooks/useConsoleCaches';
5
6
  import { useConsoleItemDetailData } from '../hooks/useConsoleItemDetailData';
6
7
  import type { ConsoleOperationsApi } from '../hooks/useConsoleOperations';
8
+ import { useConsoleToken } from '../hooks/useConsoleToken';
9
+ import { buildImageProxyUrl } from '../lib/imageProxy';
7
10
  import type { ConsoleActionKind } from '../logic/actionToast';
8
11
  import { resolveStoryColorEnum } from '../logic/grouping';
9
12
  import type { ConsoleOperationHandlers } from '../logic/operations';
@@ -50,6 +53,11 @@ export const ConsoleItemDetailContainer = ({
50
53
  onQueueAction,
51
54
  }: ConsoleItemDetailContainerProps) => {
52
55
  const detail = useConsoleItemDetailData(caches, item);
56
+ const { token } = useConsoleToken();
57
+ const resolveImageProxyUrl = useCallback(
58
+ (src: string): string => buildImageProxyUrl(src, token),
59
+ [token],
60
+ );
53
61
  const hasPullRequest = item.isPr || detail.relatedPullRequests.length > 0;
54
62
 
55
63
  const handlers: ConsoleOperationHandlers = {
@@ -140,6 +148,7 @@ export const ConsoleItemDetailContainer = ({
140
148
  commitsError={detail.commitsError}
141
149
  relatedPullRequests={detail.relatedPullRequests}
142
150
  now={now}
151
+ buildImageProxyUrl={resolveImageProxyUrl}
143
152
  commentComposer={
144
153
  <ConsoleCommentComposer
145
154
  isPr={item.isPr}
@@ -128,10 +128,8 @@ describe('ConsolePage', () => {
128
128
 
129
129
  await waitFor(() => {
130
130
  expect(
131
- getByText('Awaiting Quality Check')
132
- .closest('a')
133
- ?.querySelector('.console-tab-badge')?.textContent,
134
- ).toBe('0');
131
+ getByText('Unread').closest('a')?.getAttribute('aria-current'),
132
+ ).toBe('page');
135
133
  });
136
134
  } finally {
137
135
  jest.useRealTimers();
@@ -397,3 +395,40 @@ describe('ConsolePage auto-advance', () => {
397
395
  }
398
396
  });
399
397
  });
398
+
399
+ describe('ConsolePage auto-advance tab', () => {
400
+ beforeEach(() => {
401
+ localStorage.clear();
402
+ window.history.replaceState({}, '', '/projects/umino/prs?k=token');
403
+ installFetch();
404
+ });
405
+
406
+ it('auto-advances to the next non-empty tab on the right after the active tab is driven to zero', async () => {
407
+ jest.useFakeTimers();
408
+ try {
409
+ const { getByText, findByText } = render(<ConsolePage />);
410
+ await waitFor(() => {
411
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
412
+ });
413
+
414
+ fireEvent.click(getByText('Add serveConsole subcommand'));
415
+ expect(await findByText('← Back to list')).toBeInTheDocument();
416
+ fireEvent.click(getByText('Approve'));
417
+
418
+ act(() => {
419
+ jest.advanceTimersByTime(5100);
420
+ });
421
+
422
+ await waitFor(() => {
423
+ expect(
424
+ getByText('Notify finished issue preparation'),
425
+ ).toBeInTheDocument();
426
+ });
427
+ expect(
428
+ getByText('Unread').closest('a')?.getAttribute('aria-current'),
429
+ ).toBe('page');
430
+ } finally {
431
+ jest.useRealTimers();
432
+ }
433
+ });
434
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
2
  import { ConsoleTabList } from '../components/layout/ConsoleTabList';
3
3
  import { ConsoleItemList } from '../components/list/ConsoleItemList';
4
4
  import { ConsoleUndoToast } from '../components/operations/ConsoleUndoToast';
@@ -27,6 +27,7 @@ import {
27
27
  overlayKeyForItem,
28
28
  } from '../logic/overlay';
29
29
  import type { ConsoleSwipeDirection } from '../logic/swipe';
30
+ import { findNextNonEmptyTabToRight } from '../logic/tabAdvance';
30
31
  import type {
31
32
  ConsoleListItem,
32
33
  ConsoleOverlayStatus,
@@ -109,6 +110,26 @@ export const ConsolePage = () => {
109
110
  );
110
111
  }, [selectedItemKey, activeSnapshot]);
111
112
 
113
+ const activeCount = counts[activeTab];
114
+ const previousActiveTabCountRef = useRef<{
115
+ tab: ConsoleTabName;
116
+ count: number;
117
+ }>({ tab: activeTab, count: activeCount });
118
+ useEffect(() => {
119
+ const previous = previousActiveTabCountRef.current;
120
+ previousActiveTabCountRef.current = { tab: activeTab, count: activeCount };
121
+ if (previous.tab !== activeTab) {
122
+ return;
123
+ }
124
+ if (previous.count > 0 && activeCount === 0) {
125
+ const nextTab = findNextNonEmptyTabToRight(activeTab, counts);
126
+ if (nextTab !== null) {
127
+ navigation.selectTab(nextTab);
128
+ navigation.closeItem();
129
+ }
130
+ }
131
+ }, [activeTab, activeCount, counts, navigation]);
132
+
112
133
  const overlayStatusForSelected = ((): ConsoleOverlayStatus | null => {
113
134
  if (selectedItem === null) {
114
135
  return null;
@@ -202,6 +223,13 @@ export const ConsolePage = () => {
202
223
  />
203
224
  ) : (
204
225
  <div className="console-detail-screen" ref={detailScreenRef}>
226
+ <button
227
+ type="button"
228
+ className="console-back-button"
229
+ onClick={closeItem}
230
+ >
231
+ ← Back to list
232
+ </button>
205
233
  <ConsoleItemDetailContainer
206
234
  tab={activeTab}
207
235
  item={selectedItem}
@@ -133,6 +133,17 @@ console bundle and the token-protected \`/api\` endpoints.
133
133
  - [x] Token validation via \`?k=\` and the \`x-pv-token\` header
134
134
  `;
135
135
 
136
+ export const consoleMarkdownImageBodyFixture = `## Screenshot
137
+
138
+ The failing screen is attached below.
139
+
140
+ ![console screenshot](https://github.com/user-attachments/assets/0a1b2c3d-4e5f-6789-abcd-ef0123456789)
141
+
142
+ For reference, the avatar comes from an external host:
143
+
144
+ ![external avatar](https://example.com/avatar.png)
145
+ `;
146
+
136
147
  export const consoleMermaidBodyFixture = `Here is the request flow:
137
148
 
138
149
  \`\`\`mermaid