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.
- package/.github/workflows/commit-lint.yml +0 -2
- package/.github/workflows/empty-format-test-job.yml +28 -0
- package/.github/workflows/test.yml +1 -0
- package/CHANGELOG.md +20 -41
- package/bin/adapter/entry-points/cli/index.js +0 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +127 -16
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/repositories/BaseGitHubRepository.js +3 -2
- package/bin/adapter/repositories/BaseGitHubRepository.js.map +1 -1
- package/bin/adapter/repositories/GraphqlProjectRepository.js +16 -0
- package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
- package/bin/adapter/repositories/LocalStorageRepository.js +6 -0
- package/bin/adapter/repositories/LocalStorageRepository.js.map +1 -1
- package/bin/adapter/repositories/SystemDateRepository.js +15 -3
- package/bin/adapter/repositories/SystemDateRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +61 -9
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/CheerioIssueRepository.js +28 -2
- package/bin/adapter/repositories/issue/CheerioIssueRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +7 -0
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/RestIssueRepository.js +1 -0
- package/bin/adapter/repositories/issue/RestIssueRepository.js.map +1 -1
- package/bin/adapter/repositories/utils.js +1 -6
- package/bin/adapter/repositories/utils.js.map +1 -1
- package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js +98 -65
- package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/AnalyzeStoriesUseCase.js +108 -0
- package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -0
- package/bin/domain/usecases/ClearDependedIssueURLUseCase.js +66 -0
- package/bin/domain/usecases/ClearDependedIssueURLUseCase.js.map +1 -0
- package/bin/domain/usecases/CreateEstimationIssueUseCase.js +100 -0
- package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -0
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +124 -2
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/utils.js +24 -0
- package/bin/domain/usecases/utils.js.map +1 -0
- package/package.json +2 -2
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +73 -7
- package/src/adapter/repositories/AxiosSlackRepository.test.ts +3 -0
- package/src/adapter/repositories/BaseGitHubRepository.test.ts +3 -1
- package/src/adapter/repositories/BaseGitHubRepository.ts +3 -1
- package/src/adapter/repositories/GraphqlProjectRepository.test.ts +5 -1
- package/src/adapter/repositories/GraphqlProjectRepository.ts +25 -0
- package/src/adapter/repositories/LocalStorageCacheRepository.test.ts +1 -0
- package/src/adapter/repositories/LocalStorageRepository.ts +6 -0
- package/src/adapter/repositories/SystemDateRepository.ts +17 -3
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +8 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +103 -16
- package/src/adapter/repositories/issue/ApiV3IssueRepository.test.ts +3 -0
- package/src/adapter/repositories/issue/CheerioIssueRepository.test.ts +10 -0
- package/src/adapter/repositories/issue/CheerioIssueRepository.ts +37 -1
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +7 -1
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +15 -0
- package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.test.ts +7 -1
- package/src/adapter/repositories/issue/RestIssueRepository.test.ts +3 -0
- package/src/adapter/repositories/issue/RestIssueRepository.ts +5 -2
- package/src/adapter/repositories/utils.test.ts +16 -1
- package/src/adapter/repositories/utils.ts +1 -6
- package/src/domain/entities/Issue.ts +4 -0
- package/src/domain/entities/Project.ts +15 -4
- package/src/domain/usecases/AnalyzeProblemByIssueUseCase.ts +151 -115
- package/src/domain/usecases/AnalyzeStoriesUseCase.ts +167 -0
- package/src/domain/usecases/ClearDependedIssueURLUseCase.test.ts +840 -0
- package/src/domain/usecases/ClearDependedIssueURLUseCase.ts +107 -0
- package/src/domain/usecases/CreateEstimationIssueUseCase.ts +157 -0
- package/src/domain/usecases/GenerateWorkingTimeReportUseCase.test.ts +8 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +171 -2
- package/src/domain/usecases/adapter-interfaces/DateRepository.ts +2 -0
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +8 -1
- package/src/domain/usecases/utils.ts +28 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/repositories/BaseGitHubRepository.d.ts +3 -1
- package/types/adapter/repositories/BaseGitHubRepository.d.ts.map +1 -1
- package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
- package/types/adapter/repositories/LocalStorageRepository.d.ts +1 -0
- package/types/adapter/repositories/LocalStorageRepository.d.ts.map +1 -1
- package/types/adapter/repositories/SystemDateRepository.d.ts +2 -0
- package/types/adapter/repositories/SystemDateRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +14 -5
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts +7 -1
- package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +3 -0
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/RestIssueRepository.d.ts +1 -1
- package/types/adapter/repositories/issue/RestIssueRepository.d.ts.map +1 -1
- package/types/adapter/repositories/utils.d.ts.map +1 -1
- package/types/domain/entities/Issue.d.ts +4 -0
- package/types/domain/entities/Issue.d.ts.map +1 -1
- package/types/domain/entities/Project.d.ts +15 -4
- package/types/domain/entities/Project.d.ts.map +1 -1
- package/types/domain/usecases/AnalyzeProblemByIssueUseCase.d.ts +13 -8
- package/types/domain/usecases/AnalyzeProblemByIssueUseCase.d.ts.map +1 -1
- package/types/domain/usecases/AnalyzeStoriesUseCase.d.ts +29 -0
- package/types/domain/usecases/AnalyzeStoriesUseCase.d.ts.map +1 -0
- package/types/domain/usecases/ClearDependedIssueURLUseCase.d.ts +13 -0
- package/types/domain/usecases/ClearDependedIssueURLUseCase.d.ts.map +1 -0
- package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts +33 -0
- package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts.map +1 -0
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +26 -2
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/DateRepository.d.ts +2 -0
- package/types/domain/usecases/adapter-interfaces/DateRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +3 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/utils.d.ts +5 -0
- 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
|
|
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<
|
|
10
|
-
|
|
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() ===
|
|
41
|
+
targetDate.getHours() === 0 && targetDate.getMinutes() === 0,
|
|
28
42
|
)
|
|
29
43
|
) {
|
|
30
44
|
return;
|
|
31
45
|
}
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
'Check in progress',
|
|
111
|
+
`${assigneeToNotify.join('\n')}`,
|
|
97
112
|
[input.manager],
|
|
98
113
|
['story:workflow-management'],
|
|
99
114
|
);
|
|
100
115
|
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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)},
|
|
187
|
+
` - ${this.dateRepository.formatDurationToHHMM(workingMinutes)}, ${author}`,
|
|
131
188
|
)
|
|
132
189
|
.join('\n')}
|
|
133
|
-
|
|
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
|
|
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
|
+
}
|