[release notes] automatically retry on Github API 5xx errors (#76447)
Co-authored-by: spalger <spalger@users.noreply.github.com>
This commit is contained in:
parent
8b085b9eac
commit
c80a733e4c
|
@ -25,8 +25,7 @@ import { run, createFlagError, createFailError, REPO_ROOT } from '@kbn/dev-utils
|
|||
|
||||
import { FORMATS, SomeFormat } from './formats';
|
||||
import {
|
||||
iterRelevantPullRequests,
|
||||
getPr,
|
||||
PrApi,
|
||||
Version,
|
||||
ClassifiedPr,
|
||||
streamFromIterable,
|
||||
|
@ -48,6 +47,7 @@ export function runReleaseNotesCli() {
|
|||
if (!token || typeof token !== 'string') {
|
||||
throw createFlagError('--token must be defined');
|
||||
}
|
||||
const prApi = new PrApi(log, token);
|
||||
|
||||
const version = Version.fromFlag(flags.version);
|
||||
if (!version) {
|
||||
|
@ -80,7 +80,7 @@ export function runReleaseNotesCli() {
|
|||
}
|
||||
|
||||
const summary = new IrrelevantPrSummary(log);
|
||||
const pr = await getPr(token, number);
|
||||
const pr = await prApi.getPr(number);
|
||||
log.success(
|
||||
inspect(
|
||||
{
|
||||
|
@ -101,7 +101,7 @@ export function runReleaseNotesCli() {
|
|||
|
||||
const summary = new IrrelevantPrSummary(log);
|
||||
const prsToReport: ClassifiedPr[] = [];
|
||||
const prIterable = iterRelevantPullRequests(token, version, log);
|
||||
const prIterable = prApi.iterRelevantPullRequests(version);
|
||||
for await (const pr of prIterable) {
|
||||
if (!isPrRelevant(pr, version, includeVersions, summary)) {
|
||||
continue;
|
||||
|
|
|
@ -27,7 +27,7 @@ import {
|
|||
ASCIIDOC_SECTIONS,
|
||||
UNKNOWN_ASCIIDOC_SECTION,
|
||||
} from '../release_notes_config';
|
||||
import { PullRequest } from './pull_request';
|
||||
import { PullRequest } from './pr_api';
|
||||
|
||||
export interface ClassifiedPr extends PullRequest {
|
||||
area: Area;
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
export * from './pull_request';
|
||||
export * from './pr_api';
|
||||
export * from './version';
|
||||
export * from './is_pr_relevant';
|
||||
export * from './streams';
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
import { ToolingLog } from '@kbn/dev-utils';
|
||||
|
||||
import { PullRequest } from './pull_request';
|
||||
import { PullRequest } from './pr_api';
|
||||
import { Version } from './version';
|
||||
|
||||
export class IrrelevantPrSummary {
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
|
||||
import { Version } from './version';
|
||||
import { PullRequest } from './pull_request';
|
||||
import { PullRequest } from './pr_api';
|
||||
import { IGNORE_LABELS } from '../release_notes_config';
|
||||
import { IrrelevantPrSummary } from './irrelevant_pr_summary';
|
||||
|
||||
|
|
231
packages/kbn-release-notes/src/lib/pr_api.ts
Normal file
231
packages/kbn-release-notes/src/lib/pr_api.ts
Normal file
|
@ -0,0 +1,231 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { inspect } from 'util';
|
||||
|
||||
import Axios from 'axios';
|
||||
import gql from 'graphql-tag';
|
||||
import * as GraphqlPrinter from 'graphql/language/printer';
|
||||
import { DocumentNode } from 'graphql/language/ast';
|
||||
import makeTerminalLink from 'terminal-link';
|
||||
import { ToolingLog, isAxiosResponseError } from '@kbn/dev-utils';
|
||||
|
||||
import { Version } from './version';
|
||||
import { getFixReferences } from './get_fix_references';
|
||||
import { getNoteFromDescription } from './get_note_from_description';
|
||||
|
||||
const PrNodeFragment = gql`
|
||||
fragment PrNode on PullRequest {
|
||||
number
|
||||
url
|
||||
title
|
||||
bodyText
|
||||
bodyHTML
|
||||
mergedAt
|
||||
baseRefName
|
||||
state
|
||||
author {
|
||||
login
|
||||
... on User {
|
||||
name
|
||||
}
|
||||
}
|
||||
labels(first: 100) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface PullRequest {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
targetBranch: string;
|
||||
mergedAt: string;
|
||||
state: string;
|
||||
labels: string[];
|
||||
fixes: string[];
|
||||
user: {
|
||||
name: string;
|
||||
login: string;
|
||||
};
|
||||
versions: Version[];
|
||||
terminalLink: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class PrApi {
|
||||
constructor(private readonly log: ToolingLog, private readonly token: string) {}
|
||||
|
||||
async getPr(number: number) {
|
||||
const resp = await this.gqlRequest(
|
||||
gql`
|
||||
query($number: Int!) {
|
||||
repository(owner: "elastic", name: "kibana") {
|
||||
pullRequest(number: $number) {
|
||||
...PrNode
|
||||
}
|
||||
}
|
||||
}
|
||||
${PrNodeFragment}
|
||||
`,
|
||||
{
|
||||
number,
|
||||
}
|
||||
);
|
||||
|
||||
const node = resp.data?.repository?.pullRequest;
|
||||
if (!node) {
|
||||
throw new Error(`unexpected github response, unable to fetch PR: ${inspect(resp)}`);
|
||||
}
|
||||
|
||||
return this.parsePullRequestNode(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate all of the PRs which have the `version` label
|
||||
*/
|
||||
async *iterRelevantPullRequests(version: Version) {
|
||||
let nextCursor: string | undefined;
|
||||
let hasNextPage = true;
|
||||
|
||||
while (hasNextPage) {
|
||||
const resp = await this.gqlRequest(
|
||||
gql`
|
||||
query($cursor: String, $labels: [String!]) {
|
||||
repository(owner: "elastic", name: "kibana") {
|
||||
pullRequests(first: 100, after: $cursor, labels: $labels, states: MERGED) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
...PrNode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${PrNodeFragment}
|
||||
`,
|
||||
{
|
||||
cursor: nextCursor,
|
||||
labels: [version.label],
|
||||
}
|
||||
);
|
||||
|
||||
const pullRequests = resp.data?.repository?.pullRequests;
|
||||
if (!pullRequests) {
|
||||
throw new Error(`unexpected github response, unable to fetch PRs: ${inspect(resp)}`);
|
||||
}
|
||||
|
||||
hasNextPage = pullRequests.pageInfo?.hasNextPage;
|
||||
nextCursor = pullRequests.pageInfo?.endCursor;
|
||||
|
||||
if (hasNextPage === undefined || (hasNextPage && !nextCursor)) {
|
||||
throw new Error(
|
||||
`github response does not include valid pagination information: ${inspect(resp)}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const node of pullRequests.nodes) {
|
||||
yield this.parsePullRequestNode(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the Github API response into the structure used by this tool
|
||||
*
|
||||
* @param node A GraphQL response from Github using the PrNode fragment
|
||||
*/
|
||||
private parsePullRequestNode(node: any): PullRequest {
|
||||
const terminalLink = makeTerminalLink(`#${node.number}`, node.url);
|
||||
|
||||
const labels: string[] = node.labels.nodes.map((l: { name: string }) => l.name);
|
||||
|
||||
return {
|
||||
number: node.number,
|
||||
url: node.url,
|
||||
terminalLink,
|
||||
title: node.title,
|
||||
targetBranch: node.baseRefName,
|
||||
state: node.state,
|
||||
mergedAt: node.mergedAt,
|
||||
labels,
|
||||
fixes: getFixReferences(node.bodyText),
|
||||
user: {
|
||||
login: node.author?.login || 'deleted user',
|
||||
name: node.author?.name,
|
||||
},
|
||||
versions: labels
|
||||
.map((l) => Version.fromLabel(l))
|
||||
.filter((v): v is Version => v instanceof Version),
|
||||
note: getNoteFromDescription(node.bodyHTML),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single request to the Github v4 GraphQL API
|
||||
*/
|
||||
private async gqlRequest(query: DocumentNode, variables: Record<string, unknown> = {}) {
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
attempt += 1;
|
||||
|
||||
try {
|
||||
const resp = await Axios.request({
|
||||
url: 'https://api.github.com/graphql',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'user-agent': '@kbn/release-notes',
|
||||
authorization: `bearer ${this.token}`,
|
||||
},
|
||||
data: {
|
||||
query: GraphqlPrinter.print(query),
|
||||
variables,
|
||||
},
|
||||
});
|
||||
|
||||
return resp.data;
|
||||
} catch (error) {
|
||||
if (!isAxiosResponseError(error) || error.response.status < 500) {
|
||||
// rethrow error unless it is a 500+ response from github
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { status, data } = error.response;
|
||||
const resp = inspect(data);
|
||||
|
||||
if (attempt === 5) {
|
||||
throw new Error(
|
||||
`${status} response from Github, attempted request ${attempt} times: [${resp}]`
|
||||
);
|
||||
}
|
||||
|
||||
const delay = attempt * 2000;
|
||||
this.log.debug(`Github responded with ${status}, retrying in ${delay} ms: [${resp}]`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,206 +0,0 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { inspect } from 'util';
|
||||
|
||||
import Axios from 'axios';
|
||||
import gql from 'graphql-tag';
|
||||
import * as GraphqlPrinter from 'graphql/language/printer';
|
||||
import { DocumentNode } from 'graphql/language/ast';
|
||||
import makeTerminalLink from 'terminal-link';
|
||||
import { ToolingLog } from '@kbn/dev-utils';
|
||||
|
||||
import { Version } from './version';
|
||||
import { getFixReferences } from './get_fix_references';
|
||||
import { getNoteFromDescription } from './get_note_from_description';
|
||||
|
||||
const PrNodeFragment = gql`
|
||||
fragment PrNode on PullRequest {
|
||||
number
|
||||
url
|
||||
title
|
||||
bodyText
|
||||
bodyHTML
|
||||
mergedAt
|
||||
baseRefName
|
||||
state
|
||||
author {
|
||||
login
|
||||
... on User {
|
||||
name
|
||||
}
|
||||
}
|
||||
labels(first: 100) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface PullRequest {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
targetBranch: string;
|
||||
mergedAt: string;
|
||||
state: string;
|
||||
labels: string[];
|
||||
fixes: string[];
|
||||
user: {
|
||||
name: string;
|
||||
login: string;
|
||||
};
|
||||
versions: Version[];
|
||||
terminalLink: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single request to the Github v4 GraphQL API
|
||||
*/
|
||||
async function gqlRequest(
|
||||
token: string,
|
||||
query: DocumentNode,
|
||||
variables: Record<string, unknown> = {}
|
||||
) {
|
||||
const resp = await Axios.request({
|
||||
url: 'https://api.github.com/graphql',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'user-agent': '@kbn/release-notes',
|
||||
authorization: `bearer ${token}`,
|
||||
},
|
||||
data: {
|
||||
query: GraphqlPrinter.print(query),
|
||||
variables,
|
||||
},
|
||||
});
|
||||
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the Github API response into the structure used by this tool
|
||||
*
|
||||
* @param node A GraphQL response from Github using the PrNode fragment
|
||||
*/
|
||||
function parsePullRequestNode(node: any): PullRequest {
|
||||
const terminalLink = makeTerminalLink(`#${node.number}`, node.url);
|
||||
|
||||
const labels: string[] = node.labels.nodes.map((l: { name: string }) => l.name);
|
||||
|
||||
return {
|
||||
number: node.number,
|
||||
url: node.url,
|
||||
terminalLink,
|
||||
title: node.title,
|
||||
targetBranch: node.baseRefName,
|
||||
state: node.state,
|
||||
mergedAt: node.mergedAt,
|
||||
labels,
|
||||
fixes: getFixReferences(node.bodyText),
|
||||
user: {
|
||||
login: node.author?.login || 'deleted user',
|
||||
name: node.author?.name,
|
||||
},
|
||||
versions: labels
|
||||
.map((l) => Version.fromLabel(l))
|
||||
.filter((v): v is Version => v instanceof Version),
|
||||
note: getNoteFromDescription(node.bodyHTML),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate all of the PRs which have the `version` label
|
||||
*/
|
||||
export async function* iterRelevantPullRequests(token: string, version: Version, log: ToolingLog) {
|
||||
let nextCursor: string | undefined;
|
||||
let hasNextPage = true;
|
||||
|
||||
while (hasNextPage) {
|
||||
const resp = await gqlRequest(
|
||||
token,
|
||||
gql`
|
||||
query($cursor: String, $labels: [String!]) {
|
||||
repository(owner: "elastic", name: "kibana") {
|
||||
pullRequests(first: 100, after: $cursor, labels: $labels, states: MERGED) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
...PrNode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${PrNodeFragment}
|
||||
`,
|
||||
{
|
||||
cursor: nextCursor,
|
||||
labels: [version.label],
|
||||
}
|
||||
);
|
||||
|
||||
const pullRequests = resp.data?.repository?.pullRequests;
|
||||
if (!pullRequests) {
|
||||
throw new Error(`unexpected github response, unable to fetch PRs: ${inspect(resp)}`);
|
||||
}
|
||||
|
||||
hasNextPage = pullRequests.pageInfo?.hasNextPage;
|
||||
nextCursor = pullRequests.pageInfo?.endCursor;
|
||||
|
||||
if (hasNextPage === undefined || (hasNextPage && !nextCursor)) {
|
||||
throw new Error(
|
||||
`github response does not include valid pagination information: ${inspect(resp)}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const node of pullRequests.nodes) {
|
||||
yield parsePullRequestNode(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPr(token: string, number: number) {
|
||||
const resp = await gqlRequest(
|
||||
token,
|
||||
gql`
|
||||
query($number: Int!) {
|
||||
repository(owner: "elastic", name: "kibana") {
|
||||
pullRequest(number: $number) {
|
||||
...PrNode
|
||||
}
|
||||
}
|
||||
}
|
||||
${PrNodeFragment}
|
||||
`,
|
||||
{
|
||||
number,
|
||||
}
|
||||
);
|
||||
|
||||
const node = resp.data?.repository?.pullRequest;
|
||||
if (!node) {
|
||||
throw new Error(`unexpected github response, unable to fetch PR: ${inspect(resp)}`);
|
||||
}
|
||||
|
||||
return parsePullRequestNode(node);
|
||||
}
|
Loading…
Reference in a new issue