github-issue-tower-defence-management 1.122.1 → 1.122.3

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 (49) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/cli/index.js +9 -0
  3. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  4. package/bin/adapter/entry-points/console/consoleDoneStore.js +1 -0
  5. package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -1
  6. package/bin/adapter/entry-points/console/consoleOperationApi.js +78 -39
  7. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  8. package/bin/adapter/entry-points/console/consoleProjectResolver.js +8 -1
  9. package/bin/adapter/entry-points/console/consoleProjectResolver.js.map +1 -1
  10. package/bin/adapter/entry-points/console/ui-dist/assets/{index-syKbd_lW.js → index-CC-RYye2.js} +24 -24
  11. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  12. package/bin/adapter/entry-points/console/webServer.js +5 -1
  13. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  14. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +23 -4
  15. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/adapter/entry-points/cli/index.test.ts +3 -1
  18. package/src/adapter/entry-points/cli/index.ts +11 -0
  19. package/src/adapter/entry-points/console/consoleDataDelivery.test.ts +22 -0
  20. package/src/adapter/entry-points/console/consoleDoneStore.test.ts +8 -0
  21. package/src/adapter/entry-points/console/consoleDoneStore.ts +1 -0
  22. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +190 -11
  23. package/src/adapter/entry-points/console/consoleOperationApi.ts +97 -56
  24. package/src/adapter/entry-points/console/consoleProjectResolver.test.ts +13 -0
  25. package/src/adapter/entry-points/console/consoleProjectResolver.ts +11 -0
  26. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +3 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/logic/overlay.test.ts +10 -43
  28. package/src/adapter/entry-points/console/ui/src/features/console/logic/overlay.ts +0 -17
  29. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +5 -6
  30. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +5 -10
  31. package/src/adapter/entry-points/console/ui-dist/assets/{index-syKbd_lW.js → index-CC-RYye2.js} +24 -24
  32. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  33. package/src/adapter/entry-points/console/webServer.test.ts +4 -0
  34. package/src/adapter/entry-points/console/webServer.ts +9 -1
  35. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +86 -0
  36. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +34 -7
  37. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +1 -0
  38. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  39. package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -1
  40. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +2 -0
  41. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  42. package/types/adapter/entry-points/console/consoleProjectResolver.d.ts +2 -1
  43. package/types/adapter/entry-points/console/consoleProjectResolver.d.ts.map +1 -1
  44. package/types/adapter/entry-points/console/webServer.d.ts +2 -1
  45. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  46. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +2 -1
  47. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  48. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -1
  49. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
@@ -7,6 +7,7 @@ import { Project } from '../../../domain/entities/Project';
7
7
  import { Issue } from '../../../domain/entities/Issue';
8
8
  import {
9
9
  ConsoleOperationContext,
10
+ ConsoleProjectBinding,
10
11
  handleComment,
11
12
  handleIntmux,
12
13
  handleReview,
@@ -73,6 +74,7 @@ describe('consoleOperationApi', () => {
73
74
  issueRepository,
74
75
  resolveProject: async (pjcode: string) =>
75
76
  pjcode === 'umino' ? { pjcode, project } : null,
77
+ isPjcodeConfigured: (pjcode: string) => pjcode === 'umino',
76
78
  consoleDataOutputDir: baseDir,
77
79
  };
78
80
  });
@@ -83,6 +85,7 @@ describe('consoleOperationApi', () => {
83
85
  issueRepository,
84
86
  resolveProject: async (pjcode: string) =>
85
87
  pjcode === 'umino' ? { pjcode, project: nextProject } : null,
88
+ isPjcodeConfigured: (pjcode: string) => pjcode === 'umino',
86
89
  consoleDataOutputDir: baseDir,
87
90
  });
88
91
 
@@ -112,9 +115,13 @@ describe('consoleOperationApi', () => {
112
115
  );
113
116
  expect(issueRepository.updateStatus).toHaveBeenCalledWith(
114
117
  project,
115
- { ...issue, itemId: 'PVTI_a' },
118
+ expect.objectContaining({
119
+ itemId: 'PVTI_a',
120
+ url: 'https://github.com/o/r/pull/1',
121
+ }),
116
122
  'status_aw',
117
123
  );
124
+ expect(issueRepository.get).not.toHaveBeenCalled();
118
125
  expectRecordedAcrossTabs('PVTI_a');
119
126
  });
120
127
 
@@ -234,7 +241,7 @@ describe('consoleOperationApi', () => {
234
241
  expect(response.statusCode).toBe(400);
235
242
  });
236
243
 
237
- it('returns 400 when the issue cannot be loaded during approve', async () => {
244
+ it('approves using the request project item id without a GraphQL item fetch', async () => {
238
245
  issueRepository.get.mockResolvedValue(null);
239
246
  const response = await handleReview(context, {
240
247
  pjcode: 'umino',
@@ -242,8 +249,13 @@ describe('consoleOperationApi', () => {
242
249
  prUrl: 'https://github.com/o/r/pull/1',
243
250
  projectItemId: 'PVTI_c',
244
251
  });
245
- expect(response.statusCode).toBe(400);
246
- expect(issueRepository.updateStatus).not.toHaveBeenCalled();
252
+ expect(response.statusCode).toBe(200);
253
+ expect(issueRepository.get).not.toHaveBeenCalled();
254
+ expect(issueRepository.updateStatus).toHaveBeenCalledWith(
255
+ project,
256
+ expect.objectContaining({ itemId: 'PVTI_c' }),
257
+ 'status_aw',
258
+ );
247
259
  });
248
260
 
249
261
  it('returns 400 when the Awaiting workspace status is absent', async () => {
@@ -273,9 +285,13 @@ describe('consoleOperationApi', () => {
273
285
  expect(response.statusCode).toBe(200);
274
286
  expect(issueRepository.updateStatus).toHaveBeenCalledWith(
275
287
  project,
276
- { ...issue, itemId: 'PVTI_d' },
288
+ expect.objectContaining({
289
+ itemId: 'PVTI_d',
290
+ url: 'https://github.com/o/r/issues/1',
291
+ }),
277
292
  'status_todo',
278
293
  );
294
+ expect(issueRepository.get).not.toHaveBeenCalled();
279
295
  expectRecordedAcrossTabs('PVTI_d');
280
296
  });
281
297
 
@@ -302,9 +318,10 @@ describe('consoleOperationApi', () => {
302
318
  expect(response.statusCode).toBe(200);
303
319
  expect(issueRepository.updateStory).toHaveBeenCalledWith(
304
320
  expect.objectContaining({ id: 'PVT_1' }),
305
- { ...issue, itemId: 'PVTI_e' },
321
+ expect.objectContaining({ itemId: 'PVTI_e' }),
306
322
  'story_opt_1',
307
323
  );
324
+ expect(issueRepository.get).not.toHaveBeenCalled();
308
325
  expectRecordedAcrossTabs('PVTI_e');
309
326
  });
310
327
 
@@ -457,7 +474,7 @@ describe('consoleOperationApi', () => {
457
474
  expect(response.statusCode).toBe(400);
458
475
  });
459
476
 
460
- it('returns 400 when the issue cannot be loaded for set_story', async () => {
477
+ it('sets the story using the request project item id without a GraphQL item fetch', async () => {
461
478
  issueRepository.get.mockResolvedValue(null);
462
479
  const response = await handleTriage(context, {
463
480
  pjcode: 'umino',
@@ -466,7 +483,157 @@ describe('consoleOperationApi', () => {
466
483
  projectItemId: 'PVTI_h',
467
484
  storyOptionId: 'story_opt_1',
468
485
  });
486
+ expect(response.statusCode).toBe(200);
487
+ expect(issueRepository.get).not.toHaveBeenCalled();
488
+ expect(issueRepository.updateStory).toHaveBeenCalledWith(
489
+ expect.objectContaining({ id: 'PVT_1' }),
490
+ expect.objectContaining({ itemId: 'PVTI_h' }),
491
+ 'story_opt_1',
492
+ );
493
+ });
494
+ });
495
+
496
+ describe('close operations avoid loading the project via GraphQL', () => {
497
+ let resolveProjectSpy: jest.Mock<
498
+ Promise<ConsoleProjectBinding | null>,
499
+ [string]
500
+ >;
501
+ let spiedContext: ConsoleOperationContext;
502
+
503
+ beforeEach(() => {
504
+ resolveProjectSpy = jest.fn(async (pjcode: string) =>
505
+ pjcode === 'umino' ? { pjcode, project } : null,
506
+ );
507
+ spiedContext = {
508
+ issueRepository,
509
+ resolveProject: resolveProjectSpy,
510
+ isPjcodeConfigured: (pjcode: string) => pjcode === 'umino',
511
+ consoleDataOutputDir: baseDir,
512
+ };
513
+ });
514
+
515
+ it('closes an issue via triage without resolving the project', async () => {
516
+ const response = await handleTriage(spiedContext, {
517
+ pjcode: 'umino',
518
+ action: 'close',
519
+ issueUrl: 'https://github.com/o/r/issues/1',
520
+ projectItemId: 'PVTI_noproj_close',
521
+ commentBody: 'duplicate',
522
+ });
523
+ expect(response.statusCode).toBe(200);
524
+ expect(issueRepository.closeIssueByUrl).toHaveBeenCalledWith(
525
+ 'https://github.com/o/r/issues/1',
526
+ 'completed',
527
+ );
528
+ expect(issueRepository.createCommentByUrl).toHaveBeenCalledWith(
529
+ 'https://github.com/o/r/issues/1',
530
+ 'duplicate',
531
+ );
532
+ expect(resolveProjectSpy).not.toHaveBeenCalled();
533
+ expect(issueRepository.get).not.toHaveBeenCalled();
534
+ });
535
+
536
+ it('closes not planned via triage without resolving the project', async () => {
537
+ const response = await handleTriage(spiedContext, {
538
+ pjcode: 'umino',
539
+ action: 'close_not_planned',
540
+ issueUrl: 'https://github.com/o/r/issues/1',
541
+ projectItemId: 'PVTI_noproj_np',
542
+ });
543
+ expect(response.statusCode).toBe(200);
544
+ expect(issueRepository.closeIssueByUrl).toHaveBeenCalledWith(
545
+ 'https://github.com/o/r/issues/1',
546
+ 'not_planned',
547
+ );
548
+ expect(resolveProjectSpy).not.toHaveBeenCalled();
549
+ });
550
+
551
+ it('closes a pull request via review without resolving the project', async () => {
552
+ const response = await handleReview(spiedContext, {
553
+ pjcode: 'umino',
554
+ action: 'close',
555
+ prUrl: 'https://github.com/o/r/pull/1',
556
+ projectItemId: 'PVTI_noproj_reviewclose',
557
+ });
558
+ expect(response.statusCode).toBe(200);
559
+ expect(issueRepository.closePullRequest).toHaveBeenCalledWith(
560
+ 'https://github.com/o/r/pull/1',
561
+ );
562
+ expect(resolveProjectSpy).not.toHaveBeenCalled();
563
+ });
564
+
565
+ it('rejects a close whose pjcode is not configured without resolving the project', async () => {
566
+ const response = await handleTriage(spiedContext, {
567
+ pjcode: 'unconfigured',
568
+ action: 'close',
569
+ issueUrl: 'https://github.com/o/r/issues/1',
570
+ projectItemId: 'PVTI_noproj_badpj',
571
+ });
469
572
  expect(response.statusCode).toBe(400);
573
+ expect(issueRepository.closeIssueByUrl).not.toHaveBeenCalled();
574
+ expect(resolveProjectSpy).not.toHaveBeenCalled();
575
+ });
576
+
577
+ it('sets intmux without a GraphQL item fetch while resolving the project', async () => {
578
+ const response = await handleIntmux(spiedContext, {
579
+ pjcode: 'umino',
580
+ action: 'set_intmux',
581
+ issueUrl: 'https://github.com/o/r/issues/1',
582
+ projectItemId: 'PVTI_needproj_intmux',
583
+ });
584
+ expect(response.statusCode).toBe(200);
585
+ expect(resolveProjectSpy).toHaveBeenCalledTimes(1);
586
+ expect(issueRepository.get).not.toHaveBeenCalled();
587
+ expect(issueRepository.updateStatus).toHaveBeenCalledWith(
588
+ project,
589
+ expect.objectContaining({ itemId: 'PVTI_needproj_intmux' }),
590
+ 'status_intmux',
591
+ );
592
+ });
593
+
594
+ it('resolves the project for set_status but performs no GraphQL item fetch', async () => {
595
+ const response = await handleTriage(spiedContext, {
596
+ pjcode: 'umino',
597
+ action: 'set_status',
598
+ issueUrl: 'https://github.com/o/r/issues/1',
599
+ projectItemId: 'PVTI_needproj_status',
600
+ statusName: 'Todo',
601
+ });
602
+ expect(response.statusCode).toBe(200);
603
+ expect(resolveProjectSpy).toHaveBeenCalledTimes(1);
604
+ expect(issueRepository.get).not.toHaveBeenCalled();
605
+ expect(issueRepository.updateStatus).toHaveBeenCalledTimes(1);
606
+ });
607
+
608
+ it('resolves the project for set_story but performs no GraphQL item fetch', async () => {
609
+ const response = await handleTriage(spiedContext, {
610
+ pjcode: 'umino',
611
+ action: 'set_story',
612
+ issueUrl: 'https://github.com/o/r/issues/1',
613
+ projectItemId: 'PVTI_needproj_story',
614
+ storyOptionId: 'story_opt_1',
615
+ });
616
+ expect(response.statusCode).toBe(200);
617
+ expect(resolveProjectSpy).toHaveBeenCalledTimes(1);
618
+ expect(issueRepository.get).not.toHaveBeenCalled();
619
+ expect(issueRepository.updateStory).toHaveBeenCalledTimes(1);
620
+ });
621
+
622
+ it('resolves the project for snooze and passes the item id without a GraphQL item fetch', async () => {
623
+ const response = await handleTriage(spiedContext, {
624
+ pjcode: 'umino',
625
+ action: 'snooze_1day',
626
+ issueUrl: 'https://github.com/o/r/issues/1',
627
+ projectItemId: 'PVTI_needproj_snooze',
628
+ });
629
+ expect(response.statusCode).toBe(200);
630
+ expect(resolveProjectSpy).toHaveBeenCalledTimes(1);
631
+ expect(issueRepository.get).not.toHaveBeenCalled();
632
+ const call = issueRepository.updateNextActionDate.mock.calls[0];
633
+ expect(call[0]).toBe('https://github.com/o/r/issues/1');
634
+ expect(call[1]).toBe(project);
635
+ expect(call[2]).toBeInstanceOf(Date);
636
+ expect(call[3]).toBe('PVTI_needproj_snooze');
470
637
  });
471
638
  });
472
639
 
@@ -481,9 +648,13 @@ describe('consoleOperationApi', () => {
481
648
  expect(response.statusCode).toBe(200);
482
649
  expect(issueRepository.updateStatus).toHaveBeenCalledWith(
483
650
  project,
484
- { ...issue, itemId: 'PVTI_i' },
651
+ expect.objectContaining({
652
+ itemId: 'PVTI_i',
653
+ url: 'https://github.com/o/r/issues/1',
654
+ }),
485
655
  'status_intmux',
486
656
  );
657
+ expect(issueRepository.get).not.toHaveBeenCalled();
487
658
  expectRecordedAcrossTabs('PVTI_i');
488
659
  });
489
660
 
@@ -497,7 +668,7 @@ describe('consoleOperationApi', () => {
497
668
  expect(response.statusCode).toBe(400);
498
669
  });
499
670
 
500
- it('returns 400 when the issue cannot be loaded', async () => {
671
+ it('sets intmux using the request project item id without a GraphQL item fetch', async () => {
501
672
  issueRepository.get.mockResolvedValue(null);
502
673
  const response = await handleIntmux(context, {
503
674
  pjcode: 'umino',
@@ -505,7 +676,13 @@ describe('consoleOperationApi', () => {
505
676
  issueUrl: 'https://github.com/o/r/issues/1',
506
677
  projectItemId: 'PVTI_i',
507
678
  });
508
- expect(response.statusCode).toBe(400);
679
+ expect(response.statusCode).toBe(200);
680
+ expect(issueRepository.get).not.toHaveBeenCalled();
681
+ expect(issueRepository.updateStatus).toHaveBeenCalledWith(
682
+ project,
683
+ expect.objectContaining({ itemId: 'PVTI_i' }),
684
+ 'status_intmux',
685
+ );
509
686
  });
510
687
 
511
688
  it('rejects a missing issueUrl', async () => {
@@ -573,6 +750,8 @@ describe('consoleOperationApi', () => {
573
750
  }
574
751
  return null;
575
752
  },
753
+ isPjcodeConfigured: (pjcode: string) =>
754
+ pjcode === 'umino' || pjcode === 'xmile',
576
755
  consoleDataOutputDir: baseDir,
577
756
  };
578
757
  const response = await handleTriage(multiContext, {
@@ -585,7 +764,7 @@ describe('consoleOperationApi', () => {
585
764
  expect(response.statusCode).toBe(200);
586
765
  expect(issueRepository.updateStatus).toHaveBeenCalledWith(
587
766
  otherProject,
588
- { ...issue, itemId: 'PVTI_x' },
767
+ expect.objectContaining({ itemId: 'PVTI_x' }),
589
768
  'status_todo',
590
769
  );
591
770
  for (const tab of CONSOLE_DONE_TAB_NAMES) {
@@ -18,9 +18,12 @@ export type ConsoleProjectResolver = (
18
18
  pjcode: string,
19
19
  ) => Promise<ConsoleProjectBinding | null>;
20
20
 
21
+ export type ConsolePjcodeValidator = (pjcode: string) => boolean;
22
+
21
23
  export type ConsoleOperationContext = {
22
24
  issueRepository: IssueRepository;
23
25
  resolveProject: ConsoleProjectResolver;
26
+ isPjcodeConfigured: ConsolePjcodeValidator;
24
27
  consoleDataOutputDir: string | null;
25
28
  };
26
29
 
@@ -69,18 +72,40 @@ const resolveStatusId = (
69
72
  return match ? match.id : null;
70
73
  };
71
74
 
72
- const loadIssueWithProjectItemId = async (
73
- issueRepository: IssueRepository,
74
- project: Project,
75
+ // Builds the minimal Issue reference the ProjectV2 mutations need. The dashboard
76
+ // already supplies the project item id in the request body, and updateStatus,
77
+ // updateStory and updateNextActionHour read only `issue.itemId` (plus `url` for
78
+ // context). Constructing the reference locally avoids the GraphQL
79
+ // fetchProjectItemByUrl call that issueRepository.get would otherwise perform.
80
+ const projectItemReference = (
75
81
  issueUrl: string,
76
82
  projectItemId: string,
77
- ): Promise<Issue | null> => {
78
- const issue = await issueRepository.get(issueUrl, project);
79
- if (issue === null) {
80
- return null;
81
- }
82
- return { ...issue, itemId: projectItemId };
83
- };
83
+ ): Issue => ({
84
+ nameWithOwner: '',
85
+ number: 0,
86
+ title: '',
87
+ state: 'OPEN',
88
+ status: null,
89
+ story: null,
90
+ nextActionDate: null,
91
+ nextActionHour: null,
92
+ estimationMinutes: null,
93
+ dependedIssueUrls: [],
94
+ completionDate50PercentConfidence: null,
95
+ url: issueUrl,
96
+ assignees: [],
97
+ labels: [],
98
+ org: '',
99
+ repo: '',
100
+ body: '',
101
+ itemId: projectItemId,
102
+ isPr: isPullRequestUrl(issueUrl),
103
+ isInProgress: false,
104
+ isClosed: false,
105
+ createdAt: new Date(0),
106
+ author: '',
107
+ closingIssueReferenceUrls: [],
108
+ });
84
109
 
85
110
  const recordDone = (
86
111
  context: ConsoleOperationContext,
@@ -117,6 +142,25 @@ const isOperationResponse = (
117
142
  ): value is ConsoleOperationResponse =>
118
143
  Object.prototype.hasOwnProperty.call(value, 'statusCode');
119
144
 
145
+ // Validates that a pjcode is configured WITHOUT loading the ProjectV2 via
146
+ // GraphQL. Close operations only need the pjcode (for recordDone namespacing)
147
+ // and the issue/PR URL, both of which are handled through REST. Loading the
148
+ // full project here would make close fail whenever the GraphQL quota is
149
+ // exhausted, even though closing a GitHub issue or PR needs only REST.
150
+ const resolveConfiguredPjcode = (
151
+ context: ConsoleOperationContext,
152
+ body: Record<string, unknown>,
153
+ ): string | ConsoleOperationResponse => {
154
+ const pjcode = body.pjcode;
155
+ if (!isNonEmptyString(pjcode)) {
156
+ return badRequest('pjcode is required');
157
+ }
158
+ if (!context.isPjcodeConfigured(pjcode)) {
159
+ return badRequest(`no project configured for pjcode "${pjcode}"`);
160
+ }
161
+ return pjcode;
162
+ };
163
+
120
164
  const updateStatusByName = async (
121
165
  issueRepository: IssueRepository,
122
166
  project: Project,
@@ -128,16 +172,11 @@ const updateStatusByName = async (
128
172
  if (statusId === null) {
129
173
  return badRequest(`status option "${statusName}" not found in project`);
130
174
  }
131
- const issue = await loadIssueWithProjectItemId(
132
- issueRepository,
175
+ await issueRepository.updateStatus(
133
176
  project,
134
- issueUrl,
135
- projectItemId,
177
+ projectItemReference(issueUrl, projectItemId),
178
+ statusId,
136
179
  );
137
- if (issue === null) {
138
- return badRequest('issue not found');
139
- }
140
- await issueRepository.updateStatus(project, issue, statusId);
141
180
  return null;
142
181
  };
143
182
 
@@ -157,6 +196,20 @@ export const handleReview = async (
157
196
  if (!isNonEmptyString(projectItemId)) {
158
197
  return badRequest('projectItemId is required');
159
198
  }
199
+
200
+ if (action === 'close') {
201
+ const pjcodeResult = resolveConfiguredPjcode(context, body);
202
+ if (typeof pjcodeResult !== 'string') {
203
+ return pjcodeResult;
204
+ }
205
+ await context.issueRepository.closePullRequest(prUrl);
206
+ if (isNonEmptyString(body.commentBody)) {
207
+ await context.issueRepository.createCommentByUrl(prUrl, body.commentBody);
208
+ }
209
+ recordDone(context, pjcodeResult, projectItemId);
210
+ return ok();
211
+ }
212
+
160
213
  const binding = await resolveBinding(context, body);
161
214
  if (isOperationResponse(binding)) {
162
215
  return binding;
@@ -213,15 +266,6 @@ export const handleReview = async (
213
266
  return ok();
214
267
  }
215
268
 
216
- if (action === 'close') {
217
- await context.issueRepository.closePullRequest(prUrl);
218
- if (isNonEmptyString(body.commentBody)) {
219
- await context.issueRepository.createCommentByUrl(prUrl, body.commentBody);
220
- }
221
- recordDone(context, pjcode, projectItemId);
222
- return ok();
223
- }
224
-
225
269
  return badRequest(`unknown review action "${action}"`);
226
270
  };
227
271
 
@@ -241,6 +285,30 @@ export const handleTriage = async (
241
285
  if (!isNonEmptyString(projectItemId)) {
242
286
  return badRequest('projectItemId is required');
243
287
  }
288
+
289
+ if (action === 'close' || action === 'close_not_planned') {
290
+ const pjcodeResult = resolveConfiguredPjcode(context, body);
291
+ if (typeof pjcodeResult !== 'string') {
292
+ return pjcodeResult;
293
+ }
294
+ if (isNonEmptyString(body.commentBody)) {
295
+ await context.issueRepository.createCommentByUrl(
296
+ issueUrl,
297
+ body.commentBody,
298
+ );
299
+ }
300
+ if (isPullRequestUrl(issueUrl)) {
301
+ await context.issueRepository.closePullRequest(issueUrl);
302
+ } else {
303
+ await context.issueRepository.closeIssueByUrl(
304
+ issueUrl,
305
+ action === 'close_not_planned' ? 'not_planned' : 'completed',
306
+ );
307
+ }
308
+ recordDone(context, pjcodeResult, projectItemId);
309
+ return ok();
310
+ }
311
+
244
312
  const binding = await resolveBinding(context, body);
245
313
  if (isOperationResponse(binding)) {
246
314
  return binding;
@@ -274,43 +342,15 @@ export const handleTriage = async (
274
342
  if (project.story === null) {
275
343
  return badRequest('project does not have a story field');
276
344
  }
277
- const issue = await loadIssueWithProjectItemId(
278
- context.issueRepository,
279
- project,
280
- issueUrl,
281
- projectItemId,
282
- );
283
- if (issue === null) {
284
- return badRequest('issue not found');
285
- }
286
345
  await context.issueRepository.updateStory(
287
346
  { ...project, story: project.story },
288
- issue,
347
+ projectItemReference(issueUrl, projectItemId),
289
348
  storyOptionId,
290
349
  );
291
350
  recordDone(context, pjcode, projectItemId);
292
351
  return ok();
293
352
  }
294
353
 
295
- if (action === 'close' || action === 'close_not_planned') {
296
- if (isNonEmptyString(body.commentBody)) {
297
- await context.issueRepository.createCommentByUrl(
298
- issueUrl,
299
- body.commentBody,
300
- );
301
- }
302
- if (isPullRequestUrl(issueUrl)) {
303
- await context.issueRepository.closePullRequest(issueUrl);
304
- } else {
305
- await context.issueRepository.closeIssueByUrl(
306
- issueUrl,
307
- action === 'close_not_planned' ? 'not_planned' : 'completed',
308
- );
309
- }
310
- recordDone(context, pjcode, projectItemId);
311
- return ok();
312
- }
313
-
314
354
  if (action === 'snooze_1day' || action === 'snooze_1week') {
315
355
  const days = action === 'snooze_1day' ? 1 : 7;
316
356
  const target = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
@@ -318,6 +358,7 @@ export const handleTriage = async (
318
358
  issueUrl,
319
359
  project,
320
360
  target,
361
+ projectItemId,
321
362
  );
322
363
  recordDone(context, pjcode, projectItemId);
323
364
  return ok();
@@ -3,6 +3,7 @@ import { Project } from '../../../domain/entities/Project';
3
3
  import {
4
4
  buildPjcodeToProjectUrl,
5
5
  createConsoleProjectResolver,
6
+ createPjcodeConfigChecker,
6
7
  } from './consoleProjectResolver';
7
8
 
8
9
  describe('buildPjcodeToProjectUrl', () => {
@@ -39,6 +40,18 @@ describe('buildPjcodeToProjectUrl', () => {
39
40
  });
40
41
  });
41
42
 
43
+ describe('createPjcodeConfigChecker', () => {
44
+ it('reports true only for a configured pjcode without loading any project', () => {
45
+ const isConfigured = createPjcodeConfigChecker({
46
+ umino: 'https://github.com/orgs/umino/projects/1',
47
+ xmile: 'https://github.com/orgs/xmile/projects/2',
48
+ });
49
+ expect(isConfigured('umino')).toBe(true);
50
+ expect(isConfigured('xmile')).toBe(true);
51
+ expect(isConfigured('unknown')).toBe(false);
52
+ });
53
+ });
54
+
42
55
  describe('createConsoleProjectResolver', () => {
43
56
  const uminoProject: Project = { ...mock<Project>(), id: 'PVT_umino' };
44
57
  const xmileProject: Project = { ...mock<Project>(), id: 'PVT_xmile' };
@@ -1,5 +1,6 @@
1
1
  import { Project } from '../../../domain/entities/Project';
2
2
  import {
3
+ ConsolePjcodeValidator,
3
4
  ConsoleProjectBinding,
4
5
  ConsoleProjectResolver,
5
6
  } from './consoleOperationApi';
@@ -25,6 +26,16 @@ export const buildPjcodeToProjectUrl = (
25
26
  return mapping;
26
27
  };
27
28
 
29
+ // Builds a synchronous predicate that reports whether a pjcode is configured,
30
+ // using only the local pjcode-to-project-url mapping. This lets close
31
+ // operations validate the pjcode without loading the ProjectV2 via GraphQL.
32
+ export const createPjcodeConfigChecker = (
33
+ pjcodeToProjectUrl: Record<string, string>,
34
+ ): ConsolePjcodeValidator => {
35
+ return (pjcode: string): boolean =>
36
+ Object.prototype.hasOwnProperty.call(pjcodeToProjectUrl, pjcode);
37
+ };
38
+
28
39
  export const createConsoleProjectResolver = (
29
40
  pjcodeToProjectUrl: Record<string, string>,
30
41
  loadProject: ConsoleProjectLoader,
@@ -505,6 +505,8 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
505
505
  pjcode: string,
506
506
  ): Promise<ConsoleProjectBinding | null> =>
507
507
  pjcode === CONSOLE_E2E_PJCODE ? { pjcode, project } : null;
508
+ const isPjcodeConfigured = (pjcode: string): boolean =>
509
+ pjcode === CONSOLE_E2E_PJCODE;
508
510
 
509
511
  const reviewCommentCalls: ConsoleE2eReviewCommentCall[] = [];
510
512
  const requestChangesCalls: ConsoleE2eRequestChangesCall[] = [];
@@ -518,6 +520,7 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
518
520
  requestChangesCalls,
519
521
  ),
520
522
  resolveProject,
523
+ isPjcodeConfigured,
521
524
  issueTitleStateCache: new IssueTitleStateCache(),
522
525
  pullRequestStatusCache: new PullRequestStatusCache(),
523
526
  inTmuxDataDir: null,
@@ -1,12 +1,9 @@
1
1
  import {
2
2
  countPendingItems,
3
- countTabPendingItems,
4
3
  filterPendingItems,
5
- filterTabPendingItems,
6
4
  isOverlayEntryActed,
7
5
  overlayKeyForItem,
8
6
  overlayStorageKey,
9
- tabIgnoresDoneOverlay,
10
7
  writeOverlayEntry,
11
8
  } from './overlay';
12
9
  import type { ConsoleListItem, ConsoleOverlay } from './types';
@@ -126,59 +123,29 @@ describe('filterPendingItems', () => {
126
123
  });
127
124
  });
128
125
 
129
- describe('tabIgnoresDoneOverlay', () => {
130
- it('ignores the done overlay on the workflow-blocker tab', () => {
131
- expect(tabIgnoresDoneOverlay('workflow-blocker')).toBe(true);
132
- });
133
-
134
- it('applies the done overlay on every other tab', () => {
135
- expect(tabIgnoresDoneOverlay('prs')).toBe(false);
136
- expect(tabIgnoresDoneOverlay('triage')).toBe(false);
137
- expect(tabIgnoresDoneOverlay('unread')).toBe(false);
138
- expect(tabIgnoresDoneOverlay('failed-preparation')).toBe(false);
139
- expect(tabIgnoresDoneOverlay('todo-by-human')).toBe(false);
140
- });
141
- });
142
-
143
- describe('countTabPendingItems', () => {
144
- it('counts every workflow-blocker item even when all are marked done', () => {
126
+ describe('workflow-blocker items are filtered by the done overlay like every other tab', () => {
127
+ it('subtracts a processed workflow-blocker item from the count', () => {
145
128
  const overlay: ConsoleOverlay = {
146
129
  PVTI_1: { ts: 100, mode: 'workflow-blocker', done: true },
147
- PVTI_2: { ts: 100, mode: 'workflow-blocker', done: true },
148
130
  };
149
- expect(
150
- countTabPendingItems([item(1), item(2)], overlay, 'workflow-blocker'),
151
- ).toBe(2);
131
+ expect(countPendingItems([item(1), item(2)], overlay)).toBe(1);
152
132
  });
153
133
 
154
- it('subtracts done items on tabs other than workflow-blocker', () => {
134
+ it('removes a processed workflow-blocker item from the list', () => {
155
135
  const overlay: ConsoleOverlay = {
156
- PVTI_1: { ts: 100, mode: 'prs', done: true },
136
+ PVTI_1: { ts: 100, mode: 'workflow-blocker', done: true },
157
137
  };
158
- expect(countTabPendingItems([item(1), item(2)], overlay, 'prs')).toBe(1);
138
+ const result = filterPendingItems([item(1), item(2)], overlay);
139
+ expect(result.map((entry) => entry.number)).toEqual([2]);
159
140
  });
160
- });
161
141
 
162
- describe('filterTabPendingItems', () => {
163
- it('keeps every workflow-blocker item even when all are marked done', () => {
142
+ it('drives the count to zero when every workflow-blocker item is processed', () => {
164
143
  const overlay: ConsoleOverlay = {
165
144
  PVTI_1: { ts: 100, mode: 'workflow-blocker', done: true },
166
145
  PVTI_2: { ts: 100, mode: 'workflow-blocker', done: true },
167
146
  };
168
- const result = filterTabPendingItems(
169
- [item(1), item(2)],
170
- overlay,
171
- 'workflow-blocker',
172
- );
173
- expect(result.map((entry) => entry.number)).toEqual([1, 2]);
174
- });
175
-
176
- it('drops done items on tabs other than workflow-blocker', () => {
177
- const overlay: ConsoleOverlay = {
178
- PVTI_1: { ts: 100, mode: 'prs', done: true },
179
- };
180
- const result = filterTabPendingItems([item(1), item(2)], overlay, 'prs');
181
- expect(result.map((entry) => entry.number)).toEqual([2]);
147
+ expect(countPendingItems([item(1), item(2)], overlay)).toBe(0);
148
+ expect(filterPendingItems([item(1), item(2)], overlay)).toEqual([]);
182
149
  });
183
150
  });
184
151