github-issue-tower-defence-management 1.51.0 → 1.52.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 (24) hide show
  1. package/.github/workflows/umino-project.yml +2 -2
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +41 -2
  4. package/bin/adapter/entry-points/cli/index.js +2 -2
  5. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  6. package/bin/adapter/repositories/GraphqlProjectRepository.js +31 -9
  7. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  8. package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js +1 -1
  9. package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js.map +1 -1
  10. package/bin/domain/usecases/HandleScheduledEventUseCase.js +48 -26
  11. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/adapter/entry-points/cli/index.test.ts +2 -2
  14. package/src/adapter/entry-points/cli/index.ts +2 -2
  15. package/src/adapter/repositories/GraphqlProjectRepository.fetchProjectId.test.ts +251 -0
  16. package/src/adapter/repositories/GraphqlProjectRepository.ts +54 -25
  17. package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.test.ts +144 -10
  18. package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.ts +1 -1
  19. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +160 -0
  20. package/src/domain/usecases/HandleScheduledEventUseCase.ts +91 -29
  21. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +2 -0
  22. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  23. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -1
  24. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
@@ -0,0 +1,251 @@
1
+ const mockPost = jest.fn();
2
+
3
+ jest.mock('ky', () => ({
4
+ default: {
5
+ post: mockPost,
6
+ get: jest.fn(),
7
+ put: jest.fn(),
8
+ patch: jest.fn(),
9
+ delete: jest.fn(),
10
+ extend: jest.fn(),
11
+ create: jest.fn(),
12
+ stop: jest.fn(),
13
+ },
14
+ __esModule: true,
15
+ }));
16
+
17
+ import { GraphqlProjectRepository } from './GraphqlProjectRepository';
18
+ import { LocalStorageRepository } from './LocalStorageRepository';
19
+
20
+ const mockJsonResponse = <T>(data: T) => ({
21
+ json: jest.fn().mockResolvedValue(data),
22
+ });
23
+
24
+ describe('GraphqlProjectRepository.fetchProjectId', () => {
25
+ const localStorageRepository = new LocalStorageRepository();
26
+ let repository: GraphqlProjectRepository;
27
+
28
+ beforeEach(() => {
29
+ jest.useFakeTimers();
30
+ mockPost.mockReset();
31
+ repository = new GraphqlProjectRepository(localStorageRepository, '');
32
+ });
33
+
34
+ afterEach(() => {
35
+ jest.useRealTimers();
36
+ });
37
+
38
+ describe('user-owned project', () => {
39
+ it('should resolve project ID from user owner when organization returns null', async () => {
40
+ mockPost.mockReturnValueOnce(
41
+ mockJsonResponse({
42
+ data: {
43
+ organization: null,
44
+ user: {
45
+ projectV2: {
46
+ id: 'PVT_user123',
47
+ databaseId: 999,
48
+ },
49
+ },
50
+ },
51
+ }),
52
+ );
53
+
54
+ const result = await repository.fetchProjectId('some-user', 1);
55
+
56
+ expect(result).toBe('PVT_user123');
57
+ });
58
+
59
+ it('should resolve project ID from organization owner when user returns null', async () => {
60
+ mockPost.mockReturnValueOnce(
61
+ mockJsonResponse({
62
+ data: {
63
+ organization: {
64
+ projectV2: {
65
+ id: 'PVT_org456',
66
+ databaseId: 111,
67
+ },
68
+ },
69
+ user: null,
70
+ },
71
+ }),
72
+ );
73
+
74
+ const result = await repository.fetchProjectId('some-org', 2);
75
+
76
+ expect(result).toBe('PVT_org456');
77
+ });
78
+ });
79
+
80
+ describe('memoization', () => {
81
+ it('should return cached project ID on subsequent calls without re-fetching', async () => {
82
+ mockPost.mockReturnValueOnce(
83
+ mockJsonResponse({
84
+ data: {
85
+ organization: null,
86
+ user: {
87
+ projectV2: {
88
+ id: 'PVT_cached',
89
+ databaseId: 777,
90
+ },
91
+ },
92
+ },
93
+ }),
94
+ );
95
+
96
+ const first = await repository.fetchProjectId('owner', 10);
97
+ const second = await repository.fetchProjectId('owner', 10);
98
+
99
+ expect(first).toBe('PVT_cached');
100
+ expect(second).toBe('PVT_cached');
101
+ expect(mockPost).toHaveBeenCalledTimes(1);
102
+ });
103
+
104
+ it('should use separate cache entries for different owner+projectNumber combinations', async () => {
105
+ mockPost
106
+ .mockReturnValueOnce(
107
+ mockJsonResponse({
108
+ data: {
109
+ organization: null,
110
+ user: { projectV2: { id: 'PVT_A', databaseId: 1 } },
111
+ },
112
+ }),
113
+ )
114
+ .mockReturnValueOnce(
115
+ mockJsonResponse({
116
+ data: {
117
+ organization: null,
118
+ user: { projectV2: { id: 'PVT_B', databaseId: 2 } },
119
+ },
120
+ }),
121
+ );
122
+
123
+ const resultA = await repository.fetchProjectId('ownerA', 1);
124
+ const resultB = await repository.fetchProjectId('ownerB', 1);
125
+
126
+ expect(resultA).toBe('PVT_A');
127
+ expect(resultB).toBe('PVT_B');
128
+ expect(mockPost).toHaveBeenCalledTimes(2);
129
+ });
130
+ });
131
+
132
+ describe('errors-only response hardening', () => {
133
+ it('should throw a clear error when response has no data field', async () => {
134
+ mockPost.mockReturnValueOnce(
135
+ mockJsonResponse({
136
+ errors: [{ message: 'Could not resolve to a User' }],
137
+ }),
138
+ );
139
+
140
+ await expect(repository.fetchProjectId('bad-owner', 1)).rejects.toThrow(
141
+ 'Could not resolve to a User',
142
+ );
143
+ });
144
+
145
+ it('should throw a clear error when data is null', async () => {
146
+ mockPost.mockReturnValueOnce(
147
+ mockJsonResponse({
148
+ data: null,
149
+ errors: [{ message: 'secondary rate limit' }],
150
+ }),
151
+ );
152
+
153
+ await expect(
154
+ repository.fetchProjectId('rate-limited', 1),
155
+ ).rejects.toThrow('secondary rate limit');
156
+ });
157
+
158
+ it('should throw a clear error when data has no project in either org or user', async () => {
159
+ mockPost.mockReturnValueOnce(
160
+ mockJsonResponse({
161
+ data: {
162
+ organization: null,
163
+ user: null,
164
+ },
165
+ }),
166
+ );
167
+
168
+ await expect(
169
+ repository.fetchProjectId('no-project-owner', 99),
170
+ ).rejects.toThrow('project not found');
171
+ });
172
+
173
+ it('should throw a clear error when network call fails', async () => {
174
+ mockPost.mockReturnValueOnce({
175
+ json: jest.fn().mockRejectedValue(new Error('network failure')),
176
+ });
177
+
178
+ await expect(repository.fetchProjectId('owner', 1)).rejects.toThrow(
179
+ 'network failure',
180
+ );
181
+ });
182
+ });
183
+
184
+ describe('backoff after failure', () => {
185
+ it('should not re-call GraphQL within 1 hour after a failure', async () => {
186
+ mockPost.mockReturnValueOnce(
187
+ mockJsonResponse({
188
+ errors: [{ message: 'auth failure' }],
189
+ }),
190
+ );
191
+
192
+ await expect(repository.fetchProjectId('owner', 5)).rejects.toThrow();
193
+
194
+ await expect(repository.fetchProjectId('owner', 5)).rejects.toThrow(
195
+ 'backoff',
196
+ );
197
+
198
+ expect(mockPost).toHaveBeenCalledTimes(1);
199
+ });
200
+
201
+ it('should retry after 1 hour backoff has elapsed', async () => {
202
+ mockPost
203
+ .mockReturnValueOnce(
204
+ mockJsonResponse({
205
+ errors: [{ message: 'temporary error' }],
206
+ }),
207
+ )
208
+ .mockReturnValueOnce(
209
+ mockJsonResponse({
210
+ data: {
211
+ organization: null,
212
+ user: { projectV2: { id: 'PVT_recovered', databaseId: 42 } },
213
+ },
214
+ }),
215
+ );
216
+
217
+ await expect(repository.fetchProjectId('owner', 7)).rejects.toThrow();
218
+
219
+ jest.advanceTimersByTime(60 * 60 * 1000 + 1);
220
+
221
+ const result = await repository.fetchProjectId('owner', 7);
222
+
223
+ expect(result).toBe('PVT_recovered');
224
+ expect(mockPost).toHaveBeenCalledTimes(2);
225
+ });
226
+
227
+ it('should apply backoff independently per owner+projectNumber', async () => {
228
+ mockPost
229
+ .mockReturnValueOnce(
230
+ mockJsonResponse({
231
+ errors: [{ message: 'error for owner A' }],
232
+ }),
233
+ )
234
+ .mockReturnValueOnce(
235
+ mockJsonResponse({
236
+ data: {
237
+ organization: null,
238
+ user: { projectV2: { id: 'PVT_B', databaseId: 2 } },
239
+ },
240
+ }),
241
+ );
242
+
243
+ await expect(repository.fetchProjectId('ownerA', 1)).rejects.toThrow();
244
+
245
+ const resultB = await repository.fetchProjectId('ownerB', 1);
246
+
247
+ expect(resultB).toBe('PVT_B');
248
+ expect(mockPost).toHaveBeenCalledTimes(2);
249
+ });
250
+ });
251
+ });
@@ -4,6 +4,8 @@ import { ProjectRepository } from '../../domain/usecases/adapter-interfaces/Proj
4
4
  import { FieldOption, Project } from '../../domain/entities/Project';
5
5
  import { normalizeFieldName } from './utils';
6
6
 
7
+ const ONE_HOUR_MS = 60 * 60 * 1000;
8
+
7
9
  export class GraphqlProjectRepository
8
10
  extends BaseGitHubRepository
9
11
  implements
@@ -16,6 +18,9 @@ export class GraphqlProjectRepository
16
18
  | 'updateStatusList'
17
19
  >
18
20
  {
21
+ private readonly projectIdCache = new Map<string, string>();
22
+ private readonly fetchProjectIdFailedAt = new Map<string, number>();
23
+
19
24
  extractProjectFromUrl = (
20
25
  projectUrl: string,
21
26
  ): {
@@ -32,6 +37,17 @@ export class GraphqlProjectRepository
32
37
  login: string,
33
38
  projectNumber: number,
34
39
  ): Promise<string> => {
40
+ const cacheKey = `${login}:${projectNumber}`;
41
+ const cached = this.projectIdCache.get(cacheKey);
42
+ if (cached) {
43
+ return cached;
44
+ }
45
+ const failedAt = this.fetchProjectIdFailedAt.get(cacheKey);
46
+ if (failedAt !== undefined && Date.now() - failedAt < ONE_HOUR_MS) {
47
+ throw new Error(
48
+ `fetchProjectId for ${login}/${projectNumber} is in backoff after a recent failure`,
49
+ );
50
+ }
35
51
  const graphqlQuery = {
36
52
  query: `query GetProjectID($login: String!, $number: Int!) {
37
53
  organization(login: $login) {
@@ -53,32 +69,41 @@ export class GraphqlProjectRepository
53
69
  },
54
70
  };
55
71
 
56
- const response = await ky
57
- .post('https://api.github.com/graphql', {
58
- json: graphqlQuery,
59
- headers: {
60
- Authorization: `Bearer ${this.ghToken}`,
61
- },
62
- })
63
- .json<{
64
- data?: {
65
- organization: {
66
- projectV2: {
67
- id: string;
68
- databaseId: number;
69
- };
70
- };
71
- user: {
72
- projectV2: {
73
- id: string;
74
- databaseId: number;
75
- };
76
- };
77
- };
78
- errors?: { message: string }[];
79
- }>();
72
+ let response: {
73
+ data?: {
74
+ organization?: {
75
+ projectV2?: {
76
+ id: string;
77
+ databaseId: number;
78
+ } | null;
79
+ } | null;
80
+ user?: {
81
+ projectV2?: {
82
+ id: string;
83
+ databaseId: number;
84
+ } | null;
85
+ } | null;
86
+ } | null;
87
+ errors?: { message: string }[];
88
+ };
89
+ try {
90
+ response = await ky
91
+ .post('https://api.github.com/graphql', {
92
+ json: graphqlQuery,
93
+ headers: {
94
+ Authorization: `Bearer ${this.ghToken}`,
95
+ },
96
+ })
97
+ .json();
98
+ } catch (error) {
99
+ this.fetchProjectIdFailedAt.set(cacheKey, Date.now());
100
+ throw new Error(
101
+ `fetchProjectId network error for ${login}/${projectNumber}: ${String(error)}`,
102
+ );
103
+ }
80
104
 
81
105
  if (!response.data) {
106
+ this.fetchProjectIdFailedAt.set(cacheKey, Date.now());
82
107
  const errorMessages = response.errors
83
108
  ? response.errors.map((e) => e.message).join('; ')
84
109
  : 'no data field in response';
@@ -90,8 +115,12 @@ export class GraphqlProjectRepository
90
115
  response.data.organization?.projectV2?.id ||
91
116
  response.data.user?.projectV2?.id;
92
117
  if (!projectId) {
93
- throw new Error('projectId is not found');
118
+ this.fetchProjectIdFailedAt.set(cacheKey, Date.now());
119
+ throw new Error(
120
+ `fetchProjectId: project not found for ${login}/${projectNumber}`,
121
+ );
94
122
  }
123
+ this.projectIdCache.set(cacheKey, projectId);
95
124
  return projectId;
96
125
  };
97
126
  findProjectIdByUrl = async (
@@ -129,18 +129,146 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
129
129
  expectedGetIssueByUrlCalls: [],
130
130
  },
131
131
  {
132
- name: 'should not process when cache is used',
132
+ name: 'should process story issues even when cache is used',
133
133
  input: {
134
134
  project: basicProject,
135
- issues: [basicStoryIssue1],
135
+ issues: [basicStoryIssue1, basicStoryIssue2],
136
136
  cacheUsed: true,
137
137
  urlOfStoryView: 'https://example.com',
138
138
  storyObjectMap: basicStoryObjectMap,
139
139
  },
140
- expectedCreateNewIssueCalls: [],
141
- expectedUpdateIssueCalls: [],
142
- expectedUpdateStoryCalls: [],
143
- expectedGetIssueByUrlCalls: [],
140
+ expectedCreateNewIssueCalls: [
141
+ [
142
+ 'org',
143
+ 'repo',
144
+ 'Task 1',
145
+ '- Parent issue: https://github.com/org/repo/issues/123',
146
+ [],
147
+ [],
148
+ ],
149
+ [
150
+ 'org',
151
+ 'repo',
152
+ 'Task 2',
153
+ '- Parent issue: https://github.com/org/repo/issues/123',
154
+ [],
155
+ [],
156
+ ],
157
+ [
158
+ 'org',
159
+ 'repo',
160
+ 'Task 3',
161
+ '- Parent issue: https://github.com/org/repo/issues/456',
162
+ [],
163
+ [],
164
+ ],
165
+ [
166
+ 'org',
167
+ 'repo',
168
+ 'Task 4',
169
+ '- Parent issue: https://github.com/org/repo/issues/456',
170
+ [],
171
+ [],
172
+ ],
173
+ ],
174
+ expectedUpdateIssueCalls: [
175
+ [
176
+ {
177
+ ...basicStoryIssue1,
178
+ body: `https://example.com?sliceBy%5Bvalue%5D=Story%201
179
+
180
+ - [ ] Task 1
181
+ - [ ] Task 2`,
182
+ },
183
+ ],
184
+ [
185
+ {
186
+ ...basicStoryIssue1,
187
+ body: `https://example.com?sliceBy%5Bvalue%5D=Story%201
188
+
189
+ - [ ] https://github.com/org/repo/issues/1
190
+ - [ ] Task 2`,
191
+ },
192
+ ],
193
+ [
194
+ {
195
+ ...basicStoryIssue1,
196
+ body: `https://example.com?sliceBy%5Bvalue%5D=Story%201
197
+
198
+ - [ ] https://github.com/org/repo/issues/1
199
+ - [ ] https://github.com/org/repo/issues/2`,
200
+ },
201
+ ],
202
+ [
203
+ {
204
+ ...basicStoryIssue2,
205
+ body: `https://example.com?sliceBy%5Bvalue%5D=Story%202
206
+
207
+ - [ ] Task 3
208
+ - [ ] Task 4`,
209
+ },
210
+ ],
211
+ [
212
+ {
213
+ ...basicStoryIssue2,
214
+ body: `https://example.com?sliceBy%5Bvalue%5D=Story%202
215
+
216
+ - [ ] https://github.com/org/repo/issues/3
217
+ - [ ] Task 4`,
218
+ },
219
+ ],
220
+ [
221
+ {
222
+ ...basicStoryIssue2,
223
+ body: `https://example.com?sliceBy%5Bvalue%5D=Story%202
224
+
225
+ - [ ] https://github.com/org/repo/issues/3
226
+ - [ ] https://github.com/org/repo/issues/4`,
227
+ },
228
+ ],
229
+ ],
230
+ expectedUpdateStoryCalls: [
231
+ [
232
+ basicProject,
233
+ {
234
+ ...mock<Issue>(),
235
+ url: 'https://github.com/org/repo/issues/1',
236
+ },
237
+ 'story1',
238
+ ],
239
+ [
240
+ basicProject,
241
+ {
242
+ ...mock<Issue>(),
243
+ url: 'https://github.com/org/repo/issues/2',
244
+ },
245
+ 'story1',
246
+ ],
247
+ [
248
+ basicProject,
249
+ {
250
+ ...mock<Issue>(),
251
+ url: 'https://github.com/org/repo/issues/3',
252
+ },
253
+ 'story2',
254
+ ],
255
+ [
256
+ basicProject,
257
+ {
258
+ ...mock<Issue>(),
259
+ url: 'https://github.com/org/repo/issues/4',
260
+ },
261
+ 'story2',
262
+ ],
263
+ ],
264
+ expectedGetIssueByUrlCalls: [
265
+ ['https://github.com/org/repo/issues/123'],
266
+ ['https://github.com/org/repo/issues/1'],
267
+ ['https://github.com/org/repo/issues/2'],
268
+ ['https://github.com/org/repo/issues/456'],
269
+ ['https://github.com/org/repo/issues/3'],
270
+ ['https://github.com/org/repo/issues/4'],
271
+ ],
144
272
  },
145
273
  {
146
274
  name: 'should skip regular stories',
@@ -767,10 +895,16 @@ Some description without checkboxes`,
767
895
  const storyIssuesByUrl = new Map(
768
896
  input.issues.map((issue) => [issue.url, issue]),
769
897
  );
770
- mockIssueRepository.getIssueByUrl.mockImplementation(
771
- async (url) =>
772
- storyIssuesByUrl.get(url) ?? { ...mock<Issue>(), url },
773
- );
898
+ mockIssueRepository.getIssueByUrl.mockImplementation(async (url) => {
899
+ const storyIssue = storyIssuesByUrl.get(url);
900
+ if (storyIssue) {
901
+ return storyIssue;
902
+ }
903
+ return {
904
+ ...mock<Issue>(),
905
+ url,
906
+ };
907
+ });
774
908
 
775
909
  const useCase = new ConvertCheckboxToIssueInStoryIssueUseCase(
776
910
  mockIssueRepository,
@@ -21,7 +21,7 @@ export class ConvertCheckboxToIssueInStoryIssueUseCase {
21
21
  storyObjectMap: StoryObjectMap;
22
22
  }): Promise<void> => {
23
23
  const story = input.project.story;
24
- if (!story || input.cacheUsed) {
24
+ if (!story) {
25
25
  return;
26
26
  }
27
27