github-issue-tower-defence-management 1.88.0 → 1.89.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/CHANGELOG.md +14 -0
- package/README.md +20 -1
- package/bin/adapter/entry-points/cli/index.js +56 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleDataDelivery.js +155 -0
- package/bin/adapter/entry-points/console/consoleDataDelivery.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleDoneStore.js +100 -0
- package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleOperationApi.js +178 -0
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleReadApi.js +119 -0
- package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js +147 -3
- package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +97 -0
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -0
- package/bin/adapter/proxy/RateLimitCache.js +3 -3
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +3 -0
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/OauthTokenSelectUseCase.js +87 -0
- package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +94 -0
- package/src/adapter/entry-points/cli/index.ts +99 -0
- package/src/adapter/entry-points/console/consoleDataDelivery.test.ts +184 -0
- package/src/adapter/entry-points/console/consoleDataDelivery.ts +169 -0
- package/src/adapter/entry-points/console/consoleDoneStore.test.ts +98 -0
- package/src/adapter/entry-points/console/consoleDoneStore.ts +91 -0
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +444 -0
- package/src/adapter/entry-points/console/consoleOperationApi.ts +280 -0
- package/src/adapter/entry-points/console/consoleReadApi.test.ts +297 -0
- package/src/adapter/entry-points/console/consoleReadApi.ts +192 -0
- package/src/adapter/entry-points/console/consoleServer.test.ts +269 -0
- package/src/adapter/entry-points/console/consoleServer.ts +228 -4
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +204 -0
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +132 -0
- package/src/adapter/proxy/RateLimitCache.ts +9 -4
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +34 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +3 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +179 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.ts +158 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleDataDelivery.d.ts +23 -0
- package/types/adapter/entry-points/console/consoleDataDelivery.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleDoneStore.d.ts +10 -0
- package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts +18 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleReadApi.d.ts +44 -0
- package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleServer.d.ts +8 -1
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts +20 -0
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -0
- package/types/adapter/proxy/RateLimitCache.d.ts +2 -2
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +35 -0
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
2
|
+
import { Project } from '../../../domain/entities/Project';
|
|
3
|
+
import { Issue } from '../../../domain/entities/Issue';
|
|
4
|
+
import { recordDoneProjectItemIdAcrossTabs } from './consoleDoneStore';
|
|
5
|
+
|
|
6
|
+
export const AWAITING_WORKSPACE_STATUS_NAME = 'awaiting workspace';
|
|
7
|
+
export const IN_TMUX_BY_HUMAN_STATUS_NAME = 'in tmux by human';
|
|
8
|
+
|
|
9
|
+
export type ConsoleOperationContext = {
|
|
10
|
+
issueRepository: IssueRepository;
|
|
11
|
+
project: Project;
|
|
12
|
+
consoleDataOutputDir: string | null;
|
|
13
|
+
pjcode: string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ConsoleOperationResponse = {
|
|
17
|
+
statusCode: number;
|
|
18
|
+
body: unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ok = (): ConsoleOperationResponse => ({
|
|
22
|
+
statusCode: 200,
|
|
23
|
+
body: { ok: true },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const badRequest = (message: string): ConsoleOperationResponse => ({
|
|
27
|
+
statusCode: 400,
|
|
28
|
+
body: { error: message },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const isNonEmptyString = (value: unknown): value is string =>
|
|
32
|
+
typeof value === 'string' && value.length > 0;
|
|
33
|
+
|
|
34
|
+
const resolveStatusId = (
|
|
35
|
+
project: Project,
|
|
36
|
+
statusName: string,
|
|
37
|
+
): string | null => {
|
|
38
|
+
const lower = statusName.toLowerCase();
|
|
39
|
+
const match = project.status.statuses.find(
|
|
40
|
+
(option) => option.name.toLowerCase() === lower,
|
|
41
|
+
);
|
|
42
|
+
return match ? match.id : null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const loadIssueWithProjectItemId = async (
|
|
46
|
+
context: ConsoleOperationContext,
|
|
47
|
+
issueUrl: string,
|
|
48
|
+
projectItemId: string,
|
|
49
|
+
): Promise<Issue | null> => {
|
|
50
|
+
const issue = await context.issueRepository.get(issueUrl, context.project);
|
|
51
|
+
if (issue === null) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return { ...issue, itemId: projectItemId };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const recordDone = (
|
|
58
|
+
context: ConsoleOperationContext,
|
|
59
|
+
projectItemId: string,
|
|
60
|
+
): void => {
|
|
61
|
+
if (context.consoleDataOutputDir === null || context.pjcode === null) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
recordDoneProjectItemIdAcrossTabs(
|
|
65
|
+
context.consoleDataOutputDir,
|
|
66
|
+
context.pjcode,
|
|
67
|
+
projectItemId,
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const updateStatusByName = async (
|
|
72
|
+
context: ConsoleOperationContext,
|
|
73
|
+
issueUrl: string,
|
|
74
|
+
projectItemId: string,
|
|
75
|
+
statusName: string,
|
|
76
|
+
): Promise<ConsoleOperationResponse | null> => {
|
|
77
|
+
const statusId = resolveStatusId(context.project, statusName);
|
|
78
|
+
if (statusId === null) {
|
|
79
|
+
return badRequest(`status option "${statusName}" not found in project`);
|
|
80
|
+
}
|
|
81
|
+
const issue = await loadIssueWithProjectItemId(
|
|
82
|
+
context,
|
|
83
|
+
issueUrl,
|
|
84
|
+
projectItemId,
|
|
85
|
+
);
|
|
86
|
+
if (issue === null) {
|
|
87
|
+
return badRequest('issue not found');
|
|
88
|
+
}
|
|
89
|
+
await context.issueRepository.updateStatus(context.project, issue, statusId);
|
|
90
|
+
return null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const handleReview = async (
|
|
94
|
+
context: ConsoleOperationContext,
|
|
95
|
+
body: Record<string, unknown>,
|
|
96
|
+
): Promise<ConsoleOperationResponse> => {
|
|
97
|
+
const action = body.action;
|
|
98
|
+
const prUrl = body.prUrl;
|
|
99
|
+
const projectItemId = body.projectItemId;
|
|
100
|
+
if (!isNonEmptyString(action)) {
|
|
101
|
+
return badRequest('action is required');
|
|
102
|
+
}
|
|
103
|
+
if (!isNonEmptyString(prUrl)) {
|
|
104
|
+
return badRequest('prUrl is required');
|
|
105
|
+
}
|
|
106
|
+
if (!isNonEmptyString(projectItemId)) {
|
|
107
|
+
return badRequest('projectItemId is required');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (action === 'approve') {
|
|
111
|
+
await context.issueRepository.approvePullRequest(prUrl);
|
|
112
|
+
const failure = await updateStatusByName(
|
|
113
|
+
context,
|
|
114
|
+
prUrl,
|
|
115
|
+
projectItemId,
|
|
116
|
+
AWAITING_WORKSPACE_STATUS_NAME,
|
|
117
|
+
);
|
|
118
|
+
if (failure !== null) {
|
|
119
|
+
return failure;
|
|
120
|
+
}
|
|
121
|
+
recordDone(context, projectItemId);
|
|
122
|
+
return ok();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (action === 'request_changes') {
|
|
126
|
+
const commentBody = body.commentBody;
|
|
127
|
+
if (!isNonEmptyString(commentBody)) {
|
|
128
|
+
return badRequest('commentBody is required for request_changes');
|
|
129
|
+
}
|
|
130
|
+
const changedFilePath = isNonEmptyString(body.changedFilePath)
|
|
131
|
+
? body.changedFilePath
|
|
132
|
+
: null;
|
|
133
|
+
await context.issueRepository.requestChangesWithInlineComment(
|
|
134
|
+
prUrl,
|
|
135
|
+
changedFilePath,
|
|
136
|
+
commentBody,
|
|
137
|
+
);
|
|
138
|
+
const failure = await updateStatusByName(
|
|
139
|
+
context,
|
|
140
|
+
prUrl,
|
|
141
|
+
projectItemId,
|
|
142
|
+
AWAITING_WORKSPACE_STATUS_NAME,
|
|
143
|
+
);
|
|
144
|
+
if (failure !== null) {
|
|
145
|
+
return failure;
|
|
146
|
+
}
|
|
147
|
+
recordDone(context, projectItemId);
|
|
148
|
+
return ok();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (action === 'close') {
|
|
152
|
+
await context.issueRepository.closePullRequest(prUrl);
|
|
153
|
+
if (isNonEmptyString(body.commentBody)) {
|
|
154
|
+
await context.issueRepository.createCommentByUrl(prUrl, body.commentBody);
|
|
155
|
+
}
|
|
156
|
+
recordDone(context, projectItemId);
|
|
157
|
+
return ok();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return badRequest(`unknown review action "${action}"`);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const handleTriage = async (
|
|
164
|
+
context: ConsoleOperationContext,
|
|
165
|
+
body: Record<string, unknown>,
|
|
166
|
+
): Promise<ConsoleOperationResponse> => {
|
|
167
|
+
const action = body.action;
|
|
168
|
+
const issueUrl = body.issueUrl;
|
|
169
|
+
const projectItemId = body.projectItemId;
|
|
170
|
+
if (!isNonEmptyString(action)) {
|
|
171
|
+
return badRequest('action is required');
|
|
172
|
+
}
|
|
173
|
+
if (!isNonEmptyString(issueUrl)) {
|
|
174
|
+
return badRequest('issueUrl is required');
|
|
175
|
+
}
|
|
176
|
+
if (!isNonEmptyString(projectItemId)) {
|
|
177
|
+
return badRequest('projectItemId is required');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (action === 'set_status') {
|
|
181
|
+
const statusName = body.statusName;
|
|
182
|
+
if (!isNonEmptyString(statusName)) {
|
|
183
|
+
return badRequest('statusName is required for set_status');
|
|
184
|
+
}
|
|
185
|
+
const failure = await updateStatusByName(
|
|
186
|
+
context,
|
|
187
|
+
issueUrl,
|
|
188
|
+
projectItemId,
|
|
189
|
+
statusName,
|
|
190
|
+
);
|
|
191
|
+
if (failure !== null) {
|
|
192
|
+
return failure;
|
|
193
|
+
}
|
|
194
|
+
recordDone(context, projectItemId);
|
|
195
|
+
return ok();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (action === 'set_story') {
|
|
199
|
+
const storyOptionId = body.storyOptionId;
|
|
200
|
+
if (!isNonEmptyString(storyOptionId)) {
|
|
201
|
+
return badRequest('storyOptionId is required for set_story');
|
|
202
|
+
}
|
|
203
|
+
if (context.project.story === null) {
|
|
204
|
+
return badRequest('project does not have a story field');
|
|
205
|
+
}
|
|
206
|
+
const issue = await loadIssueWithProjectItemId(
|
|
207
|
+
context,
|
|
208
|
+
issueUrl,
|
|
209
|
+
projectItemId,
|
|
210
|
+
);
|
|
211
|
+
if (issue === null) {
|
|
212
|
+
return badRequest('issue not found');
|
|
213
|
+
}
|
|
214
|
+
await context.issueRepository.updateStory(
|
|
215
|
+
{ ...context.project, story: context.project.story },
|
|
216
|
+
issue,
|
|
217
|
+
storyOptionId,
|
|
218
|
+
);
|
|
219
|
+
recordDone(context, projectItemId);
|
|
220
|
+
return ok();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (action === 'close') {
|
|
224
|
+
await context.issueRepository.closePullRequest(issueUrl);
|
|
225
|
+
if (isNonEmptyString(body.commentBody)) {
|
|
226
|
+
await context.issueRepository.createCommentByUrl(
|
|
227
|
+
issueUrl,
|
|
228
|
+
body.commentBody,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
recordDone(context, projectItemId);
|
|
232
|
+
return ok();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (action === 'snooze_1day' || action === 'snooze_1week') {
|
|
236
|
+
const days = action === 'snooze_1day' ? 1 : 7;
|
|
237
|
+
const target = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
|
238
|
+
await context.issueRepository.updateNextActionDate(
|
|
239
|
+
issueUrl,
|
|
240
|
+
context.project,
|
|
241
|
+
target,
|
|
242
|
+
);
|
|
243
|
+
recordDone(context, projectItemId);
|
|
244
|
+
return ok();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return badRequest(`unknown triage action "${action}"`);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export const handleIntmux = async (
|
|
251
|
+
context: ConsoleOperationContext,
|
|
252
|
+
body: Record<string, unknown>,
|
|
253
|
+
): Promise<ConsoleOperationResponse> => {
|
|
254
|
+
const action = body.action;
|
|
255
|
+
const issueUrl = body.issueUrl;
|
|
256
|
+
const projectItemId = body.projectItemId;
|
|
257
|
+
if (!isNonEmptyString(action)) {
|
|
258
|
+
return badRequest('action is required');
|
|
259
|
+
}
|
|
260
|
+
if (action !== 'set_intmux') {
|
|
261
|
+
return badRequest(`unknown intmux action "${action}"`);
|
|
262
|
+
}
|
|
263
|
+
if (!isNonEmptyString(issueUrl)) {
|
|
264
|
+
return badRequest('issueUrl is required');
|
|
265
|
+
}
|
|
266
|
+
if (!isNonEmptyString(projectItemId)) {
|
|
267
|
+
return badRequest('projectItemId is required');
|
|
268
|
+
}
|
|
269
|
+
const failure = await updateStatusByName(
|
|
270
|
+
context,
|
|
271
|
+
issueUrl,
|
|
272
|
+
projectItemId,
|
|
273
|
+
IN_TMUX_BY_HUMAN_STATUS_NAME,
|
|
274
|
+
);
|
|
275
|
+
if (failure !== null) {
|
|
276
|
+
return failure;
|
|
277
|
+
}
|
|
278
|
+
recordDone(context, projectItemId);
|
|
279
|
+
return ok();
|
|
280
|
+
};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { mock } from 'jest-mock-extended';
|
|
2
|
+
import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
3
|
+
import {
|
|
4
|
+
ISSUE_TITLE_CACHE_TTL_MS,
|
|
5
|
+
IssueTitleStateCache,
|
|
6
|
+
handleComments,
|
|
7
|
+
handleIssueTitle,
|
|
8
|
+
handleItemBody,
|
|
9
|
+
handlePrCommits,
|
|
10
|
+
handlePrFiles,
|
|
11
|
+
handleRelatedPrs,
|
|
12
|
+
} from './consoleReadApi';
|
|
13
|
+
|
|
14
|
+
describe('consoleReadApi', () => {
|
|
15
|
+
describe('handleItemBody', () => {
|
|
16
|
+
it('returns 400 when url is missing', async () => {
|
|
17
|
+
const issueRepository = mock<IssueRepository>();
|
|
18
|
+
const response = await handleItemBody(issueRepository, null);
|
|
19
|
+
expect(response.statusCode).toBe(400);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns the body from the repository', async () => {
|
|
23
|
+
const issueRepository = mock<IssueRepository>();
|
|
24
|
+
issueRepository.getIssueOrPullRequestBody.mockResolvedValue('body text');
|
|
25
|
+
const response = await handleItemBody(
|
|
26
|
+
issueRepository,
|
|
27
|
+
'https://github.com/o/r/issues/1',
|
|
28
|
+
);
|
|
29
|
+
expect(response.statusCode).toBe(200);
|
|
30
|
+
expect(response.body).toEqual({ body: 'body text' });
|
|
31
|
+
expect(issueRepository.getIssueOrPullRequestBody).toHaveBeenCalledWith(
|
|
32
|
+
'https://github.com/o/r/issues/1',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('handleComments', () => {
|
|
38
|
+
it('serializes comment createdAt to ISO string', async () => {
|
|
39
|
+
const issueRepository = mock<IssueRepository>();
|
|
40
|
+
issueRepository.getIssueOrPullRequestComments.mockResolvedValue([
|
|
41
|
+
{
|
|
42
|
+
author: 'octocat',
|
|
43
|
+
body: 'hello',
|
|
44
|
+
createdAt: new Date('2026-01-02T03:04:05Z'),
|
|
45
|
+
},
|
|
46
|
+
]);
|
|
47
|
+
const response = await handleComments(
|
|
48
|
+
issueRepository,
|
|
49
|
+
'https://github.com/o/r/issues/1',
|
|
50
|
+
);
|
|
51
|
+
expect(response.statusCode).toBe(200);
|
|
52
|
+
expect(response.body).toEqual({
|
|
53
|
+
comments: [
|
|
54
|
+
{
|
|
55
|
+
author: 'octocat',
|
|
56
|
+
body: 'hello',
|
|
57
|
+
createdAt: '2026-01-02T03:04:05.000Z',
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns 400 when url is missing', async () => {
|
|
64
|
+
const issueRepository = mock<IssueRepository>();
|
|
65
|
+
const response = await handleComments(issueRepository, null);
|
|
66
|
+
expect(response.statusCode).toBe(400);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('handlePrFiles', () => {
|
|
71
|
+
it('returns the files of the pull request detail', async () => {
|
|
72
|
+
const issueRepository = mock<IssueRepository>();
|
|
73
|
+
issueRepository.getPullRequestDetail.mockResolvedValue({
|
|
74
|
+
title: 't',
|
|
75
|
+
state: 'OPEN',
|
|
76
|
+
merged: false,
|
|
77
|
+
isDraft: false,
|
|
78
|
+
additions: 1,
|
|
79
|
+
deletions: 0,
|
|
80
|
+
changedFiles: 1,
|
|
81
|
+
headRefName: 'feature',
|
|
82
|
+
baseRefName: 'main',
|
|
83
|
+
author: 'octocat',
|
|
84
|
+
files: [
|
|
85
|
+
{
|
|
86
|
+
filename: 'a.ts',
|
|
87
|
+
status: 'modified',
|
|
88
|
+
additions: 1,
|
|
89
|
+
deletions: 0,
|
|
90
|
+
patch: '@@ -1 +1 @@',
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
const response = await handlePrFiles(
|
|
95
|
+
issueRepository,
|
|
96
|
+
'https://github.com/o/r/pull/1',
|
|
97
|
+
);
|
|
98
|
+
expect(response.statusCode).toBe(200);
|
|
99
|
+
expect(response.body).toEqual({
|
|
100
|
+
files: [
|
|
101
|
+
{
|
|
102
|
+
filename: 'a.ts',
|
|
103
|
+
status: 'modified',
|
|
104
|
+
additions: 1,
|
|
105
|
+
deletions: 0,
|
|
106
|
+
patch: '@@ -1 +1 @@',
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns null files when detail is null', async () => {
|
|
113
|
+
const issueRepository = mock<IssueRepository>();
|
|
114
|
+
issueRepository.getPullRequestDetail.mockResolvedValue(null);
|
|
115
|
+
const response = await handlePrFiles(
|
|
116
|
+
issueRepository,
|
|
117
|
+
'https://github.com/o/r/pull/1',
|
|
118
|
+
);
|
|
119
|
+
expect(response.statusCode).toBe(200);
|
|
120
|
+
expect(response.body).toEqual({ files: null });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns 400 when url is missing', async () => {
|
|
124
|
+
const issueRepository = mock<IssueRepository>();
|
|
125
|
+
const response = await handlePrFiles(issueRepository, null);
|
|
126
|
+
expect(response.statusCode).toBe(400);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('handlePrCommits', () => {
|
|
131
|
+
it('serializes commit authoredAt to ISO string', async () => {
|
|
132
|
+
const issueRepository = mock<IssueRepository>();
|
|
133
|
+
issueRepository.getPullRequestCommits.mockResolvedValue([
|
|
134
|
+
{
|
|
135
|
+
sha: 'abc',
|
|
136
|
+
message: 'msg',
|
|
137
|
+
author: 'octocat',
|
|
138
|
+
authoredAt: new Date('2026-01-02T03:04:05Z'),
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
const response = await handlePrCommits(
|
|
142
|
+
issueRepository,
|
|
143
|
+
'https://github.com/o/r/pull/1',
|
|
144
|
+
);
|
|
145
|
+
expect(response.statusCode).toBe(200);
|
|
146
|
+
expect(response.body).toEqual({
|
|
147
|
+
commits: [
|
|
148
|
+
{
|
|
149
|
+
sha: 'abc',
|
|
150
|
+
message: 'msg',
|
|
151
|
+
author: 'octocat',
|
|
152
|
+
authoredAt: '2026-01-02T03:04:05.000Z',
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns 400 when url is missing', async () => {
|
|
159
|
+
const issueRepository = mock<IssueRepository>();
|
|
160
|
+
const response = await handlePrCommits(issueRepository, null);
|
|
161
|
+
expect(response.statusCode).toBe(400);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('handleRelatedPrs', () => {
|
|
166
|
+
it('combines related pull requests with their summaries', async () => {
|
|
167
|
+
const issueRepository = mock<IssueRepository>();
|
|
168
|
+
issueRepository.findRelatedOpenPRs.mockResolvedValue([
|
|
169
|
+
{
|
|
170
|
+
url: 'https://github.com/o/r/pull/2',
|
|
171
|
+
branchName: 'feature',
|
|
172
|
+
createdAt: new Date('2026-01-02T03:04:05Z'),
|
|
173
|
+
isDraft: false,
|
|
174
|
+
isConflicted: false,
|
|
175
|
+
isPassedAllCiJob: true,
|
|
176
|
+
isCiStateSuccess: true,
|
|
177
|
+
isResolvedAllReviewComments: true,
|
|
178
|
+
isBranchOutOfDate: false,
|
|
179
|
+
missingRequiredCheckNames: [],
|
|
180
|
+
},
|
|
181
|
+
]);
|
|
182
|
+
issueRepository.getPullRequestSummary.mockResolvedValue({
|
|
183
|
+
title: 'summary title',
|
|
184
|
+
body: 'summary body',
|
|
185
|
+
additions: 3,
|
|
186
|
+
deletions: 1,
|
|
187
|
+
changedFiles: 2,
|
|
188
|
+
});
|
|
189
|
+
const response = await handleRelatedPrs(
|
|
190
|
+
issueRepository,
|
|
191
|
+
'https://github.com/o/r/issues/1',
|
|
192
|
+
);
|
|
193
|
+
expect(response.statusCode).toBe(200);
|
|
194
|
+
expect(response.body).toEqual({
|
|
195
|
+
relatedPullRequests: [
|
|
196
|
+
{
|
|
197
|
+
url: 'https://github.com/o/r/pull/2',
|
|
198
|
+
branchName: 'feature',
|
|
199
|
+
createdAt: '2026-01-02T03:04:05.000Z',
|
|
200
|
+
isDraft: false,
|
|
201
|
+
isConflicted: false,
|
|
202
|
+
isPassedAllCiJob: true,
|
|
203
|
+
isCiStateSuccess: true,
|
|
204
|
+
isResolvedAllReviewComments: true,
|
|
205
|
+
isBranchOutOfDate: false,
|
|
206
|
+
missingRequiredCheckNames: [],
|
|
207
|
+
summary: {
|
|
208
|
+
title: 'summary title',
|
|
209
|
+
body: 'summary body',
|
|
210
|
+
additions: 3,
|
|
211
|
+
deletions: 1,
|
|
212
|
+
changedFiles: 2,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('returns 400 when url is missing', async () => {
|
|
220
|
+
const issueRepository = mock<IssueRepository>();
|
|
221
|
+
const response = await handleRelatedPrs(issueRepository, null);
|
|
222
|
+
expect(response.statusCode).toBe(400);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('handleIssueTitle with the TTL cache', () => {
|
|
227
|
+
it('returns 400 when url is missing', async () => {
|
|
228
|
+
const issueRepository = mock<IssueRepository>();
|
|
229
|
+
const cache = new IssueTitleStateCache();
|
|
230
|
+
const response = await handleIssueTitle(issueRepository, cache, null);
|
|
231
|
+
expect(response.statusCode).toBe(400);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('fetches on a cache miss and caches the result', async () => {
|
|
235
|
+
const issueRepository = mock<IssueRepository>();
|
|
236
|
+
issueRepository.getIssueOrPullRequestState.mockResolvedValue({
|
|
237
|
+
state: 'OPEN',
|
|
238
|
+
merged: false,
|
|
239
|
+
isPullRequest: false,
|
|
240
|
+
});
|
|
241
|
+
const cache = new IssueTitleStateCache(() => 0);
|
|
242
|
+
const url = 'https://github.com/o/r/issues/1';
|
|
243
|
+
const first = await handleIssueTitle(issueRepository, cache, url);
|
|
244
|
+
expect(first.body).toEqual({
|
|
245
|
+
state: 'OPEN',
|
|
246
|
+
merged: false,
|
|
247
|
+
isPullRequest: false,
|
|
248
|
+
});
|
|
249
|
+
const second = await handleIssueTitle(issueRepository, cache, url);
|
|
250
|
+
expect(second.body).toEqual(first.body);
|
|
251
|
+
expect(issueRepository.getIssueOrPullRequestState).toHaveBeenCalledTimes(
|
|
252
|
+
1,
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('re-fetches a non-merged result after the TTL elapses', async () => {
|
|
257
|
+
const issueRepository = mock<IssueRepository>();
|
|
258
|
+
issueRepository.getIssueOrPullRequestState.mockResolvedValue({
|
|
259
|
+
state: 'OPEN',
|
|
260
|
+
merged: false,
|
|
261
|
+
isPullRequest: true,
|
|
262
|
+
});
|
|
263
|
+
let now = 0;
|
|
264
|
+
const cache = new IssueTitleStateCache(() => now);
|
|
265
|
+
const url = 'https://github.com/o/r/pull/1';
|
|
266
|
+
await handleIssueTitle(issueRepository, cache, url);
|
|
267
|
+
now = ISSUE_TITLE_CACHE_TTL_MS - 1;
|
|
268
|
+
await handleIssueTitle(issueRepository, cache, url);
|
|
269
|
+
expect(issueRepository.getIssueOrPullRequestState).toHaveBeenCalledTimes(
|
|
270
|
+
1,
|
|
271
|
+
);
|
|
272
|
+
now = ISSUE_TITLE_CACHE_TTL_MS;
|
|
273
|
+
await handleIssueTitle(issueRepository, cache, url);
|
|
274
|
+
expect(issueRepository.getIssueOrPullRequestState).toHaveBeenCalledTimes(
|
|
275
|
+
2,
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('caches a merged result permanently', async () => {
|
|
280
|
+
const issueRepository = mock<IssueRepository>();
|
|
281
|
+
issueRepository.getIssueOrPullRequestState.mockResolvedValue({
|
|
282
|
+
state: 'CLOSED',
|
|
283
|
+
merged: true,
|
|
284
|
+
isPullRequest: true,
|
|
285
|
+
});
|
|
286
|
+
let now = 0;
|
|
287
|
+
const cache = new IssueTitleStateCache(() => now);
|
|
288
|
+
const url = 'https://github.com/o/r/pull/1';
|
|
289
|
+
await handleIssueTitle(issueRepository, cache, url);
|
|
290
|
+
now = ISSUE_TITLE_CACHE_TTL_MS * 1000;
|
|
291
|
+
await handleIssueTitle(issueRepository, cache, url);
|
|
292
|
+
expect(issueRepository.getIssueOrPullRequestState).toHaveBeenCalledTimes(
|
|
293
|
+
1,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|