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