github-issue-tower-defence-management 1.71.1 → 1.73.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/umino-project.yml +15 -7
- package/CHANGELOG.md +14 -0
- package/README.md +2 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +51 -0
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/ChangeTargetPullRequestApprover.js +45 -0
- package/bin/domain/usecases/ChangeTargetPullRequestApprover.js.map +1 -0
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +4 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.js +5 -1
- package/bin/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +139 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +69 -0
- package/src/domain/usecases/ChangeTargetPullRequestApprover.test.ts +131 -0
- package/src/domain/usecases/ChangeTargetPullRequestApprover.ts +58 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +249 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +12 -1
- package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +225 -0
- package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.ts +15 -1
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -0
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +2 -0
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/ChangeTargetPullRequestApprover.d.ts +9 -0
- package/types/domain/usecases/ChangeTargetPullRequestApprover.d.ts.map +1 -0
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +2 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.d.ts +2 -1
- package/types/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
|
@@ -213,6 +213,16 @@ function isDirectPullRequestResponse(
|
|
|
213
213
|
return true;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
type PullRequestFilesResponseItem = {
|
|
217
|
+
filename: string;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
function isPullRequestFilesResponse(
|
|
221
|
+
value: unknown,
|
|
222
|
+
): value is PullRequestFilesResponseItem[] {
|
|
223
|
+
return typia.is<PullRequestFilesResponseItem[]>(value);
|
|
224
|
+
}
|
|
225
|
+
|
|
216
226
|
const fnmatch = (pattern: string, str: string): boolean => {
|
|
217
227
|
let regexStr = '^';
|
|
218
228
|
let i = 0;
|
|
@@ -1095,6 +1105,65 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
1095
1105
|
}
|
|
1096
1106
|
};
|
|
1097
1107
|
|
|
1108
|
+
getPullRequestChangedFilePaths = async (prUrl: string): Promise<string[]> => {
|
|
1109
|
+
const { owner, repo, issueNumber: prNumber } = this.parseIssueUrl(prUrl);
|
|
1110
|
+
const perPage = 100;
|
|
1111
|
+
const collectedPaths: string[] = [];
|
|
1112
|
+
let page = 1;
|
|
1113
|
+
let hasMore = true;
|
|
1114
|
+
while (hasMore) {
|
|
1115
|
+
const response = await fetch(
|
|
1116
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=${perPage}&page=${page}`,
|
|
1117
|
+
{
|
|
1118
|
+
method: 'GET',
|
|
1119
|
+
headers: {
|
|
1120
|
+
Authorization: `Bearer ${this.ghToken}`,
|
|
1121
|
+
Accept: 'application/vnd.github+json',
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
);
|
|
1125
|
+
if (!response.ok) {
|
|
1126
|
+
throw new Error(
|
|
1127
|
+
`Failed to fetch changed files for PR ${prUrl}: HTTP ${response.status}`,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
const body: unknown = await response.json();
|
|
1131
|
+
if (!isPullRequestFilesResponse(body)) {
|
|
1132
|
+
throw new Error(
|
|
1133
|
+
`Unexpected response shape when fetching changed files for PR ${prUrl}`,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
for (const file of body) {
|
|
1137
|
+
collectedPaths.push(file.filename);
|
|
1138
|
+
}
|
|
1139
|
+
if (body.length < perPage) {
|
|
1140
|
+
hasMore = false;
|
|
1141
|
+
} else {
|
|
1142
|
+
page += 1;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return collectedPaths;
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
approvePullRequest = async (prUrl: string): Promise<void> => {
|
|
1149
|
+
const { owner, repo, issueNumber: prNumber } = this.parseIssueUrl(prUrl);
|
|
1150
|
+
const response = await fetch(
|
|
1151
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
|
|
1152
|
+
{
|
|
1153
|
+
method: 'POST',
|
|
1154
|
+
headers: {
|
|
1155
|
+
Authorization: `Bearer ${this.ghToken}`,
|
|
1156
|
+
'Content-Type': 'application/json',
|
|
1157
|
+
Accept: 'application/vnd.github+json',
|
|
1158
|
+
},
|
|
1159
|
+
body: JSON.stringify({ event: 'APPROVE' }),
|
|
1160
|
+
},
|
|
1161
|
+
);
|
|
1162
|
+
if (!response.ok) {
|
|
1163
|
+
throw new Error(`Failed to approve PR ${prUrl}: HTTP ${response.status}`);
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1098
1167
|
deletePullRequestBranch = async (
|
|
1099
1168
|
prUrl: string,
|
|
1100
1169
|
branchName: string,
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ChangeTargetPullRequestApprover } from './ChangeTargetPullRequestApprover';
|
|
2
|
+
|
|
3
|
+
describe('ChangeTargetPullRequestApprover', () => {
|
|
4
|
+
let mockIssueRepository: {
|
|
5
|
+
getPullRequestChangedFilePaths: jest.Mock;
|
|
6
|
+
approvePullRequest: jest.Mock;
|
|
7
|
+
};
|
|
8
|
+
let approver: ChangeTargetPullRequestApprover;
|
|
9
|
+
const prUrl = 'https://github.com/user/repo/pull/1';
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
jest.resetAllMocks();
|
|
13
|
+
mockIssueRepository = {
|
|
14
|
+
getPullRequestChangedFilePaths: jest.fn().mockResolvedValue([]),
|
|
15
|
+
approvePullRequest: jest.fn().mockResolvedValue(undefined),
|
|
16
|
+
};
|
|
17
|
+
approver = new ChangeTargetPullRequestApprover(mockIssueRepository);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should do nothing when approvedPrUrl is null', async () => {
|
|
21
|
+
await approver.approveIfConfined(['change-target:src/domain'], null);
|
|
22
|
+
|
|
23
|
+
expect(
|
|
24
|
+
mockIssueRepository.getPullRequestChangedFilePaths,
|
|
25
|
+
).not.toHaveBeenCalled();
|
|
26
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should do nothing when issue has no change-target label', async () => {
|
|
30
|
+
await approver.approveIfConfined(['category:e2e'], prUrl);
|
|
31
|
+
|
|
32
|
+
expect(
|
|
33
|
+
mockIssueRepository.getPullRequestChangedFilePaths,
|
|
34
|
+
).not.toHaveBeenCalled();
|
|
35
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should approve when all files are confined to the labeled path', async () => {
|
|
39
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
40
|
+
'src/domain/entities/Foo.ts',
|
|
41
|
+
'src/domain/usecases/Bar.ts',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
await approver.approveIfConfined(['change-target:src/domain'], prUrl);
|
|
45
|
+
|
|
46
|
+
expect(
|
|
47
|
+
mockIssueRepository.getPullRequestChangedFilePaths,
|
|
48
|
+
).toHaveBeenCalledWith(prUrl);
|
|
49
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should not approve when any file is outside the labeled path', async () => {
|
|
53
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
54
|
+
'src/domain/entities/Foo.ts',
|
|
55
|
+
'src/adapter/Outside.ts',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
await approver.approveIfConfined(['change-target:src/domain'], prUrl);
|
|
59
|
+
|
|
60
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should approve when files are confined under any of multiple labels', async () => {
|
|
64
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
65
|
+
'src/domain/Foo.ts',
|
|
66
|
+
'docs/intro.md',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
await approver.approveIfConfined(
|
|
70
|
+
['change-target:src/domain', 'change-target:docs'],
|
|
71
|
+
prUrl,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should match boundary-safely (foo matches foo/bar.ts but not foobar/baz.ts)', async () => {
|
|
78
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
79
|
+
'foo/bar.ts',
|
|
80
|
+
'foobar/baz.ts',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
await approver.approveIfConfined(['change-target:foo'], prUrl);
|
|
84
|
+
|
|
85
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should approve when changed file equals the labeled path exactly', async () => {
|
|
89
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
90
|
+
'README.md',
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
await approver.approveIfConfined(['change-target:README.md'], prUrl);
|
|
94
|
+
|
|
95
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should not approve when there are zero changed files', async () => {
|
|
99
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([]);
|
|
100
|
+
|
|
101
|
+
await approver.approveIfConfined(['change-target:src/domain'], prUrl);
|
|
102
|
+
|
|
103
|
+
expect(
|
|
104
|
+
mockIssueRepository.getPullRequestChangedFilePaths,
|
|
105
|
+
).toHaveBeenCalledWith(prUrl);
|
|
106
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should normalize trailing slashes in change-target label paths', async () => {
|
|
110
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
111
|
+
'src/domain/entities/Foo.ts',
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
await approver.approveIfConfined(['change-target:src/domain/'], prUrl);
|
|
115
|
+
|
|
116
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should ignore empty change-target label values', async () => {
|
|
120
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
121
|
+
'src/domain/Foo.ts',
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
await approver.approveIfConfined(['change-target:'], prUrl);
|
|
125
|
+
|
|
126
|
+
expect(
|
|
127
|
+
mockIssueRepository.getPullRequestChangedFilePaths,
|
|
128
|
+
).not.toHaveBeenCalled();
|
|
129
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
2
|
+
|
|
3
|
+
export class ChangeTargetPullRequestApprover {
|
|
4
|
+
constructor(
|
|
5
|
+
private readonly issueRepository: Pick<
|
|
6
|
+
IssueRepository,
|
|
7
|
+
'getPullRequestChangedFilePaths' | 'approvePullRequest'
|
|
8
|
+
>,
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
approveIfConfined = async (
|
|
12
|
+
issueLabels: string[],
|
|
13
|
+
approvedPrUrl: string | null,
|
|
14
|
+
): Promise<void> => {
|
|
15
|
+
if (approvedPrUrl === null) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const changeTargetPaths = this.extractChangeTargetPaths(issueLabels);
|
|
19
|
+
if (changeTargetPaths.length === 0) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const changedFilePaths =
|
|
23
|
+
await this.issueRepository.getPullRequestChangedFilePaths(approvedPrUrl);
|
|
24
|
+
if (changedFilePaths.length === 0) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const allConfined = changedFilePaths.every((filePath) =>
|
|
28
|
+
this.isFilePathConfinedToAllowedPaths(filePath, changeTargetPaths),
|
|
29
|
+
);
|
|
30
|
+
if (!allConfined) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await this.issueRepository.approvePullRequest(approvedPrUrl);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
private extractChangeTargetPaths = (labels: string[]): string[] => {
|
|
37
|
+
const prefix = 'change-target:';
|
|
38
|
+
const paths: string[] = [];
|
|
39
|
+
for (const label of labels) {
|
|
40
|
+
if (!label.startsWith(prefix)) continue;
|
|
41
|
+
const raw = label.slice(prefix.length).trim();
|
|
42
|
+
if (raw.length === 0) continue;
|
|
43
|
+
const normalized = raw.replace(/\/+$/, '');
|
|
44
|
+
if (normalized.length === 0) continue;
|
|
45
|
+
paths.push(normalized);
|
|
46
|
+
}
|
|
47
|
+
return paths;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
private isFilePathConfinedToAllowedPaths = (
|
|
51
|
+
filePath: string,
|
|
52
|
+
allowedPaths: string[],
|
|
53
|
+
): boolean =>
|
|
54
|
+
allowedPaths.some(
|
|
55
|
+
(allowedPath) =>
|
|
56
|
+
filePath === allowedPath || filePath.startsWith(`${allowedPath}/`),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -94,6 +94,8 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
94
94
|
findRelatedOpenPRs: jest.Mock;
|
|
95
95
|
getStoryObjectMap: jest.Mock;
|
|
96
96
|
getOpenPullRequest: jest.Mock;
|
|
97
|
+
getPullRequestChangedFilePaths: jest.Mock;
|
|
98
|
+
approvePullRequest: jest.Mock;
|
|
97
99
|
setDependedIssueUrl: jest.Mock;
|
|
98
100
|
};
|
|
99
101
|
let mockIssueCommentRepository: {
|
|
@@ -126,6 +128,8 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
126
128
|
updateStatus: jest.fn(),
|
|
127
129
|
findRelatedOpenPRs: jest.fn(),
|
|
128
130
|
getOpenPullRequest: jest.fn(),
|
|
131
|
+
getPullRequestChangedFilePaths: jest.fn().mockResolvedValue([]),
|
|
132
|
+
approvePullRequest: jest.fn().mockResolvedValue(undefined),
|
|
129
133
|
setDependedIssueUrl: jest.fn(),
|
|
130
134
|
};
|
|
131
135
|
|
|
@@ -2763,4 +2767,249 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
2763
2767
|
);
|
|
2764
2768
|
});
|
|
2765
2769
|
});
|
|
2770
|
+
|
|
2771
|
+
describe('change-target label auto-approve', () => {
|
|
2772
|
+
const setupApprovedPrScenario = (issueOverrides: Partial<Issue> = {}) => {
|
|
2773
|
+
const issue = createMockIssue({
|
|
2774
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
2775
|
+
status: 'Preparation',
|
|
2776
|
+
...issueOverrides,
|
|
2777
|
+
});
|
|
2778
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2779
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
2780
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
2781
|
+
createMockComment({ content: 'From: :robot: Agent report' }),
|
|
2782
|
+
]);
|
|
2783
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
|
|
2784
|
+
{
|
|
2785
|
+
url: 'https://github.com/user/repo/pull/1',
|
|
2786
|
+
isConflicted: false,
|
|
2787
|
+
isPassedAllCiJob: true,
|
|
2788
|
+
isCiStateSuccess: true,
|
|
2789
|
+
isResolvedAllReviewComments: true,
|
|
2790
|
+
isBranchOutOfDate: false,
|
|
2791
|
+
missingRequiredCheckNames: [],
|
|
2792
|
+
},
|
|
2793
|
+
]);
|
|
2794
|
+
};
|
|
2795
|
+
|
|
2796
|
+
it('should not approve PR when issue has no change-target label', async () => {
|
|
2797
|
+
setupApprovedPrScenario();
|
|
2798
|
+
|
|
2799
|
+
await useCase.run({
|
|
2800
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
2801
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
2802
|
+
thresholdForAutoReject: 3,
|
|
2803
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
2804
|
+
allowedIssueAuthors: null,
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
expect(
|
|
2808
|
+
mockIssueRepository.getPullRequestChangedFilePaths,
|
|
2809
|
+
).not.toHaveBeenCalled();
|
|
2810
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
2811
|
+
expect(mockIssueRepository.update).toHaveBeenCalledWith(
|
|
2812
|
+
expect.objectContaining({ status: 'Awaiting Quality Check' }),
|
|
2813
|
+
mockProject,
|
|
2814
|
+
);
|
|
2815
|
+
});
|
|
2816
|
+
|
|
2817
|
+
it('should approve PR when issue has change-target label and all files are confined', async () => {
|
|
2818
|
+
setupApprovedPrScenario({ labels: ['change-target:src/domain'] });
|
|
2819
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
2820
|
+
'src/domain/entities/Foo.ts',
|
|
2821
|
+
'src/domain/usecases/Bar.ts',
|
|
2822
|
+
]);
|
|
2823
|
+
|
|
2824
|
+
await useCase.run({
|
|
2825
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
2826
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
2827
|
+
thresholdForAutoReject: 3,
|
|
2828
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
2829
|
+
allowedIssueAuthors: null,
|
|
2830
|
+
});
|
|
2831
|
+
|
|
2832
|
+
expect(
|
|
2833
|
+
mockIssueRepository.getPullRequestChangedFilePaths,
|
|
2834
|
+
).toHaveBeenCalledWith('https://github.com/user/repo/pull/1');
|
|
2835
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(
|
|
2836
|
+
'https://github.com/user/repo/pull/1',
|
|
2837
|
+
);
|
|
2838
|
+
expect(mockIssueRepository.update).toHaveBeenCalledWith(
|
|
2839
|
+
expect.objectContaining({ status: 'Awaiting Quality Check' }),
|
|
2840
|
+
mockProject,
|
|
2841
|
+
);
|
|
2842
|
+
});
|
|
2843
|
+
|
|
2844
|
+
it('should not approve PR when any changed file is outside the labeled path', async () => {
|
|
2845
|
+
setupApprovedPrScenario({ labels: ['change-target:src/domain'] });
|
|
2846
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
2847
|
+
'src/domain/entities/Foo.ts',
|
|
2848
|
+
'src/adapter/repositories/Outside.ts',
|
|
2849
|
+
]);
|
|
2850
|
+
|
|
2851
|
+
await useCase.run({
|
|
2852
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
2853
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
2854
|
+
thresholdForAutoReject: 3,
|
|
2855
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
2856
|
+
allowedIssueAuthors: null,
|
|
2857
|
+
});
|
|
2858
|
+
|
|
2859
|
+
expect(
|
|
2860
|
+
mockIssueRepository.getPullRequestChangedFilePaths,
|
|
2861
|
+
).toHaveBeenCalledWith('https://github.com/user/repo/pull/1');
|
|
2862
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
2863
|
+
expect(mockIssueRepository.update).toHaveBeenCalledWith(
|
|
2864
|
+
expect.objectContaining({ status: 'Awaiting Quality Check' }),
|
|
2865
|
+
mockProject,
|
|
2866
|
+
);
|
|
2867
|
+
});
|
|
2868
|
+
|
|
2869
|
+
it('should approve when files are confined under any of multiple change-target labels', async () => {
|
|
2870
|
+
setupApprovedPrScenario({
|
|
2871
|
+
labels: ['change-target:src/domain', 'change-target:docs'],
|
|
2872
|
+
});
|
|
2873
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
2874
|
+
'src/domain/entities/Foo.ts',
|
|
2875
|
+
'docs/intro.md',
|
|
2876
|
+
]);
|
|
2877
|
+
|
|
2878
|
+
await useCase.run({
|
|
2879
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
2880
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
2881
|
+
thresholdForAutoReject: 3,
|
|
2882
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
2883
|
+
allowedIssueAuthors: null,
|
|
2884
|
+
});
|
|
2885
|
+
|
|
2886
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(
|
|
2887
|
+
'https://github.com/user/repo/pull/1',
|
|
2888
|
+
);
|
|
2889
|
+
});
|
|
2890
|
+
|
|
2891
|
+
it('should not approve when PR has more than 100 changed files and one file beyond entry 100 is outside the labeled path', async () => {
|
|
2892
|
+
setupApprovedPrScenario({ labels: ['change-target:src/domain'] });
|
|
2893
|
+
const filePaths: string[] = [];
|
|
2894
|
+
for (let i = 0; i < 150; i += 1) {
|
|
2895
|
+
filePaths.push(`src/domain/file${i}.ts`);
|
|
2896
|
+
}
|
|
2897
|
+
filePaths.push('src/adapter/Outside.ts');
|
|
2898
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue(
|
|
2899
|
+
filePaths,
|
|
2900
|
+
);
|
|
2901
|
+
|
|
2902
|
+
await useCase.run({
|
|
2903
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
2904
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
2905
|
+
thresholdForAutoReject: 3,
|
|
2906
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
2907
|
+
allowedIssueAuthors: null,
|
|
2908
|
+
});
|
|
2909
|
+
|
|
2910
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2913
|
+
it('should match boundary-safely (change-target:foo matches foo/bar.ts but not foobar/baz.ts)', async () => {
|
|
2914
|
+
setupApprovedPrScenario({ labels: ['change-target:foo'] });
|
|
2915
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
2916
|
+
'foo/bar.ts',
|
|
2917
|
+
'foobar/baz.ts',
|
|
2918
|
+
]);
|
|
2919
|
+
|
|
2920
|
+
await useCase.run({
|
|
2921
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
2922
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
2923
|
+
thresholdForAutoReject: 3,
|
|
2924
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
2925
|
+
allowedIssueAuthors: null,
|
|
2926
|
+
});
|
|
2927
|
+
|
|
2928
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
2929
|
+
});
|
|
2930
|
+
|
|
2931
|
+
it('should approve when changed files match exact path or subpath of the labeled path', async () => {
|
|
2932
|
+
setupApprovedPrScenario({ labels: ['change-target:foo'] });
|
|
2933
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
2934
|
+
'foo/bar.ts',
|
|
2935
|
+
'foo/nested/baz.ts',
|
|
2936
|
+
]);
|
|
2937
|
+
|
|
2938
|
+
await useCase.run({
|
|
2939
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
2940
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
2941
|
+
thresholdForAutoReject: 3,
|
|
2942
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
2943
|
+
allowedIssueAuthors: null,
|
|
2944
|
+
});
|
|
2945
|
+
|
|
2946
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(
|
|
2947
|
+
'https://github.com/user/repo/pull/1',
|
|
2948
|
+
);
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
it('should not approve when PR has zero changed files', async () => {
|
|
2952
|
+
setupApprovedPrScenario({ labels: ['change-target:src/domain'] });
|
|
2953
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([]);
|
|
2954
|
+
|
|
2955
|
+
await useCase.run({
|
|
2956
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
2957
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
2958
|
+
thresholdForAutoReject: 3,
|
|
2959
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
2960
|
+
allowedIssueAuthors: null,
|
|
2961
|
+
});
|
|
2962
|
+
|
|
2963
|
+
expect(
|
|
2964
|
+
mockIssueRepository.getPullRequestChangedFilePaths,
|
|
2965
|
+
).toHaveBeenCalledWith('https://github.com/user/repo/pull/1');
|
|
2966
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
2967
|
+
});
|
|
2968
|
+
|
|
2969
|
+
it('should not approve when there is no approved PR even if change-target label is present', async () => {
|
|
2970
|
+
const issue = createMockIssue({
|
|
2971
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
2972
|
+
status: 'Preparation',
|
|
2973
|
+
labels: ['change-target:src/domain'],
|
|
2974
|
+
});
|
|
2975
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
2976
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
2977
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
2978
|
+
createMockComment({ content: 'From: :robot: Agent report' }),
|
|
2979
|
+
]);
|
|
2980
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
|
|
2981
|
+
|
|
2982
|
+
await useCase.run({
|
|
2983
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
2984
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
2985
|
+
thresholdForAutoReject: 3,
|
|
2986
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
2987
|
+
allowedIssueAuthors: null,
|
|
2988
|
+
});
|
|
2989
|
+
|
|
2990
|
+
expect(
|
|
2991
|
+
mockIssueRepository.getPullRequestChangedFilePaths,
|
|
2992
|
+
).not.toHaveBeenCalled();
|
|
2993
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
2994
|
+
});
|
|
2995
|
+
|
|
2996
|
+
it('should normalize trailing slashes in change-target label paths', async () => {
|
|
2997
|
+
setupApprovedPrScenario({ labels: ['change-target:src/domain/'] });
|
|
2998
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
2999
|
+
'src/domain/entities/Foo.ts',
|
|
3000
|
+
]);
|
|
3001
|
+
|
|
3002
|
+
await useCase.run({
|
|
3003
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
3004
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
3005
|
+
thresholdForAutoReject: 3,
|
|
3006
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
3007
|
+
allowedIssueAuthors: null,
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(
|
|
3011
|
+
'https://github.com/user/repo/pull/1',
|
|
3012
|
+
);
|
|
3013
|
+
});
|
|
3014
|
+
});
|
|
2766
3015
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
IssueRejectionEvaluator,
|
|
13
13
|
PrRejectedReasonType,
|
|
14
14
|
} from './IssueRejectionEvaluator';
|
|
15
|
+
import { ChangeTargetPullRequestApprover } from './ChangeTargetPullRequestApprover';
|
|
15
16
|
|
|
16
17
|
export class IssueNotFoundError extends Error {
|
|
17
18
|
constructor(issueUrl: string) {
|
|
@@ -38,6 +39,7 @@ type RejectedReasonType =
|
|
|
38
39
|
|
|
39
40
|
export class NotifyFinishedIssuePreparationUseCase {
|
|
40
41
|
private readonly issueRejectionEvaluator: IssueRejectionEvaluator;
|
|
42
|
+
private readonly changeTargetPullRequestApprover: ChangeTargetPullRequestApprover;
|
|
41
43
|
|
|
42
44
|
constructor(
|
|
43
45
|
private readonly projectRepository: Pick<ProjectRepository, 'getByUrl'>,
|
|
@@ -49,6 +51,8 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
49
51
|
| 'findRelatedOpenPRs'
|
|
50
52
|
| 'getStoryObjectMap'
|
|
51
53
|
| 'getOpenPullRequest'
|
|
54
|
+
| 'getPullRequestChangedFilePaths'
|
|
55
|
+
| 'approvePullRequest'
|
|
52
56
|
| 'setDependedIssueUrl'
|
|
53
57
|
>,
|
|
54
58
|
private readonly issueCommentRepository: Pick<
|
|
@@ -61,6 +65,9 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
61
65
|
>,
|
|
62
66
|
) {
|
|
63
67
|
this.issueRejectionEvaluator = new IssueRejectionEvaluator(issueRepository);
|
|
68
|
+
this.changeTargetPullRequestApprover = new ChangeTargetPullRequestApprover(
|
|
69
|
+
issueRepository,
|
|
70
|
+
);
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
run = async (params: {
|
|
@@ -171,7 +178,7 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
171
178
|
const isTrustedAuthor = (author: string): boolean =>
|
|
172
179
|
this.isAuthorTrusted(author, params.allowedIssueAuthors ?? null);
|
|
173
180
|
|
|
174
|
-
const { rejections } = await this.collectRejections(
|
|
181
|
+
const { rejections, approvedPrUrl } = await this.collectRejections(
|
|
175
182
|
issue,
|
|
176
183
|
comments,
|
|
177
184
|
isTrustedAuthor,
|
|
@@ -225,6 +232,10 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
225
232
|
}
|
|
226
233
|
|
|
227
234
|
if (rejections.length <= 0) {
|
|
235
|
+
await this.changeTargetPullRequestApprover.approveIfConfined(
|
|
236
|
+
issue.labels,
|
|
237
|
+
approvedPrUrl,
|
|
238
|
+
);
|
|
228
239
|
issue.status = AWAITING_QUALITY_CHECK_STATUS_NAME;
|
|
229
240
|
await this.issueRepository.update(issue, project);
|
|
230
241
|
await this.issueRepository.updateStatus(
|