github-issue-tower-defence-management 1.1.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 (221) hide show
  1. package/.env.example +6 -0
  2. package/.eslintrc.cjs +55 -0
  3. package/.github/CODEOWNERS +2 -0
  4. package/.github/workflows/assign-all-cards-to-owner.yml +14 -0
  5. package/.github/workflows/commit-lint.yml +54 -0
  6. package/.github/workflows/configs/commitlint.config.js +27 -0
  7. package/.github/workflows/create-pr.yml +64 -0
  8. package/.github/workflows/format.yml +25 -0
  9. package/.github/workflows/publish.yml +47 -0
  10. package/.github/workflows/test.yml +45 -0
  11. package/.github/workflows/umino-project.yml +181 -0
  12. package/.prettierignore +22 -0
  13. package/.prettierrc +5 -0
  14. package/CHANGELOG.md +49 -0
  15. package/CONTRIBUTING.md +107 -0
  16. package/README.md +108 -0
  17. package/bin/adapter/entry-points/cli/index.js +26 -0
  18. package/bin/adapter/entry-points/cli/index.js.map +1 -0
  19. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +142 -0
  20. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -0
  21. package/bin/adapter/repositories/AxiosSlackRepository.js +124 -0
  22. package/bin/adapter/repositories/AxiosSlackRepository.js.map +1 -0
  23. package/bin/adapter/repositories/BaseGitHubRepository.js +136 -0
  24. package/bin/adapter/repositories/BaseGitHubRepository.js.map +1 -0
  25. package/bin/adapter/repositories/GoogleSpreadsheetRepository.js +123 -0
  26. package/bin/adapter/repositories/GoogleSpreadsheetRepository.js.map +1 -0
  27. package/bin/adapter/repositories/GraphqlProjectRepository.js +167 -0
  28. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -0
  29. package/bin/adapter/repositories/LocalStorageCacheRepository.js +46 -0
  30. package/bin/adapter/repositories/LocalStorageCacheRepository.js.map +1 -0
  31. package/bin/adapter/repositories/LocalStorageRepository.js +30 -0
  32. package/bin/adapter/repositories/LocalStorageRepository.js.map +1 -0
  33. package/bin/adapter/repositories/SystemDateRepository.js +23 -0
  34. package/bin/adapter/repositories/SystemDateRepository.js.map +1 -0
  35. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +148 -0
  36. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -0
  37. package/bin/adapter/repositories/issue/ApiV3IssueRepository.js +48 -0
  38. package/bin/adapter/repositories/issue/ApiV3IssueRepository.js.map +1 -0
  39. package/bin/adapter/repositories/issue/CheerioIssueRepository.js +120 -0
  40. package/bin/adapter/repositories/issue/CheerioIssueRepository.js.map +1 -0
  41. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +485 -0
  42. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -0
  43. package/bin/adapter/repositories/issue/InternalGraphqlIssueRepository.js +114 -0
  44. package/bin/adapter/repositories/issue/InternalGraphqlIssueRepository.js.map +1 -0
  45. package/bin/adapter/repositories/issue/RestIssueRepository.js +79 -0
  46. package/bin/adapter/repositories/issue/RestIssueRepository.js.map +1 -0
  47. package/bin/adapter/repositories/issue/issueTimelineUtils.js +38 -0
  48. package/bin/adapter/repositories/issue/issueTimelineUtils.js.map +1 -0
  49. package/bin/adapter/repositories/utils.js +45 -0
  50. package/bin/adapter/repositories/utils.js.map +1 -0
  51. package/bin/domain/entities/Issue.js +3 -0
  52. package/bin/domain/entities/Issue.js.map +1 -0
  53. package/bin/domain/entities/Member.js +3 -0
  54. package/bin/domain/entities/Member.js.map +1 -0
  55. package/bin/domain/entities/Project.js +3 -0
  56. package/bin/domain/entities/Project.js.map +1 -0
  57. package/bin/domain/entities/ProjectField.js +3 -0
  58. package/bin/domain/entities/ProjectField.js.map +1 -0
  59. package/bin/domain/entities/ProjectFieldSingleSelect.js +3 -0
  60. package/bin/domain/entities/ProjectFieldSingleSelect.js.map +1 -0
  61. package/bin/domain/entities/ProjectFieldSingleSelectOption.js +3 -0
  62. package/bin/domain/entities/ProjectFieldSingleSelectOption.js.map +1 -0
  63. package/bin/domain/entities/WorkingTime.js +3 -0
  64. package/bin/domain/entities/WorkingTime.js.map +1 -0
  65. package/bin/domain/usecases/ActionAnnouncementUseCase.js +46 -0
  66. package/bin/domain/usecases/ActionAnnouncementUseCase.js.map +1 -0
  67. package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js +116 -0
  68. package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js.map +1 -0
  69. package/bin/domain/usecases/ClearNextActionHourUseCase.js +38 -0
  70. package/bin/domain/usecases/ClearNextActionHourUseCase.js.map +1 -0
  71. package/bin/domain/usecases/GenerateWorkingTimeReportUseCase.js +180 -0
  72. package/bin/domain/usecases/GenerateWorkingTimeReportUseCase.js.map +1 -0
  73. package/bin/domain/usecases/HandleScheduledEventUseCase.js +122 -0
  74. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -0
  75. package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js +35 -0
  76. package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js.map +1 -0
  77. package/bin/domain/usecases/adapter-interfaces/DateRepository.js +3 -0
  78. package/bin/domain/usecases/adapter-interfaces/DateRepository.js.map +1 -0
  79. package/bin/domain/usecases/adapter-interfaces/IssueRepository.js +3 -0
  80. package/bin/domain/usecases/adapter-interfaces/IssueRepository.js.map +1 -0
  81. package/bin/domain/usecases/adapter-interfaces/ProjectRepository.js +3 -0
  82. package/bin/domain/usecases/adapter-interfaces/ProjectRepository.js.map +1 -0
  83. package/bin/domain/usecases/adapter-interfaces/SlackRepository.js +3 -0
  84. package/bin/domain/usecases/adapter-interfaces/SlackRepository.js.map +1 -0
  85. package/bin/domain/usecases/adapter-interfaces/SpreadsheetRepository.js +3 -0
  86. package/bin/domain/usecases/adapter-interfaces/SpreadsheetRepository.js.map +1 -0
  87. package/bin/index.js +13 -0
  88. package/bin/index.js.map +1 -0
  89. package/commitlint.config.js +6 -0
  90. package/jest.config.js +19 -0
  91. package/package.json +80 -0
  92. package/renovate.json +37 -0
  93. package/src/adapter/entry-points/cli/index.test.ts +20 -0
  94. package/src/adapter/entry-points/cli/index.ts +36 -0
  95. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +95 -0
  96. package/src/adapter/repositories/AxiosSlackRepository.test.ts +119 -0
  97. package/src/adapter/repositories/AxiosSlackRepository.ts +184 -0
  98. package/src/adapter/repositories/BaseGitHubRepository.test.ts +95 -0
  99. package/src/adapter/repositories/BaseGitHubRepository.ts +172 -0
  100. package/src/adapter/repositories/GoogleSpreadsheetRepository.test.ts +124 -0
  101. package/src/adapter/repositories/GoogleSpreadsheetRepository.ts +151 -0
  102. package/src/adapter/repositories/GraphqlProjectRepository.test.ts +46 -0
  103. package/src/adapter/repositories/GraphqlProjectRepository.ts +236 -0
  104. package/src/adapter/repositories/LocalStorageCacheRepository.test.ts +146 -0
  105. package/src/adapter/repositories/LocalStorageCacheRepository.ts +53 -0
  106. package/src/adapter/repositories/LocalStorageRepository.integration.test.ts +142 -0
  107. package/src/adapter/repositories/LocalStorageRepository.test.ts +161 -0
  108. package/src/adapter/repositories/LocalStorageRepository.ts +21 -0
  109. package/src/adapter/repositories/SystemDateRepository.ts +20 -0
  110. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +158 -0
  111. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +274 -0
  112. package/src/adapter/repositories/issue/ApiV3IssueRepository.test.ts +26 -0
  113. package/src/adapter/repositories/issue/ApiV3IssueRepository.ts +59 -0
  114. package/src/adapter/repositories/issue/CheerioIssueRepository.test.ts +6610 -0
  115. package/src/adapter/repositories/issue/CheerioIssueRepository.ts +127 -0
  116. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +49 -0
  117. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +745 -0
  118. package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.test.ts +71 -0
  119. package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.ts +263 -0
  120. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +29 -0
  121. package/src/adapter/repositories/issue/RestIssueRepository.ts +105 -0
  122. package/src/adapter/repositories/issue/issueTimelineUtils.test.ts +79 -0
  123. package/src/adapter/repositories/issue/issueTimelineUtils.ts +52 -0
  124. package/src/adapter/repositories/utils.test.ts +40 -0
  125. package/src/adapter/repositories/utils.ts +50 -0
  126. package/src/domain/entities/Issue.ts +23 -0
  127. package/src/domain/entities/Member.ts +3 -0
  128. package/src/domain/entities/Project.ts +29 -0
  129. package/src/domain/entities/ProjectField.ts +3 -0
  130. package/src/domain/entities/ProjectFieldSingleSelect.ts +8 -0
  131. package/src/domain/entities/ProjectFieldSingleSelectOption.ts +8 -0
  132. package/src/domain/entities/WorkingTime.ts +8 -0
  133. package/src/domain/usecases/ActionAnnouncementUseCase.ts +76 -0
  134. package/src/domain/usecases/AnalyzeProblemByIssueUseCase.ts +209 -0
  135. package/src/domain/usecases/ClearNextActionHourUseCase.ts +51 -0
  136. package/src/domain/usecases/GenerateWorkingTimeReportUseCase.test.ts +382 -0
  137. package/src/domain/usecases/GenerateWorkingTimeReportUseCase.ts +284 -0
  138. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +58 -0
  139. package/src/domain/usecases/HandleScheduledEventUseCase.ts +209 -0
  140. package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.ts +46 -0
  141. package/src/domain/usecases/adapter-interfaces/DateRepository.ts +5 -0
  142. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +44 -0
  143. package/src/domain/usecases/adapter-interfaces/ProjectRepository.ts +6 -0
  144. package/src/domain/usecases/adapter-interfaces/SlackRepository.ts +20 -0
  145. package/src/domain/usecases/adapter-interfaces/SpreadsheetRepository.ts +18 -0
  146. package/src/index.test.ts +8 -0
  147. package/src/index.ts +7 -0
  148. package/tsconfig.build.json +11 -0
  149. package/tsconfig.json +22 -0
  150. package/types/adapter/entry-points/cli/index.d.ts +3 -0
  151. package/types/adapter/entry-points/cli/index.d.ts.map +1 -0
  152. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts +11 -0
  153. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -0
  154. package/types/adapter/repositories/AxiosSlackRepository.d.ts +13 -0
  155. package/types/adapter/repositories/AxiosSlackRepository.d.ts.map +1 -0
  156. package/types/adapter/repositories/BaseGitHubRepository.d.ts +32 -0
  157. package/types/adapter/repositories/BaseGitHubRepository.d.ts.map +1 -0
  158. package/types/adapter/repositories/GoogleSpreadsheetRepository.d.ts +13 -0
  159. package/types/adapter/repositories/GoogleSpreadsheetRepository.d.ts.map +1 -0
  160. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +13 -0
  161. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -0
  162. package/types/adapter/repositories/LocalStorageCacheRepository.d.ts +12 -0
  163. package/types/adapter/repositories/LocalStorageCacheRepository.d.ts.map +1 -0
  164. package/types/adapter/repositories/LocalStorageRepository.d.ts +7 -0
  165. package/types/adapter/repositories/LocalStorageRepository.d.ts.map +1 -0
  166. package/types/adapter/repositories/SystemDateRepository.d.ts +7 -0
  167. package/types/adapter/repositories/SystemDateRepository.d.ts.map +1 -0
  168. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +38 -0
  169. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -0
  170. package/types/adapter/repositories/issue/ApiV3IssueRepository.d.ts +17 -0
  171. package/types/adapter/repositories/issue/ApiV3IssueRepository.d.ts.map +1 -0
  172. package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts +31 -0
  173. package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts.map +1 -0
  174. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +39 -0
  175. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -0
  176. package/types/adapter/repositories/issue/InternalGraphqlIssueRepository.d.ts +83 -0
  177. package/types/adapter/repositories/issue/InternalGraphqlIssueRepository.d.ts.map +1 -0
  178. package/types/adapter/repositories/issue/RestIssueRepository.d.ts +16 -0
  179. package/types/adapter/repositories/issue/RestIssueRepository.d.ts.map +1 -0
  180. package/types/adapter/repositories/issue/issueTimelineUtils.d.ts +12 -0
  181. package/types/adapter/repositories/issue/issueTimelineUtils.d.ts.map +1 -0
  182. package/types/adapter/repositories/utils.d.ts +4 -0
  183. package/types/adapter/repositories/utils.d.ts.map +1 -0
  184. package/types/domain/entities/Issue.d.ts +24 -0
  185. package/types/domain/entities/Issue.d.ts.map +1 -0
  186. package/types/domain/entities/Member.d.ts +4 -0
  187. package/types/domain/entities/Member.d.ts.map +1 -0
  188. package/types/domain/entities/Project.d.ts +29 -0
  189. package/types/domain/entities/Project.d.ts.map +1 -0
  190. package/types/domain/entities/ProjectField.d.ts +3 -0
  191. package/types/domain/entities/ProjectField.d.ts.map +1 -0
  192. package/types/domain/entities/ProjectFieldSingleSelect.d.ts +8 -0
  193. package/types/domain/entities/ProjectFieldSingleSelect.d.ts.map +1 -0
  194. package/types/domain/entities/ProjectFieldSingleSelectOption.d.ts +8 -0
  195. package/types/domain/entities/ProjectFieldSingleSelectOption.d.ts.map +1 -0
  196. package/types/domain/entities/WorkingTime.d.ts +8 -0
  197. package/types/domain/entities/WorkingTime.d.ts.map +1 -0
  198. package/types/domain/usecases/ActionAnnouncementUseCase.d.ts +17 -0
  199. package/types/domain/usecases/ActionAnnouncementUseCase.d.ts.map +1 -0
  200. package/types/domain/usecases/AnalyzeProblemByIssueUseCase.d.ts +26 -0
  201. package/types/domain/usecases/AnalyzeProblemByIssueUseCase.d.ts.map +1 -0
  202. package/types/domain/usecases/ClearNextActionHourUseCase.d.ts +14 -0
  203. package/types/domain/usecases/ClearNextActionHourUseCase.d.ts.map +1 -0
  204. package/types/domain/usecases/GenerateWorkingTimeReportUseCase.d.ts +50 -0
  205. package/types/domain/usecases/GenerateWorkingTimeReportUseCase.d.ts.map +1 -0
  206. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +63 -0
  207. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -0
  208. package/types/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.d.ts +14 -0
  209. package/types/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.d.ts.map +1 -0
  210. package/types/domain/usecases/adapter-interfaces/DateRepository.d.ts +6 -0
  211. package/types/domain/usecases/adapter-interfaces/DateRepository.d.ts.map +1 -0
  212. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +23 -0
  213. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -0
  214. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts +6 -0
  215. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts.map +1 -0
  216. package/types/domain/usecases/adapter-interfaces/SlackRepository.d.ts +9 -0
  217. package/types/domain/usecases/adapter-interfaces/SlackRepository.d.ts.map +1 -0
  218. package/types/domain/usecases/adapter-interfaces/SpreadsheetRepository.d.ts +6 -0
  219. package/types/domain/usecases/adapter-interfaces/SpreadsheetRepository.d.ts.map +1 -0
  220. package/types/index.d.ts +10 -0
  221. package/types/index.d.ts.map +1 -0
@@ -0,0 +1,382 @@
1
+ import { Issue } from '../entities/Issue';
2
+ import { Member } from '../entities/Member';
3
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
4
+ import {
5
+ GenerateWorkingTimeReportUseCase,
6
+ WorkingReportTimelineEvent,
7
+ } from './GenerateWorkingTimeReportUseCase';
8
+ import { SpreadsheetRepository } from './adapter-interfaces/SpreadsheetRepository';
9
+ import { DateRepository } from './adapter-interfaces/DateRepository';
10
+ import { mock } from 'jest-mock-extended';
11
+
12
+ describe('GenerateWorkingTimeReportUseCase', () => {
13
+ jest.setTimeout(30 * 1000);
14
+ const mockIssueRepository = mock<IssueRepository>();
15
+ const mockSpreadsheetRepository = mock<SpreadsheetRepository>();
16
+ const mockDateRepository = mock<DateRepository>();
17
+
18
+ const useCase = new GenerateWorkingTimeReportUseCase(
19
+ mockIssueRepository,
20
+ mockSpreadsheetRepository,
21
+ mockDateRepository,
22
+ );
23
+ describe('getWorkingReportIssueTemplate', () => {
24
+ interface TestCase {
25
+ name: string;
26
+ input: {
27
+ reportIssueTemplate?: string;
28
+ manager: Member['name'];
29
+ spreadsheetUrl: string;
30
+ };
31
+ expected: string;
32
+ }
33
+
34
+ const testCases: TestCase[] = [
35
+ {
36
+ name: 'should return custom template when provided',
37
+ input: {
38
+ reportIssueTemplate: 'Custom template for {AUTHOR}',
39
+ manager: 'manager1',
40
+ spreadsheetUrl: 'https://example.com',
41
+ },
42
+ expected: 'Custom template for {AUTHOR}',
43
+ },
44
+ {
45
+ name: 'should return default template when no custom template provided',
46
+ input: {
47
+ manager: 'manager1',
48
+ spreadsheetUrl: 'https://example.com',
49
+ },
50
+ expected: `
51
+ Please confirm each working time and total working time and assign to :bow:
52
+ Fix warnings if you have :warning: mark in Detail section.
53
+ If you have any questions, please put comment and assign to @manager1 :pray:
54
+
55
+ ## Working report for {AUTHOR} on {DATE_WITH_DAY_OF_WEEK}
56
+ ### Total
57
+ \`\`\`
58
+ {TOTAL_WORKING_TIME_HHMM}
59
+ \`\`\`
60
+
61
+ ### Detail
62
+ {TIMELINE_DETAILS}
63
+
64
+ Summary of working report: https://example.com
65
+ `,
66
+ },
67
+ ];
68
+
69
+ testCases.forEach(({ name, input, expected }) => {
70
+ it(name, async () => {
71
+ const result = await useCase.getWorkingReportIssueTemplate(input);
72
+ expect(result).toBe(expected);
73
+ });
74
+ });
75
+ });
76
+
77
+ describe('filterTimelineAndSortByAuthor', () => {
78
+ interface TestCase {
79
+ name: string;
80
+ input: {
81
+ issues: Issue[];
82
+ targetDate: Date;
83
+ author: Member['name'];
84
+ workingTimeThresholdHour: number;
85
+ };
86
+ expected: WorkingReportTimelineEvent[];
87
+ }
88
+
89
+ const testCases: TestCase[] = [
90
+ {
91
+ name: 'should filter and sort timeline events correctly',
92
+ input: {
93
+ issues: [
94
+ {
95
+ nameWithOwner: 'org/repo',
96
+ number: 1,
97
+ title: 'Issue 1',
98
+ state: 'OPEN',
99
+ url: 'https://example.com/1',
100
+ assignees: ['user1'],
101
+ labels: [],
102
+ workingTimeline: [
103
+ {
104
+ author: 'user1',
105
+ startedAt: new Date('2024-01-01T09:00:00Z'),
106
+ endedAt: new Date('2024-01-01T12:00:00Z'),
107
+ durationMinutes: 180,
108
+ },
109
+ ],
110
+ status: 'In Progress',
111
+ story: 'test story',
112
+ nextActionDate: new Date('2024-01-02'),
113
+ nextActionHour: 10,
114
+ estimationMinutes: 180,
115
+ org: 'org',
116
+ repo: 'repo',
117
+ body: 'test body',
118
+ itemId: 'itemId',
119
+ isPr: false,
120
+ },
121
+ ],
122
+ targetDate: new Date('2024-01-01'),
123
+ author: 'user1',
124
+ workingTimeThresholdHour: 6,
125
+ },
126
+ expected: [
127
+ {
128
+ issueUrl: 'https://example.com/1',
129
+ startHhmm: '09:00',
130
+ endHhmm: '12:00',
131
+ durationHhmm: '03:00',
132
+ warnings: [],
133
+ labels: [],
134
+ nameWithOwner: 'org/repo',
135
+ issueTitle: 'Issue 1',
136
+ },
137
+ ],
138
+ },
139
+ ];
140
+
141
+ testCases.forEach(({ name, input, expected }) => {
142
+ it(name, () => {
143
+ const result = useCase.filterTimelineAndSortByAuthor(
144
+ input.issues,
145
+ input.targetDate,
146
+ input.author,
147
+ input.workingTimeThresholdHour,
148
+ );
149
+ expect(result).toEqual(expected);
150
+ });
151
+ });
152
+ });
153
+
154
+ describe('convertIsoToHhmm', () => {
155
+ interface TestCase {
156
+ name: string;
157
+ input: string;
158
+ expected: string;
159
+ }
160
+
161
+ const testCases: TestCase[] = [
162
+ {
163
+ name: 'should convert ISO string to HH:mm format',
164
+ input: '2024-01-01T09:30:00Z',
165
+ expected: '09:30',
166
+ },
167
+ {
168
+ name: 'should pad single digit hours and minutes',
169
+ input: '2024-01-01T05:05:00Z',
170
+ expected: '05:05',
171
+ },
172
+ ];
173
+
174
+ testCases.forEach(({ name, input, expected }) => {
175
+ it(name, () => {
176
+ const result = useCase.convertIsoToHhmm(input);
177
+ expect(result).toBe(expected);
178
+ });
179
+ });
180
+ });
181
+
182
+ describe('calculateDuration', () => {
183
+ interface TestCase {
184
+ name: string;
185
+ input: {
186
+ startIsoString: string;
187
+ endIsoString: string;
188
+ };
189
+ expected: string;
190
+ }
191
+
192
+ const testCases: TestCase[] = [
193
+ {
194
+ name: 'should calculate duration correctly',
195
+ input: {
196
+ startIsoString: '2024-01-01T09:00:00Z',
197
+ endIsoString: '2024-01-01T12:30:00Z',
198
+ },
199
+ expected: '03:30',
200
+ },
201
+ {
202
+ name: 'should handle same hour different minutes',
203
+ input: {
204
+ startIsoString: '2024-01-01T09:00:00Z',
205
+ endIsoString: '2024-01-01T09:30:00Z',
206
+ },
207
+ expected: '00:30',
208
+ },
209
+ ];
210
+
211
+ testCases.forEach(({ name, input, expected }) => {
212
+ it(name, () => {
213
+ const result = useCase.calculateDuration(
214
+ input.startIsoString,
215
+ input.endIsoString,
216
+ );
217
+ expect(result).toBe(expected);
218
+ });
219
+ });
220
+ });
221
+
222
+ describe('calculateTotalHhmm', () => {
223
+ interface TestCase {
224
+ name: string;
225
+ input: WorkingReportTimelineEvent[];
226
+ expected: string;
227
+ }
228
+
229
+ const testCases: TestCase[] = [
230
+ {
231
+ name: 'should calculate total duration correctly',
232
+ input: [
233
+ {
234
+ issueUrl: 'https://example.com/1',
235
+ startHhmm: '09:00',
236
+ endHhmm: '12:00',
237
+ durationHhmm: '03:00',
238
+ warnings: [],
239
+ labels: [],
240
+ issueTitle: 'Issue 1',
241
+ nameWithOwner: 'org/repo',
242
+ },
243
+ {
244
+ issueUrl: 'https://example.com/2',
245
+ startHhmm: '13:00',
246
+ endHhmm: '15:30',
247
+ durationHhmm: '02:30',
248
+ warnings: [],
249
+ labels: [],
250
+ issueTitle: 'Issue 2',
251
+ nameWithOwner: 'org/repo',
252
+ },
253
+ ],
254
+ expected: '05:30',
255
+ },
256
+ ];
257
+
258
+ testCases.forEach(({ name, input, expected }) => {
259
+ it(name, () => {
260
+ mockDateRepository.formatDurationToHHMM.mockImplementation(
261
+ (durationMinutes: number) => {
262
+ if (durationMinutes === 330) return '05:30';
263
+ return '';
264
+ },
265
+ );
266
+ const result = useCase.calculateTotalHhmm(input);
267
+ expect(result).toBe(expected);
268
+ });
269
+ });
270
+ });
271
+
272
+ describe('applyReplacementToTemplate', () => {
273
+ interface TestCase {
274
+ name: string;
275
+ input: {
276
+ template: string;
277
+ replacement: Record<string, string>;
278
+ };
279
+ expected: string | Error;
280
+ }
281
+
282
+ const testCases: TestCase[] = [
283
+ {
284
+ name: 'should replace all placeholders correctly',
285
+ input: {
286
+ template: 'Hello {NAME}, your score is {SCORE}',
287
+ replacement: {
288
+ NAME: 'John',
289
+ SCORE: '100',
290
+ },
291
+ },
292
+ expected: 'Hello John, your score is 100',
293
+ },
294
+ {
295
+ name: 'should throw error for unknown placeholders',
296
+ input: {
297
+ template: 'Hello {NAME}, your score is {UNKNOWN}',
298
+ replacement: {
299
+ NAME: 'John',
300
+ },
301
+ },
302
+ expected: new Error('Failed to replace. Unknown keys: UNKNOWN'),
303
+ },
304
+ ];
305
+
306
+ testCases.forEach(({ name, input, expected }) => {
307
+ it(name, () => {
308
+ if (expected instanceof Error) {
309
+ expect(() => useCase.applyReplacementToTemplate(input)).toThrowError(
310
+ expected.message,
311
+ );
312
+ } else {
313
+ const result = useCase.applyReplacementToTemplate(input);
314
+ expect(result).toBe(expected);
315
+ }
316
+ });
317
+ });
318
+ });
319
+
320
+ describe('run', () => {
321
+ interface TestCase {
322
+ name: string;
323
+ input: Parameters<GenerateWorkingTimeReportUseCase['run']>[0];
324
+ expectedCalls: number;
325
+ }
326
+
327
+ const testCases: TestCase[] = [
328
+ {
329
+ name: 'should create issues for all members',
330
+ input: {
331
+ issues: [
332
+ {
333
+ nameWithOwner: 'org/repo',
334
+ number: 1,
335
+ title: 'Issue 1',
336
+ state: 'OPEN',
337
+ url: 'https://example.com/1',
338
+ assignees: ['user1'],
339
+ labels: [],
340
+ workingTimeline: [
341
+ {
342
+ author: 'user1',
343
+ startedAt: new Date(),
344
+ endedAt: new Date(),
345
+ durationMinutes: 60,
346
+ },
347
+ ],
348
+ status: 'In Progress',
349
+ story: 'test story',
350
+ nextActionDate: new Date(),
351
+ nextActionHour: 10,
352
+ estimationMinutes: 60,
353
+ org: 'org',
354
+ repo: 'repo',
355
+ body: 'test body',
356
+ itemId: 'itemId',
357
+ isPr: false,
358
+ },
359
+ ],
360
+ members: ['user1', 'user2'],
361
+ manager: 'manager1',
362
+ spreadsheetUrl: 'https://example.com',
363
+ org: 'testOrg',
364
+ repo: 'testRepo',
365
+ reportIssueLabels: ['report'],
366
+ warningThresholdHour: 6,
367
+ targetDate: new Date(),
368
+ },
369
+ expectedCalls: 2,
370
+ },
371
+ ];
372
+
373
+ testCases.forEach(({ name, input, expectedCalls }) => {
374
+ it(name, async () => {
375
+ await useCase.run(input);
376
+ expect(mockIssueRepository.createNewIssue).toHaveBeenCalledTimes(
377
+ expectedCalls,
378
+ );
379
+ });
380
+ });
381
+ });
382
+ });
@@ -0,0 +1,284 @@
1
+ import { Issue, Label } from '../entities/Issue';
2
+ import { Member } from '../entities/Member';
3
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
4
+ import { SpreadsheetRepository } from './adapter-interfaces/SpreadsheetRepository';
5
+ import { DateRepository } from './adapter-interfaces/DateRepository';
6
+
7
+ export type WorkingReportTimelineEvent = {
8
+ issueUrl: string;
9
+ issueTitle: string;
10
+ startHhmm: string;
11
+ endHhmm: string;
12
+ durationHhmm: string;
13
+ warnings: string[];
14
+ labels: string[];
15
+ nameWithOwner: string;
16
+ };
17
+
18
+ export class GenerateWorkingTimeReportUseCase {
19
+ constructor(
20
+ readonly issueRepository: IssueRepository,
21
+ readonly spreadsheetRepository: SpreadsheetRepository,
22
+ readonly dateRepository: DateRepository,
23
+ ) {}
24
+
25
+ run = async (input: {
26
+ issues: Issue[];
27
+ members: Member['name'][];
28
+ manager: Member['name'];
29
+ spreadsheetUrl: string;
30
+ reportIssueTemplate?: string;
31
+ org: string;
32
+ repo: string;
33
+ reportIssueLabels: Label[];
34
+ warningThresholdHour?: number;
35
+ targetDate: Date;
36
+ }): Promise<void> => {
37
+ const workingReportIssueTemplate =
38
+ await this.getWorkingReportIssueTemplate(input);
39
+ const reportRows: string[][] = [];
40
+
41
+ for (const member of input.members) {
42
+ try {
43
+ const memberReportRows = await this.createIssueForEachAuthor(
44
+ member,
45
+ input.targetDate,
46
+ input.issues,
47
+ input.org,
48
+ input.repo,
49
+ input.reportIssueLabels,
50
+ workingReportIssueTemplate,
51
+ input.warningThresholdHour,
52
+ );
53
+ reportRows.push(...memberReportRows);
54
+ await new Promise((resolve) => setTimeout(resolve, 5000));
55
+ } catch (e) {
56
+ await this.issueRepository.createNewIssue(
57
+ input.org,
58
+ input.repo,
59
+ `Error occured while creating working report for ${member}`,
60
+ `${JSON.stringify(e)}`,
61
+ [input.manager],
62
+ ['bug'],
63
+ );
64
+ }
65
+ }
66
+ await this.spreadsheetRepository.appendSheetValues(
67
+ input.spreadsheetUrl,
68
+ 'IssueLogEditable',
69
+ reportRows,
70
+ );
71
+ await this.spreadsheetRepository.appendSheetValues(
72
+ input.spreadsheetUrl,
73
+ 'IssueLogRawData',
74
+ reportRows,
75
+ );
76
+ };
77
+ getWorkingReportIssueTemplate = async (input: {
78
+ reportIssueTemplate?: string;
79
+ manager: Member['name'];
80
+ spreadsheetUrl: string;
81
+ }): Promise<string> => {
82
+ if (input.reportIssueTemplate) {
83
+ return input.reportIssueTemplate;
84
+ }
85
+ return `
86
+ Please confirm each working time and total working time and assign to :bow:
87
+ Fix warnings if you have :warning: mark in Detail section.
88
+ If you have any questions, please put comment and assign to @${input.manager} :pray:
89
+
90
+ ## Working report for {AUTHOR} on {DATE_WITH_DAY_OF_WEEK}
91
+ ### Total
92
+ \`\`\`
93
+ {TOTAL_WORKING_TIME_HHMM}
94
+ \`\`\`
95
+
96
+ ### Detail
97
+ {TIMELINE_DETAILS}
98
+
99
+ Summary of working report: ${input.spreadsheetUrl}
100
+ `;
101
+ };
102
+ createIssueForEachAuthor = async (
103
+ author: string,
104
+ date: Date,
105
+ issues: Issue[],
106
+ org: string,
107
+ repo: string,
108
+ labels: Label[],
109
+ workingReportIssueTemplate: string,
110
+ workingTimeThresholdHour = 6,
111
+ ): Promise<string[][]> => {
112
+ const dateString = `${date.toISOString().split('T')[0]}`;
113
+ const dateStringWithDoW = `${date.toISOString().split('T')[0]} (${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getDay()]})`;
114
+
115
+ const timelineEvents: WorkingReportTimelineEvent[] =
116
+ this.filterTimelineAndSortByAuthor(
117
+ issues,
118
+ date,
119
+ author,
120
+ workingTimeThresholdHour,
121
+ );
122
+ const totalHhmm = this.calculateTotalHhmm(timelineEvents);
123
+ const timelineDetails = this.applyToTimelineDetails(timelineEvents);
124
+ const title = this.applyReplacementToTemplate({
125
+ template: 'Working report for {AUTHOR} on {DATE_WITH_DAY_OF_WEEK}',
126
+ replacement: {
127
+ AUTHOR: author,
128
+ DATE_WITH_DAY_OF_WEEK: dateStringWithDoW,
129
+ },
130
+ });
131
+ const body = this.applyReplacementToTemplate({
132
+ template: workingReportIssueTemplate,
133
+ replacement: {
134
+ AUTHOR: author,
135
+ DATE_WITH_DAY_OF_WEEK: dateStringWithDoW,
136
+ TOTAL_WORKING_TIME_HHMM: totalHhmm,
137
+ TIMELINE_DETAILS: timelineDetails,
138
+ },
139
+ });
140
+ await this.issueRepository.createNewIssue(
141
+ org,
142
+ repo,
143
+ title,
144
+ body,
145
+ [author],
146
+ labels,
147
+ );
148
+ const issueLogRows: string[][] = timelineEvents.map((event) => [
149
+ dateString,
150
+ event.startHhmm,
151
+ event.endHhmm,
152
+ author,
153
+ event.warnings.join(':'),
154
+ event.issueUrl,
155
+ event.labels.join(':'),
156
+ ]);
157
+
158
+ return issueLogRows;
159
+ };
160
+ filterTimelineAndSortByAuthor = (
161
+ issues: Issue[],
162
+ targetDate: Date,
163
+ author: Member['name'],
164
+ workingTimeThresholdHour: number,
165
+ ): WorkingReportTimelineEvent[] => {
166
+ const dateString = targetDate.toISOString().split('T')[0];
167
+ const timelineEvents: WorkingReportTimelineEvent[] = [];
168
+ for (const issue of issues) {
169
+ const filteredTimeline = issue.workingTimeline.filter(
170
+ (event) => event.author === author,
171
+ );
172
+ for (const event of filteredTimeline) {
173
+ const start = event.startedAt.toISOString();
174
+ const end = event.endedAt.toISOString();
175
+ if (end.split('T')[0] !== dateString) {
176
+ continue;
177
+ }
178
+ const startHhmm = this.convertIsoToHhmm(start);
179
+ const endHhmm = this.convertIsoToHhmm(end);
180
+ const durationHhmm = this.calculateDuration(start, end);
181
+ timelineEvents.push({
182
+ issueUrl: issue.url,
183
+ issueTitle: issue.title,
184
+ startHhmm,
185
+ endHhmm,
186
+ durationHhmm,
187
+ warnings: [],
188
+ labels: issue.labels,
189
+ nameWithOwner: issue.nameWithOwner,
190
+ });
191
+ }
192
+ }
193
+ const sortedTimelineEvents = timelineEvents.sort((a, b) => {
194
+ if (a.endHhmm === b.endHhmm) {
195
+ return a.startHhmm.localeCompare(b.startHhmm);
196
+ }
197
+ return a.endHhmm.localeCompare(b.endHhmm);
198
+ });
199
+ for (let i = 0; i < sortedTimelineEvents.length - 1; i++) {
200
+ const current = sortedTimelineEvents[i];
201
+ const currentDuration = sortedTimelineEvents[i].durationHhmm;
202
+ const [hh] = currentDuration.split(':').map(Number);
203
+ if (hh >= workingTimeThresholdHour) {
204
+ current.warnings.push(`Over ${workingTimeThresholdHour} hours`);
205
+ }
206
+ if (i === 0) {
207
+ continue;
208
+ }
209
+ const previous = sortedTimelineEvents[i - 1];
210
+ if (previous.endHhmm > current.startHhmm) {
211
+ current.warnings.push(`Overlap`);
212
+ }
213
+ }
214
+ return sortedTimelineEvents;
215
+ };
216
+ convertIsoToHhmm = (isoString: string): string => {
217
+ const date = new Date(isoString);
218
+ const hh = date.getHours();
219
+ const mm = date.getMinutes();
220
+ return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
221
+ };
222
+ calculateDuration = (
223
+ startIsoString: string,
224
+ endIsoString: string,
225
+ ): string => {
226
+ const startDate = new Date(startIsoString);
227
+ const endDate = new Date(endIsoString);
228
+ startDate.setMilliseconds(0);
229
+ startDate.setSeconds(0);
230
+ endDate.setMilliseconds(0);
231
+ endDate.setSeconds(0);
232
+ const duration = endDate.getTime() - startDate.getTime();
233
+ const hh = Math.floor(duration / (1000 * 60 * 60));
234
+ const mm = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60));
235
+ return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
236
+ };
237
+ calculateTotalHhmm = (
238
+ timelineEvents: WorkingReportTimelineEvent[],
239
+ ): string => {
240
+ const totalDuration = timelineEvents.reduce((acc, event) => {
241
+ const [hh, mm] = event.durationHhmm.split(':').map(Number);
242
+ return acc + hh * 60 + mm;
243
+ }, 0);
244
+ return this.dateRepository.formatDurationToHHMM(totalDuration);
245
+ };
246
+ applyToTimelineDetails = (
247
+ timelineEvents: WorkingReportTimelineEvent[],
248
+ ): string => {
249
+ return `
250
+ - Start, End, Duration, Issue title, Labels
251
+ ${timelineEvents.map((event) => this.applyToTimelineDetail(event)).join('\n')}
252
+ `;
253
+ };
254
+ applyToTimelineDetail = (
255
+ timelineEvents: WorkingReportTimelineEvent,
256
+ ): string => {
257
+ const labelUrls = timelineEvents.labels.map(
258
+ (label) =>
259
+ `https://github.com/${timelineEvents.nameWithOwner}/labels/${encodeURI(label).replace(/:/g, '%3A')}`,
260
+ );
261
+ return `- ${timelineEvents.startHhmm}, ${timelineEvents.endHhmm}, ${timelineEvents.durationHhmm}, ${timelineEvents.warnings.length > 0 ? `:warning: ${timelineEvents.warnings.join(' ')}` : ''} ${timelineEvents.issueUrl}, ${labelUrls.join(' ')}`;
262
+ };
263
+ applyReplacementToTemplate = (input: {
264
+ template: string;
265
+ replacement: Record<string, string>;
266
+ }): string => {
267
+ const replacedText = Object.entries(input.replacement).reduce(
268
+ (acc, [key, value]) => {
269
+ return acc.replace(new RegExp(`{${key}}`, 'g'), value);
270
+ },
271
+ input.template,
272
+ );
273
+ if (replacedText.includes('{')) {
274
+ const unknownKeys = replacedText.match(/\{[^}]+}/g);
275
+ if (!unknownKeys) {
276
+ throw new Error(`Broken template: ${replacedText}`);
277
+ }
278
+ throw new Error(
279
+ `Failed to replace. Unknown keys: ${unknownKeys.map((key) => key.slice(1, -1)).join(', ')}`,
280
+ );
281
+ }
282
+ return replacedText;
283
+ };
284
+ }