github-issue-tower-defence-management 1.70.0 → 1.71.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.
@@ -19,6 +19,7 @@ import { StartPreparationUseCase } from '../../../domain/usecases/StartPreparati
19
19
  import { writeRotationOrderFile } from '../handlers/rotationOrderFileWriter';
20
20
  import { ProxyClaudeTokenUsageRepository } from '../../repositories/ProxyClaudeTokenUsageRepository';
21
21
  import { NotifyFinishedIssuePreparationUseCase } from '../../../domain/usecases/NotifyFinishedIssuePreparationUseCase';
22
+ import { CheckIssueReviewReadinessUseCase } from '../../../domain/usecases/CheckIssueReviewReadinessUseCase';
22
23
  import { LocalStorageRepository } from '../../repositories/LocalStorageRepository';
23
24
  import { GraphqlProjectRepository } from '../../repositories/GraphqlProjectRepository';
24
25
  import { ApiV3IssueRepository } from '../../repositories/issue/ApiV3IssueRepository';
@@ -53,6 +54,12 @@ type NotifyFinishedOptions = {
53
54
  configFilePath: string;
54
55
  };
55
56
 
57
+ type CheckIssueReviewReadinessOptions = {
58
+ issueUrl: string;
59
+ projectUrl?: string;
60
+ configFilePath: string;
61
+ };
62
+
56
63
  const buildGithubRepositoryParams = (
57
64
  localStorageRepository: LocalStorageRepository,
58
65
  token: string,
@@ -444,6 +451,100 @@ program
444
451
  });
445
452
  });
446
453
 
454
+ program
455
+ .command('checkIssueReviewReadiness')
456
+ .description(
457
+ 'Check whether an issue is in a review-ready state without mutating any field or posting any comment',
458
+ )
459
+ .requiredOption(
460
+ '--configFilePath <path>',
461
+ 'Path to config file for tower defence management',
462
+ )
463
+ .requiredOption('--issueUrl <url>', 'GitHub issue URL')
464
+ .option('--projectUrl <url>', 'GitHub project URL')
465
+ .action(async (options: CheckIssueReviewReadinessOptions) => {
466
+ const token = process.env.GH_TOKEN;
467
+ if (!token) {
468
+ console.error('GH_TOKEN environment variable is required');
469
+ process.exit(1);
470
+ }
471
+
472
+ const configFileValues = loadConfigFile(options.configFilePath);
473
+
474
+ const cliOverrides: ConfigFile = {
475
+ projectUrl: options.projectUrl,
476
+ };
477
+
478
+ const tempProjectUrl =
479
+ cliOverrides.projectUrl ?? configFileValues.projectUrl;
480
+
481
+ let readmeOverrides: ConfigFile = {};
482
+ if (tempProjectUrl) {
483
+ const readme = await fetchProjectReadme(tempProjectUrl, token);
484
+ if (readme) {
485
+ readmeOverrides = parseProjectReadmeConfig(readme, tempProjectUrl);
486
+ }
487
+ }
488
+
489
+ const config = mergeConfigs(
490
+ configFileValues,
491
+ cliOverrides,
492
+ readmeOverrides,
493
+ );
494
+
495
+ const projectUrl = config.projectUrl;
496
+
497
+ if (!projectUrl) {
498
+ console.error(
499
+ 'projectUrl is required. Provide via --projectUrl, config file, or project README.',
500
+ );
501
+ process.exit(1);
502
+ }
503
+
504
+ const projectName = config.projectName ?? 'default';
505
+ const localStorageRepository = new LocalStorageRepository();
506
+ const cachePath = `./tmp/cache/${projectName}`;
507
+ const localStorageCacheRepository = new LocalStorageCacheRepository(
508
+ localStorageRepository,
509
+ cachePath,
510
+ );
511
+ const githubRepositoryParams = buildGithubRepositoryParams(
512
+ localStorageRepository,
513
+ token,
514
+ );
515
+ const projectRepository = new GraphqlProjectRepository(
516
+ ...githubRepositoryParams,
517
+ );
518
+ const apiV3IssueRepository = new ApiV3IssueRepository(
519
+ ...githubRepositoryParams,
520
+ );
521
+ const restIssueRepository = new RestIssueRepository(
522
+ ...githubRepositoryParams,
523
+ );
524
+ const graphqlProjectItemRepository = new GraphqlProjectItemRepository(
525
+ ...githubRepositoryParams,
526
+ );
527
+ const issueRepository = new ApiV3CheerioRestIssueRepository(
528
+ apiV3IssueRepository,
529
+ restIssueRepository,
530
+ graphqlProjectItemRepository,
531
+ localStorageCacheRepository,
532
+ ...githubRepositoryParams,
533
+ );
534
+
535
+ const useCase = new CheckIssueReviewReadinessUseCase(
536
+ projectRepository,
537
+ issueRepository,
538
+ );
539
+
540
+ const result = await useCase.run({
541
+ projectUrl,
542
+ issueUrl: options.issueUrl,
543
+ });
544
+
545
+ process.stdout.write(`${JSON.stringify(result)}\n`);
546
+ });
547
+
447
548
  /* istanbul ignore next */
448
549
  if (process.argv && require.main === module) {
449
550
  program.parse(process.argv);
@@ -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) => {