github-issue-tower-defence-management 1.71.0 → 1.72.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 (24) hide show
  1. package/.github/workflows/umino-project.yml +15 -7
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +1 -0
  4. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +51 -0
  5. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  6. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +88 -13
  7. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  8. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +37 -1
  9. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +139 -0
  12. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +69 -0
  13. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +184 -0
  14. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +118 -55
  15. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +318 -0
  16. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +52 -1
  17. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -0
  18. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +2 -0
  19. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  20. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  21. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +4 -1
  22. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  23. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
  24. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
@@ -267,6 +267,145 @@ describe('ApiV3CheerioRestIssueRepository', () => {
267
267
  });
268
268
  });
269
269
 
270
+ describe('getPullRequestChangedFilePaths', () => {
271
+ afterEach(() => {
272
+ jest.restoreAllMocks();
273
+ });
274
+
275
+ it('should fetch a single page of changed files and return their paths', async () => {
276
+ const fetchSpy = jest
277
+ .spyOn(global, 'fetch')
278
+ .mockResolvedValueOnce(
279
+ new Response(
280
+ JSON.stringify([
281
+ { filename: 'src/domain/Foo.ts' },
282
+ { filename: 'src/domain/Bar.ts' },
283
+ ]),
284
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
285
+ ),
286
+ );
287
+
288
+ const { repository } = createApiV3CheerioRestIssueRepository();
289
+ const result = await repository.getPullRequestChangedFilePaths(
290
+ 'https://github.com/HiromiShikata/test-repository/pull/42',
291
+ );
292
+
293
+ expect(result).toEqual(['src/domain/Foo.ts', 'src/domain/Bar.ts']);
294
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
295
+ expect(fetchSpy).toHaveBeenCalledWith(
296
+ 'https://api.github.com/repos/HiromiShikata/test-repository/pulls/42/files?per_page=100&page=1',
297
+ expect.objectContaining({ method: 'GET' }),
298
+ );
299
+ });
300
+
301
+ it('should paginate when a page returns exactly 100 entries and stop when fewer are returned', async () => {
302
+ const firstPage: { filename: string }[] = [];
303
+ for (let i = 0; i < 100; i += 1) {
304
+ firstPage.push({ filename: `src/domain/file${i}.ts` });
305
+ }
306
+ const secondPage = [
307
+ { filename: 'src/domain/extra-a.ts' },
308
+ { filename: 'src/domain/extra-b.ts' },
309
+ ];
310
+ const fetchSpy = jest
311
+ .spyOn(global, 'fetch')
312
+ .mockResolvedValueOnce(
313
+ new Response(JSON.stringify(firstPage), {
314
+ status: 200,
315
+ headers: { 'Content-Type': 'application/json' },
316
+ }),
317
+ )
318
+ .mockResolvedValueOnce(
319
+ new Response(JSON.stringify(secondPage), {
320
+ status: 200,
321
+ headers: { 'Content-Type': 'application/json' },
322
+ }),
323
+ );
324
+
325
+ const { repository } = createApiV3CheerioRestIssueRepository();
326
+ const result = await repository.getPullRequestChangedFilePaths(
327
+ 'https://github.com/HiromiShikata/test-repository/pull/42',
328
+ );
329
+
330
+ expect(result).toHaveLength(102);
331
+ expect(result[0]).toBe('src/domain/file0.ts');
332
+ expect(result[99]).toBe('src/domain/file99.ts');
333
+ expect(result[100]).toBe('src/domain/extra-a.ts');
334
+ expect(result[101]).toBe('src/domain/extra-b.ts');
335
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
336
+ expect(fetchSpy).toHaveBeenNthCalledWith(
337
+ 1,
338
+ 'https://api.github.com/repos/HiromiShikata/test-repository/pulls/42/files?per_page=100&page=1',
339
+ expect.objectContaining({ method: 'GET' }),
340
+ );
341
+ expect(fetchSpy).toHaveBeenNthCalledWith(
342
+ 2,
343
+ 'https://api.github.com/repos/HiromiShikata/test-repository/pulls/42/files?per_page=100&page=2',
344
+ expect.objectContaining({ method: 'GET' }),
345
+ );
346
+ });
347
+
348
+ it('should throw when the API responds with a non-2xx status', async () => {
349
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
350
+ new Response('Not Found', {
351
+ status: 404,
352
+ statusText: 'Not Found',
353
+ }),
354
+ );
355
+
356
+ const { repository } = createApiV3CheerioRestIssueRepository();
357
+ await expect(
358
+ repository.getPullRequestChangedFilePaths(
359
+ 'https://github.com/HiromiShikata/test-repository/pull/42',
360
+ ),
361
+ ).rejects.toThrow('404');
362
+ });
363
+ });
364
+
365
+ describe('approvePullRequest', () => {
366
+ afterEach(() => {
367
+ jest.restoreAllMocks();
368
+ });
369
+
370
+ it('should POST an APPROVE review to the GitHub API for the PR', async () => {
371
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
372
+ new Response(JSON.stringify({ id: 1, state: 'APPROVED' }), {
373
+ status: 200,
374
+ headers: { 'Content-Type': 'application/json' },
375
+ }),
376
+ );
377
+
378
+ const { repository } = createApiV3CheerioRestIssueRepository();
379
+ await repository.approvePullRequest(
380
+ 'https://github.com/HiromiShikata/test-repository/pull/42',
381
+ );
382
+
383
+ expect(fetchSpy).toHaveBeenCalledWith(
384
+ 'https://api.github.com/repos/HiromiShikata/test-repository/pulls/42/reviews',
385
+ expect.objectContaining({
386
+ method: 'POST',
387
+ body: JSON.stringify({ event: 'APPROVE' }),
388
+ }),
389
+ );
390
+ });
391
+
392
+ it('should throw when the API responds with a non-2xx status', async () => {
393
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
394
+ new Response('Unprocessable Entity', {
395
+ status: 422,
396
+ statusText: 'Unprocessable Entity',
397
+ }),
398
+ );
399
+
400
+ const { repository } = createApiV3CheerioRestIssueRepository();
401
+ await expect(
402
+ repository.approvePullRequest(
403
+ 'https://github.com/HiromiShikata/test-repository/pull/42',
404
+ ),
405
+ ).rejects.toThrow('422');
406
+ });
407
+ });
408
+
270
409
  const createApiV3CheerioRestIssueRepository = () => {
271
410
  const apiV3IssueRepository = mock<ApiV3IssueRepository>();
272
411
  const restIssueRepository = mock<RestIssueRepository>();
@@ -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,
@@ -49,6 +49,24 @@ const extractRequestedFirstFromMockCall = (
49
49
  return typeof first === 'number' ? first : undefined;
50
50
  };
51
51
 
52
+ const extractRequestedQueryFromMockCall = (
53
+ call: unknown,
54
+ ): string | undefined => {
55
+ if (!Array.isArray(call)) {
56
+ return undefined;
57
+ }
58
+ const second: unknown = call[1];
59
+ if (!isRecord(second)) {
60
+ return undefined;
61
+ }
62
+ const json: unknown = second.json;
63
+ if (!isRecord(json)) {
64
+ return undefined;
65
+ }
66
+ const query: unknown = json.query;
67
+ return typeof query === 'string' ? query : undefined;
68
+ };
69
+
52
70
  const extractErrorMessage = (value: unknown): string => {
53
71
  if (value instanceof Error) {
54
72
  return value.message;
@@ -477,4 +495,170 @@ describe('GraphqlProjectItemRepository', () => {
477
495
  ]);
478
496
  });
479
497
  });
498
+
499
+ describe('fetchProjectItemByUrl', () => {
500
+ afterEach(() => {
501
+ mockPost.mockClear();
502
+ });
503
+
504
+ const makeContentNode = (url: string, number: number, title: string) => ({
505
+ number,
506
+ title,
507
+ state: 'OPEN',
508
+ url,
509
+ body: 'body text',
510
+ createdAt: '2024-01-01T00:00:00Z',
511
+ author: { login: 'octocat' },
512
+ labels: { nodes: [{ name: 'bug' }] },
513
+ assignees: { nodes: [{ login: 'octocat' }] },
514
+ repository: { nameWithOwner: 'owner/repo' },
515
+ projectItems: {
516
+ nodes: [
517
+ {
518
+ id: `item-${number}`,
519
+ fieldValues: {
520
+ nodes: [
521
+ {
522
+ __typename: 'ProjectV2ItemFieldSingleSelectValue',
523
+ name: 'Preparation',
524
+ field: { name: 'Status' },
525
+ },
526
+ ],
527
+ },
528
+ },
529
+ ],
530
+ },
531
+ });
532
+
533
+ it('should return project item for an issue URL', async () => {
534
+ const localStorageRepository = new LocalStorageRepository();
535
+ const repository = new GraphqlProjectItemRepository(
536
+ localStorageRepository,
537
+ 'dummy-token',
538
+ );
539
+
540
+ mockPost.mockReturnValueOnce(
541
+ mockJsonResponse({
542
+ data: {
543
+ repository: {
544
+ issue: makeContentNode(
545
+ 'https://github.com/owner/repo/issues/7',
546
+ 7,
547
+ 'Issue Title',
548
+ ),
549
+ pullRequest: null,
550
+ },
551
+ },
552
+ }),
553
+ );
554
+
555
+ const result = await repository.fetchProjectItemByUrl(
556
+ 'https://github.com/owner/repo/issues/7',
557
+ );
558
+
559
+ expect(mockPost).toHaveBeenCalledTimes(1);
560
+ expect(result).not.toBeNull();
561
+ expect(result?.url).toBe('https://github.com/owner/repo/issues/7');
562
+ expect(result?.title).toBe('Issue Title');
563
+ expect(result?.id).toBe('item-7');
564
+ expect(result?.customFields).toEqual([
565
+ { name: 'Status', value: 'Preparation' },
566
+ ]);
567
+ });
568
+
569
+ it('should return project item for a pull request URL when repository.issue is null', async () => {
570
+ const localStorageRepository = new LocalStorageRepository();
571
+ const repository = new GraphqlProjectItemRepository(
572
+ localStorageRepository,
573
+ 'dummy-token',
574
+ );
575
+
576
+ mockPost.mockReturnValueOnce(
577
+ mockJsonResponse({
578
+ data: {
579
+ repository: {
580
+ issue: null,
581
+ pullRequest: makeContentNode(
582
+ 'https://github.com/owner/repo/pull/9',
583
+ 9,
584
+ 'PR Title',
585
+ ),
586
+ },
587
+ },
588
+ }),
589
+ );
590
+
591
+ const result = await repository.fetchProjectItemByUrl(
592
+ 'https://github.com/owner/repo/pull/9',
593
+ );
594
+
595
+ expect(mockPost).toHaveBeenCalledTimes(1);
596
+ expect(result).not.toBeNull();
597
+ expect(result?.url).toBe('https://github.com/owner/repo/pull/9');
598
+ expect(result?.title).toBe('PR Title');
599
+ expect(result?.id).toBe('item-9');
600
+ expect(result?.customFields).toEqual([
601
+ { name: 'Status', value: 'Preparation' },
602
+ ]);
603
+ });
604
+
605
+ it('should query both issue and pullRequest fields in a single request', async () => {
606
+ const localStorageRepository = new LocalStorageRepository();
607
+ const repository = new GraphqlProjectItemRepository(
608
+ localStorageRepository,
609
+ 'dummy-token',
610
+ );
611
+
612
+ mockPost.mockReturnValueOnce(
613
+ mockJsonResponse({
614
+ data: {
615
+ repository: {
616
+ issue: null,
617
+ pullRequest: makeContentNode(
618
+ 'https://github.com/owner/repo/pull/3',
619
+ 3,
620
+ 'PR Title',
621
+ ),
622
+ },
623
+ },
624
+ }),
625
+ );
626
+
627
+ await repository.fetchProjectItemByUrl(
628
+ 'https://github.com/owner/repo/pull/3',
629
+ );
630
+
631
+ const sentQuery = extractRequestedQueryFromMockCall(
632
+ mockPost.mock.calls[0],
633
+ );
634
+ expect(typeof sentQuery).toBe('string');
635
+ expect(sentQuery).toContain('issue(number: $number)');
636
+ expect(sentQuery).toContain('pullRequest(number: $number)');
637
+ });
638
+
639
+ it('should return null when neither issue nor pullRequest exists', async () => {
640
+ const localStorageRepository = new LocalStorageRepository();
641
+ const repository = new GraphqlProjectItemRepository(
642
+ localStorageRepository,
643
+ 'dummy-token',
644
+ );
645
+
646
+ mockPost.mockReturnValueOnce(
647
+ mockJsonResponse({
648
+ data: {
649
+ repository: {
650
+ issue: null,
651
+ pullRequest: null,
652
+ },
653
+ },
654
+ }),
655
+ );
656
+
657
+ const result = await repository.fetchProjectItemByUrl(
658
+ 'https://github.com/owner/repo/issues/123',
659
+ );
660
+
661
+ expect(result).toBeNull();
662
+ });
663
+ });
480
664
  });
@@ -665,7 +665,7 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
665
665
  issueUrl: string,
666
666
  ): Promise<ProjectItem | null> => {
667
667
  const { owner, repo, issueNumber } = this.extractIssueFromUrl(issueUrl);
668
- const graphql = `query GetIssue($owner: String!, $repo: String!, $number: Int!) {
668
+ const graphql = `query GetIssueOrPullRequest($owner: String!, $repo: String!, $number: Int!) {
669
669
  repository(owner: $owner, name: $repo) {
670
670
  issue(number: $number) {
671
671
  number
@@ -741,6 +741,80 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
741
741
  }
742
742
  }
743
743
  }
744
+ pullRequest(number: $number) {
745
+ number
746
+ title
747
+ state
748
+ url
749
+ body
750
+ createdAt
751
+ author {
752
+ login
753
+ }
754
+ labels(first: 100) {
755
+ nodes {
756
+ name
757
+ }
758
+ }
759
+ assignees(first: 20) {
760
+ nodes {
761
+ login
762
+ }
763
+ }
764
+ repository {
765
+ nameWithOwner
766
+ }
767
+ projectItems(first: 10) {
768
+ nodes {
769
+ id
770
+ fieldValues(first: 10) {
771
+ nodes {
772
+ ... on ProjectV2ItemFieldTextValue {
773
+ text
774
+ field {
775
+ ... on ProjectV2Field {
776
+ name
777
+ }
778
+ }
779
+ }
780
+ ... on ProjectV2ItemFieldNumberValue {
781
+ number
782
+ id
783
+ field {
784
+ ... on ProjectV2Field {
785
+ name
786
+ }
787
+ }
788
+ }
789
+ ... on ProjectV2ItemFieldDateValue {
790
+ date
791
+ field {
792
+ ... on ProjectV2Field {
793
+ name
794
+ }
795
+ }
796
+ }
797
+ ... on ProjectV2ItemFieldSingleSelectValue {
798
+ name
799
+ field {
800
+ ... on ProjectV2SingleSelectField {
801
+ name
802
+ }
803
+ }
804
+ }
805
+ ... on ProjectV2ItemFieldIterationValue {
806
+ title
807
+ field {
808
+ ... on ProjectV2Field {
809
+ name
810
+ }
811
+ }
812
+ }
813
+ }
814
+ }
815
+ }
816
+ }
817
+ }
744
818
  }
745
819
  }`;
746
820
  const graphqlQuery = {
@@ -751,6 +825,34 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
751
825
  number: issueNumber,
752
826
  },
753
827
  };
828
+ type ContentNode = {
829
+ number: number;
830
+ title: string;
831
+ state: string;
832
+ url: string;
833
+ body: string;
834
+ createdAt: string;
835
+ author: { login: string } | null;
836
+ labels: { nodes: { name: string }[] };
837
+ assignees: { nodes: { login: string }[] };
838
+ repository: { nameWithOwner: string };
839
+ projectItems: {
840
+ nodes: {
841
+ id: string;
842
+ fieldValues: {
843
+ nodes: {
844
+ text: string;
845
+ number: number;
846
+ date: string;
847
+ name: string;
848
+ field: {
849
+ name: string;
850
+ };
851
+ }[];
852
+ };
853
+ }[];
854
+ };
855
+ };
754
856
  const response = await ky
755
857
  .post('https://api.github.com/graphql', {
756
858
  json: graphqlQuery,
@@ -761,34 +863,8 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
761
863
  .json<{
762
864
  data?: {
763
865
  repository: {
764
- issue: {
765
- number: number;
766
- title: string;
767
- state: string;
768
- url: string;
769
- body: string;
770
- createdAt: string;
771
- author: { login: string } | null;
772
- labels: { nodes: { name: string }[] };
773
- assignees: { nodes: { login: string }[] };
774
- repository: { nameWithOwner: string };
775
- projectItems: {
776
- nodes: {
777
- id: string;
778
- fieldValues: {
779
- nodes: {
780
- text: string;
781
- number: number;
782
- date: string;
783
- name: string;
784
- field: {
785
- name: string;
786
- };
787
- }[];
788
- };
789
- }[];
790
- };
791
- };
866
+ issue: ContentNode | null;
867
+ pullRequest: ContentNode | null;
792
868
  };
793
869
  };
794
870
  errors?: { message: string }[];
@@ -802,40 +878,27 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
802
878
  );
803
879
  }
804
880
  const data = response.data;
805
- if (!data.repository.issue) {
881
+ const content = data.repository.issue ?? data.repository.pullRequest;
882
+ if (!content) {
806
883
  return null;
807
884
  }
808
- const projectItems: {
809
- id: string;
810
- fieldValues: {
811
- nodes: {
812
- text: string;
813
- number: number;
814
- date: string;
815
- name: string;
816
- field: {
817
- name: string;
818
- };
819
- }[];
820
- };
821
- }[] = data.repository.issue.projectItems.nodes;
885
+ const projectItems = content.projectItems.nodes;
822
886
  const item = projectItems[0];
823
887
  if (!item) {
824
888
  throw new Error(`No project item found for issue ${issueUrl}`);
825
889
  }
826
890
  return {
827
891
  id: item.id,
828
- nameWithOwner: data.repository.issue.repository.nameWithOwner,
829
- number: data.repository.issue.number,
830
- title: data.repository.issue.title,
831
- state: this.convertStrToState(data.repository.issue.state),
832
- url: data.repository.issue.url,
833
- body: data.repository.issue.body,
834
- labels: data.repository.issue.labels?.nodes?.map((l) => l.name) || [],
835
- assignees:
836
- data.repository.issue.assignees?.nodes?.map((a) => a.login) || [],
837
- createdAt: data.repository.issue.createdAt || new Date().toISOString(),
838
- author: data.repository.issue.author?.login || '',
892
+ nameWithOwner: content.repository.nameWithOwner,
893
+ number: content.number,
894
+ title: content.title,
895
+ state: this.convertStrToState(content.state),
896
+ url: content.url,
897
+ body: content.body,
898
+ labels: content.labels?.nodes?.map((l) => l.name) || [],
899
+ assignees: content.assignees?.nodes?.map((a) => a.login) || [],
900
+ createdAt: content.createdAt || new Date().toISOString(),
901
+ author: content.author?.login || '',
839
902
  customFields: item.fieldValues.nodes
840
903
  .filter((field) => !!field.field)
841
904
  .map((field) => {