github-issue-tower-defence-management 1.40.0 → 1.41.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 (52) hide show
  1. package/.github/workflows/umino-project.yml +5 -4
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +21 -9
  4. package/bin/adapter/entry-points/cli/index.js +45 -10
  5. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +32 -8
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  8. package/bin/adapter/repositories/NodeLocalCommandRunner.js +3 -3
  9. package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -1
  10. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +412 -177
  11. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  12. package/bin/domain/usecases/HandleScheduledEventUseCase.js +5 -2
  13. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  14. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +7 -2
  15. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  16. package/bin/domain/usecases/StartPreparationUseCase.js +107 -72
  17. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/adapter/entry-points/cli/index.test.ts +26 -13
  20. package/src/adapter/entry-points/cli/index.ts +74 -13
  21. package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +12 -12
  22. package/src/adapter/repositories/NodeLocalCommandRunner.ts +7 -4
  23. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +3 -0
  24. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +626 -265
  25. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +3 -0
  26. package/src/domain/entities/Issue.ts +1 -0
  27. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +1 -0
  28. package/src/domain/usecases/HandleScheduledEventUseCase.ts +11 -3
  29. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +1 -0
  30. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +22 -9
  31. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +8 -3
  32. package/src/domain/usecases/StartPreparationUseCase.test.ts +1696 -290
  33. package/src/domain/usecases/StartPreparationUseCase.ts +171 -126
  34. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -1
  35. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +4 -1
  36. package/types/adapter/entry-points/cli/index.d.ts +4 -1
  37. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  38. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +1 -1
  39. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
  40. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +5 -3
  41. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  42. package/types/domain/entities/Issue.d.ts +1 -0
  43. package/types/domain/entities/Issue.d.ts.map +1 -1
  44. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +5 -1
  45. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  46. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  47. package/types/domain/usecases/StartPreparationUseCase.d.ts +10 -18
  48. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  49. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -1
  50. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  51. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +1 -1
  52. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
@@ -18,90 +18,250 @@ import { normalizeFieldName } from '../utils';
18
18
  import { LocalStorageRepository } from '../LocalStorageRepository';
19
19
  import { Member } from '../../../domain/entities/Member';
20
20
 
21
- type GetPullRequestResponse = {
22
- data?: {
23
- repository?: {
24
- pullRequest?: {
25
- state: string;
26
- mergeable: string;
27
- commits: {
28
- nodes: {
29
- commit: {
30
- statusCheckRollup: {
31
- state: string;
32
- contexts: {
33
- nodes: (
34
- | {
35
- name?: string;
36
- status?: string;
37
- conclusion?: string | null;
38
- }
39
- | {
40
- context?: string;
41
- state?: string;
42
- }
43
- )[];
44
- };
45
- } | null;
21
+ type TimelineItem = {
22
+ __typename: string;
23
+ willCloseTarget?: boolean;
24
+ source?: {
25
+ __typename: string;
26
+ url?: string;
27
+ number?: number;
28
+ state?: string;
29
+ mergeable?: string;
30
+ headRefName?: string;
31
+ baseRefName?: string;
32
+ baseRepository?: {
33
+ branchProtectionRules?: {
34
+ nodes: Array<{
35
+ pattern: string;
36
+ requiredStatusCheckContexts: string[];
37
+ }>;
38
+ };
39
+ defaultBranchRef?: {
40
+ name: string;
41
+ } | null;
42
+ rulesets?: {
43
+ nodes: Array<{
44
+ name: string;
45
+ enforcement: string;
46
+ conditions: {
47
+ refName: {
48
+ include: string[];
49
+ exclude: string[];
46
50
  };
47
- }[];
48
- };
49
- reviewThreads: {
50
- nodes: { isResolved: boolean }[];
51
- };
52
- baseRepository: {
53
- branchProtectionRules: {
54
- nodes: { requiredStatusCheckContexts: string[] }[];
55
51
  };
56
- rulesets: {
57
- nodes: {
58
- rules: {
59
- nodes: {
60
- type: string;
61
- parameters?: {
62
- requiredStatusChecks?: { context: string }[];
63
- };
64
- }[];
65
- };
66
- }[];
52
+ rules: {
53
+ nodes: Array<{
54
+ type: string;
55
+ parameters:
56
+ | {
57
+ requiredStatusChecks: Array<{
58
+ context: string;
59
+ }>;
60
+ }
61
+ | Record<string, never>;
62
+ }>;
67
63
  };
68
- };
64
+ }>;
69
65
  };
70
66
  };
67
+ commits?: {
68
+ nodes: Array<{
69
+ commit: {
70
+ statusCheckRollup?: {
71
+ state: string;
72
+ contexts?: {
73
+ nodes: Array<
74
+ | {
75
+ __typename: 'CheckRun';
76
+ name: string;
77
+ conclusion: string | null;
78
+ }
79
+ | {
80
+ __typename: 'StatusContext';
81
+ context: string;
82
+ state: string;
83
+ }
84
+ >;
85
+ };
86
+ } | null;
87
+ };
88
+ }>;
89
+ };
90
+ reviewThreads?: {
91
+ nodes: Array<{
92
+ isResolved: boolean;
93
+ }>;
94
+ };
95
+ baseRef?: {
96
+ name: string;
97
+ } | null;
71
98
  };
72
- errors?: { message: string }[];
73
99
  };
74
100
 
75
- type FindRelatedPRsResponse = {
101
+ type IssueTimelineResponse = {
76
102
  data?: {
77
103
  repository?: {
78
104
  issue?: {
79
105
  timelineItems: {
80
- nodes: {
81
- source?: {
82
- url?: string;
83
- state?: string;
84
- };
85
- }[];
106
+ pageInfo: {
107
+ endCursor: string;
108
+ hasNextPage: boolean;
109
+ };
110
+ nodes: TimelineItem[];
111
+ };
112
+ };
113
+ };
114
+ };
115
+ errors?: Array<{ message: string }>;
116
+ };
117
+
118
+ type PrStatusComputationData = {
119
+ mergeable?: string;
120
+ baseRepository?: {
121
+ branchProtectionRules?: {
122
+ nodes: Array<{
123
+ pattern: string;
124
+ requiredStatusCheckContexts: string[];
125
+ }>;
126
+ };
127
+ defaultBranchRef?: {
128
+ name: string;
129
+ } | null;
130
+ rulesets?: {
131
+ nodes: Array<{
132
+ name: string;
133
+ enforcement: string;
134
+ conditions: {
135
+ refName: {
136
+ include: string[];
137
+ exclude: string[];
138
+ };
139
+ };
140
+ rules: {
141
+ nodes: Array<{
142
+ type: string;
143
+ parameters:
144
+ | {
145
+ requiredStatusChecks: Array<{
146
+ context: string;
147
+ }>;
148
+ }
149
+ | Record<string, never>;
150
+ }>;
86
151
  };
152
+ }>;
153
+ };
154
+ };
155
+ commits?: {
156
+ nodes: Array<{
157
+ commit: {
158
+ statusCheckRollup?: {
159
+ state: string;
160
+ contexts?: {
161
+ nodes: Array<
162
+ | {
163
+ __typename: 'CheckRun';
164
+ name: string;
165
+ conclusion: string | null;
166
+ }
167
+ | {
168
+ __typename: 'StatusContext';
169
+ context: string;
170
+ state: string;
171
+ }
172
+ >;
173
+ };
174
+ } | null;
87
175
  };
176
+ }>;
177
+ };
178
+ reviewThreads?: {
179
+ nodes: Array<{
180
+ isResolved: boolean;
181
+ }>;
182
+ };
183
+ };
184
+
185
+ type DirectPullRequestResponse = {
186
+ data?: {
187
+ repository?: {
188
+ pullRequest?: {
189
+ url: string;
190
+ state: string;
191
+ headRefName?: string;
192
+ baseRefName?: string;
193
+ } & PrStatusComputationData;
88
194
  };
89
195
  };
90
- errors?: { message: string }[];
196
+ errors?: Array<{ message: string }>;
91
197
  };
92
198
 
93
- function isGetPullRequestResponse(
199
+ function isIssueTimelineResponse(
94
200
  value: unknown,
95
- ): value is GetPullRequestResponse {
96
- return typia.is<GetPullRequestResponse>(value);
201
+ ): value is IssueTimelineResponse {
202
+ if (typeof value !== 'object' || value === null) return false;
203
+ return true;
97
204
  }
98
205
 
99
- function isFindRelatedPRsResponse(
206
+ function isDirectPullRequestResponse(
100
207
  value: unknown,
101
- ): value is FindRelatedPRsResponse {
102
- return typia.is<FindRelatedPRsResponse>(value);
208
+ ): value is DirectPullRequestResponse {
209
+ if (typeof value !== 'object' || value === null) return false;
210
+ return true;
103
211
  }
104
212
 
213
+ const fnmatch = (pattern: string, str: string): boolean => {
214
+ let regexStr = '^';
215
+ let i = 0;
216
+ while (i < pattern.length) {
217
+ const c = pattern[i];
218
+ if (c === '*') {
219
+ if (pattern[i + 1] === '*') {
220
+ regexStr += '.*';
221
+ i += 2;
222
+ if (pattern[i] === '/') {
223
+ i++;
224
+ }
225
+ } else {
226
+ regexStr += '[^/]*';
227
+ i++;
228
+ }
229
+ } else if (c === '?') {
230
+ regexStr += '[^/]';
231
+ i++;
232
+ } else if (c === '[') {
233
+ let j = i + 1;
234
+ while (j < pattern.length && pattern[j] !== ']') {
235
+ j++;
236
+ }
237
+ if (j >= pattern.length) {
238
+ regexStr += '\\[';
239
+ i++;
240
+ continue;
241
+ }
242
+ const content = pattern.slice(i + 1, j);
243
+ if (content.length > 0 && (content[0] === '!' || content[0] === '^')) {
244
+ const body = content.slice(1).replace(/\\/g, '\\\\');
245
+ regexStr += '[^' + body + ']';
246
+ } else {
247
+ const escapedContent = content.replace(/\\/g, '\\\\');
248
+ regexStr += '[' + escapedContent + ']';
249
+ }
250
+ i = j + 1;
251
+ } else {
252
+ regexStr += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
253
+ i++;
254
+ }
255
+ }
256
+ regexStr += '$';
257
+ try {
258
+ const regex = new RegExp(regexStr);
259
+ return regex.test(str);
260
+ } catch {
261
+ return pattern === str;
262
+ }
263
+ };
264
+
105
265
  export class ApiV3CheerioRestIssueRepository
106
266
  extends BaseGitHubRepository
107
267
  implements IssueRepository
@@ -215,6 +375,7 @@ export class ApiV3CheerioRestIssueRepository
215
375
  isInProgress: normalizeFieldName(status || '').includes('progress'),
216
376
  isClosed: item.state !== 'OPEN',
217
377
  createdAt: new Date(item.createdAt || '2000-01-01'),
378
+ author: '',
218
379
  };
219
380
  };
220
381
  getAllIssuesFromCache = async (
@@ -342,163 +503,6 @@ export class ApiV3CheerioRestIssueRepository
342
503
  { date: date.toISOString().split('T')[0] },
343
504
  );
344
505
  };
345
-
346
- getOpenPullRequest = async (
347
- prUrl: string,
348
- ): Promise<RelatedPullRequest | null> => {
349
- const match = prUrl.match(
350
- /https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
351
- );
352
- if (!match) {
353
- return null;
354
- }
355
- const [, owner, repo, prNumberStr] = match;
356
- const prNumber = parseInt(prNumberStr, 10);
357
-
358
- const query = `query GetPullRequest($owner: String!, $repo: String!, $number: Int!) {
359
- repository(owner: $owner, name: $repo) {
360
- pullRequest(number: $number) {
361
- state
362
- mergeable
363
- commits(last: 1) {
364
- nodes {
365
- commit {
366
- statusCheckRollup {
367
- state
368
- contexts(first: 100) {
369
- nodes {
370
- ... on CheckRun {
371
- name
372
- status
373
- conclusion
374
- }
375
- ... on StatusContext {
376
- context
377
- state
378
- }
379
- }
380
- }
381
- }
382
- }
383
- }
384
- }
385
- reviewThreads(first: 100) {
386
- nodes {
387
- isResolved
388
- }
389
- }
390
- baseRepository {
391
- branchProtectionRules(first: 10) {
392
- nodes {
393
- requiredStatusCheckContexts
394
- }
395
- }
396
- rulesets(first: 10) {
397
- nodes {
398
- rules(first: 50) {
399
- nodes {
400
- type
401
- parameters {
402
- ... on RequiredStatusChecksParameters {
403
- requiredStatusChecks {
404
- context
405
- }
406
- }
407
- }
408
- }
409
- }
410
- }
411
- }
412
- }
413
- }
414
- }
415
- }`;
416
-
417
- const response = await fetch('https://api.github.com/graphql', {
418
- method: 'POST',
419
- headers: {
420
- Authorization: `Bearer ${this.ghToken}`,
421
- 'Content-Type': 'application/json',
422
- },
423
- body: JSON.stringify({
424
- query,
425
- variables: { owner, repo, number: prNumber },
426
- }),
427
- });
428
-
429
- const responseData: unknown = await response.json();
430
- if (!isGetPullRequestResponse(responseData)) {
431
- throw new Error(
432
- 'Unexpected response shape when fetching pull request from GitHub GraphQL API',
433
- );
434
- }
435
-
436
- if (responseData.errors && responseData.errors.length > 0) {
437
- throw new Error(responseData.errors.map((e) => e.message).join('\n'));
438
- }
439
-
440
- const pr = responseData.data?.repository?.pullRequest;
441
- if (!pr || pr.state !== 'OPEN') {
442
- return null;
443
- }
444
-
445
- const isConflicted = pr.mergeable === 'CONFLICTING';
446
-
447
- const lastCommit = pr.commits.nodes[pr.commits.nodes.length - 1];
448
- const rollup = lastCommit?.commit?.statusCheckRollup;
449
- const isCiStateSuccess = rollup?.state === 'SUCCESS';
450
-
451
- const requiredCheckNames: string[] = [];
452
- for (const rule of pr.baseRepository.branchProtectionRules.nodes) {
453
- requiredCheckNames.push(...rule.requiredStatusCheckContexts);
454
- }
455
- for (const ruleset of pr.baseRepository.rulesets.nodes) {
456
- for (const rule of ruleset.rules.nodes) {
457
- if (
458
- rule.type === 'REQUIRED_STATUS_CHECKS' &&
459
- rule.parameters?.requiredStatusChecks
460
- ) {
461
- requiredCheckNames.push(
462
- ...rule.parameters.requiredStatusChecks.map((c) => c.context),
463
- );
464
- }
465
- }
466
- }
467
-
468
- const contextNodes = rollup?.contexts?.nodes ?? [];
469
- const completedCheckNames = contextNodes
470
- .map((node) => {
471
- if ('name' in node && node.name) {
472
- return node.name;
473
- }
474
- if ('context' in node && node.context) {
475
- return node.context;
476
- }
477
- return null;
478
- })
479
- .filter((name): name is string => name !== null);
480
-
481
- const missingRequiredCheckNames = requiredCheckNames.filter(
482
- (required) => !completedCheckNames.includes(required),
483
- );
484
-
485
- const isPassedAllCiJob =
486
- isCiStateSuccess && missingRequiredCheckNames.length === 0;
487
-
488
- const isResolvedAllReviewComments = pr.reviewThreads.nodes.every(
489
- (thread) => thread.isResolved,
490
- );
491
-
492
- return {
493
- url: prUrl,
494
- isConflicted,
495
- isPassedAllCiJob,
496
- isCiStateSuccess,
497
- isResolvedAllReviewComments,
498
- isBranchOutOfDate: false,
499
- missingRequiredCheckNames,
500
- };
501
- };
502
506
  updateNextActionHour = async (
503
507
  project: Project & {
504
508
  nextActionHour: NonNullable<Project['nextActionHour']>;
@@ -572,36 +576,409 @@ export class ApiV3CheerioRestIssueRepository
572
576
  update = async (issue: Issue, _project: Project): Promise<void> => {
573
577
  await this.updateIssue(issue);
574
578
  };
579
+ private parseIssueUrl = (
580
+ issueUrl: string,
581
+ ): {
582
+ owner: string;
583
+ repo: string;
584
+ issueNumber: number;
585
+ isPr: boolean;
586
+ } => {
587
+ const urlMatch = issueUrl.match(
588
+ /github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)/,
589
+ );
590
+ if (!urlMatch) {
591
+ throw new Error(`Invalid GitHub issue URL: ${issueUrl}`);
592
+ }
593
+ return {
594
+ owner: urlMatch[1],
595
+ repo: urlMatch[2],
596
+ issueNumber: parseInt(urlMatch[4], 10),
597
+ isPr: urlMatch[3] === 'pull',
598
+ };
599
+ };
600
+
601
+ private computePrStatus = (
602
+ prUrl: string,
603
+ headRefName: string | undefined,
604
+ baseRefName: string | undefined,
605
+ data: PrStatusComputationData,
606
+ ): RelatedPullRequest => {
607
+ const isConflicted = data.mergeable === 'CONFLICTING';
608
+ const lastCommit = data.commits?.nodes[0]?.commit;
609
+ const ciState = lastCommit?.statusCheckRollup?.state;
610
+ const contexts = lastCommit?.statusCheckRollup?.contexts?.nodes || [];
611
+
612
+ const branchProtectionRules =
613
+ data.baseRepository?.branchProtectionRules?.nodes || [];
614
+ const matchingRules = baseRefName
615
+ ? branchProtectionRules.filter(
616
+ (rule) =>
617
+ rule.pattern === baseRefName || fnmatch(rule.pattern, baseRefName),
618
+ )
619
+ : [];
620
+ const requiredCheckNamesSet = new Set<string>();
621
+ for (const rule of matchingRules) {
622
+ for (const name of rule.requiredStatusCheckContexts) {
623
+ requiredCheckNamesSet.add(name);
624
+ }
625
+ }
626
+
627
+ const rulesets = data.baseRepository?.rulesets?.nodes || [];
628
+ const defaultBranchName = data.baseRepository?.defaultBranchRef?.name || '';
629
+ for (const ruleset of rulesets) {
630
+ if (ruleset.enforcement !== 'ACTIVE') continue;
631
+ const refIncludes = ruleset.conditions.refName.include;
632
+ const refExcludes = ruleset.conditions.refName.exclude;
633
+ const matchesInclude =
634
+ baseRefName !== undefined &&
635
+ refIncludes.some((pattern) => {
636
+ if (pattern === '~DEFAULT_BRANCH') {
637
+ return baseRefName === defaultBranchName;
638
+ }
639
+ if (pattern === '~ALL') {
640
+ return true;
641
+ }
642
+ const branchPattern = pattern.replace(/^refs\/heads\//, '');
643
+ return (
644
+ branchPattern === baseRefName || fnmatch(branchPattern, baseRefName)
645
+ );
646
+ });
647
+ if (!matchesInclude) continue;
648
+ const matchesExclude =
649
+ baseRefName !== undefined &&
650
+ refExcludes.some((pattern) => {
651
+ if (pattern === '~DEFAULT_BRANCH') {
652
+ return baseRefName === defaultBranchName;
653
+ }
654
+ const branchPattern = pattern.replace(/^refs\/heads\//, '');
655
+ return (
656
+ branchPattern === baseRefName || fnmatch(branchPattern, baseRefName)
657
+ );
658
+ });
659
+ if (matchesExclude) continue;
660
+ for (const rule of ruleset.rules.nodes) {
661
+ if (rule.type !== 'REQUIRED_STATUS_CHECKS') continue;
662
+ if ('requiredStatusChecks' in rule.parameters) {
663
+ for (const check of rule.parameters.requiredStatusChecks) {
664
+ requiredCheckNamesSet.add(check.context);
665
+ }
666
+ }
667
+ }
668
+ }
669
+
670
+ const requiredCheckNames = Array.from(requiredCheckNamesSet);
671
+ const seenContextNames = new Set<string>();
672
+ for (const ctx of contexts) {
673
+ if ('name' in ctx) {
674
+ seenContextNames.add(ctx.name);
675
+ }
676
+ if ('context' in ctx) {
677
+ seenContextNames.add(ctx.context);
678
+ }
679
+ }
680
+
681
+ const missingRequiredCheckNames = requiredCheckNames.filter(
682
+ (name) => !seenContextNames.has(name),
683
+ );
684
+ const allRequiredChecksPassed = missingRequiredCheckNames.length === 0;
685
+ const isCiStateSuccess = ciState === 'SUCCESS';
686
+ const isPassedAllCiJob = isCiStateSuccess && allRequiredChecksPassed;
687
+
688
+ const reviewThreads = data.reviewThreads?.nodes || [];
689
+ const isResolvedAllReviewComments =
690
+ reviewThreads.length === 0 ||
691
+ reviewThreads.every((thread) => thread.isResolved);
692
+
693
+ return {
694
+ url: prUrl,
695
+ branchName: headRefName ?? null,
696
+ isConflicted,
697
+ isPassedAllCiJob,
698
+ isCiStateSuccess,
699
+ isResolvedAllReviewComments,
700
+ isBranchOutOfDate: false,
701
+ missingRequiredCheckNames,
702
+ };
703
+ };
704
+
575
705
  findRelatedOpenPRs = async (
576
706
  issueUrl: string,
577
707
  ): Promise<RelatedPullRequest[]> => {
578
- const match = issueUrl.match(
579
- /https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/,
580
- );
581
- if (!match) {
582
- return [];
708
+ const { owner, repo, issueNumber, isPr } = this.parseIssueUrl(issueUrl);
709
+ if (isPr) {
710
+ throw new Error(
711
+ 'findRelatedOpenPRs only supports issue URLs, not pull request URLs',
712
+ );
713
+ }
714
+
715
+ const query = `
716
+ query($owner: String!, $repo: String!, $issueNumber: Int!, $after: String) {
717
+ repository(owner: $owner, name: $repo) {
718
+ issue(number: $issueNumber) {
719
+ timelineItems(first: 100, after: $after, itemTypes: [CROSS_REFERENCED_EVENT]) {
720
+ pageInfo {
721
+ endCursor
722
+ hasNextPage
723
+ }
724
+ nodes {
725
+ __typename
726
+ ... on CrossReferencedEvent {
727
+ willCloseTarget
728
+ source {
729
+ __typename
730
+ ... on PullRequest {
731
+ url
732
+ number
733
+ state
734
+ mergeable
735
+ headRefName
736
+ baseRefName
737
+ baseRepository {
738
+ branchProtectionRules(first: 100) {
739
+ nodes {
740
+ pattern
741
+ requiredStatusCheckContexts
742
+ }
743
+ }
744
+ defaultBranchRef {
745
+ name
746
+ }
747
+ rulesets(first: 100) {
748
+ nodes {
749
+ name
750
+ enforcement
751
+ conditions {
752
+ refName {
753
+ include
754
+ exclude
755
+ }
756
+ }
757
+ rules(first: 100) {
758
+ nodes {
759
+ type
760
+ parameters {
761
+ ... on RequiredStatusChecksParameters {
762
+ requiredStatusChecks {
763
+ context
764
+ }
765
+ }
766
+ }
767
+ }
768
+ }
769
+ }
770
+ }
771
+ }
772
+ commits(last: 1) {
773
+ nodes {
774
+ commit {
775
+ statusCheckRollup {
776
+ state
777
+ contexts(first: 100) {
778
+ nodes {
779
+ __typename
780
+ ... on CheckRun {
781
+ name
782
+ conclusion
783
+ }
784
+ ... on StatusContext {
785
+ context
786
+ state
787
+ }
788
+ }
789
+ }
790
+ }
791
+ }
792
+ }
793
+ }
794
+ reviewThreads(first: 100) {
795
+ nodes {
796
+ isResolved
797
+ }
798
+ }
799
+ baseRef {
800
+ name
801
+ }
802
+ }
803
+ }
804
+ }
805
+ }
806
+ }
807
+ }
808
+ }
809
+ }
810
+ `;
811
+
812
+ const relatedPRsMap: Map<string, RelatedPullRequest> = new Map();
813
+ let after: string | null = null;
814
+ let hasNextPage = true;
815
+
816
+ while (hasNextPage) {
817
+ const response = await fetch('https://api.github.com/graphql', {
818
+ method: 'POST',
819
+ headers: {
820
+ Authorization: `Bearer ${this.ghToken}`,
821
+ 'Content-Type': 'application/json',
822
+ },
823
+ body: JSON.stringify({
824
+ query,
825
+ variables: { owner, repo, issueNumber, after },
826
+ }),
827
+ });
828
+
829
+ if (!response.ok) {
830
+ throw new Error(
831
+ `Failed to fetch issue timeline from GitHub GraphQL API: HTTP ${response.status}`,
832
+ );
833
+ }
834
+
835
+ const responseData: unknown = await response.json();
836
+ if (!isIssueTimelineResponse(responseData)) {
837
+ throw new Error(
838
+ 'Unexpected response shape when fetching issue timeline',
839
+ );
840
+ }
841
+
842
+ const issueData = responseData.data?.repository?.issue;
843
+ if (!issueData) {
844
+ throw new Error(
845
+ 'Issue not found when fetching timeline from GitHub GraphQL API',
846
+ );
847
+ }
848
+
849
+ for (const item of issueData.timelineItems.nodes) {
850
+ if (item.__typename !== 'CrossReferencedEvent') continue;
851
+ if (!item.source || item.source.__typename !== 'PullRequest') continue;
852
+ if (item.source.state !== 'OPEN') continue;
853
+ if (!item.willCloseTarget) continue;
854
+
855
+ const pr = item.source;
856
+ const prUrl = pr.url || '';
857
+ const baseRefName = pr.baseRefName ?? pr.baseRef?.name;
858
+
859
+ relatedPRsMap.set(
860
+ prUrl,
861
+ this.computePrStatus(prUrl, pr.headRefName, baseRefName, pr),
862
+ );
863
+ }
864
+
865
+ hasNextPage = issueData.timelineItems.pageInfo.hasNextPage;
866
+ after = issueData.timelineItems.pageInfo.endCursor;
867
+ }
868
+
869
+ return Array.from(relatedPRsMap.values());
870
+ };
871
+
872
+ getAllOpened = async (project: Project): Promise<Issue[]> => {
873
+ const { issues } = await this.getAllIssues(project.id, 0);
874
+ return issues.filter((issue) => !issue.isClosed);
875
+ };
876
+
877
+ getStoryObjectMap = async (project: Project): Promise<StoryObjectMap> => {
878
+ const { issues } = await this.getAllIssues(project.id, 0);
879
+ const storyObjectMap: StoryObjectMap = new Map();
880
+ const targetStories = project.story?.stories || [];
881
+ for (const story of targetStories) {
882
+ const storyIssue = issues.find((issue) =>
883
+ story.name.startsWith(issue.title),
884
+ );
885
+ storyObjectMap.set(story.name, {
886
+ story,
887
+ storyIssue: storyIssue || null,
888
+ issues: [],
889
+ });
890
+ for (const issue of issues) {
891
+ if (issue.story !== story.name) continue;
892
+ storyObjectMap.get(story.name)?.issues.push(issue);
893
+ }
894
+ }
895
+ return storyObjectMap;
896
+ };
897
+
898
+ getOpenPullRequest = async (
899
+ prUrl: string,
900
+ ): Promise<RelatedPullRequest | null> => {
901
+ const parsedUrl = this.parseIssueUrl(prUrl);
902
+ if (!parsedUrl.isPr) {
903
+ return null;
583
904
  }
584
- const [, owner, repo, issueNumberStr] = match;
585
- const issueNumber = parseInt(issueNumberStr, 10);
586
-
587
- const query = `query FindRelatedPRs($owner: String!, $repo: String!, $number: Int!) {
588
- repository(owner: $owner, name: $repo) {
589
- issue(number: $number) {
590
- timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], first: 50) {
591
- nodes {
592
- ... on CrossReferencedEvent {
593
- source {
594
- ... on PullRequest {
595
- url
905
+ const { owner, repo, issueNumber: prNumber } = parsedUrl;
906
+
907
+ const query = `
908
+ query($owner: String!, $repo: String!, $prNumber: Int!) {
909
+ repository(owner: $owner, name: $repo) {
910
+ pullRequest(number: $prNumber) {
911
+ url
912
+ state
913
+ headRefName
914
+ baseRefName
915
+ mergeable
916
+ baseRepository {
917
+ branchProtectionRules(first: 100) {
918
+ nodes {
919
+ pattern
920
+ requiredStatusCheckContexts
921
+ }
922
+ }
923
+ defaultBranchRef {
924
+ name
925
+ }
926
+ rulesets(first: 100) {
927
+ nodes {
928
+ name
929
+ enforcement
930
+ conditions {
931
+ refName {
932
+ include
933
+ exclude
934
+ }
935
+ }
936
+ rules(first: 100) {
937
+ nodes {
938
+ type
939
+ parameters {
940
+ ... on RequiredStatusChecksParameters {
941
+ requiredStatusChecks {
942
+ context
943
+ }
944
+ }
945
+ }
946
+ }
947
+ }
948
+ }
949
+ }
950
+ }
951
+ commits(last: 1) {
952
+ nodes {
953
+ commit {
954
+ statusCheckRollup {
596
955
  state
956
+ contexts(first: 100) {
957
+ nodes {
958
+ __typename
959
+ ... on CheckRun {
960
+ name
961
+ conclusion
962
+ }
963
+ ... on StatusContext {
964
+ context
965
+ state
966
+ }
967
+ }
968
+ }
597
969
  }
598
970
  }
599
971
  }
600
972
  }
973
+ reviewThreads(first: 100) {
974
+ nodes {
975
+ isResolved
976
+ }
977
+ }
601
978
  }
602
979
  }
603
980
  }
604
- }`;
981
+ `;
605
982
 
606
983
  const response = await fetch('https://api.github.com/graphql', {
607
984
  method: 'POST',
@@ -611,46 +988,30 @@ export class ApiV3CheerioRestIssueRepository
611
988
  },
612
989
  body: JSON.stringify({
613
990
  query,
614
- variables: { owner, repo, number: issueNumber },
991
+ variables: { owner, repo, prNumber },
615
992
  }),
616
993
  });
617
994
 
618
- const responseData: unknown = await response.json();
619
- if (!isFindRelatedPRsResponse(responseData)) {
995
+ if (!response.ok) {
620
996
  throw new Error(
621
- 'Unexpected response shape when fetching related PRs from GitHub GraphQL API',
997
+ `Failed to fetch pull request from GitHub GraphQL API: HTTP ${response.status}`,
622
998
  );
623
999
  }
624
1000
 
1001
+ const responseData: unknown = await response.json();
1002
+ if (!isDirectPullRequestResponse(responseData)) {
1003
+ throw new Error('Unexpected response shape when fetching pull request');
1004
+ }
1005
+
625
1006
  if (responseData.errors && responseData.errors.length > 0) {
626
- throw new Error(responseData.errors.map((e) => e.message).join('\n'));
1007
+ throw new Error(`GraphQL errors: ${JSON.stringify(responseData.errors)}`);
627
1008
  }
628
1009
 
629
- const nodes =
630
- responseData.data?.repository?.issue?.timelineItems?.nodes ?? [];
631
- const openPrUrls = nodes
632
- .filter(
633
- (node) =>
634
- node.source?.url &&
635
- node.source?.state === 'OPEN' &&
636
- node.source.url.includes('/pull/'),
637
- )
638
- .map((node) => node.source?.url)
639
- .filter((url): url is string => url !== undefined);
640
-
641
- const results: RelatedPullRequest[] = [];
642
- for (const prUrl of openPrUrls) {
643
- const pr = await this.getOpenPullRequest(prUrl);
644
- if (pr) {
645
- results.push(pr);
646
- }
1010
+ const pr = responseData.data?.repository?.pullRequest;
1011
+ if (!pr || pr.state !== 'OPEN') {
1012
+ return null;
647
1013
  }
648
- return results;
649
- };
650
- getAllOpened = async (_project: Project): Promise<Issue[]> => {
651
- throw new Error('getAllOpened is not implemented');
652
- };
653
- getStoryObjectMap = async (_project: Project): Promise<StoryObjectMap> => {
654
- throw new Error('getStoryObjectMap is not implemented');
1014
+
1015
+ return this.computePrStatus(pr.url, pr.headRefName, pr.baseRefName, pr);
655
1016
  };
656
1017
  }