github-issue-tower-defence-management 1.88.0 → 1.88.1

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 (43) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +9 -1
  3. package/bin/adapter/entry-points/cli/index.js +35 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/consoleDataDelivery.js +155 -0
  6. package/bin/adapter/entry-points/console/consoleDataDelivery.js.map +1 -0
  7. package/bin/adapter/entry-points/console/consoleDoneStore.js +100 -0
  8. package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -0
  9. package/bin/adapter/entry-points/console/consoleOperationApi.js +178 -0
  10. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -0
  11. package/bin/adapter/entry-points/console/consoleReadApi.js +119 -0
  12. package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -0
  13. package/bin/adapter/entry-points/console/consoleServer.js +147 -3
  14. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  15. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +3 -0
  16. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  17. package/package.json +1 -1
  18. package/src/adapter/entry-points/cli/index.test.ts +94 -0
  19. package/src/adapter/entry-points/cli/index.ts +61 -0
  20. package/src/adapter/entry-points/console/consoleDataDelivery.test.ts +184 -0
  21. package/src/adapter/entry-points/console/consoleDataDelivery.ts +169 -0
  22. package/src/adapter/entry-points/console/consoleDoneStore.test.ts +98 -0
  23. package/src/adapter/entry-points/console/consoleDoneStore.ts +91 -0
  24. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +444 -0
  25. package/src/adapter/entry-points/console/consoleOperationApi.ts +280 -0
  26. package/src/adapter/entry-points/console/consoleReadApi.test.ts +297 -0
  27. package/src/adapter/entry-points/console/consoleReadApi.ts +192 -0
  28. package/src/adapter/entry-points/console/consoleServer.test.ts +269 -0
  29. package/src/adapter/entry-points/console/consoleServer.ts +228 -4
  30. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +34 -0
  31. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +3 -0
  32. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  33. package/types/adapter/entry-points/console/consoleDataDelivery.d.ts +23 -0
  34. package/types/adapter/entry-points/console/consoleDataDelivery.d.ts.map +1 -0
  35. package/types/adapter/entry-points/console/consoleDoneStore.d.ts +10 -0
  36. package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -0
  37. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +18 -0
  38. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -0
  39. package/types/adapter/entry-points/console/consoleReadApi.d.ts +44 -0
  40. package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -0
  41. package/types/adapter/entry-points/console/consoleServer.d.ts +8 -1
  42. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  43. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
@@ -0,0 +1,192 @@
1
+ import {
2
+ IssueRepository,
3
+ IssueComment,
4
+ PullRequestCommit,
5
+ } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
6
+
7
+ export const ISSUE_TITLE_CACHE_TTL_MS = 300 * 1000;
8
+
9
+ export type IssueOrPullRequestState = {
10
+ state: string;
11
+ merged: boolean;
12
+ isPullRequest: boolean;
13
+ };
14
+
15
+ type IssueTitleCacheEntry = {
16
+ state: IssueOrPullRequestState;
17
+ fetchedAtMs: number;
18
+ };
19
+
20
+ export class IssueTitleStateCache {
21
+ private readonly entries = new Map<string, IssueTitleCacheEntry>();
22
+
23
+ constructor(private readonly nowMs: () => number = () => Date.now()) {}
24
+
25
+ get = (url: string): IssueOrPullRequestState | null => {
26
+ const entry = this.entries.get(url);
27
+ if (!entry) {
28
+ return null;
29
+ }
30
+ if (entry.state.merged) {
31
+ return entry.state;
32
+ }
33
+ if (this.nowMs() - entry.fetchedAtMs >= ISSUE_TITLE_CACHE_TTL_MS) {
34
+ return null;
35
+ }
36
+ return entry.state;
37
+ };
38
+
39
+ set = (url: string, state: IssueOrPullRequestState): void => {
40
+ this.entries.set(url, { state, fetchedAtMs: this.nowMs() });
41
+ };
42
+ }
43
+
44
+ export type ConsoleReadApiResponse = {
45
+ statusCode: number;
46
+ body: unknown;
47
+ };
48
+
49
+ const badRequest = (message: string): ConsoleReadApiResponse => ({
50
+ statusCode: 400,
51
+ body: { error: message },
52
+ });
53
+
54
+ const ok = (body: unknown): ConsoleReadApiResponse => ({
55
+ statusCode: 200,
56
+ body,
57
+ });
58
+
59
+ export type RelatedPullRequestWithSummary = {
60
+ url: string;
61
+ branchName: string | null;
62
+ createdAt: string;
63
+ isDraft: boolean;
64
+ isConflicted: boolean;
65
+ isPassedAllCiJob: boolean;
66
+ isCiStateSuccess: boolean;
67
+ isResolvedAllReviewComments: boolean;
68
+ isBranchOutOfDate: boolean;
69
+ missingRequiredCheckNames: string[];
70
+ summary: {
71
+ title: string;
72
+ body: string;
73
+ additions: number;
74
+ deletions: number;
75
+ changedFiles: number;
76
+ } | null;
77
+ };
78
+
79
+ const serializeComments = (
80
+ comments: IssueComment[],
81
+ ): { author: string; body: string; createdAt: string }[] =>
82
+ comments.map((comment) => ({
83
+ author: comment.author,
84
+ body: comment.body,
85
+ createdAt: comment.createdAt.toISOString(),
86
+ }));
87
+
88
+ const serializeCommits = (
89
+ commits: PullRequestCommit[],
90
+ ): { sha: string; message: string; author: string; authoredAt: string }[] =>
91
+ commits.map((commit) => ({
92
+ sha: commit.sha,
93
+ message: commit.message,
94
+ author: commit.author,
95
+ authoredAt: commit.authoredAt.toISOString(),
96
+ }));
97
+
98
+ export const handleItemBody = async (
99
+ issueRepository: IssueRepository,
100
+ url: string | null,
101
+ ): Promise<ConsoleReadApiResponse> => {
102
+ if (!url) {
103
+ return badRequest('url query parameter is required');
104
+ }
105
+ const body = await issueRepository.getIssueOrPullRequestBody(url);
106
+ return ok({ body });
107
+ };
108
+
109
+ export const handleComments = async (
110
+ issueRepository: IssueRepository,
111
+ url: string | null,
112
+ ): Promise<ConsoleReadApiResponse> => {
113
+ if (!url) {
114
+ return badRequest('url query parameter is required');
115
+ }
116
+ const comments = await issueRepository.getIssueOrPullRequestComments(url);
117
+ return ok({ comments: serializeComments(comments) });
118
+ };
119
+
120
+ export const handlePrFiles = async (
121
+ issueRepository: IssueRepository,
122
+ url: string | null,
123
+ ): Promise<ConsoleReadApiResponse> => {
124
+ if (!url) {
125
+ return badRequest('url query parameter is required');
126
+ }
127
+ const detail = await issueRepository.getPullRequestDetail(url);
128
+ if (detail === null) {
129
+ return ok({ files: null });
130
+ }
131
+ return ok({ files: detail.files });
132
+ };
133
+
134
+ export const handlePrCommits = async (
135
+ issueRepository: IssueRepository,
136
+ url: string | null,
137
+ ): Promise<ConsoleReadApiResponse> => {
138
+ if (!url) {
139
+ return badRequest('url query parameter is required');
140
+ }
141
+ const commits = await issueRepository.getPullRequestCommits(url);
142
+ return ok({ commits: serializeCommits(commits) });
143
+ };
144
+
145
+ export const handleRelatedPrs = async (
146
+ issueRepository: IssueRepository,
147
+ url: string | null,
148
+ ): Promise<ConsoleReadApiResponse> => {
149
+ if (!url) {
150
+ return badRequest('url query parameter is required');
151
+ }
152
+ const relatedPullRequests = await issueRepository.findRelatedOpenPRs(url);
153
+ const withSummaries: RelatedPullRequestWithSummary[] = await Promise.all(
154
+ relatedPullRequests.map(async (relatedPullRequest) => {
155
+ const summary = await issueRepository.getPullRequestSummary(
156
+ relatedPullRequest.url,
157
+ );
158
+ return {
159
+ url: relatedPullRequest.url,
160
+ branchName: relatedPullRequest.branchName,
161
+ createdAt: relatedPullRequest.createdAt.toISOString(),
162
+ isDraft: relatedPullRequest.isDraft,
163
+ isConflicted: relatedPullRequest.isConflicted,
164
+ isPassedAllCiJob: relatedPullRequest.isPassedAllCiJob,
165
+ isCiStateSuccess: relatedPullRequest.isCiStateSuccess,
166
+ isResolvedAllReviewComments:
167
+ relatedPullRequest.isResolvedAllReviewComments,
168
+ isBranchOutOfDate: relatedPullRequest.isBranchOutOfDate,
169
+ missingRequiredCheckNames: relatedPullRequest.missingRequiredCheckNames,
170
+ summary,
171
+ };
172
+ }),
173
+ );
174
+ return ok({ relatedPullRequests: withSummaries });
175
+ };
176
+
177
+ export const handleIssueTitle = async (
178
+ issueRepository: IssueRepository,
179
+ cache: IssueTitleStateCache,
180
+ url: string | null,
181
+ ): Promise<ConsoleReadApiResponse> => {
182
+ if (!url) {
183
+ return badRequest('url query parameter is required');
184
+ }
185
+ const cached = cache.get(url);
186
+ if (cached !== null) {
187
+ return ok(cached);
188
+ }
189
+ const state = await issueRepository.getIssueOrPullRequestState(url);
190
+ cache.set(url, state);
191
+ return ok(state);
192
+ };
@@ -2,6 +2,7 @@ import * as http from 'http';
2
2
  import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
+ import { mock } from 'jest-mock-extended';
5
6
  import {
6
7
  DEFAULT_CONSOLE_PORT,
7
8
  CONSOLE_TOKEN_HEADER,
@@ -11,6 +12,11 @@ import {
11
12
  extractProvidedToken,
12
13
  startConsoleServer,
13
14
  } from './consoleServer';
15
+ import { IssueTitleStateCache } from './consoleReadApi';
16
+ import { readDoneProjectItemIds } from './consoleDoneStore';
17
+ import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
18
+ import { Project } from '../../../domain/entities/Project';
19
+ import { Issue } from '../../../domain/entities/Issue';
14
20
 
15
21
  describe('consoleServer pure helpers', () => {
16
22
  describe('DEFAULT_CONSOLE_PORT', () => {
@@ -295,3 +301,266 @@ describe('consoleServer integration', () => {
295
301
  }
296
302
  });
297
303
  });
304
+
305
+ describe('consoleServer new routes integration', () => {
306
+ const testToken = 'integration-test-token-value';
307
+
308
+ const closeServer = (server: http.Server): Promise<void> =>
309
+ new Promise((resolve, reject) => {
310
+ server.close((error) => {
311
+ if (error) {
312
+ reject(error);
313
+ return;
314
+ }
315
+ resolve();
316
+ });
317
+ });
318
+
319
+ const request = (
320
+ server: http.Server,
321
+ method: string,
322
+ requestPath: string,
323
+ body?: unknown,
324
+ ): Promise<{ statusCode: number; body: string }> => {
325
+ const address = server.address();
326
+ if (address === null || typeof address === 'string') {
327
+ throw new Error('server is not listening on a TCP port');
328
+ }
329
+ const port = address.port;
330
+ const payload = body === undefined ? null : JSON.stringify(body);
331
+ return new Promise((resolve, reject) => {
332
+ const httpRequest = http.request(
333
+ {
334
+ host: '127.0.0.1',
335
+ port,
336
+ path: requestPath,
337
+ method,
338
+ headers:
339
+ payload === null
340
+ ? {}
341
+ : {
342
+ 'Content-Type': 'application/json',
343
+ 'Content-Length': Buffer.byteLength(payload),
344
+ },
345
+ },
346
+ (response) => {
347
+ const chunks: Uint8Array[] = [];
348
+ response.on('data', (chunk: Uint8Array) => chunks.push(chunk));
349
+ response.on('end', () => {
350
+ resolve({
351
+ statusCode: response.statusCode ?? 0,
352
+ body: Buffer.concat(chunks).toString('utf-8'),
353
+ });
354
+ });
355
+ },
356
+ );
357
+ httpRequest.on('error', reject);
358
+ if (payload !== null) {
359
+ httpRequest.write(payload);
360
+ }
361
+ httpRequest.end();
362
+ });
363
+ };
364
+
365
+ const buildProject = (): Project => ({
366
+ ...mock<Project>(),
367
+ id: 'PVT_1',
368
+ status: {
369
+ name: 'Status',
370
+ fieldId: 'statusField',
371
+ statuses: [
372
+ {
373
+ id: 'status_aw',
374
+ name: 'Awaiting workspace',
375
+ color: 'GRAY',
376
+ description: '',
377
+ },
378
+ ],
379
+ },
380
+ });
381
+
382
+ it('serves a data list file with the done exclusion through the token gate', async () => {
383
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
384
+ const dataDir = path.join(tmpDir, 'data');
385
+ const listDir = path.join(dataDir, 'umino', 'prs');
386
+ fs.mkdirSync(listDir, { recursive: true });
387
+ fs.writeFileSync(
388
+ path.join(listDir, 'list.json'),
389
+ JSON.stringify({
390
+ pjcode: 'umino',
391
+ items: [{ projectItemId: 'PVTI_keep' }, { projectItemId: 'PVTI_drop' }],
392
+ }),
393
+ );
394
+ fs.writeFileSync(
395
+ path.join(listDir, '.done.json'),
396
+ JSON.stringify({ projectItemIds: ['PVTI_drop'] }),
397
+ );
398
+ const server = await startConsoleServer({
399
+ accessToken: testToken,
400
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
401
+ consoleDataOutputDir: dataDir,
402
+ pjcode: 'umino',
403
+ port: 0,
404
+ });
405
+ try {
406
+ const unauthorized = await request(
407
+ server,
408
+ 'GET',
409
+ '/projects/umino/prs/list.json',
410
+ );
411
+ expect(unauthorized.statusCode).toBe(401);
412
+
413
+ const authorized = await request(
414
+ server,
415
+ 'GET',
416
+ `/projects/umino/prs/list.json?k=${testToken}`,
417
+ );
418
+ expect(authorized.statusCode).toBe(200);
419
+ const parsed: unknown = JSON.parse(authorized.body);
420
+ expect(parsed).toEqual({
421
+ pjcode: 'umino',
422
+ items: [{ projectItemId: 'PVTI_keep' }],
423
+ });
424
+ } finally {
425
+ await closeServer(server);
426
+ fs.rmSync(tmpDir, { recursive: true, force: true });
427
+ }
428
+ });
429
+
430
+ it('serves a read api response when an issue repository is injected', async () => {
431
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
432
+ const issueRepository = mock<IssueRepository>();
433
+ issueRepository.getIssueOrPullRequestBody.mockResolvedValue('body text');
434
+ const server = await startConsoleServer({
435
+ accessToken: testToken,
436
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
437
+ consoleDataOutputDir: null,
438
+ issueRepository,
439
+ issueTitleStateCache: new IssueTitleStateCache(),
440
+ port: 0,
441
+ });
442
+ try {
443
+ const response = await request(
444
+ server,
445
+ 'GET',
446
+ `/api/itembody?k=${testToken}&url=https://github.com/o/r/issues/1`,
447
+ );
448
+ expect(response.statusCode).toBe(200);
449
+ expect(JSON.parse(response.body)).toEqual({ body: 'body text' });
450
+ } finally {
451
+ await closeServer(server);
452
+ fs.rmSync(tmpDir, { recursive: true, force: true });
453
+ }
454
+ });
455
+
456
+ it('runs an operation api and records the done exclusion', async () => {
457
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
458
+ const dataDir = path.join(tmpDir, 'data');
459
+ const issueRepository = mock<IssueRepository>();
460
+ issueRepository.get.mockResolvedValue({
461
+ ...mock<Issue>(),
462
+ itemId: 'PVTI_loaded',
463
+ });
464
+ const server = await startConsoleServer({
465
+ accessToken: testToken,
466
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
467
+ consoleDataOutputDir: dataDir,
468
+ pjcode: 'umino',
469
+ issueRepository,
470
+ project: buildProject(),
471
+ port: 0,
472
+ });
473
+ try {
474
+ const response = await request(
475
+ server,
476
+ 'POST',
477
+ `/api/review?k=${testToken}`,
478
+ {
479
+ action: 'approve',
480
+ prUrl: 'https://github.com/o/r/pull/1',
481
+ projectItemId: 'PVTI_op',
482
+ },
483
+ );
484
+ expect(response.statusCode).toBe(200);
485
+ expect(issueRepository.approvePullRequest).toHaveBeenCalledWith(
486
+ 'https://github.com/o/r/pull/1',
487
+ );
488
+ expect(readDoneProjectItemIds(dataDir, 'umino', 'prs')).toContain(
489
+ 'PVTI_op',
490
+ );
491
+ } finally {
492
+ await closeServer(server);
493
+ fs.rmSync(tmpDir, { recursive: true, force: true });
494
+ }
495
+ });
496
+
497
+ it('rejects an operation api with a malformed json body', async () => {
498
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
499
+ const issueRepository = mock<IssueRepository>();
500
+ const server = await startConsoleServer({
501
+ accessToken: testToken,
502
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
503
+ consoleDataOutputDir: null,
504
+ issueRepository,
505
+ project: buildProject(),
506
+ port: 0,
507
+ });
508
+ try {
509
+ const address = server.address();
510
+ if (address === null || typeof address === 'string') {
511
+ throw new Error('server is not listening on a TCP port');
512
+ }
513
+ const malformed = await new Promise<{ statusCode: number }>(
514
+ (resolve, reject) => {
515
+ const payload = '{ not json';
516
+ const httpRequest = http.request(
517
+ {
518
+ host: '127.0.0.1',
519
+ port: address.port,
520
+ path: `/api/review?k=${testToken}`,
521
+ method: 'POST',
522
+ headers: {
523
+ 'Content-Type': 'application/json',
524
+ 'Content-Length': Buffer.byteLength(payload),
525
+ },
526
+ },
527
+ (response) => {
528
+ response.on('data', () => undefined);
529
+ response.on('end', () =>
530
+ resolve({ statusCode: response.statusCode ?? 0 }),
531
+ );
532
+ },
533
+ );
534
+ httpRequest.on('error', reject);
535
+ httpRequest.write(payload);
536
+ httpRequest.end();
537
+ },
538
+ );
539
+ expect(malformed.statusCode).toBe(400);
540
+ } finally {
541
+ await closeServer(server);
542
+ fs.rmSync(tmpDir, { recursive: true, force: true });
543
+ }
544
+ });
545
+
546
+ it('returns 404 for a read api when no repository is injected', async () => {
547
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
548
+ const server = await startConsoleServer({
549
+ accessToken: testToken,
550
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
551
+ consoleDataOutputDir: null,
552
+ port: 0,
553
+ });
554
+ try {
555
+ const response = await request(
556
+ server,
557
+ 'GET',
558
+ `/api/itembody?k=${testToken}&url=https://github.com/o/r/issues/1`,
559
+ );
560
+ expect(response.statusCode).toBe(404);
561
+ } finally {
562
+ await closeServer(server);
563
+ fs.rmSync(tmpDir, { recursive: true, force: true });
564
+ }
565
+ });
566
+ });