Skip to content

Commit

Permalink
[7.x] [release notes] automatically retry on Github API 5xx errors (#…
Browse files Browse the repository at this point in the history
…76447) (#76674)

Co-authored-by: spalger <[email protected]>
  • Loading branch information
Spencer and spalger authored Sep 3, 2020
1 parent ec95a24 commit c9df4d7
Show file tree
Hide file tree
Showing 7 changed files with 239 additions and 214 deletions.
8 changes: 4 additions & 4 deletions packages/kbn-release-notes/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
{
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/kbn-release-notes/src/lib/classify_pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/kbn-release-notes/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/kbn-release-notes/src/lib/is_pr_relevant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
231 changes: 231 additions & 0 deletions packages/kbn-release-notes/src/lib/pr_api.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
Loading

0 comments on commit c9df4d7

Please sign in to comment.