github-issue-tower-defence-management 1.1.0 → 1.2.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 (108) hide show
  1. package/.github/workflows/commit-lint.yml +0 -2
  2. package/.github/workflows/empty-format-test-job.yml +28 -0
  3. package/.github/workflows/test.yml +1 -0
  4. package/CHANGELOG.md +20 -41
  5. package/bin/adapter/entry-points/cli/index.js +0 -0
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +127 -16
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  8. package/bin/adapter/repositories/BaseGitHubRepository.js +3 -2
  9. package/bin/adapter/repositories/BaseGitHubRepository.js.map +1 -1
  10. package/bin/adapter/repositories/GraphqlProjectRepository.js +16 -0
  11. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  12. package/bin/adapter/repositories/LocalStorageRepository.js +6 -0
  13. package/bin/adapter/repositories/LocalStorageRepository.js.map +1 -1
  14. package/bin/adapter/repositories/SystemDateRepository.js +15 -3
  15. package/bin/adapter/repositories/SystemDateRepository.js.map +1 -1
  16. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +61 -9
  17. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  18. package/bin/adapter/repositories/issue/CheerioIssueRepository.js +28 -2
  19. package/bin/adapter/repositories/issue/CheerioIssueRepository.js.map +1 -1
  20. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +7 -0
  21. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  22. package/bin/adapter/repositories/issue/RestIssueRepository.js +1 -0
  23. package/bin/adapter/repositories/issue/RestIssueRepository.js.map +1 -1
  24. package/bin/adapter/repositories/utils.js +1 -6
  25. package/bin/adapter/repositories/utils.js.map +1 -1
  26. package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js +98 -65
  27. package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js.map +1 -1
  28. package/bin/domain/usecases/AnalyzeStoriesUseCase.js +108 -0
  29. package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -0
  30. package/bin/domain/usecases/ClearDependedIssueURLUseCase.js +66 -0
  31. package/bin/domain/usecases/ClearDependedIssueURLUseCase.js.map +1 -0
  32. package/bin/domain/usecases/CreateEstimationIssueUseCase.js +100 -0
  33. package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -0
  34. package/bin/domain/usecases/HandleScheduledEventUseCase.js +124 -2
  35. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  36. package/bin/domain/usecases/utils.js +24 -0
  37. package/bin/domain/usecases/utils.js.map +1 -0
  38. package/package.json +2 -2
  39. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +73 -7
  40. package/src/adapter/repositories/AxiosSlackRepository.test.ts +3 -0
  41. package/src/adapter/repositories/BaseGitHubRepository.test.ts +3 -1
  42. package/src/adapter/repositories/BaseGitHubRepository.ts +3 -1
  43. package/src/adapter/repositories/GraphqlProjectRepository.test.ts +5 -1
  44. package/src/adapter/repositories/GraphqlProjectRepository.ts +25 -0
  45. package/src/adapter/repositories/LocalStorageCacheRepository.test.ts +1 -0
  46. package/src/adapter/repositories/LocalStorageRepository.ts +6 -0
  47. package/src/adapter/repositories/SystemDateRepository.ts +17 -3
  48. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +8 -0
  49. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +103 -16
  50. package/src/adapter/repositories/issue/ApiV3IssueRepository.test.ts +3 -0
  51. package/src/adapter/repositories/issue/CheerioIssueRepository.test.ts +10 -0
  52. package/src/adapter/repositories/issue/CheerioIssueRepository.ts +37 -1
  53. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +7 -1
  54. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +15 -0
  55. package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.test.ts +7 -1
  56. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +3 -0
  57. package/src/adapter/repositories/issue/RestIssueRepository.ts +5 -2
  58. package/src/adapter/repositories/utils.test.ts +16 -1
  59. package/src/adapter/repositories/utils.ts +1 -6
  60. package/src/domain/entities/Issue.ts +4 -0
  61. package/src/domain/entities/Project.ts +15 -4
  62. package/src/domain/usecases/AnalyzeProblemByIssueUseCase.ts +151 -115
  63. package/src/domain/usecases/AnalyzeStoriesUseCase.ts +167 -0
  64. package/src/domain/usecases/ClearDependedIssueURLUseCase.test.ts +840 -0
  65. package/src/domain/usecases/ClearDependedIssueURLUseCase.ts +107 -0
  66. package/src/domain/usecases/CreateEstimationIssueUseCase.ts +157 -0
  67. package/src/domain/usecases/GenerateWorkingTimeReportUseCase.test.ts +8 -0
  68. package/src/domain/usecases/HandleScheduledEventUseCase.ts +171 -2
  69. package/src/domain/usecases/adapter-interfaces/DateRepository.ts +2 -0
  70. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +8 -1
  71. package/src/domain/usecases/utils.ts +28 -0
  72. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  73. package/types/adapter/repositories/BaseGitHubRepository.d.ts +3 -1
  74. package/types/adapter/repositories/BaseGitHubRepository.d.ts.map +1 -1
  75. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  76. package/types/adapter/repositories/LocalStorageRepository.d.ts +1 -0
  77. package/types/adapter/repositories/LocalStorageRepository.d.ts.map +1 -1
  78. package/types/adapter/repositories/SystemDateRepository.d.ts +2 -0
  79. package/types/adapter/repositories/SystemDateRepository.d.ts.map +1 -1
  80. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +14 -5
  81. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  82. package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts +7 -1
  83. package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts.map +1 -1
  84. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +3 -0
  85. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  86. package/types/adapter/repositories/issue/RestIssueRepository.d.ts +1 -1
  87. package/types/adapter/repositories/issue/RestIssueRepository.d.ts.map +1 -1
  88. package/types/adapter/repositories/utils.d.ts.map +1 -1
  89. package/types/domain/entities/Issue.d.ts +4 -0
  90. package/types/domain/entities/Issue.d.ts.map +1 -1
  91. package/types/domain/entities/Project.d.ts +15 -4
  92. package/types/domain/entities/Project.d.ts.map +1 -1
  93. package/types/domain/usecases/AnalyzeProblemByIssueUseCase.d.ts +13 -8
  94. package/types/domain/usecases/AnalyzeProblemByIssueUseCase.d.ts.map +1 -1
  95. package/types/domain/usecases/AnalyzeStoriesUseCase.d.ts +29 -0
  96. package/types/domain/usecases/AnalyzeStoriesUseCase.d.ts.map +1 -0
  97. package/types/domain/usecases/ClearDependedIssueURLUseCase.d.ts +13 -0
  98. package/types/domain/usecases/ClearDependedIssueURLUseCase.d.ts.map +1 -0
  99. package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts +33 -0
  100. package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts.map +1 -0
  101. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +26 -2
  102. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  103. package/types/domain/usecases/adapter-interfaces/DateRepository.d.ts +2 -0
  104. package/types/domain/usecases/adapter-interfaces/DateRepository.d.ts.map +1 -1
  105. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +3 -1
  106. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  107. package/types/domain/usecases/utils.d.ts +5 -0
  108. package/types/domain/usecases/utils.d.ts.map +1 -0
@@ -1,5 +1,7 @@
1
1
  import axios from 'axios';
2
2
  import { BaseGitHubRepository } from '../BaseGitHubRepository';
3
+ import { Project } from '../../../domain/entities/Project';
4
+ import { Issue } from '../../../domain/entities/Issue';
3
5
  export type ProjectItem = {
4
6
  id: string;
5
7
  nameWithOwner: string;
@@ -429,6 +431,7 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
429
431
  issue: {
430
432
  projectItems: {
431
433
  nodes: {
434
+ id: string;
432
435
  fieldValues: {
433
436
  nodes: {
434
437
  __typename: string;
@@ -509,6 +512,7 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
509
512
  }
510
513
  projectItems(first: 10) {
511
514
  nodes {
515
+ id
512
516
  fieldValues(first: 10) {
513
517
  nodes {
514
518
  ... on ProjectV2ItemFieldTextValue {
@@ -624,6 +628,9 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
624
628
  };
625
629
  }[] = data.repository.issue.projectItems.nodes;
626
630
  const item = projectItems[0];
631
+ if (!item) {
632
+ throw new Error(`No project item found for issue ${issueUrl}`);
633
+ }
627
634
  return {
628
635
  id: item.id,
629
636
  nameWithOwner: data.repository.issue.repository.nameWithOwner,
@@ -742,4 +749,12 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
742
749
  throw new Error(res.data.errors.map((e) => e.message).join('\n'));
743
750
  }
744
751
  };
752
+ updateProjectTextField = async (
753
+ project: Project['id'],
754
+ fieldId: string,
755
+ issue: Issue['itemId'],
756
+ text: string,
757
+ ): Promise<void> => {
758
+ await this.updateProjectField(project, fieldId, issue, { text });
759
+ };
745
760
  }
@@ -1,8 +1,10 @@
1
1
  import { InternalGraphqlIssueRepository } from './InternalGraphqlIssueRepository';
2
+ import { LocalStorageRepository } from '../LocalStorageRepository';
2
3
 
3
4
  describe('InternalGraphqlIssueRepository', () => {
4
5
  jest.setTimeout(30 * 1000);
5
- const repository = new InternalGraphqlIssueRepository();
6
+ const localStorageRepository = new LocalStorageRepository();
7
+ const repository = new InternalGraphqlIssueRepository(localStorageRepository);
6
8
 
7
9
  const testIssueUrl =
8
10
  'https://github.com/HiromiShikata/test-repository/issues/38';
@@ -10,6 +12,10 @@ describe('InternalGraphqlIssueRepository', () => {
10
12
  const testIssueId = 'I_kwDOCNXcUc6GaFia';
11
13
  const testCount = 10;
12
14
 
15
+ beforeAll(async () => {
16
+ await repository.getCookie();
17
+ });
18
+
13
19
  test('getFrontTimelineItems returns timeline with proper types', async () => {
14
20
  const result = await repository.getFrontTimelineItems(
15
21
  testIssueUrl,
@@ -1,7 +1,10 @@
1
1
  import { RestIssueRepository } from './RestIssueRepository';
2
+ import { LocalStorageRepository } from '../LocalStorageRepository';
2
3
 
3
4
  describe('RestIssueRepository', () => {
5
+ const localStorageRepository = new LocalStorageRepository();
4
6
  const restIssueRepository: RestIssueRepository = new RestIssueRepository(
7
+ localStorageRepository,
5
8
  '',
6
9
  process.env.GH_TOKEN || 'dummy',
7
10
  );
@@ -28,8 +28,10 @@ export class RestIssueRepository extends BaseGitHubRepository {
28
28
  body: string,
29
29
  assignees: string[],
30
30
  labels: string[],
31
- ) => {
32
- const response = await axios.post(
31
+ ): Promise<number> => {
32
+ const response = await axios.post<{
33
+ number: number;
34
+ }>(
33
35
  `https://api.github.com/repos/${owner}/${repo}/issues`,
34
36
  {
35
37
  title,
@@ -47,6 +49,7 @@ export class RestIssueRepository extends BaseGitHubRepository {
47
49
  if (response.status !== 201) {
48
50
  throw new Error(`Failed to create issue: ${response.status}`);
49
51
  }
52
+ return response.data.number;
50
53
  };
51
54
  getIssue = async (
52
55
  issueUrl: string,
@@ -1,4 +1,4 @@
1
- import { calcrateDuration, totalDuration } from './utils';
1
+ import { calcrateDuration, normalizeFieldName, totalDuration } from './utils';
2
2
 
3
3
  describe('utils', () => {
4
4
  describe('calculateDuration', () => {
@@ -37,4 +37,19 @@ describe('utils', () => {
37
37
  },
38
38
  );
39
39
  });
40
+ describe('normalizeFieldName', () => {
41
+ test.each`
42
+ fieldName | expected
43
+ ${'Field Name'} | ${'fieldname'}
44
+ ${'Field-Name'} | ${'fieldname'}
45
+ ${'Field Name (with) ()'} | ${'fieldnamewith'}
46
+ ${'Depended Issue URL separated by comma'} | ${'dependedissueurlseparatedbycomma'}
47
+ `(
48
+ 'should return $expected',
49
+ ({ fieldName, expected }: { fieldName: string; expected: string }) => {
50
+ const result = normalizeFieldName(fieldName);
51
+ expect(result).toEqual(expected);
52
+ },
53
+ );
54
+ });
40
55
  });
@@ -41,10 +41,5 @@ export const totalDuration = (
41
41
  };
42
42
 
43
43
  export const normalizeFieldName = (fieldName: string) => {
44
- return fieldName
45
- .toLowerCase()
46
- .replace(' ', '')
47
- .replace('-', '')
48
- .replace(' ', '')
49
- .replace(' ', '');
44
+ return fieldName.toLowerCase().replace(/[\s-()]/g, '');
50
45
  };
@@ -11,6 +11,8 @@ export type Issue = {
11
11
  nextActionDate: Date | null;
12
12
  nextActionHour: number | null;
13
13
  estimationMinutes: number | null;
14
+ dependedIssueUrls: string[];
15
+ completionDate50PercentConfidence: Date | null;
14
16
  url: string;
15
17
  assignees: Member['name'][];
16
18
  workingTimeline: WorkingTime[];
@@ -20,4 +22,6 @@ export type Issue = {
20
22
  body: string;
21
23
  itemId: string;
22
24
  isPr: boolean;
25
+ isInProgress: boolean;
26
+ isClosed: boolean;
23
27
  };
@@ -1,3 +1,9 @@
1
+ export type StoryOption = {
2
+ id: string;
3
+ name: string;
4
+ color: string;
5
+ description: string;
6
+ };
1
7
  export type Project = {
2
8
  id: string;
3
9
  name: string;
@@ -13,10 +19,7 @@ export type Project = {
13
19
  story: {
14
20
  name: string;
15
21
  fieldId: string;
16
- stories: {
17
- id: string;
18
- name: string;
19
- }[];
22
+ stories: StoryOption[];
20
23
  workflowManagementStory: {
21
24
  id: string;
22
25
  name: string;
@@ -26,4 +29,12 @@ export type Project = {
26
29
  name: string;
27
30
  fieldId: string;
28
31
  } | null;
32
+ dependedIssueUrlSeparatedByComma: {
33
+ name: string;
34
+ fieldId: string;
35
+ } | null;
36
+ completionDate50PercentConfidence: {
37
+ name: string;
38
+ fieldId: string;
39
+ } | null;
29
40
  };
@@ -3,11 +3,22 @@ import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
3
  import { Project } from '../entities/Project';
4
4
  import { Member } from '../entities/Member';
5
5
  import { DateRepository } from './adapter-interfaces/DateRepository';
6
+ import { StoryObject, StoryObjectMap } from './HandleScheduledEventUseCase';
7
+ import { isVisibleIssue } from './utils';
6
8
 
7
9
  export class AnalyzeProblemByIssueUseCase {
8
10
  constructor(
9
- readonly issueRepository: Pick<IssueRepository, 'createNewIssue'>,
10
- readonly dateRepository: Pick<DateRepository, 'formatDurationToHHMM'>,
11
+ readonly issueRepository: Pick<
12
+ IssueRepository,
13
+ 'createNewIssue' | 'createComment'
14
+ >,
15
+ readonly dateRepository: Pick<
16
+ DateRepository,
17
+ | 'formatDurationToHHMM'
18
+ | 'formatDateTimeWithDayOfWeek'
19
+ | 'formatStartEnd'
20
+ | 'formatDateWithDayOfWeek'
21
+ >,
11
22
  ) {}
12
23
 
13
24
  run = async (input: {
@@ -16,140 +27,185 @@ export class AnalyzeProblemByIssueUseCase {
16
27
  issues: Issue[];
17
28
  cacheUsed: boolean;
18
29
  manager: Member['name'];
30
+ members: Member['name'][];
19
31
  org: string;
20
32
  repo: string;
33
+ storyObjectMap: StoryObjectMap;
34
+ disabledStatus: string;
21
35
  }): Promise<void> => {
22
36
  const story = input.project.story;
23
37
  if (
24
38
  !story ||
25
39
  !input.targetDates.find(
26
40
  (targetDate) =>
27
- targetDate.getHours() === 7 && targetDate.getMinutes() === 0,
41
+ targetDate.getHours() === 0 && targetDate.getMinutes() === 0,
28
42
  )
29
43
  ) {
30
44
  return;
31
45
  }
32
- const isTargetIssue = (issue: Issue): boolean => {
33
- return (
34
- !issue.isPr &&
35
- (issue.nextActionDate === null ||
36
- issue.nextActionDate.getTime() <= input.targetDates[0].getTime()) &&
37
- issue.nextActionHour === null
46
+ const targetDate = input.targetDates[input.targetDates.length - 1];
47
+ if (!targetDate) {
48
+ return;
49
+ }
50
+ await this.checkInProgress({ ...input, targetDate });
51
+ for (const storyObject of input.storyObjectMap.values()) {
52
+ const storyIssue = storyObject.storyIssue;
53
+ if (!storyIssue) {
54
+ continue;
55
+ }
56
+ await this.issueRepository.createComment(
57
+ storyIssue,
58
+ this.createSummaryCommentBody({ ...storyObject, storyIssue }),
38
59
  );
39
- };
40
- const summaryStoryIssue = new Map<
41
- string,
42
- Map<
43
- Issue,
44
- {
45
- totalWorkingTime: number;
46
- totalWorkingTimeByAssignee: Map<string, number>;
47
- }
48
- >
49
- >();
50
- const targetStory = input.project.story?.stories.slice(0, 12) || [];
51
- for (const story of targetStory) {
52
- summaryStoryIssue.set(story.name, new Map());
53
- for (const issue of input.issues) {
54
- if (issue.story !== story.name || !isTargetIssue(issue)) {
60
+ await new Promise((resolve) => setTimeout(resolve, 5000));
61
+ }
62
+ };
63
+ checkInProgress = async (
64
+ input: Parameters<AnalyzeProblemByIssueUseCase['run']>[0] & {
65
+ targetDate: Date;
66
+ },
67
+ ) => {
68
+ const assigneeToNotify: Member['name'][] = [];
69
+ for (const member of input.members) {
70
+ let topIssue: Issue | null = null;
71
+ for (const story of input.storyObjectMap.values()) {
72
+ const storyIssueObject = input.storyObjectMap.get(story.story.name);
73
+ if (!storyIssueObject) {
55
74
  continue;
75
+ } else if (assigneeToNotify.includes(member)) {
76
+ break;
77
+ }
78
+ for (const issue of storyIssueObject.issues) {
79
+ if (
80
+ !isVisibleIssue(
81
+ issue,
82
+ member,
83
+ input.targetDate,
84
+ input.disabledStatus,
85
+ ) ||
86
+ issue.status?.toLowerCase().includes('review') ||
87
+ issue.title.toLowerCase().includes('review') ||
88
+ issue.isPr
89
+ ) {
90
+ continue;
91
+ }
92
+ if (topIssue === null) {
93
+ topIssue = issue;
94
+ break;
95
+ }
96
+ if (!issue.isInProgress) {
97
+ continue;
98
+ }
99
+ assigneeToNotify.push(member);
100
+ break;
56
101
  }
57
- const totalWorkingTimeByAssignee =
58
- this.calculateTotalWorkingMinutesByAssignee(issue);
59
- const totalWorkingTime = Math.round(
60
- Array.from(totalWorkingTimeByAssignee.values()).reduce(
61
- (a, b) => a + b,
62
- 0,
63
- ),
64
- );
65
- const issueSummary: {
66
- totalWorkingTime: number;
67
- totalWorkingTimeByAssignee: Map<string, number>;
68
- } = {
69
- totalWorkingTime,
70
- totalWorkingTimeByAssignee,
71
- };
72
- summaryStoryIssue.get(story.name)?.set(issue, issueSummary);
73
- // if (totalWorkingTime < 240 || totalWorkingTime > 1440) {
74
- // continue;
75
- // }
76
- // await this.issueRepository.createNewIssue(
77
- // issue.org,
78
- // issue.repo,
79
- // `Please share the situation about #${issue.number} (${issue.title}) / total working time: ${totalWorkingTime} minutes`,
80
- // this.createQuestionIssueBody(
81
- // issue,
82
- // totalWorkingTime,
83
- // totalWorkingTimeByAssignee,
84
- // ),
85
- //
86
- // [input.manager],
87
- // ['story:workflow-management'],
88
- // );
89
102
  }
90
103
  }
104
+ if (assigneeToNotify.length === 0) {
105
+ return;
106
+ }
91
107
  await this.issueRepository.createNewIssue(
92
108
  input.org,
93
109
  input.repo,
94
- `Summary of story issues`,
95
- this.createSummaryIssueBody(summaryStoryIssue),
96
-
110
+ 'Check in progress',
111
+ `${assigneeToNotify.join('\n')}`,
97
112
  [input.manager],
98
113
  ['story:workflow-management'],
99
114
  );
100
115
  };
101
- createSummaryIssueBody = (
102
- summaryStoryIssue: Map<
103
- string,
104
- Map<
105
- Issue,
106
- {
107
- totalWorkingTime: number;
108
- totalWorkingTimeByAssignee: Map<string, number>;
109
- }
110
- >
111
- >,
116
+ createSummaryCommentBody = (
117
+ storyObject: StoryObject & {
118
+ storyIssue: NonNullable<StoryObject['storyIssue']>;
119
+ },
112
120
  ): string => {
113
- let noMultipleNewLineBody = `${Array.from(summaryStoryIssue)
121
+ const getFlowchartIdFromUrl = (url: string) => {
122
+ return url.split('/').slice(-3).join('/');
123
+ };
124
+ const issueTitleForFlowchart = (title: string) => {
125
+ return title.replace('"', "'");
126
+ };
127
+ const flowChart = `
128
+ \`\`\`mermaid
129
+
130
+ flowchart TD
131
+ ${storyObject.issues
132
+ .map(
133
+ (issue) =>
134
+ ` ${getFlowchartIdFromUrl(issue.url)}["${issue.isClosed ? '🟣' : '🟢'}#${issue.number} ${issue.isClosed ? 'Closed' : 'Open'}<br/>${issue.assignees.map((a) => `${a}`).join('<br/>')}<br/>${issueTitleForFlowchart(issue.title)}"]`,
135
+ )
136
+ .join('\n')}
137
+ ${storyObject.issues
138
+ .map((issue) =>
139
+ Array.from(issue.dependedIssueUrls)
140
+ .map((dependedIssueUrl) => {
141
+ if (issue.isClosed) {
142
+ issue.totalWorkingTimeByAssignee;
143
+ return ` ${getFlowchartIdFromUrl(dependedIssueUrl)} -->|total: ${this.dateRepository.formatDurationToHHMM(issue.totalWorkingTime)}<br/>${Array.from(
144
+ issue.totalWorkingTimeByAssignee,
145
+ )
146
+ .map(
147
+ ([author, workingMinutes]) =>
148
+ `@${author} ${this.dateRepository.formatDurationToHHMM(workingMinutes)}`,
149
+ )
150
+ .join('<br/>')}| ${getFlowchartIdFromUrl(issue.url)}`;
151
+ }
152
+ return ` ${getFlowchartIdFromUrl(dependedIssueUrl)} -->|${
153
+ issue.estimationMinutes
154
+ ? `Estimation: ${this.dateRepository.formatDurationToHHMM(issue.estimationMinutes)}<br/>`
155
+ : ''
156
+ }<br/>by ${
157
+ issue.completionDate50PercentConfidence
158
+ ? this.dateRepository.formatDateWithDayOfWeek(
159
+ issue.completionDate50PercentConfidence,
160
+ )
161
+ : 'Unknown'
162
+ }| ${getFlowchartIdFromUrl(issue.url)}`;
163
+ })
164
+ .join('\n'),
165
+ )
166
+ .join('\n')}
167
+ %% click event
168
+ ${storyObject.issues
114
169
  .map(
115
- ([story, issues]) =>
116
- `## ${this.dateRepository.formatDurationToHHMM(Array.from(issues.values()).reduce((a, b) => a + b.totalWorkingTime, 0))} ${story}
117
- ${Array.from(issues)
170
+ (issue) => `click ${getFlowchartIdFromUrl(issue.url)} "${issue.url}"`,
171
+ )
172
+ .join('\n')}
173
+
174
+ \`\`\``;
175
+ let noMultipleNewLineBody = `
176
+ Total: ${this.dateRepository.formatDurationToHHMM(Array.from(storyObject.issues.values()).reduce((a, b) => a + b.totalWorkingTime, 0))}
177
+
178
+ ${storyObject.issues
118
179
  .map(
119
- ([issue, { totalWorkingTime, totalWorkingTimeByAssignee }]) =>
120
- `- ${this.dateRepository.formatDurationToHHMM(totalWorkingTime)} ${totalWorkingTime > 300 ? ':warning: over 300min' : ''} ${issue.url} ${issue.assignees.map((a) => `@${a}`).join(' ')} ${issue.labels
121
- .map(
122
- (label) =>
123
- `https://github.com/${issue.nameWithOwner}/labels/${encodeURI(label).replace(/:/g, '%3A')}`,
124
- )
125
- .join(' ')}
126
- ${issue.workingTimeline.length > 0 ? ` - Total` : ''}
127
- ${Array.from(totalWorkingTimeByAssignee)
180
+ (
181
+ issue,
182
+ ) => `- ${this.dateRepository.formatDurationToHHMM(issue.totalWorkingTime)} ${issue.url}
183
+ - Total
184
+ ${Array.from(issue.totalWorkingTimeByAssignee)
128
185
  .map(
129
186
  ([author, workingMinutes]) =>
130
- ` - ${this.dateRepository.formatDurationToHHMM(workingMinutes)}, @${author}`,
187
+ ` - ${this.dateRepository.formatDurationToHHMM(workingMinutes)}, ${author}`,
131
188
  )
132
189
  .join('\n')}
133
- ${issue.workingTimeline.length > 0 ? ` - Timeline` : ''}
190
+ - Timeline
134
191
  ${issue.workingTimeline
135
192
  .map(
136
193
  ({ startedAt, endedAt, durationMinutes, author }) =>
137
- ` - ${this.dateRepository.formatDurationToHHMM(
138
- startedAt.getMinutes(),
139
- )}, ${this.dateRepository.formatDurationToHHMM(
140
- endedAt.getMinutes(),
141
- )}, ${this.dateRepository.formatDurationToHHMM(durationMinutes)}, @${author}`,
194
+ ` - ${this.dateRepository.formatDurationToHHMM(durationMinutes)}, ${this.dateRepository.formatStartEnd(startedAt, endedAt)}}, ${author}`,
142
195
  )
143
196
  .join('\n')}`,
144
197
  )
145
- .join('\n')}`,
146
- )
147
- .join('\n')}`;
198
+ .join('\n')}`;
199
+
148
200
  while (noMultipleNewLineBody.includes('\n\n')) {
149
201
  noMultipleNewLineBody = noMultipleNewLineBody.replace('\n\n', '\n');
150
202
  }
151
- return noMultipleNewLineBody;
203
+ return `${flowChart}
204
+
205
+ ${noMultipleNewLineBody}
206
+ `;
152
207
  };
208
+
153
209
  createQuestionIssueBody = (
154
210
  issue: Issue,
155
211
  totalWorkingTime: number,
@@ -186,24 +242,4 @@ ${issue.workingTimeline
186
242
  .join('\n')}
187
243
  `;
188
244
  };
189
-
190
- calculateTotalWorkingMinutesByAssignee = (
191
- issue: Issue,
192
- ): Map<string, number> => {
193
- const workingTimeLine = issue.workingTimeline;
194
- const mapWorkingTimeByAssignee: Map<string, number> = new Map();
195
- for (const workingTime of workingTimeLine) {
196
- const author = workingTime.author;
197
- const workingMinutes = workingTime.durationMinutes;
198
- if (!mapWorkingTimeByAssignee.has(author)) {
199
- mapWorkingTimeByAssignee.set(author, 0);
200
- }
201
- const currentWorkingMinutes = mapWorkingTimeByAssignee.get(author) || 0;
202
- mapWorkingTimeByAssignee.set(
203
- author,
204
- currentWorkingMinutes + workingMinutes,
205
- );
206
- }
207
- return mapWorkingTimeByAssignee;
208
- };
209
245
  }
@@ -0,0 +1,167 @@
1
+ import { Issue } from '../entities/Issue';
2
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
+ import { Project } from '../entities/Project';
4
+ import { Member } from '../entities/Member';
5
+ import { DateRepository } from './adapter-interfaces/DateRepository';
6
+ import { StoryObjectMap } from './HandleScheduledEventUseCase';
7
+ import { encodeForURI } from './utils';
8
+
9
+ export class AnalyzeStoriesUseCase {
10
+ constructor(
11
+ readonly issueRepository: Pick<IssueRepository, 'createNewIssue'>,
12
+ readonly dateRepository: Pick<DateRepository, 'formatDurationToHHMM'>,
13
+ ) {}
14
+
15
+ run = async (input: {
16
+ targetDates: Date[];
17
+ project: Project;
18
+ issues: Issue[];
19
+ cacheUsed: boolean;
20
+ manager: Member['name'];
21
+ org: string;
22
+ repo: string;
23
+ urlOfStoryView: string;
24
+ disabledStatus: string;
25
+ storyObjectMap: StoryObjectMap;
26
+ }): Promise<void> => {
27
+ const story = input.project.story;
28
+ if (
29
+ !story ||
30
+ !input.targetDates.find(
31
+ (targetDate) =>
32
+ targetDate.getHours() === 7 && targetDate.getMinutes() === 0,
33
+ )
34
+ ) {
35
+ return;
36
+ }
37
+
38
+ const phases = new Map<
39
+ string,
40
+ (Issue & {
41
+ name: string;
42
+ color: string;
43
+ description: string;
44
+ })[]
45
+ >();
46
+
47
+ phases.set('story:phase:requirement:opened', []);
48
+ phases.set('story:phase:requirement:finished-prd', []);
49
+ phases.set('story:phase:requirement:finished-figma', []);
50
+ phases.set('story:phase:requirement:finished-testcase', []);
51
+ phases.set('story:phase:requirement:finished-deviding-task', []);
52
+ phases.set('story:phase:implementation-finished', []);
53
+ phases.set('story:phase:finished-qa', []);
54
+ phases.set('others', []);
55
+
56
+ for (const story of input.project.story?.stories || []) {
57
+ const storyIssue = input.issues.find((issue) =>
58
+ story.name.startsWith(issue.title),
59
+ );
60
+ if (story.name.startsWith('regular / ')) {
61
+ continue;
62
+ } else if (!storyIssue) {
63
+ throw new Error(`Story issue not found: ${story.name}`);
64
+ }
65
+ const storyIssueObject = {
66
+ ...storyIssue,
67
+ ...story,
68
+ };
69
+
70
+ if (storyIssue.status === input.disabledStatus) {
71
+ phases.get('others')?.push(storyIssueObject);
72
+ } else if (storyIssue.labels.includes('story:phase:finished-qa')) {
73
+ phases.get('story:phase:finished-qa')?.push(storyIssueObject);
74
+ } else if (
75
+ storyIssue.labels.includes('story:phase:implementation-finished')
76
+ ) {
77
+ phases
78
+ .get('story:phase:implementation-finished')
79
+ ?.push(storyIssueObject);
80
+ } else if (
81
+ storyIssue.labels.includes(
82
+ 'story:phase:requirement:finished-deviding-task',
83
+ )
84
+ ) {
85
+ phases
86
+ .get('story:phase:requirement:finished-deviding-task')
87
+ ?.push(storyIssueObject);
88
+ } else if (
89
+ storyIssue.labels.includes('story:phase:requirement:finished-testcase')
90
+ ) {
91
+ phases
92
+ .get('story:phase:requirement:finished-testcase')
93
+ ?.push(storyIssueObject);
94
+ } else if (
95
+ storyIssue.labels.includes('story:phase:requirement:finished-figma')
96
+ ) {
97
+ phases
98
+ .get('story:phase:requirement:finished-figma')
99
+ ?.push(storyIssueObject);
100
+ } else if (
101
+ storyIssue.labels.includes('story:phase:requirement:finished-prd')
102
+ ) {
103
+ phases
104
+ .get('story:phase:requirement:finished-prd')
105
+ ?.push(storyIssueObject);
106
+ } else if (storyIssue.labels.includes('story:phase:requirement:opened')) {
107
+ phases.get('story:phase:requirement:opened')?.push(storyIssueObject);
108
+ } else {
109
+ phases.get('others')?.push(storyIssueObject);
110
+ }
111
+ }
112
+ await this.issueRepository.createNewIssue(
113
+ input.org,
114
+ input.repo,
115
+ `Story progress`,
116
+ this.createSummaryIssueBody(phases, input.urlOfStoryView),
117
+ [input.manager],
118
+ ['story:workflow-management'],
119
+ );
120
+ };
121
+ createSummaryIssueBody = (
122
+ summaryStoryIssue: Map<
123
+ string,
124
+ (Issue & {
125
+ name: string;
126
+ color: string;
127
+ description: string;
128
+ })[]
129
+ >,
130
+ urlOfStoryView: string,
131
+ ): string => {
132
+ return `
133
+
134
+ ${Array.from(summaryStoryIssue.keys())
135
+ .map((key) => {
136
+ return `
137
+ ## ${key}
138
+ ${summaryStoryIssue
139
+ .get(key)
140
+ ?.map((issue) => {
141
+ const storyColor = `:${issue.color === 'BLUE' ? 'large_' : ''}${issue.color === 'GRAY' ? 'black' : issue.color === 'PINK' ? 'red' : issue.color.toLowerCase()}_circle:`;
142
+ const stakeHolder = issue.labels.find(
143
+ (label) => label === 'story:stakeholder:user',
144
+ )
145
+ ? `:bust_in_silhouette:`
146
+ : issue.labels.find((label) => label === 'story:stakeholder:engineer')
147
+ ? `:gear:`
148
+ : issue.labels.find((label) => label === 'story:stakeholder:cs-team')
149
+ ? `:headphones:`
150
+ : issue.labels.find(
151
+ (label) => label === 'story:stakeholder:potential-user',
152
+ )
153
+ ? ':busts_in_silhouette:'
154
+ : issue.labels.find(
155
+ (label) => label === 'story:stakeholder:sales-team',
156
+ )
157
+ ? ':briefcase:'
158
+ : ':question:';
159
+ const boardUrl = `${urlOfStoryView}?filterQuery=story%3A%22${encodeForURI(issue.story)}%22+is%3Aopen`;
160
+
161
+ return `- ${storyColor} ${stakeHolder} ${issue.url} [:memo:](${boardUrl})`;
162
+ })
163
+ .join('\n')}`;
164
+ })
165
+ .join('\n')}`;
166
+ };
167
+ }