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.
- package/.github/workflows/umino-project.yml +3 -0
- package/CHANGELOG.md +14 -0
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +275 -276
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +794 -325
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +464 -421
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +6 -0
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { mock } from 'jest-mock-extended';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ApiV3CheerioRestIssueRepository,
|
|
4
|
+
REQUIRED_CHECKS_CACHE_TTL_MS,
|
|
5
|
+
} from './ApiV3CheerioRestIssueRepository';
|
|
3
6
|
import { ApiV3IssueRepository } from './ApiV3IssueRepository';
|
|
4
7
|
import { RestIssueRepository } from './RestIssueRepository';
|
|
5
8
|
import {
|
|
@@ -1965,74 +1968,202 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
1965
1968
|
});
|
|
1966
1969
|
});
|
|
1967
1970
|
|
|
1971
|
+
const jsonResponse = (body: object): Response =>
|
|
1972
|
+
new Response(JSON.stringify(body), {
|
|
1973
|
+
status: 200,
|
|
1974
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
const toResponse = (value: Response | object): Response =>
|
|
1978
|
+
value instanceof Response ? value : jsonResponse(value);
|
|
1979
|
+
|
|
1980
|
+
type FetchRoutes = {
|
|
1981
|
+
timeline?: () => Response | object;
|
|
1982
|
+
mergeability?: () => Response | object;
|
|
1983
|
+
slimPullRequest?: (variables: {
|
|
1984
|
+
prNumber: number;
|
|
1985
|
+
reviewThreadsAfter: string | null;
|
|
1986
|
+
}) => Response | object;
|
|
1987
|
+
branchRules?: (url: string) => Response | object;
|
|
1988
|
+
branchDetail?: (url: string) => Response | object;
|
|
1989
|
+
checkRuns?: (url: string) => Response | object;
|
|
1990
|
+
combinedStatus?: (url: string) => Response | object;
|
|
1991
|
+
};
|
|
1992
|
+
|
|
1993
|
+
const requestUrlOf = (input: RequestInfo | URL): string =>
|
|
1994
|
+
typeof input === 'string'
|
|
1995
|
+
? input
|
|
1996
|
+
: input instanceof URL
|
|
1997
|
+
? input.toString()
|
|
1998
|
+
: input.url;
|
|
1999
|
+
|
|
2000
|
+
const requestBodyOf = (init?: RequestInit): string =>
|
|
2001
|
+
typeof init?.body === 'string' ? init.body : '';
|
|
2002
|
+
|
|
2003
|
+
type GraphqlRequestBody = {
|
|
2004
|
+
query: string;
|
|
2005
|
+
variables: {
|
|
2006
|
+
prNumber: number;
|
|
2007
|
+
reviewThreadsAfter: string | null;
|
|
2008
|
+
};
|
|
2009
|
+
};
|
|
2010
|
+
|
|
2011
|
+
const parseGraphqlRequestBody = (init?: RequestInit): GraphqlRequestBody => {
|
|
2012
|
+
const parsed: unknown = JSON.parse(requestBodyOf(init));
|
|
2013
|
+
if (
|
|
2014
|
+
typeof parsed !== 'object' ||
|
|
2015
|
+
parsed === null ||
|
|
2016
|
+
!('query' in parsed) ||
|
|
2017
|
+
typeof parsed.query !== 'string'
|
|
2018
|
+
) {
|
|
2019
|
+
throw new Error('Unexpected GraphQL request body in test');
|
|
2020
|
+
}
|
|
2021
|
+
const rawVariables: unknown =
|
|
2022
|
+
'variables' in parsed ? parsed.variables : null;
|
|
2023
|
+
const variables =
|
|
2024
|
+
typeof rawVariables === 'object' && rawVariables !== null
|
|
2025
|
+
? rawVariables
|
|
2026
|
+
: {};
|
|
2027
|
+
const prNumber =
|
|
2028
|
+
'prNumber' in variables && typeof variables.prNumber === 'number'
|
|
2029
|
+
? variables.prNumber
|
|
2030
|
+
: 0;
|
|
2031
|
+
const reviewThreadsAfter =
|
|
2032
|
+
'reviewThreadsAfter' in variables &&
|
|
2033
|
+
typeof variables.reviewThreadsAfter === 'string'
|
|
2034
|
+
? variables.reviewThreadsAfter
|
|
2035
|
+
: null;
|
|
2036
|
+
return { query: parsed.query, variables: { prNumber, reviewThreadsAfter } };
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
const mockFetchRoutes = (routes: FetchRoutes) =>
|
|
2040
|
+
jest
|
|
2041
|
+
.spyOn(global, 'fetch')
|
|
2042
|
+
.mockImplementation(
|
|
2043
|
+
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
2044
|
+
const url = requestUrlOf(input);
|
|
2045
|
+
if (url === 'https://api.github.com/graphql') {
|
|
2046
|
+
const body = parseGraphqlRequestBody(init);
|
|
2047
|
+
if (body.query.includes('timelineItems') && routes.timeline) {
|
|
2048
|
+
return toResponse(routes.timeline());
|
|
2049
|
+
}
|
|
2050
|
+
if (
|
|
2051
|
+
body.query.includes('mergeStateStatus') &&
|
|
2052
|
+
routes.mergeability
|
|
2053
|
+
) {
|
|
2054
|
+
return toResponse(routes.mergeability());
|
|
2055
|
+
}
|
|
2056
|
+
if (body.query.includes('headRefOid') && routes.slimPullRequest) {
|
|
2057
|
+
return toResponse(routes.slimPullRequest(body.variables));
|
|
2058
|
+
}
|
|
2059
|
+
throw new Error(`Unexpected GraphQL query in test: ${body.query}`);
|
|
2060
|
+
}
|
|
2061
|
+
if (url.includes('/rules/branches/')) {
|
|
2062
|
+
return toResponse(
|
|
2063
|
+
routes.branchRules ? routes.branchRules(url) : [],
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
if (/\/branches\/[^/?]+$/.test(url)) {
|
|
2067
|
+
return toResponse(
|
|
2068
|
+
routes.branchDetail ? routes.branchDetail(url) : {},
|
|
2069
|
+
);
|
|
2070
|
+
}
|
|
2071
|
+
if (url.includes('/check-runs')) {
|
|
2072
|
+
return toResponse(
|
|
2073
|
+
routes.checkRuns
|
|
2074
|
+
? routes.checkRuns(url)
|
|
2075
|
+
: { total_count: 0, check_runs: [] },
|
|
2076
|
+
);
|
|
2077
|
+
}
|
|
2078
|
+
if (url.includes('/status?')) {
|
|
2079
|
+
return toResponse(
|
|
2080
|
+
routes.combinedStatus
|
|
2081
|
+
? routes.combinedStatus(url)
|
|
2082
|
+
: { statuses: [] },
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
throw new Error(`Unexpected fetch URL in test: ${url}`);
|
|
2086
|
+
},
|
|
2087
|
+
);
|
|
2088
|
+
|
|
2089
|
+
type FetchSpy = ReturnType<typeof mockFetchRoutes>;
|
|
2090
|
+
|
|
2091
|
+
const countCallsMatching = (
|
|
2092
|
+
fetchSpy: FetchSpy,
|
|
2093
|
+
predicate: (url: string, body: string) => boolean,
|
|
2094
|
+
): number =>
|
|
2095
|
+
fetchSpy.mock.calls.filter(([input, init]) =>
|
|
2096
|
+
predicate(requestUrlOf(input), requestBodyOf(init)),
|
|
2097
|
+
).length;
|
|
2098
|
+
|
|
2099
|
+
const countMergeabilityQueries = (fetchSpy: FetchSpy): number =>
|
|
2100
|
+
countCallsMatching(
|
|
2101
|
+
fetchSpy,
|
|
2102
|
+
(url, body) =>
|
|
2103
|
+
url === 'https://api.github.com/graphql' &&
|
|
2104
|
+
body.includes('mergeStateStatus'),
|
|
2105
|
+
);
|
|
2106
|
+
|
|
2107
|
+
const buildSlimPullRequestResponse = (
|
|
2108
|
+
overrides: {
|
|
2109
|
+
url?: string;
|
|
2110
|
+
state?: string;
|
|
2111
|
+
isDraft?: boolean;
|
|
2112
|
+
headRefName?: string;
|
|
2113
|
+
baseRefName?: string;
|
|
2114
|
+
mergeable?: string;
|
|
2115
|
+
headRefOid?: string;
|
|
2116
|
+
reviewThreads?: {
|
|
2117
|
+
pageInfo: { endCursor: string | null; hasNextPage: boolean };
|
|
2118
|
+
nodes: Array<{ isResolved: boolean }>;
|
|
2119
|
+
};
|
|
2120
|
+
} = {},
|
|
2121
|
+
) => ({
|
|
2122
|
+
data: {
|
|
2123
|
+
repository: {
|
|
2124
|
+
pullRequest: {
|
|
2125
|
+
url:
|
|
2126
|
+
overrides.url ??
|
|
2127
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2128
|
+
state: overrides.state ?? 'OPEN',
|
|
2129
|
+
isDraft: overrides.isDraft ?? false,
|
|
2130
|
+
headRefName: overrides.headRefName ?? 'feature-branch',
|
|
2131
|
+
baseRefName: overrides.baseRefName ?? 'main',
|
|
2132
|
+
mergeable: overrides.mergeable ?? 'MERGEABLE',
|
|
2133
|
+
headRefOid: overrides.headRefOid ?? 'headsha123',
|
|
2134
|
+
reviewThreads: overrides.reviewThreads ?? {
|
|
2135
|
+
pageInfo: { endCursor: null, hasNextPage: false },
|
|
2136
|
+
nodes: [],
|
|
2137
|
+
},
|
|
2138
|
+
},
|
|
2139
|
+
},
|
|
2140
|
+
},
|
|
2141
|
+
});
|
|
2142
|
+
|
|
1968
2143
|
describe('getOpenPullRequest CI state computation', () => {
|
|
1969
2144
|
afterEach(() => {
|
|
1970
2145
|
jest.restoreAllMocks();
|
|
1971
2146
|
});
|
|
1972
2147
|
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
pullRequest: {
|
|
1984
|
-
url: 'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
1985
|
-
state: 'OPEN',
|
|
1986
|
-
isDraft: false,
|
|
1987
|
-
headRefName: 'dependabot/npm_and_yarn/some-package-2.0.0',
|
|
1988
|
-
baseRefName: 'main',
|
|
1989
|
-
mergeable: 'MERGEABLE',
|
|
1990
|
-
baseRepository: {
|
|
1991
|
-
branchProtectionRules: { nodes: [] },
|
|
1992
|
-
defaultBranchRef: { name: 'main' },
|
|
1993
|
-
rulesets: { nodes: [] },
|
|
2148
|
+
it('returns isCiStateSuccess true when the latest check run per name is success even though an older run has failure', async () => {
|
|
2149
|
+
mockFetchRoutes({
|
|
2150
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2151
|
+
checkRuns: () => ({
|
|
2152
|
+
total_count: 2,
|
|
2153
|
+
check_runs: [
|
|
2154
|
+
{
|
|
2155
|
+
id: 100,
|
|
2156
|
+
name: 'check_pull_requests_to_link_issues',
|
|
2157
|
+
conclusion: 'failure',
|
|
1994
2158
|
},
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
statusCheckRollup: {
|
|
2000
|
-
contexts: {
|
|
2001
|
-
nodes: checkRunNodes,
|
|
2002
|
-
},
|
|
2003
|
-
},
|
|
2004
|
-
},
|
|
2005
|
-
},
|
|
2006
|
-
],
|
|
2159
|
+
{
|
|
2160
|
+
id: 200,
|
|
2161
|
+
name: 'check_pull_requests_to_link_issues',
|
|
2162
|
+
conclusion: 'success',
|
|
2007
2163
|
},
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
},
|
|
2012
|
-
});
|
|
2013
|
-
|
|
2014
|
-
it('returns isCiStateSuccess true when latest CheckRun per name is SUCCESS even though an older run has FAILURE', async () => {
|
|
2015
|
-
jest.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
2016
|
-
new Response(
|
|
2017
|
-
JSON.stringify(
|
|
2018
|
-
buildGraphqlPrResponse([
|
|
2019
|
-
{
|
|
2020
|
-
__typename: 'CheckRun',
|
|
2021
|
-
databaseId: 100,
|
|
2022
|
-
name: 'check_pull_requests_to_link_issues',
|
|
2023
|
-
conclusion: 'FAILURE',
|
|
2024
|
-
},
|
|
2025
|
-
{
|
|
2026
|
-
__typename: 'CheckRun',
|
|
2027
|
-
databaseId: 200,
|
|
2028
|
-
name: 'check_pull_requests_to_link_issues',
|
|
2029
|
-
conclusion: 'SUCCESS',
|
|
2030
|
-
},
|
|
2031
|
-
]),
|
|
2032
|
-
),
|
|
2033
|
-
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
2034
|
-
),
|
|
2035
|
-
);
|
|
2164
|
+
],
|
|
2165
|
+
}),
|
|
2166
|
+
});
|
|
2036
2167
|
|
|
2037
2168
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2038
2169
|
const result = await repository.getOpenPullRequest(
|
|
@@ -2044,29 +2175,40 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2044
2175
|
expect(result?.isPassedAllCiJob).toBe(true);
|
|
2045
2176
|
});
|
|
2046
2177
|
|
|
2047
|
-
it('returns isCiStateSuccess false when latest
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
conclusion: 'FAILURE',
|
|
2063
|
-
},
|
|
2064
|
-
]),
|
|
2065
|
-
),
|
|
2066
|
-
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
2067
|
-
),
|
|
2178
|
+
it('returns isCiStateSuccess false when the latest check run per name has failure conclusion', async () => {
|
|
2179
|
+
mockFetchRoutes({
|
|
2180
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2181
|
+
checkRuns: () => ({
|
|
2182
|
+
total_count: 2,
|
|
2183
|
+
check_runs: [
|
|
2184
|
+
{ id: 100, name: 'ci', conclusion: 'success' },
|
|
2185
|
+
{ id: 200, name: 'ci', conclusion: 'failure' },
|
|
2186
|
+
],
|
|
2187
|
+
}),
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2191
|
+
const result = await repository.getOpenPullRequest(
|
|
2192
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2068
2193
|
);
|
|
2069
2194
|
|
|
2195
|
+
expect(result).not.toBeNull();
|
|
2196
|
+
expect(result?.isCiStateSuccess).toBe(false);
|
|
2197
|
+
expect(result?.isPassedAllCiJob).toBe(false);
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
it('returns isCiStateSuccess false when the latest check run per name has null conclusion (still running)', async () => {
|
|
2201
|
+
mockFetchRoutes({
|
|
2202
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2203
|
+
checkRuns: () => ({
|
|
2204
|
+
total_count: 2,
|
|
2205
|
+
check_runs: [
|
|
2206
|
+
{ id: 100, name: 'ci', conclusion: 'failure' },
|
|
2207
|
+
{ id: 200, name: 'ci', conclusion: null },
|
|
2208
|
+
],
|
|
2209
|
+
}),
|
|
2210
|
+
});
|
|
2211
|
+
|
|
2070
2212
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2071
2213
|
const result = await repository.getOpenPullRequest(
|
|
2072
2214
|
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
@@ -2077,29 +2219,28 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2077
2219
|
expect(result?.isPassedAllCiJob).toBe(false);
|
|
2078
2220
|
});
|
|
2079
2221
|
|
|
2080
|
-
it('returns isCiStateSuccess false when
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
{
|
|
2092
|
-
__typename: 'CheckRun',
|
|
2093
|
-
databaseId: 200,
|
|
2094
|
-
name: 'ci',
|
|
2095
|
-
conclusion: null,
|
|
2096
|
-
},
|
|
2097
|
-
]),
|
|
2098
|
-
),
|
|
2099
|
-
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
2100
|
-
),
|
|
2222
|
+
it('returns isCiStateSuccess false when a commit status context has failure state', async () => {
|
|
2223
|
+
mockFetchRoutes({
|
|
2224
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2225
|
+
combinedStatus: () => ({
|
|
2226
|
+
statuses: [{ context: 'external-ci', state: 'failure' }],
|
|
2227
|
+
}),
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2231
|
+
const result = await repository.getOpenPullRequest(
|
|
2232
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2101
2233
|
);
|
|
2102
2234
|
|
|
2235
|
+
expect(result).not.toBeNull();
|
|
2236
|
+
expect(result?.isCiStateSuccess).toBe(false);
|
|
2237
|
+
});
|
|
2238
|
+
|
|
2239
|
+
it('returns isCiStateSuccess false when the head commit has no check runs and no commit statuses', async () => {
|
|
2240
|
+
mockFetchRoutes({
|
|
2241
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2103
2244
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2104
2245
|
const result = await repository.getOpenPullRequest(
|
|
2105
2246
|
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
@@ -2110,99 +2251,374 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2110
2251
|
expect(result?.isPassedAllCiJob).toBe(false);
|
|
2111
2252
|
});
|
|
2112
2253
|
|
|
2113
|
-
it('
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
},
|
|
2150
|
-
reviewThreads: { nodes: [] },
|
|
2151
|
-
},
|
|
2152
|
-
},
|
|
2254
|
+
it('combines REST check runs and commit statuses into one CI success evaluation', async () => {
|
|
2255
|
+
mockFetchRoutes({
|
|
2256
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2257
|
+
checkRuns: () => ({
|
|
2258
|
+
total_count: 1,
|
|
2259
|
+
check_runs: [{ id: 1, name: 'unit-test', conclusion: 'success' }],
|
|
2260
|
+
}),
|
|
2261
|
+
combinedStatus: () => ({
|
|
2262
|
+
statuses: [{ context: 'external-ci', state: 'success' }],
|
|
2263
|
+
}),
|
|
2264
|
+
});
|
|
2265
|
+
|
|
2266
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2267
|
+
const result = await repository.getOpenPullRequest(
|
|
2268
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2269
|
+
);
|
|
2270
|
+
|
|
2271
|
+
expect(result).not.toBeNull();
|
|
2272
|
+
expect(result?.isCiStateSuccess).toBe(true);
|
|
2273
|
+
expect(result?.isPassedAllCiJob).toBe(true);
|
|
2274
|
+
});
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
describe('getOpenPullRequest required check resolution via REST', () => {
|
|
2278
|
+
afterEach(() => {
|
|
2279
|
+
jest.restoreAllMocks();
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
it('reports missing required checks merged from branch rules and classic branch protection', async () => {
|
|
2283
|
+
mockFetchRoutes({
|
|
2284
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2285
|
+
branchRules: () => [
|
|
2286
|
+
{
|
|
2287
|
+
type: 'required_status_checks',
|
|
2288
|
+
parameters: {
|
|
2289
|
+
required_status_checks: [{ context: 'ruleset-check' }],
|
|
2153
2290
|
},
|
|
2154
|
-
}
|
|
2155
|
-
{
|
|
2156
|
-
|
|
2291
|
+
},
|
|
2292
|
+
{ type: 'deletion' },
|
|
2293
|
+
],
|
|
2294
|
+
branchDetail: () => ({
|
|
2295
|
+
protection: {
|
|
2296
|
+
required_status_checks: { contexts: ['classic-check'] },
|
|
2297
|
+
},
|
|
2298
|
+
}),
|
|
2299
|
+
checkRuns: () => ({
|
|
2300
|
+
total_count: 1,
|
|
2301
|
+
check_runs: [{ id: 1, name: 'ruleset-check', conclusion: 'success' }],
|
|
2302
|
+
}),
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2306
|
+
const result = await repository.getOpenPullRequest(
|
|
2307
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2157
2308
|
);
|
|
2158
2309
|
|
|
2310
|
+
expect(result).not.toBeNull();
|
|
2311
|
+
expect(result?.missingRequiredCheckNames).toEqual(['classic-check']);
|
|
2312
|
+
expect(result?.isCiStateSuccess).toBe(true);
|
|
2313
|
+
expect(result?.isPassedAllCiJob).toBe(false);
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
it('passes all required checks when every required check name has reported on the head commit', async () => {
|
|
2317
|
+
mockFetchRoutes({
|
|
2318
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2319
|
+
branchRules: () => [
|
|
2320
|
+
{
|
|
2321
|
+
type: 'required_status_checks',
|
|
2322
|
+
parameters: {
|
|
2323
|
+
required_status_checks: [{ context: 'ruleset-check' }],
|
|
2324
|
+
},
|
|
2325
|
+
},
|
|
2326
|
+
],
|
|
2327
|
+
branchDetail: () => ({
|
|
2328
|
+
protection: {
|
|
2329
|
+
required_status_checks: { contexts: ['classic-check'] },
|
|
2330
|
+
},
|
|
2331
|
+
}),
|
|
2332
|
+
checkRuns: () => ({
|
|
2333
|
+
total_count: 2,
|
|
2334
|
+
check_runs: [
|
|
2335
|
+
{ id: 1, name: 'ruleset-check', conclusion: 'success' },
|
|
2336
|
+
{ id: 2, name: 'classic-check', conclusion: 'success' },
|
|
2337
|
+
],
|
|
2338
|
+
}),
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2159
2341
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2160
2342
|
const result = await repository.getOpenPullRequest(
|
|
2161
2343
|
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2162
2344
|
);
|
|
2163
2345
|
|
|
2164
2346
|
expect(result).not.toBeNull();
|
|
2165
|
-
expect(result?.
|
|
2347
|
+
expect(result?.missingRequiredCheckNames).toEqual([]);
|
|
2348
|
+
expect(result?.isPassedAllCiJob).toBe(true);
|
|
2166
2349
|
});
|
|
2167
2350
|
|
|
2168
|
-
it('
|
|
2169
|
-
jest.spyOn(
|
|
2351
|
+
it('treats HTTP 403 from branch rules and branch detail as no required checks and keeps evaluating the PR', async () => {
|
|
2352
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
2353
|
+
const planLimitationMessage =
|
|
2354
|
+
'Upgrade to GitHub Pro or make this repository public to enable this feature.';
|
|
2355
|
+
mockFetchRoutes({
|
|
2356
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2357
|
+
branchRules: () =>
|
|
2358
|
+
new Response(JSON.stringify({ message: planLimitationMessage }), {
|
|
2359
|
+
status: 403,
|
|
2360
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2361
|
+
}),
|
|
2362
|
+
branchDetail: () =>
|
|
2363
|
+
new Response(JSON.stringify({ message: planLimitationMessage }), {
|
|
2364
|
+
status: 403,
|
|
2365
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2366
|
+
}),
|
|
2367
|
+
checkRuns: () => ({
|
|
2368
|
+
total_count: 1,
|
|
2369
|
+
check_runs: [{ id: 1, name: 'unit-test', conclusion: 'success' }],
|
|
2370
|
+
}),
|
|
2371
|
+
});
|
|
2372
|
+
|
|
2373
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2374
|
+
const result = await repository.getOpenPullRequest(
|
|
2375
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2376
|
+
);
|
|
2377
|
+
|
|
2378
|
+
expect(result).not.toBeNull();
|
|
2379
|
+
expect(result?.missingRequiredCheckNames).toEqual([]);
|
|
2380
|
+
expect(result?.isCiStateSuccess).toBe(true);
|
|
2381
|
+
expect(result?.isPassedAllCiJob).toBe(true);
|
|
2382
|
+
const warnMessages = warnSpy.mock.calls.map((call) => String(call[0]));
|
|
2383
|
+
expect(
|
|
2384
|
+
warnMessages.some(
|
|
2385
|
+
(message) =>
|
|
2386
|
+
message.includes('branch rules are not accessible') &&
|
|
2387
|
+
message.includes(planLimitationMessage),
|
|
2388
|
+
),
|
|
2389
|
+
).toBe(true);
|
|
2390
|
+
expect(
|
|
2391
|
+
warnMessages.some(
|
|
2392
|
+
(message) =>
|
|
2393
|
+
message.includes(
|
|
2394
|
+
'branch detail (classic protection) is not accessible',
|
|
2395
|
+
) && message.includes(planLimitationMessage),
|
|
2396
|
+
),
|
|
2397
|
+
).toBe(true);
|
|
2398
|
+
});
|
|
2399
|
+
|
|
2400
|
+
it('caches the empty result from an HTTP 403 so the branch rules endpoint is not fetched again within the TTL', async () => {
|
|
2401
|
+
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
2402
|
+
const forbidden = () =>
|
|
2170
2403
|
new Response(
|
|
2171
2404
|
JSON.stringify({
|
|
2172
|
-
|
|
2173
|
-
repository
|
|
2174
|
-
pullRequest: {
|
|
2175
|
-
url: 'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2176
|
-
state: 'OPEN',
|
|
2177
|
-
isDraft: false,
|
|
2178
|
-
headRefName: 'feature-branch',
|
|
2179
|
-
baseRefName: 'main',
|
|
2180
|
-
mergeable: 'MERGEABLE',
|
|
2181
|
-
baseRepository: {
|
|
2182
|
-
branchProtectionRules: { nodes: [] },
|
|
2183
|
-
defaultBranchRef: { name: 'main' },
|
|
2184
|
-
rulesets: { nodes: [] },
|
|
2185
|
-
},
|
|
2186
|
-
commits: {
|
|
2187
|
-
nodes: [{ commit: { statusCheckRollup: null } }],
|
|
2188
|
-
},
|
|
2189
|
-
reviewThreads: { nodes: [] },
|
|
2190
|
-
},
|
|
2191
|
-
},
|
|
2192
|
-
},
|
|
2405
|
+
message:
|
|
2406
|
+
'Upgrade to GitHub Pro or make this repository public to enable this feature.',
|
|
2193
2407
|
}),
|
|
2194
|
-
{ status:
|
|
2408
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
|
2409
|
+
);
|
|
2410
|
+
const fetchSpy = mockFetchRoutes({
|
|
2411
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2412
|
+
branchRules: forbidden,
|
|
2413
|
+
branchDetail: forbidden,
|
|
2414
|
+
});
|
|
2415
|
+
|
|
2416
|
+
const { repository, dateRepository } =
|
|
2417
|
+
createApiV3CheerioRestIssueRepository();
|
|
2418
|
+
dateRepository.now.mockResolvedValue(
|
|
2419
|
+
new Date('2026-01-01T00:00:00.000Z'),
|
|
2420
|
+
);
|
|
2421
|
+
await repository.getOpenPullRequest(
|
|
2422
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2423
|
+
);
|
|
2424
|
+
await repository.getOpenPullRequest(
|
|
2425
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2426
|
+
);
|
|
2427
|
+
|
|
2428
|
+
expect(
|
|
2429
|
+
countCallsMatching(fetchSpy, (url) => url.includes('/rules/branches/')),
|
|
2430
|
+
).toBe(1);
|
|
2431
|
+
expect(
|
|
2432
|
+
countCallsMatching(fetchSpy, (url) => /\/branches\/[^/?]+$/.test(url)),
|
|
2433
|
+
).toBe(1);
|
|
2434
|
+
});
|
|
2435
|
+
|
|
2436
|
+
it('treats HTTP 404 from branch rules and branch detail as no required checks without warning', async () => {
|
|
2437
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
2438
|
+
mockFetchRoutes({
|
|
2439
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2440
|
+
branchRules: () =>
|
|
2441
|
+
new Response(JSON.stringify({ message: 'Not Found' }), {
|
|
2442
|
+
status: 404,
|
|
2443
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2444
|
+
}),
|
|
2445
|
+
branchDetail: () =>
|
|
2446
|
+
new Response(JSON.stringify({ message: 'Branch not found' }), {
|
|
2447
|
+
status: 404,
|
|
2448
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2449
|
+
}),
|
|
2450
|
+
checkRuns: () => ({
|
|
2451
|
+
total_count: 1,
|
|
2452
|
+
check_runs: [{ id: 1, name: 'unit-test', conclusion: 'success' }],
|
|
2453
|
+
}),
|
|
2454
|
+
});
|
|
2455
|
+
|
|
2456
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2457
|
+
const result = await repository.getOpenPullRequest(
|
|
2458
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2459
|
+
);
|
|
2460
|
+
|
|
2461
|
+
expect(result).not.toBeNull();
|
|
2462
|
+
expect(result?.missingRequiredCheckNames).toEqual([]);
|
|
2463
|
+
expect(result?.isPassedAllCiJob).toBe(true);
|
|
2464
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
it('still throws when branch rules fetch fails with a non-403 non-404 status', async () => {
|
|
2468
|
+
mockFetchRoutes({
|
|
2469
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2470
|
+
branchRules: () =>
|
|
2471
|
+
new Response(JSON.stringify({ message: 'Server Error' }), {
|
|
2472
|
+
status: 500,
|
|
2473
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2474
|
+
}),
|
|
2475
|
+
});
|
|
2476
|
+
|
|
2477
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2478
|
+
await expect(
|
|
2479
|
+
repository.getOpenPullRequest(
|
|
2480
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2195
2481
|
),
|
|
2482
|
+
).rejects.toThrow(/Failed to fetch branch rules/);
|
|
2483
|
+
});
|
|
2484
|
+
});
|
|
2485
|
+
|
|
2486
|
+
describe('required check names TTL cache', () => {
|
|
2487
|
+
afterEach(() => {
|
|
2488
|
+
jest.restoreAllMocks();
|
|
2489
|
+
});
|
|
2490
|
+
|
|
2491
|
+
it('fetches branch rules only once for repeated calls on the same base branch within the TTL', async () => {
|
|
2492
|
+
const fetchSpy = mockFetchRoutes({
|
|
2493
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2494
|
+
});
|
|
2495
|
+
|
|
2496
|
+
const { repository, dateRepository } =
|
|
2497
|
+
createApiV3CheerioRestIssueRepository();
|
|
2498
|
+
dateRepository.now.mockResolvedValue(
|
|
2499
|
+
new Date('2026-01-01T00:00:00.000Z'),
|
|
2500
|
+
);
|
|
2501
|
+
await repository.getOpenPullRequest(
|
|
2502
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2503
|
+
);
|
|
2504
|
+
await repository.getOpenPullRequest(
|
|
2505
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2506
|
+
);
|
|
2507
|
+
|
|
2508
|
+
expect(
|
|
2509
|
+
countCallsMatching(fetchSpy, (url) => url.includes('/rules/branches/')),
|
|
2510
|
+
).toBe(1);
|
|
2511
|
+
expect(
|
|
2512
|
+
countCallsMatching(fetchSpy, (url) => /\/branches\/[^/?]+$/.test(url)),
|
|
2513
|
+
).toBe(1);
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
it('fetches branch rules again after the TTL has expired', async () => {
|
|
2517
|
+
const fetchSpy = mockFetchRoutes({
|
|
2518
|
+
slimPullRequest: () => buildSlimPullRequestResponse(),
|
|
2519
|
+
});
|
|
2520
|
+
|
|
2521
|
+
const { repository, dateRepository } =
|
|
2522
|
+
createApiV3CheerioRestIssueRepository();
|
|
2523
|
+
const baseTimeMs = new Date('2026-01-01T00:00:00.000Z').getTime();
|
|
2524
|
+
dateRepository.now
|
|
2525
|
+
.mockResolvedValueOnce(new Date(baseTimeMs))
|
|
2526
|
+
.mockResolvedValueOnce(
|
|
2527
|
+
new Date(baseTimeMs + REQUIRED_CHECKS_CACHE_TTL_MS + 1),
|
|
2528
|
+
);
|
|
2529
|
+
await repository.getOpenPullRequest(
|
|
2530
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2531
|
+
);
|
|
2532
|
+
await repository.getOpenPullRequest(
|
|
2533
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2196
2534
|
);
|
|
2197
2535
|
|
|
2536
|
+
expect(
|
|
2537
|
+
countCallsMatching(fetchSpy, (url) => url.includes('/rules/branches/')),
|
|
2538
|
+
).toBe(2);
|
|
2539
|
+
});
|
|
2540
|
+
|
|
2541
|
+
it('fetches branch rules separately for different base branches (cache miss)', async () => {
|
|
2542
|
+
const fetchSpy = mockFetchRoutes({
|
|
2543
|
+
slimPullRequest: (variables) =>
|
|
2544
|
+
buildSlimPullRequestResponse({
|
|
2545
|
+
url: `https://github.com/HiromiShikata/test-repository/pull/${variables.prNumber}`,
|
|
2546
|
+
baseRefName: variables.prNumber === 31 ? 'main' : 'develop',
|
|
2547
|
+
}),
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
const { repository, dateRepository } =
|
|
2551
|
+
createApiV3CheerioRestIssueRepository();
|
|
2552
|
+
dateRepository.now.mockResolvedValue(
|
|
2553
|
+
new Date('2026-01-01T00:00:00.000Z'),
|
|
2554
|
+
);
|
|
2555
|
+
await repository.getOpenPullRequest(
|
|
2556
|
+
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2557
|
+
);
|
|
2558
|
+
await repository.getOpenPullRequest(
|
|
2559
|
+
'https://github.com/HiromiShikata/test-repository/pull/32',
|
|
2560
|
+
);
|
|
2561
|
+
|
|
2562
|
+
expect(
|
|
2563
|
+
countCallsMatching(fetchSpy, (url) =>
|
|
2564
|
+
url.includes('/rules/branches/main'),
|
|
2565
|
+
),
|
|
2566
|
+
).toBe(1);
|
|
2567
|
+
expect(
|
|
2568
|
+
countCallsMatching(fetchSpy, (url) =>
|
|
2569
|
+
url.includes('/rules/branches/develop'),
|
|
2570
|
+
),
|
|
2571
|
+
).toBe(1);
|
|
2572
|
+
});
|
|
2573
|
+
});
|
|
2574
|
+
|
|
2575
|
+
describe('getOpenPullRequest review thread paging', () => {
|
|
2576
|
+
afterEach(() => {
|
|
2577
|
+
jest.restoreAllMocks();
|
|
2578
|
+
});
|
|
2579
|
+
|
|
2580
|
+
it('fetches every review thread page and evaluates resolution over all pages', async () => {
|
|
2581
|
+
const fetchSpy = mockFetchRoutes({
|
|
2582
|
+
slimPullRequest: (variables) =>
|
|
2583
|
+
buildSlimPullRequestResponse({
|
|
2584
|
+
reviewThreads:
|
|
2585
|
+
variables.reviewThreadsAfter === null
|
|
2586
|
+
? {
|
|
2587
|
+
pageInfo: { endCursor: 'cursor-1', hasNextPage: true },
|
|
2588
|
+
nodes: [{ isResolved: true }],
|
|
2589
|
+
}
|
|
2590
|
+
: {
|
|
2591
|
+
pageInfo: { endCursor: null, hasNextPage: false },
|
|
2592
|
+
nodes: [{ isResolved: false }],
|
|
2593
|
+
},
|
|
2594
|
+
}),
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2198
2597
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2199
2598
|
const result = await repository.getOpenPullRequest(
|
|
2200
2599
|
'https://github.com/HiromiShikata/test-repository/pull/31',
|
|
2201
2600
|
);
|
|
2202
2601
|
|
|
2203
2602
|
expect(result).not.toBeNull();
|
|
2204
|
-
expect(result?.
|
|
2205
|
-
expect(
|
|
2603
|
+
expect(result?.isResolvedAllReviewComments).toBe(false);
|
|
2604
|
+
expect(
|
|
2605
|
+
countCallsMatching(
|
|
2606
|
+
fetchSpy,
|
|
2607
|
+
(url, body) =>
|
|
2608
|
+
url === 'https://api.github.com/graphql' &&
|
|
2609
|
+
body.includes('headRefOid'),
|
|
2610
|
+
),
|
|
2611
|
+
).toBe(2);
|
|
2612
|
+
const secondSlimCall = fetchSpy.mock.calls
|
|
2613
|
+
.map(([input, init]) =>
|
|
2614
|
+
requestUrlOf(input) === 'https://api.github.com/graphql'
|
|
2615
|
+
? parseGraphqlRequestBody(init)
|
|
2616
|
+
: null,
|
|
2617
|
+
)
|
|
2618
|
+
.filter(
|
|
2619
|
+
(body) => body !== null && body.query.includes('headRefOid'),
|
|
2620
|
+
)[1];
|
|
2621
|
+
expect(secondSlimCall?.variables.reviewThreadsAfter).toBe('cursor-1');
|
|
2206
2622
|
});
|
|
2207
2623
|
});
|
|
2208
2624
|
|
|
@@ -2211,37 +2627,33 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2211
2627
|
jest.restoreAllMocks();
|
|
2212
2628
|
});
|
|
2213
2629
|
|
|
2630
|
+
const buildPullRequestTimelineNode = (
|
|
2631
|
+
prNumber: number,
|
|
2632
|
+
mergeable: string,
|
|
2633
|
+
) => ({
|
|
2634
|
+
__typename: 'CrossReferencedEvent',
|
|
2635
|
+
willCloseTarget: true,
|
|
2636
|
+
source: {
|
|
2637
|
+
__typename: 'PullRequest',
|
|
2638
|
+
url: `https://github.com/HiromiShikata/test-repository/pull/${prNumber}`,
|
|
2639
|
+
number: prNumber,
|
|
2640
|
+
state: 'OPEN',
|
|
2641
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
2642
|
+
isDraft: false,
|
|
2643
|
+
mergeable,
|
|
2644
|
+
headRefName: 'feature-branch',
|
|
2645
|
+
baseRefName: 'main',
|
|
2646
|
+
baseRef: { name: 'main' },
|
|
2647
|
+
},
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2214
2650
|
const buildTimelineResponse = (mergeable: string) => ({
|
|
2215
2651
|
data: {
|
|
2216
2652
|
repository: {
|
|
2217
2653
|
issue: {
|
|
2218
2654
|
timelineItems: {
|
|
2219
2655
|
pageInfo: { endCursor: null, hasNextPage: false },
|
|
2220
|
-
nodes: [
|
|
2221
|
-
{
|
|
2222
|
-
__typename: 'CrossReferencedEvent',
|
|
2223
|
-
willCloseTarget: true,
|
|
2224
|
-
source: {
|
|
2225
|
-
__typename: 'PullRequest',
|
|
2226
|
-
url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
|
|
2227
|
-
number: 11148,
|
|
2228
|
-
state: 'OPEN',
|
|
2229
|
-
createdAt: '2024-01-01T00:00:00Z',
|
|
2230
|
-
isDraft: false,
|
|
2231
|
-
mergeable,
|
|
2232
|
-
headRefName: 'feature-branch',
|
|
2233
|
-
baseRefName: 'main',
|
|
2234
|
-
baseRepository: {
|
|
2235
|
-
branchProtectionRules: { nodes: [] },
|
|
2236
|
-
defaultBranchRef: { name: 'main' },
|
|
2237
|
-
rulesets: { nodes: [] },
|
|
2238
|
-
},
|
|
2239
|
-
commits: { nodes: [] },
|
|
2240
|
-
reviewThreads: { nodes: [] },
|
|
2241
|
-
baseRef: { name: 'main' },
|
|
2242
|
-
},
|
|
2243
|
-
},
|
|
2244
|
-
],
|
|
2656
|
+
nodes: [buildPullRequestTimelineNode(11148, mergeable)],
|
|
2245
2657
|
},
|
|
2246
2658
|
},
|
|
2247
2659
|
},
|
|
@@ -2262,56 +2674,35 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2262
2674
|
},
|
|
2263
2675
|
});
|
|
2264
2676
|
|
|
2677
|
+
const slimForPrNumber = (variables: { prNumber: number }) =>
|
|
2678
|
+
buildSlimPullRequestResponse({
|
|
2679
|
+
url: `https://github.com/HiromiShikata/test-repository/pull/${variables.prNumber}`,
|
|
2680
|
+
});
|
|
2681
|
+
|
|
2265
2682
|
it('resolves isConflicted true via a direct query when the timeline node reports mergeable UNKNOWN but the direct query reports CONFLICTING', async () => {
|
|
2266
|
-
const fetchSpy =
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2272
|
-
}),
|
|
2273
|
-
)
|
|
2274
|
-
.mockResolvedValueOnce(
|
|
2275
|
-
new Response(
|
|
2276
|
-
JSON.stringify(buildMergeabilityResponse('CONFLICTING', 'DIRTY')),
|
|
2277
|
-
{
|
|
2278
|
-
status: 200,
|
|
2279
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2280
|
-
},
|
|
2281
|
-
),
|
|
2282
|
-
);
|
|
2683
|
+
const fetchSpy = mockFetchRoutes({
|
|
2684
|
+
timeline: () => buildTimelineResponse('UNKNOWN'),
|
|
2685
|
+
mergeability: () => buildMergeabilityResponse('CONFLICTING', 'DIRTY'),
|
|
2686
|
+
slimPullRequest: slimForPrNumber,
|
|
2687
|
+
});
|
|
2283
2688
|
|
|
2284
2689
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2285
2690
|
const result = await repository.findRelatedOpenPRs(
|
|
2286
2691
|
'https://github.com/HiromiShikata/test-repository/issues/11194',
|
|
2287
2692
|
);
|
|
2288
2693
|
|
|
2289
|
-
expect(fetchSpy).
|
|
2694
|
+
expect(countMergeabilityQueries(fetchSpy)).toBe(1);
|
|
2290
2695
|
expect(result).toHaveLength(1);
|
|
2291
2696
|
expect(result[0].isConflicted).toBe(true);
|
|
2292
2697
|
expect(result[0].mergeable).toBe('CONFLICTING');
|
|
2293
2698
|
});
|
|
2294
2699
|
|
|
2295
2700
|
it('resolves isConflicted true when the direct query returns mergeable UNKNOWN but mergeStateStatus DIRTY', async () => {
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2302
|
-
}),
|
|
2303
|
-
)
|
|
2304
|
-
.mockImplementation(() =>
|
|
2305
|
-
Promise.resolve(
|
|
2306
|
-
new Response(
|
|
2307
|
-
JSON.stringify(buildMergeabilityResponse('UNKNOWN', 'DIRTY')),
|
|
2308
|
-
{
|
|
2309
|
-
status: 200,
|
|
2310
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2311
|
-
},
|
|
2312
|
-
),
|
|
2313
|
-
),
|
|
2314
|
-
);
|
|
2701
|
+
mockFetchRoutes({
|
|
2702
|
+
timeline: () => buildTimelineResponse('UNKNOWN'),
|
|
2703
|
+
mergeability: () => buildMergeabilityResponse('UNKNOWN', 'DIRTY'),
|
|
2704
|
+
slimPullRequest: slimForPrNumber,
|
|
2705
|
+
});
|
|
2315
2706
|
|
|
2316
2707
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2317
2708
|
const result = await repository.findRelatedOpenPRs(
|
|
@@ -2323,82 +2714,39 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2323
2714
|
});
|
|
2324
2715
|
|
|
2325
2716
|
it('keeps isConflicted false when mergeability stays UNKNOWN after the bounded retries', async () => {
|
|
2326
|
-
const fetchSpy =
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2332
|
-
}),
|
|
2333
|
-
)
|
|
2334
|
-
.mockImplementation(() =>
|
|
2335
|
-
Promise.resolve(
|
|
2336
|
-
new Response(
|
|
2337
|
-
JSON.stringify(buildMergeabilityResponse('UNKNOWN', 'UNKNOWN')),
|
|
2338
|
-
{
|
|
2339
|
-
status: 200,
|
|
2340
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2341
|
-
},
|
|
2342
|
-
),
|
|
2343
|
-
),
|
|
2344
|
-
);
|
|
2717
|
+
const fetchSpy = mockFetchRoutes({
|
|
2718
|
+
timeline: () => buildTimelineResponse('UNKNOWN'),
|
|
2719
|
+
mergeability: () => buildMergeabilityResponse('UNKNOWN', 'UNKNOWN'),
|
|
2720
|
+
slimPullRequest: slimForPrNumber,
|
|
2721
|
+
});
|
|
2345
2722
|
|
|
2346
2723
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2347
2724
|
const result = await repository.findRelatedOpenPRs(
|
|
2348
2725
|
'https://github.com/HiromiShikata/test-repository/issues/11194',
|
|
2349
2726
|
);
|
|
2350
2727
|
|
|
2351
|
-
expect(fetchSpy).
|
|
2728
|
+
expect(countMergeabilityQueries(fetchSpy)).toBe(3);
|
|
2352
2729
|
expect(result).toHaveLength(1);
|
|
2353
2730
|
expect(result[0].isConflicted).toBe(false);
|
|
2354
2731
|
});
|
|
2355
2732
|
|
|
2356
2733
|
it('does not issue a direct mergeability query when the timeline node already reports a definitive mergeable value', async () => {
|
|
2357
|
-
const fetchSpy =
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
}),
|
|
2362
|
-
);
|
|
2734
|
+
const fetchSpy = mockFetchRoutes({
|
|
2735
|
+
timeline: () => buildTimelineResponse('MERGEABLE'),
|
|
2736
|
+
slimPullRequest: slimForPrNumber,
|
|
2737
|
+
});
|
|
2363
2738
|
|
|
2364
2739
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2365
2740
|
const result = await repository.findRelatedOpenPRs(
|
|
2366
2741
|
'https://github.com/HiromiShikata/test-repository/issues/11194',
|
|
2367
2742
|
);
|
|
2368
2743
|
|
|
2369
|
-
expect(fetchSpy).
|
|
2744
|
+
expect(countMergeabilityQueries(fetchSpy)).toBe(0);
|
|
2370
2745
|
expect(result).toHaveLength(1);
|
|
2371
2746
|
expect(result[0].isConflicted).toBe(false);
|
|
2372
2747
|
expect(result[0].mergeable).toBe('MERGEABLE');
|
|
2373
2748
|
});
|
|
2374
2749
|
|
|
2375
|
-
const buildPullRequestTimelineNode = (
|
|
2376
|
-
prNumber: number,
|
|
2377
|
-
mergeable: string,
|
|
2378
|
-
) => ({
|
|
2379
|
-
__typename: 'CrossReferencedEvent',
|
|
2380
|
-
willCloseTarget: true,
|
|
2381
|
-
source: {
|
|
2382
|
-
__typename: 'PullRequest',
|
|
2383
|
-
url: `https://github.com/HiromiShikata/test-repository/pull/${prNumber}`,
|
|
2384
|
-
number: prNumber,
|
|
2385
|
-
state: 'OPEN',
|
|
2386
|
-
createdAt: '2024-01-01T00:00:00Z',
|
|
2387
|
-
isDraft: false,
|
|
2388
|
-
mergeable,
|
|
2389
|
-
headRefName: 'feature-branch',
|
|
2390
|
-
baseRefName: 'main',
|
|
2391
|
-
baseRepository: {
|
|
2392
|
-
branchProtectionRules: { nodes: [] },
|
|
2393
|
-
defaultBranchRef: { name: 'main' },
|
|
2394
|
-
rulesets: { nodes: [] },
|
|
2395
|
-
},
|
|
2396
|
-
commits: { nodes: [] },
|
|
2397
|
-
reviewThreads: { nodes: [] },
|
|
2398
|
-
baseRef: { name: 'main' },
|
|
2399
|
-
},
|
|
2400
|
-
});
|
|
2401
|
-
|
|
2402
2750
|
const buildTwoPrTimelineResponse = () => ({
|
|
2403
2751
|
data: {
|
|
2404
2752
|
repository: {
|
|
@@ -2419,33 +2767,21 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2419
2767
|
const consoleInfoSpy = jest
|
|
2420
2768
|
.spyOn(console, 'info')
|
|
2421
2769
|
.mockImplementation(() => undefined);
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2428
|
-
}),
|
|
2429
|
-
)
|
|
2430
|
-
.mockResolvedValueOnce(
|
|
2431
|
-
new Response(
|
|
2432
|
-
JSON.stringify({
|
|
2433
|
-
data: { repository: { pullRequest: null } },
|
|
2434
|
-
errors: [
|
|
2435
|
-
{
|
|
2436
|
-
type: 'NOT_FOUND',
|
|
2437
|
-
path: ['repository', 'pullRequest'],
|
|
2438
|
-
message:
|
|
2439
|
-
'Could not resolve to a PullRequest with the number of 11148.',
|
|
2440
|
-
},
|
|
2441
|
-
],
|
|
2442
|
-
}),
|
|
2770
|
+
mockFetchRoutes({
|
|
2771
|
+
timeline: () => buildTwoPrTimelineResponse(),
|
|
2772
|
+
mergeability: () => ({
|
|
2773
|
+
data: { repository: { pullRequest: null } },
|
|
2774
|
+
errors: [
|
|
2443
2775
|
{
|
|
2444
|
-
|
|
2445
|
-
|
|
2776
|
+
type: 'NOT_FOUND',
|
|
2777
|
+
path: ['repository', 'pullRequest'],
|
|
2778
|
+
message:
|
|
2779
|
+
'Could not resolve to a PullRequest with the number of 11148.',
|
|
2446
2780
|
},
|
|
2447
|
-
|
|
2448
|
-
)
|
|
2781
|
+
],
|
|
2782
|
+
}),
|
|
2783
|
+
slimPullRequest: slimForPrNumber,
|
|
2784
|
+
});
|
|
2449
2785
|
|
|
2450
2786
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2451
2787
|
const result = await repository.findRelatedOpenPRs(
|
|
@@ -2468,17 +2804,12 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2468
2804
|
const consoleWarnSpy = jest
|
|
2469
2805
|
.spyOn(console, 'warn')
|
|
2470
2806
|
.mockImplementation(() => undefined);
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
new Response(JSON.stringify(buildTwoPrTimelineResponse()), {
|
|
2475
|
-
status: 200,
|
|
2476
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2477
|
-
}),
|
|
2478
|
-
)
|
|
2479
|
-
.mockResolvedValueOnce(
|
|
2807
|
+
mockFetchRoutes({
|
|
2808
|
+
timeline: () => buildTwoPrTimelineResponse(),
|
|
2809
|
+
mergeability: () =>
|
|
2480
2810
|
new Response('Internal Server Error', { status: 500 }),
|
|
2481
|
-
|
|
2811
|
+
slimPullRequest: slimForPrNumber,
|
|
2812
|
+
});
|
|
2482
2813
|
|
|
2483
2814
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2484
2815
|
const result = await repository.findRelatedOpenPRs(
|
|
@@ -2498,6 +2829,143 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2498
2829
|
});
|
|
2499
2830
|
});
|
|
2500
2831
|
|
|
2832
|
+
describe('findRelatedOpenPRs two-stage split', () => {
|
|
2833
|
+
afterEach(() => {
|
|
2834
|
+
jest.restoreAllMocks();
|
|
2835
|
+
});
|
|
2836
|
+
|
|
2837
|
+
const buildSlimTimelineResponse = () => ({
|
|
2838
|
+
data: {
|
|
2839
|
+
repository: {
|
|
2840
|
+
issue: {
|
|
2841
|
+
timelineItems: {
|
|
2842
|
+
pageInfo: { endCursor: null, hasNextPage: false },
|
|
2843
|
+
nodes: [
|
|
2844
|
+
{
|
|
2845
|
+
__typename: 'CrossReferencedEvent',
|
|
2846
|
+
willCloseTarget: true,
|
|
2847
|
+
source: {
|
|
2848
|
+
__typename: 'PullRequest',
|
|
2849
|
+
url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
|
|
2850
|
+
number: 11148,
|
|
2851
|
+
state: 'OPEN',
|
|
2852
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
2853
|
+
isDraft: false,
|
|
2854
|
+
mergeable: 'MERGEABLE',
|
|
2855
|
+
headRefName: 'feature-branch',
|
|
2856
|
+
baseRefName: 'main',
|
|
2857
|
+
baseRef: { name: 'main' },
|
|
2858
|
+
},
|
|
2859
|
+
},
|
|
2860
|
+
],
|
|
2861
|
+
},
|
|
2862
|
+
},
|
|
2863
|
+
},
|
|
2864
|
+
},
|
|
2865
|
+
});
|
|
2866
|
+
|
|
2867
|
+
it('produces the same evaluation inputs from the two-stage flow and sends no nested rules connections in any GraphQL query', async () => {
|
|
2868
|
+
const fetchSpy = mockFetchRoutes({
|
|
2869
|
+
timeline: () => buildSlimTimelineResponse(),
|
|
2870
|
+
slimPullRequest: () =>
|
|
2871
|
+
buildSlimPullRequestResponse({
|
|
2872
|
+
url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
|
|
2873
|
+
reviewThreads: {
|
|
2874
|
+
pageInfo: { endCursor: null, hasNextPage: false },
|
|
2875
|
+
nodes: [{ isResolved: true }],
|
|
2876
|
+
},
|
|
2877
|
+
}),
|
|
2878
|
+
branchRules: () => [
|
|
2879
|
+
{
|
|
2880
|
+
type: 'required_status_checks',
|
|
2881
|
+
parameters: { required_status_checks: [{ context: 'ci' }] },
|
|
2882
|
+
},
|
|
2883
|
+
],
|
|
2884
|
+
checkRuns: () => ({
|
|
2885
|
+
total_count: 1,
|
|
2886
|
+
check_runs: [{ id: 1, name: 'ci', conclusion: 'success' }],
|
|
2887
|
+
}),
|
|
2888
|
+
});
|
|
2889
|
+
|
|
2890
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2891
|
+
const result = await repository.findRelatedOpenPRs(
|
|
2892
|
+
'https://github.com/HiromiShikata/test-repository/issues/11194',
|
|
2893
|
+
);
|
|
2894
|
+
|
|
2895
|
+
expect(result).toHaveLength(1);
|
|
2896
|
+
expect(result[0]).toEqual({
|
|
2897
|
+
url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
|
|
2898
|
+
branchName: 'feature-branch',
|
|
2899
|
+
createdAt: new Date('2024-01-01T00:00:00Z'),
|
|
2900
|
+
isDraft: false,
|
|
2901
|
+
isConflicted: false,
|
|
2902
|
+
mergeable: 'MERGEABLE',
|
|
2903
|
+
isPassedAllCiJob: true,
|
|
2904
|
+
isCiStateSuccess: true,
|
|
2905
|
+
isResolvedAllReviewComments: true,
|
|
2906
|
+
isBranchOutOfDate: false,
|
|
2907
|
+
missingRequiredCheckNames: [],
|
|
2908
|
+
});
|
|
2909
|
+
expect(
|
|
2910
|
+
countCallsMatching(
|
|
2911
|
+
fetchSpy,
|
|
2912
|
+
(url, body) =>
|
|
2913
|
+
url === 'https://api.github.com/graphql' &&
|
|
2914
|
+
(body.includes('branchProtectionRules') ||
|
|
2915
|
+
body.includes('rulesets') ||
|
|
2916
|
+
body.includes('statusCheckRollup')),
|
|
2917
|
+
),
|
|
2918
|
+
).toBe(0);
|
|
2919
|
+
});
|
|
2920
|
+
|
|
2921
|
+
it('excludes a PR that is no longer open at the second stage', async () => {
|
|
2922
|
+
const consoleInfoSpy = jest
|
|
2923
|
+
.spyOn(console, 'info')
|
|
2924
|
+
.mockImplementation(() => undefined);
|
|
2925
|
+
mockFetchRoutes({
|
|
2926
|
+
timeline: () => buildSlimTimelineResponse(),
|
|
2927
|
+
slimPullRequest: () =>
|
|
2928
|
+
buildSlimPullRequestResponse({ state: 'MERGED' }),
|
|
2929
|
+
});
|
|
2930
|
+
|
|
2931
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2932
|
+
const result = await repository.findRelatedOpenPRs(
|
|
2933
|
+
'https://github.com/HiromiShikata/test-repository/issues/11194',
|
|
2934
|
+
);
|
|
2935
|
+
|
|
2936
|
+
expect(result).toHaveLength(0);
|
|
2937
|
+
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
|
2938
|
+
expect.stringContaining(
|
|
2939
|
+
'https://github.com/HiromiShikata/test-repository/pull/11148',
|
|
2940
|
+
),
|
|
2941
|
+
);
|
|
2942
|
+
});
|
|
2943
|
+
|
|
2944
|
+
it('skips a PR whose second-stage status fetch fails and logs one warning', async () => {
|
|
2945
|
+
const consoleWarnSpy = jest
|
|
2946
|
+
.spyOn(console, 'warn')
|
|
2947
|
+
.mockImplementation(() => undefined);
|
|
2948
|
+
mockFetchRoutes({
|
|
2949
|
+
timeline: () => buildSlimTimelineResponse(),
|
|
2950
|
+
slimPullRequest: () =>
|
|
2951
|
+
new Response('Internal Server Error', { status: 500 }),
|
|
2952
|
+
});
|
|
2953
|
+
|
|
2954
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
2955
|
+
const result = await repository.findRelatedOpenPRs(
|
|
2956
|
+
'https://github.com/HiromiShikata/test-repository/issues/11194',
|
|
2957
|
+
);
|
|
2958
|
+
|
|
2959
|
+
expect(result).toHaveLength(0);
|
|
2960
|
+
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
|
2961
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
2962
|
+
expect.stringContaining(
|
|
2963
|
+
'https://github.com/HiromiShikata/test-repository/pull/11148',
|
|
2964
|
+
),
|
|
2965
|
+
);
|
|
2966
|
+
});
|
|
2967
|
+
});
|
|
2968
|
+
|
|
2501
2969
|
const createApiV3CheerioRestIssueRepository = () => {
|
|
2502
2970
|
const apiV3IssueRepository = mock<ApiV3IssueRepository>();
|
|
2503
2971
|
const restIssueRepository = mock<RestIssueRepository>();
|
|
@@ -2519,6 +2987,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2519
2987
|
'dummy',
|
|
2520
2988
|
sleep,
|
|
2521
2989
|
);
|
|
2990
|
+
dateRepository.now.mockResolvedValue(new Date('2026-01-01T00:00:00.000Z'));
|
|
2522
2991
|
restIssueRepository.getIssue.mockResolvedValue({
|
|
2523
2992
|
labels: [],
|
|
2524
2993
|
assignees: [],
|