github-issue-tower-defence-management 1.123.3 → 1.125.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 (28) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/cli/projectConfig.js +6 -11
  3. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  4. package/bin/adapter/repositories/GraphqlProjectRepository.js +21 -36
  5. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  6. package/bin/adapter/repositories/githubGraphqlClient.js +99 -0
  7. package/bin/adapter/repositories/githubGraphqlClient.js.map +1 -0
  8. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +278 -304
  9. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  10. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +79 -142
  11. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/adapter/entry-points/cli/projectConfig.ts +6 -11
  14. package/src/adapter/repositories/GraphqlProjectRepository.ts +81 -77
  15. package/src/adapter/repositories/githubGraphqlClient.test.ts +251 -0
  16. package/src/adapter/repositories/githubGraphqlClient.ts +122 -0
  17. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +663 -328
  18. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +465 -449
  19. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +62 -0
  20. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +208 -237
  21. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  22. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  23. package/types/adapter/repositories/githubGraphqlClient.d.ts +21 -0
  24. package/types/adapter/repositories/githubGraphqlClient.d.ts.map +1 -0
  25. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +6 -0
  26. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  27. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +2 -0
  28. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
@@ -20,6 +20,7 @@ import {
20
20
  import { LocalStorageCacheRepository } from '../LocalStorageCacheRepository';
21
21
  import typia from 'typia';
22
22
  import { BaseGitHubRepository } from '../BaseGitHubRepository';
23
+ import { fetchGithubGraphql } from '../githubGraphqlClient';
23
24
  import { normalizeFieldName } from '../utils';
24
25
  import { LocalStorageRepository } from '../LocalStorageRepository';
25
26
  import { Member } from '../../../domain/entities/Member';
@@ -35,6 +36,7 @@ import {
35
36
 
36
37
  export const FULL_ISSUE_FETCH_INTERVAL_MS = 60 * 60 * 1000;
37
38
  export const INCREMENTAL_FETCH_SKEW_BUFFER_MS = 5 * 60 * 1000;
39
+ export const REQUIRED_CHECKS_CACHE_TTL_MS = 10 * 60 * 1000;
38
40
 
39
41
  export type CachedProjectIssues = {
40
42
  lastFetchedAt: string;
@@ -56,69 +58,6 @@ type TimelineItem = {
56
58
  mergeable?: string;
57
59
  headRefName?: string;
58
60
  baseRefName?: string;
59
- baseRepository?: {
60
- branchProtectionRules?: {
61
- nodes: Array<{
62
- pattern: string;
63
- requiredStatusCheckContexts: string[];
64
- }>;
65
- };
66
- defaultBranchRef?: {
67
- name: string;
68
- } | null;
69
- rulesets?: {
70
- nodes: Array<{
71
- name: string;
72
- enforcement: string;
73
- conditions: {
74
- refName: {
75
- include: string[];
76
- exclude: string[];
77
- };
78
- };
79
- rules: {
80
- nodes: Array<{
81
- type: string;
82
- parameters:
83
- | {
84
- requiredStatusChecks: Array<{
85
- context: string;
86
- }>;
87
- }
88
- | Record<string, never>;
89
- }>;
90
- };
91
- }>;
92
- };
93
- };
94
- commits?: {
95
- nodes: Array<{
96
- commit: {
97
- statusCheckRollup?: {
98
- contexts?: {
99
- nodes: Array<
100
- | {
101
- __typename: 'CheckRun';
102
- name: string;
103
- conclusion: string | null;
104
- databaseId: number;
105
- }
106
- | {
107
- __typename: 'StatusContext';
108
- context: string;
109
- state: string;
110
- }
111
- >;
112
- };
113
- } | null;
114
- };
115
- }>;
116
- };
117
- reviewThreads?: {
118
- nodes: Array<{
119
- isResolved: boolean;
120
- }>;
121
- };
122
61
  baseRef?: {
123
62
  name: string;
124
63
  } | null;
@@ -142,88 +81,101 @@ type IssueTimelineResponse = {
142
81
  errors?: Array<{ message: string }>;
143
82
  };
144
83
 
84
+ type CiContextNode =
85
+ | {
86
+ __typename: 'CheckRun';
87
+ name: string;
88
+ conclusion: string | null;
89
+ databaseId: number;
90
+ }
91
+ | {
92
+ __typename: 'StatusContext';
93
+ context: string;
94
+ state: string;
95
+ };
96
+
145
97
  type PrStatusComputationData = {
146
98
  isDraft?: boolean;
147
99
  mergeable?: string;
148
- baseRepository?: {
149
- branchProtectionRules?: {
150
- nodes: Array<{
151
- pattern: string;
152
- requiredStatusCheckContexts: string[];
153
- }>;
154
- };
155
- defaultBranchRef?: {
156
- name: string;
157
- } | null;
158
- rulesets?: {
159
- nodes: Array<{
160
- name: string;
161
- enforcement: string;
162
- conditions: {
163
- refName: {
164
- include: string[];
165
- exclude: string[];
166
- };
167
- };
168
- rules: {
169
- nodes: Array<{
170
- type: string;
171
- parameters:
172
- | {
173
- requiredStatusChecks: Array<{
174
- context: string;
175
- }>;
176
- }
177
- | Record<string, never>;
178
- }>;
179
- };
180
- }>;
181
- };
182
- };
183
- commits?: {
184
- nodes: Array<{
185
- commit: {
186
- statusCheckRollup?: {
187
- contexts?: {
188
- nodes: Array<
189
- | {
190
- __typename: 'CheckRun';
191
- name: string;
192
- conclusion: string | null;
193
- databaseId: number;
194
- }
195
- | {
196
- __typename: 'StatusContext';
197
- context: string;
198
- state: string;
199
- }
200
- >;
201
- };
202
- } | null;
203
- };
204
- }>;
205
- };
206
- reviewThreads?: {
207
- nodes: Array<{
208
- isResolved: boolean;
209
- }>;
210
- };
100
+ requiredCheckNames: string[];
101
+ ciContexts: CiContextNode[];
102
+ reviewThreads: Array<{
103
+ isResolved: boolean;
104
+ }>;
211
105
  };
212
106
 
213
- type DirectPullRequestResponse = {
107
+ type SlimPullRequestResponse = {
214
108
  data?: {
215
109
  repository?: {
216
110
  pullRequest?: {
217
111
  url: string;
218
112
  state: string;
113
+ isDraft?: boolean;
219
114
  headRefName?: string;
220
115
  baseRefName?: string;
221
- } & PrStatusComputationData;
222
- };
116
+ mergeable?: string;
117
+ headRefOid?: string;
118
+ reviewThreads?: {
119
+ pageInfo: {
120
+ endCursor: string | null;
121
+ hasNextPage: boolean;
122
+ };
123
+ nodes: Array<{
124
+ isResolved: boolean;
125
+ }>;
126
+ };
127
+ } | null;
128
+ } | null;
223
129
  };
224
130
  errors?: Array<{ message: string }>;
225
131
  };
226
132
 
133
+ type SlimPullRequest = {
134
+ url: string;
135
+ state: string;
136
+ isDraft?: boolean;
137
+ headRefName?: string;
138
+ baseRefName?: string;
139
+ mergeable?: string;
140
+ headRefOid?: string;
141
+ reviewThreads: Array<{
142
+ isResolved: boolean;
143
+ }>;
144
+ };
145
+
146
+ type BranchRulesResponseItem = {
147
+ type: string;
148
+ parameters?: {
149
+ required_status_checks?: Array<{
150
+ context: string;
151
+ }>;
152
+ };
153
+ };
154
+
155
+ type BranchDetailResponse = {
156
+ protection?: {
157
+ required_status_checks?: {
158
+ contexts?: string[];
159
+ } | null;
160
+ } | null;
161
+ };
162
+
163
+ type CheckRunsResponse = {
164
+ total_count: number;
165
+ check_runs: Array<{
166
+ id: number;
167
+ name: string;
168
+ conclusion: string | null;
169
+ }>;
170
+ };
171
+
172
+ type CombinedStatusResponse = {
173
+ statuses: Array<{
174
+ context: string;
175
+ state: string;
176
+ }>;
177
+ };
178
+
227
179
  type PullRequestMergeabilityResponse = {
228
180
  data?: {
229
181
  repository?: {
@@ -243,13 +195,36 @@ function isIssueTimelineResponse(
243
195
  return true;
244
196
  }
245
197
 
246
- function isDirectPullRequestResponse(
198
+ function isSlimPullRequestResponse(
247
199
  value: unknown,
248
- ): value is DirectPullRequestResponse {
200
+ ): value is SlimPullRequestResponse {
201
+ if (typeof value !== 'object' || value === null) return false;
202
+ return true;
203
+ }
204
+
205
+ function isBranchRulesResponse(
206
+ value: unknown,
207
+ ): value is BranchRulesResponseItem[] {
208
+ return Array.isArray(value);
209
+ }
210
+
211
+ function isBranchDetailResponse(value: unknown): value is BranchDetailResponse {
249
212
  if (typeof value !== 'object' || value === null) return false;
250
213
  return true;
251
214
  }
252
215
 
216
+ function isCheckRunsResponse(value: unknown): value is CheckRunsResponse {
217
+ if (typeof value !== 'object' || value === null) return false;
218
+ return 'check_runs' in value && Array.isArray(value.check_runs);
219
+ }
220
+
221
+ function isCombinedStatusResponse(
222
+ value: unknown,
223
+ ): value is CombinedStatusResponse {
224
+ if (typeof value !== 'object' || value === null) return false;
225
+ return 'statuses' in value && Array.isArray(value.statuses);
226
+ }
227
+
253
228
  function isPullRequestMergeabilityResponse(
254
229
  value: unknown,
255
230
  ): value is PullRequestMergeabilityResponse {
@@ -427,58 +402,6 @@ function isPullRequestCommitsResponse(
427
402
  return Array.isArray(value) && value.every(isPullRequestCommitsResponseItem);
428
403
  }
429
404
 
430
- const fnmatch = (pattern: string, str: string): boolean => {
431
- let regexStr = '^';
432
- let i = 0;
433
- while (i < pattern.length) {
434
- const c = pattern[i];
435
- if (c === '*') {
436
- if (pattern[i + 1] === '*') {
437
- regexStr += '.*';
438
- i += 2;
439
- if (pattern[i] === '/') {
440
- i++;
441
- }
442
- } else {
443
- regexStr += '[^/]*';
444
- i++;
445
- }
446
- } else if (c === '?') {
447
- regexStr += '[^/]';
448
- i++;
449
- } else if (c === '[') {
450
- let j = i + 1;
451
- while (j < pattern.length && pattern[j] !== ']') {
452
- j++;
453
- }
454
- if (j >= pattern.length) {
455
- regexStr += '\\[';
456
- i++;
457
- continue;
458
- }
459
- const content = pattern.slice(i + 1, j);
460
- if (content.length > 0 && (content[0] === '!' || content[0] === '^')) {
461
- const body = content.slice(1).replace(/\\/g, '\\\\');
462
- regexStr += '[^' + body + ']';
463
- } else {
464
- const escapedContent = content.replace(/\\/g, '\\\\');
465
- regexStr += '[' + escapedContent + ']';
466
- }
467
- i = j + 1;
468
- } else {
469
- regexStr += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
470
- i++;
471
- }
472
- }
473
- regexStr += '$';
474
- try {
475
- const regex = new RegExp(regexStr);
476
- return regex.test(str);
477
- } catch {
478
- return pattern === str;
479
- }
480
- };
481
-
482
405
  export class ApiV3CheerioRestIssueRepository
483
406
  extends BaseGitHubRepository
484
407
  implements IssueRepository
@@ -999,73 +922,13 @@ export class ApiV3CheerioRestIssueRepository
999
922
  private computePrStatus = (
1000
923
  prUrl: string,
1001
924
  headRefName: string | undefined,
1002
- baseRefName: string | undefined,
1003
925
  data: PrStatusComputationData,
1004
926
  ): RelatedPullRequest => {
1005
927
  const isConflicted = data.mergeable === 'CONFLICTING';
1006
- const lastCommit = data.commits?.nodes[0]?.commit;
1007
- const statusCheckRollup = lastCommit?.statusCheckRollup;
1008
- const contexts = statusCheckRollup?.contexts?.nodes || [];
1009
-
1010
- const branchProtectionRules =
1011
- data.baseRepository?.branchProtectionRules?.nodes || [];
1012
- const matchingRules = baseRefName
1013
- ? branchProtectionRules.filter(
1014
- (rule) =>
1015
- rule.pattern === baseRefName || fnmatch(rule.pattern, baseRefName),
1016
- )
1017
- : [];
1018
- const requiredCheckNamesSet = new Set<string>();
1019
- for (const rule of matchingRules) {
1020
- for (const name of rule.requiredStatusCheckContexts) {
1021
- requiredCheckNamesSet.add(name);
1022
- }
1023
- }
928
+ const hasStatusCheckRollup = data.ciContexts.length > 0;
929
+ const contexts = data.ciContexts;
1024
930
 
1025
- const rulesets = data.baseRepository?.rulesets?.nodes || [];
1026
- const defaultBranchName = data.baseRepository?.defaultBranchRef?.name || '';
1027
- for (const ruleset of rulesets) {
1028
- if (ruleset.enforcement !== 'ACTIVE') continue;
1029
- const refIncludes = ruleset.conditions.refName.include;
1030
- const refExcludes = ruleset.conditions.refName.exclude;
1031
- const matchesInclude =
1032
- baseRefName !== undefined &&
1033
- refIncludes.some((pattern) => {
1034
- if (pattern === '~DEFAULT_BRANCH') {
1035
- return baseRefName === defaultBranchName;
1036
- }
1037
- if (pattern === '~ALL') {
1038
- return true;
1039
- }
1040
- const branchPattern = pattern.replace(/^refs\/heads\//, '');
1041
- return (
1042
- branchPattern === baseRefName || fnmatch(branchPattern, baseRefName)
1043
- );
1044
- });
1045
- if (!matchesInclude) continue;
1046
- const matchesExclude =
1047
- baseRefName !== undefined &&
1048
- refExcludes.some((pattern) => {
1049
- if (pattern === '~DEFAULT_BRANCH') {
1050
- return baseRefName === defaultBranchName;
1051
- }
1052
- const branchPattern = pattern.replace(/^refs\/heads\//, '');
1053
- return (
1054
- branchPattern === baseRefName || fnmatch(branchPattern, baseRefName)
1055
- );
1056
- });
1057
- if (matchesExclude) continue;
1058
- for (const rule of ruleset.rules.nodes) {
1059
- if (rule.type !== 'REQUIRED_STATUS_CHECKS') continue;
1060
- if ('requiredStatusChecks' in rule.parameters) {
1061
- for (const check of rule.parameters.requiredStatusChecks) {
1062
- requiredCheckNamesSet.add(check.context);
1063
- }
1064
- }
1065
- }
1066
- }
1067
-
1068
- const requiredCheckNames = Array.from(requiredCheckNamesSet);
931
+ const requiredCheckNames = data.requiredCheckNames;
1069
932
  const seenContextNames = new Set<string>();
1070
933
  for (const ctx of contexts) {
1071
934
  if ('name' in ctx) {
@@ -1105,7 +968,7 @@ export class ApiV3CheerioRestIssueRepository
1105
968
  'STALE',
1106
969
  ]);
1107
970
  const isCiStateSuccess = (() => {
1108
- if (!statusCheckRollup) return false;
971
+ if (!hasStatusCheckRollup) return false;
1109
972
  const latestRuns = [...latestCheckRunByName.values()];
1110
973
  const statusContexts = contexts.filter(
1111
974
  (
@@ -1131,7 +994,7 @@ export class ApiV3CheerioRestIssueRepository
1131
994
  })();
1132
995
  const isPassedAllCiJob = isCiStateSuccess && allRequiredChecksPassed;
1133
996
 
1134
- const reviewThreads = data.reviewThreads?.nodes || [];
997
+ const reviewThreads = data.reviewThreads;
1135
998
  const isResolvedAllReviewComments =
1136
999
  reviewThreads.length === 0 ||
1137
1000
  reviewThreads.every((thread) => thread.isResolved);
@@ -1151,6 +1014,310 @@ export class ApiV3CheerioRestIssueRepository
1151
1014
  };
1152
1015
  };
1153
1016
 
1017
+ private readonly requiredCheckNamesCache = new Map<
1018
+ string,
1019
+ { fetchedAtMs: number; names: string[] }
1020
+ >();
1021
+
1022
+ private getRequiredCheckNames = async (
1023
+ owner: string,
1024
+ repo: string,
1025
+ branch: string,
1026
+ ): Promise<string[]> => {
1027
+ const cacheKey = `${owner}/${repo}/${branch}`;
1028
+ const nowMs = (await this.dateRepository.now()).getTime();
1029
+ const cached = this.requiredCheckNamesCache.get(cacheKey);
1030
+ if (cached && nowMs - cached.fetchedAtMs < REQUIRED_CHECKS_CACHE_TTL_MS) {
1031
+ return cached.names;
1032
+ }
1033
+
1034
+ const ownerSegment = encodeURIComponent(owner);
1035
+ const repoSegment = encodeURIComponent(repo);
1036
+ const branchSegment = encodeURIComponent(branch);
1037
+ const requiredCheckNamesSet = new Set<string>();
1038
+
1039
+ const rulesResponse = await this.fetchWithRateLimitRetry(() =>
1040
+ fetch(
1041
+ `https://api.github.com/repos/${ownerSegment}/${repoSegment}/rules/branches/${branchSegment}?per_page=100`,
1042
+ {
1043
+ method: 'GET',
1044
+ headers: {
1045
+ Authorization: `Bearer ${this.ghToken}`,
1046
+ Accept: 'application/vnd.github+json',
1047
+ },
1048
+ },
1049
+ ),
1050
+ );
1051
+ if (rulesResponse.ok) {
1052
+ const rulesBody: unknown = await rulesResponse.json();
1053
+ if (!isBranchRulesResponse(rulesBody)) {
1054
+ throw new Error(
1055
+ `Unexpected response shape when fetching branch rules: ${owner}/${repo}/${branch}`,
1056
+ );
1057
+ }
1058
+ for (const rule of rulesBody) {
1059
+ if (rule.type !== 'required_status_checks') continue;
1060
+ for (const check of rule.parameters?.required_status_checks || []) {
1061
+ requiredCheckNamesSet.add(check.context);
1062
+ }
1063
+ }
1064
+ } else if (rulesResponse.status !== 404) {
1065
+ const reason = await this.formatGitHubErrorWithStatus(rulesResponse);
1066
+ throw new Error(
1067
+ `Failed to fetch branch rules for ${owner}/${repo}/${branch}: ${reason}`,
1068
+ );
1069
+ }
1070
+
1071
+ const branchResponse = await this.fetchWithRateLimitRetry(() =>
1072
+ fetch(
1073
+ `https://api.github.com/repos/${ownerSegment}/${repoSegment}/branches/${branchSegment}`,
1074
+ {
1075
+ method: 'GET',
1076
+ headers: {
1077
+ Authorization: `Bearer ${this.ghToken}`,
1078
+ Accept: 'application/vnd.github+json',
1079
+ },
1080
+ },
1081
+ ),
1082
+ );
1083
+ if (branchResponse.ok) {
1084
+ const branchBody: unknown = await branchResponse.json();
1085
+ if (!isBranchDetailResponse(branchBody)) {
1086
+ throw new Error(
1087
+ `Unexpected response shape when fetching branch detail: ${owner}/${repo}/${branch}`,
1088
+ );
1089
+ }
1090
+ for (const context of branchBody.protection?.required_status_checks
1091
+ ?.contexts || []) {
1092
+ requiredCheckNamesSet.add(context);
1093
+ }
1094
+ } else if (branchResponse.status !== 404) {
1095
+ const reason = await this.formatGitHubErrorWithStatus(branchResponse);
1096
+ throw new Error(
1097
+ `Failed to fetch branch detail for ${owner}/${repo}/${branch}: ${reason}`,
1098
+ );
1099
+ }
1100
+
1101
+ const names = Array.from(requiredCheckNamesSet);
1102
+ this.requiredCheckNamesCache.set(cacheKey, {
1103
+ fetchedAtMs: nowMs,
1104
+ names,
1105
+ });
1106
+ return names;
1107
+ };
1108
+
1109
+ private getCommitCiContexts = async (
1110
+ owner: string,
1111
+ repo: string,
1112
+ commitSha: string,
1113
+ ): Promise<CiContextNode[]> => {
1114
+ const ownerSegment = encodeURIComponent(owner);
1115
+ const repoSegment = encodeURIComponent(repo);
1116
+ const shaSegment = encodeURIComponent(commitSha);
1117
+ const contexts: CiContextNode[] = [];
1118
+
1119
+ const perPage = 100;
1120
+ let page = 1;
1121
+ let hasMore = true;
1122
+ while (hasMore) {
1123
+ const checkRunsResponse = await this.fetchWithRateLimitRetry(() =>
1124
+ fetch(
1125
+ `https://api.github.com/repos/${ownerSegment}/${repoSegment}/commits/${shaSegment}/check-runs?per_page=${perPage}&page=${page}`,
1126
+ {
1127
+ method: 'GET',
1128
+ headers: {
1129
+ Authorization: `Bearer ${this.ghToken}`,
1130
+ Accept: 'application/vnd.github+json',
1131
+ },
1132
+ },
1133
+ ),
1134
+ );
1135
+ if (!checkRunsResponse.ok) {
1136
+ const reason =
1137
+ await this.formatGitHubErrorWithStatus(checkRunsResponse);
1138
+ throw new Error(
1139
+ `Failed to fetch check runs for ${owner}/${repo}@${commitSha}: ${reason}`,
1140
+ );
1141
+ }
1142
+ const checkRunsBody: unknown = await checkRunsResponse.json();
1143
+ if (!isCheckRunsResponse(checkRunsBody)) {
1144
+ throw new Error(
1145
+ `Unexpected response shape when fetching check runs: ${owner}/${repo}@${commitSha}`,
1146
+ );
1147
+ }
1148
+ for (const checkRun of checkRunsBody.check_runs) {
1149
+ contexts.push({
1150
+ __typename: 'CheckRun',
1151
+ name: checkRun.name,
1152
+ conclusion: checkRun.conclusion
1153
+ ? checkRun.conclusion.toUpperCase()
1154
+ : null,
1155
+ databaseId: checkRun.id,
1156
+ });
1157
+ }
1158
+ if (
1159
+ checkRunsBody.check_runs.length < perPage ||
1160
+ page * perPage >= checkRunsBody.total_count
1161
+ ) {
1162
+ hasMore = false;
1163
+ } else {
1164
+ page += 1;
1165
+ }
1166
+ }
1167
+
1168
+ const combinedStatusResponse = await this.fetchWithRateLimitRetry(() =>
1169
+ fetch(
1170
+ `https://api.github.com/repos/${ownerSegment}/${repoSegment}/commits/${shaSegment}/status?per_page=100`,
1171
+ {
1172
+ method: 'GET',
1173
+ headers: {
1174
+ Authorization: `Bearer ${this.ghToken}`,
1175
+ Accept: 'application/vnd.github+json',
1176
+ },
1177
+ },
1178
+ ),
1179
+ );
1180
+ if (!combinedStatusResponse.ok) {
1181
+ const reason = await this.formatGitHubErrorWithStatus(
1182
+ combinedStatusResponse,
1183
+ );
1184
+ throw new Error(
1185
+ `Failed to fetch combined status for ${owner}/${repo}@${commitSha}: ${reason}`,
1186
+ );
1187
+ }
1188
+ const combinedStatusBody: unknown = await combinedStatusResponse.json();
1189
+ if (!isCombinedStatusResponse(combinedStatusBody)) {
1190
+ throw new Error(
1191
+ `Unexpected response shape when fetching combined status: ${owner}/${repo}@${commitSha}`,
1192
+ );
1193
+ }
1194
+ for (const status of combinedStatusBody.statuses) {
1195
+ contexts.push({
1196
+ __typename: 'StatusContext',
1197
+ context: status.context,
1198
+ state: status.state.toUpperCase(),
1199
+ });
1200
+ }
1201
+
1202
+ return contexts;
1203
+ };
1204
+
1205
+ private fetchSlimPullRequest = async (
1206
+ owner: string,
1207
+ repo: string,
1208
+ prNumber: number,
1209
+ ): Promise<SlimPullRequest | null> => {
1210
+ const query = `
1211
+ query PullRequestSlimStatus($owner: String!, $repo: String!, $prNumber: Int!, $reviewThreadsAfter: String) {
1212
+ repository(owner: $owner, name: $repo) {
1213
+ pullRequest(number: $prNumber) {
1214
+ url
1215
+ state
1216
+ isDraft
1217
+ headRefName
1218
+ baseRefName
1219
+ mergeable
1220
+ headRefOid
1221
+ reviewThreads(first: 100, after: $reviewThreadsAfter) {
1222
+ pageInfo {
1223
+ endCursor
1224
+ hasNextPage
1225
+ }
1226
+ nodes {
1227
+ isResolved
1228
+ }
1229
+ }
1230
+ }
1231
+ }
1232
+ }
1233
+ `;
1234
+
1235
+ let slimPullRequest: SlimPullRequest | null = null;
1236
+ let reviewThreadsAfter: string | null = null;
1237
+ let hasNextPage = true;
1238
+
1239
+ while (hasNextPage) {
1240
+ const response = await this.fetchWithRateLimitRetry(() =>
1241
+ fetchGithubGraphql({
1242
+ ghToken: this.ghToken,
1243
+ query,
1244
+ variables: { owner, repo, prNumber, reviewThreadsAfter },
1245
+ }),
1246
+ );
1247
+
1248
+ if (!response.ok) {
1249
+ throw new Error(
1250
+ `Failed to fetch pull request from GitHub GraphQL API: HTTP ${response.status}`,
1251
+ );
1252
+ }
1253
+
1254
+ const responseData: unknown = await response.json();
1255
+ if (!isSlimPullRequestResponse(responseData)) {
1256
+ throw new Error('Unexpected response shape when fetching pull request');
1257
+ }
1258
+
1259
+ if (responseData.errors && responseData.errors.length > 0) {
1260
+ throw new Error(
1261
+ `GraphQL errors: ${JSON.stringify(responseData.errors)}`,
1262
+ );
1263
+ }
1264
+
1265
+ const pr = responseData.data?.repository?.pullRequest;
1266
+ if (!pr) {
1267
+ return null;
1268
+ }
1269
+
1270
+ if (!slimPullRequest) {
1271
+ slimPullRequest = {
1272
+ url: pr.url,
1273
+ state: pr.state,
1274
+ isDraft: pr.isDraft,
1275
+ headRefName: pr.headRefName,
1276
+ baseRefName: pr.baseRefName,
1277
+ mergeable: pr.mergeable,
1278
+ headRefOid: pr.headRefOid,
1279
+ reviewThreads: [],
1280
+ };
1281
+ }
1282
+ for (const thread of pr.reviewThreads?.nodes || []) {
1283
+ slimPullRequest.reviewThreads.push({ isResolved: thread.isResolved });
1284
+ }
1285
+
1286
+ hasNextPage = pr.reviewThreads?.pageInfo.hasNextPage === true;
1287
+ reviewThreadsAfter = pr.reviewThreads?.pageInfo.endCursor ?? null;
1288
+ }
1289
+
1290
+ return slimPullRequest;
1291
+ };
1292
+
1293
+ private buildRelatedPullRequestFromSlim = async (
1294
+ owner: string,
1295
+ repo: string,
1296
+ slimPullRequest: SlimPullRequest,
1297
+ ): Promise<RelatedPullRequest> => {
1298
+ const requiredCheckNames = slimPullRequest.baseRefName
1299
+ ? await this.getRequiredCheckNames(
1300
+ owner,
1301
+ repo,
1302
+ slimPullRequest.baseRefName,
1303
+ )
1304
+ : [];
1305
+ const ciContexts = slimPullRequest.headRefOid
1306
+ ? await this.getCommitCiContexts(owner, repo, slimPullRequest.headRefOid)
1307
+ : [];
1308
+ return this.computePrStatus(
1309
+ slimPullRequest.url,
1310
+ slimPullRequest.headRefName,
1311
+ {
1312
+ isDraft: slimPullRequest.isDraft,
1313
+ mergeable: slimPullRequest.mergeable,
1314
+ requiredCheckNames,
1315
+ ciContexts,
1316
+ reviewThreads: slimPullRequest.reviewThreads,
1317
+ },
1318
+ );
1319
+ };
1320
+
1154
1321
  private resolveMergeabilityWithRetry = async (
1155
1322
  owner: string,
1156
1323
  repo: string,
@@ -1160,7 +1327,7 @@ export class ApiV3CheerioRestIssueRepository
1160
1327
  mergeStateStatus: string | null;
1161
1328
  } | null> => {
1162
1329
  const query = `
1163
- query($owner: String!, $repo: String!, $prNumber: Int!) {
1330
+ query PullRequestMergeability($owner: String!, $repo: String!, $prNumber: Int!) {
1164
1331
  repository(owner: $owner, name: $repo) {
1165
1332
  pullRequest(number: $prNumber) {
1166
1333
  mergeable
@@ -1183,16 +1350,10 @@ export class ApiV3CheerioRestIssueRepository
1183
1350
  }
1184
1351
 
1185
1352
  const response = await this.fetchWithRateLimitRetry(() =>
1186
- fetch('https://api.github.com/graphql', {
1187
- method: 'POST',
1188
- headers: {
1189
- Authorization: `Bearer ${this.ghToken}`,
1190
- 'Content-Type': 'application/json',
1191
- },
1192
- body: JSON.stringify({
1193
- query,
1194
- variables: { owner, repo, prNumber },
1195
- }),
1353
+ fetchGithubGraphql({
1354
+ ghToken: this.ghToken,
1355
+ query,
1356
+ variables: { owner, repo, prNumber },
1196
1357
  }),
1197
1358
  );
1198
1359
 
@@ -1244,7 +1405,7 @@ export class ApiV3CheerioRestIssueRepository
1244
1405
  }
1245
1406
 
1246
1407
  const query = `
1247
- query($owner: String!, $repo: String!, $issueNumber: Int!, $after: String) {
1408
+ query IssueRelatedOpenPullRequests($owner: String!, $repo: String!, $issueNumber: Int!, $after: String) {
1248
1409
  repository(owner: $owner, name: $repo) {
1249
1410
  issue(number: $issueNumber) {
1250
1411
  timelineItems(first: 100, after: $after, itemTypes: [CROSS_REFERENCED_EVENT]) {
@@ -1267,68 +1428,6 @@ export class ApiV3CheerioRestIssueRepository
1267
1428
  mergeable
1268
1429
  headRefName
1269
1430
  baseRefName
1270
- baseRepository {
1271
- branchProtectionRules(first: 100) {
1272
- nodes {
1273
- pattern
1274
- requiredStatusCheckContexts
1275
- }
1276
- }
1277
- defaultBranchRef {
1278
- name
1279
- }
1280
- rulesets(first: 100) {
1281
- nodes {
1282
- name
1283
- enforcement
1284
- conditions {
1285
- refName {
1286
- include
1287
- exclude
1288
- }
1289
- }
1290
- rules(first: 100) {
1291
- nodes {
1292
- type
1293
- parameters {
1294
- ... on RequiredStatusChecksParameters {
1295
- requiredStatusChecks {
1296
- context
1297
- }
1298
- }
1299
- }
1300
- }
1301
- }
1302
- }
1303
- }
1304
- }
1305
- commits(last: 1) {
1306
- nodes {
1307
- commit {
1308
- statusCheckRollup {
1309
- contexts(first: 100) {
1310
- nodes {
1311
- __typename
1312
- ... on CheckRun {
1313
- databaseId
1314
- name
1315
- conclusion
1316
- }
1317
- ... on StatusContext {
1318
- context
1319
- state
1320
- }
1321
- }
1322
- }
1323
- }
1324
- }
1325
- }
1326
- }
1327
- reviewThreads(first: 100) {
1328
- nodes {
1329
- isResolved
1330
- }
1331
- }
1332
1431
  baseRef {
1333
1432
  name
1334
1433
  }
@@ -1348,16 +1447,10 @@ export class ApiV3CheerioRestIssueRepository
1348
1447
 
1349
1448
  while (hasNextPage) {
1350
1449
  const response = await this.fetchWithRateLimitRetry(() =>
1351
- fetch('https://api.github.com/graphql', {
1352
- method: 'POST',
1353
- headers: {
1354
- Authorization: `Bearer ${this.ghToken}`,
1355
- 'Content-Type': 'application/json',
1356
- },
1357
- body: JSON.stringify({
1358
- query,
1359
- variables: { owner, repo, issueNumber, after },
1360
- }),
1450
+ fetchGithubGraphql({
1451
+ ghToken: this.ghToken,
1452
+ query,
1453
+ variables: { owner, repo, issueNumber, after },
1361
1454
  }),
1362
1455
  );
1363
1456
 
@@ -1389,16 +1482,9 @@ export class ApiV3CheerioRestIssueRepository
1389
1482
 
1390
1483
  const pr = item.source;
1391
1484
  const prUrl = pr.url || '';
1392
- const baseRefName = pr.baseRefName ?? pr.baseRef?.name;
1393
- const prStatus = this.computePrStatus(
1394
- prUrl,
1395
- pr.headRefName,
1396
- baseRefName,
1397
- pr,
1398
- );
1399
1485
 
1400
- let isConflicted = prStatus.isConflicted;
1401
- let mergeable = prStatus.mergeable;
1486
+ let isConflicted = pr.mergeable === 'CONFLICTING';
1487
+ let mergeable: string | null = pr.mergeable ?? null;
1402
1488
  if (
1403
1489
  pr.number !== undefined &&
1404
1490
  (pr.mergeable === undefined ||
@@ -1437,6 +1523,38 @@ export class ApiV3CheerioRestIssueRepository
1437
1523
  }
1438
1524
  }
1439
1525
 
1526
+ if (pr.number === undefined) continue;
1527
+
1528
+ let prStatus: RelatedPullRequest;
1529
+ try {
1530
+ const slimPullRequest = await this.fetchSlimPullRequest(
1531
+ owner,
1532
+ repo,
1533
+ pr.number,
1534
+ );
1535
+ if (!slimPullRequest || slimPullRequest.state !== 'OPEN') {
1536
+ console.info(
1537
+ `ApiV3CheerioRestIssueRepository: pull request is no longer open, excluding it from related open PRs. prUrl: ${prUrl}`,
1538
+ );
1539
+ continue;
1540
+ }
1541
+ const baseRefName =
1542
+ slimPullRequest.baseRefName ?? pr.baseRefName ?? pr.baseRef?.name;
1543
+ prStatus = await this.buildRelatedPullRequestFromSlim(owner, repo, {
1544
+ ...slimPullRequest,
1545
+ url: slimPullRequest.url || prUrl,
1546
+ headRefName: slimPullRequest.headRefName ?? pr.headRefName,
1547
+ baseRefName,
1548
+ });
1549
+ } catch (error) {
1550
+ const errorMessage =
1551
+ error instanceof Error ? error.message : String(error);
1552
+ console.warn(
1553
+ `ApiV3CheerioRestIssueRepository: fetching pull request status failed, skipping PR for this cycle. prUrl: ${prUrl} error: ${errorMessage}`,
1554
+ );
1555
+ continue;
1556
+ }
1557
+
1440
1558
  relatedPRsMap.set(prUrl, {
1441
1559
  ...prStatus,
1442
1560
  isConflicted,
@@ -1487,118 +1605,16 @@ export class ApiV3CheerioRestIssueRepository
1487
1605
  }
1488
1606
  const { owner, repo, issueNumber: prNumber } = parsedUrl;
1489
1607
 
1490
- const query = `
1491
- query($owner: String!, $repo: String!, $prNumber: Int!) {
1492
- repository(owner: $owner, name: $repo) {
1493
- pullRequest(number: $prNumber) {
1494
- url
1495
- state
1496
- isDraft
1497
- headRefName
1498
- baseRefName
1499
- mergeable
1500
- baseRepository {
1501
- branchProtectionRules(first: 100) {
1502
- nodes {
1503
- pattern
1504
- requiredStatusCheckContexts
1505
- }
1506
- }
1507
- defaultBranchRef {
1508
- name
1509
- }
1510
- rulesets(first: 100) {
1511
- nodes {
1512
- name
1513
- enforcement
1514
- conditions {
1515
- refName {
1516
- include
1517
- exclude
1518
- }
1519
- }
1520
- rules(first: 100) {
1521
- nodes {
1522
- type
1523
- parameters {
1524
- ... on RequiredStatusChecksParameters {
1525
- requiredStatusChecks {
1526
- context
1527
- }
1528
- }
1529
- }
1530
- }
1531
- }
1532
- }
1533
- }
1534
- }
1535
- commits(last: 1) {
1536
- nodes {
1537
- commit {
1538
- statusCheckRollup {
1539
- contexts(first: 100) {
1540
- nodes {
1541
- __typename
1542
- ... on CheckRun {
1543
- databaseId
1544
- name
1545
- conclusion
1546
- }
1547
- ... on StatusContext {
1548
- context
1549
- state
1550
- }
1551
- }
1552
- }
1553
- }
1554
- }
1555
- }
1556
- }
1557
- reviewThreads(first: 100) {
1558
- nodes {
1559
- isResolved
1560
- }
1561
- }
1562
- }
1563
- }
1564
- }
1565
- `;
1566
-
1567
- const response = await this.fetchWithRateLimitRetry(() =>
1568
- fetch('https://api.github.com/graphql', {
1569
- method: 'POST',
1570
- headers: {
1571
- Authorization: `Bearer ${this.ghToken}`,
1572
- 'Content-Type': 'application/json',
1573
- },
1574
- body: JSON.stringify({
1575
- query,
1576
- variables: { owner, repo, prNumber },
1577
- }),
1578
- }),
1608
+ const slimPullRequest = await this.fetchSlimPullRequest(
1609
+ owner,
1610
+ repo,
1611
+ prNumber,
1579
1612
  );
1580
-
1581
- if (!response.ok) {
1582
- throw new Error(
1583
- `Failed to fetch pull request from GitHub GraphQL API: HTTP ${response.status}`,
1584
- );
1585
- }
1586
-
1587
- const responseData: unknown = await response.json();
1588
- if (!isDirectPullRequestResponse(responseData)) {
1589
- throw new Error('Unexpected response shape when fetching pull request');
1590
- }
1591
-
1592
- if (responseData.errors && responseData.errors.length > 0) {
1593
- throw new Error(`GraphQL errors: ${JSON.stringify(responseData.errors)}`);
1594
- }
1595
-
1596
- const pr = responseData.data?.repository?.pullRequest;
1597
- if (!pr || pr.state !== 'OPEN') {
1613
+ if (!slimPullRequest || slimPullRequest.state !== 'OPEN') {
1598
1614
  return null;
1599
1615
  }
1600
1616
 
1601
- return this.computePrStatus(pr.url, pr.headRefName, pr.baseRefName, pr);
1617
+ return this.buildRelatedPullRequestFromSlim(owner, repo, slimPullRequest);
1602
1618
  };
1603
1619
 
1604
1620
  closePullRequest = async (prUrl: string): Promise<void> => {