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.
Files changed (31) hide show
  1. package/.github/workflows/umino-project.yml +15 -7
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +2 -1
  4. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +51 -0
  5. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  6. package/bin/domain/usecases/ChangeTargetPullRequestApprover.js +45 -0
  7. package/bin/domain/usecases/ChangeTargetPullRequestApprover.js.map +1 -0
  8. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +4 -1
  9. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  10. package/bin/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.js +5 -1
  11. package/bin/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +139 -0
  14. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +69 -0
  15. package/src/domain/usecases/ChangeTargetPullRequestApprover.test.ts +131 -0
  16. package/src/domain/usecases/ChangeTargetPullRequestApprover.ts +58 -0
  17. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +249 -0
  18. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +12 -1
  19. package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +225 -0
  20. package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.ts +15 -1
  21. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -0
  22. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +2 -0
  23. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  24. package/types/domain/usecases/ChangeTargetPullRequestApprover.d.ts +9 -0
  25. package/types/domain/usecases/ChangeTargetPullRequestApprover.d.ts.map +1 -0
  26. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +2 -1
  27. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  28. package/types/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.d.ts +2 -1
  29. package/types/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.d.ts.map +1 -1
  30. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
  31. 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(