release-please
Version:
generate release PRs based on the conventionalcommits.org spec
1,171 lines • 53.7 kB
JavaScript
"use strict";
// Copyright 2021 Google LLC
//
// Licensed 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.
Object.defineProperty(exports, "__esModule", { value: true });
exports.sleepInMs = exports.GitHub = exports.GH_GRAPHQL_URL = exports.GH_API_URL = void 0;
const code_suggester_1 = require("code-suggester");
const rest_1 = require("@octokit/rest");
const request_1 = require("@octokit/request");
const graphql_1 = require("@octokit/graphql");
const request_error_1 = require("@octokit/request-error");
const errors_1 = require("./errors");
const MAX_ISSUE_BODY_SIZE = 65536;
const MAX_SLEEP_SECONDS = 20;
exports.GH_API_URL = 'https://api.github.com';
exports.GH_GRAPHQL_URL = 'https://api.github.com';
const logger_1 = require("./util/logger");
const manifest_1 = require("./manifest");
const signoff_commit_message_1 = require("./util/signoff-commit-message");
const git_file_utils_1 = require("@google-automations/git-file-utils");
const https_proxy_agent_1 = require("https-proxy-agent");
const http_proxy_agent_1 = require("http-proxy-agent");
class GitHub {
constructor(options) {
var _a;
/**
* Get the list of file paths modified in a given commit.
*
* @param {string} sha The commit SHA
* @returns {string[]} File paths
* @throws {GitHubAPIError} on an API error
*/
this.getCommitFiles = wrapAsync(async (sha) => {
this.logger.debug(`Backfilling file list for commit: ${sha}`);
const files = [];
for await (const resp of this.octokit.paginate.iterator('GET /repos/{owner}/{repo}/commits/{ref}', {
owner: this.repository.owner,
repo: this.repository.repo,
ref: sha,
})) {
// Paginate plugin doesn't have types for listing files on a commit
const data = resp.data;
for (const f of data.files || []) {
if (f.filename) {
files.push(f.filename);
}
}
}
if (files.length >= 3000) {
this.logger.warn(`Found ${files.length} files. This may not include all the files.`);
}
else {
this.logger.debug(`Found ${files.length} files`);
}
return files;
});
this.graphqlRequest = wrapAsync(async (opts, options) => {
var _a;
let maxRetries = (_a = options === null || options === void 0 ? void 0 : options.maxRetries) !== null && _a !== void 0 ? _a : 5;
let seconds = 1;
while (maxRetries >= 0) {
try {
const response = await this.graphql(opts);
if (response) {
return response;
}
this.logger.trace('no GraphQL response, retrying');
}
catch (err) {
if (err.status !== 502) {
throw err;
}
if (maxRetries === 0) {
this.logger.warn('ran out of retries and response is required');
throw err;
}
this.logger.info(`received 502 error, ${maxRetries} attempts remaining`);
}
maxRetries -= 1;
if (maxRetries >= 0) {
this.logger.trace(`sleeping ${seconds} seconds`);
await (0, exports.sleepInMs)(1000 * seconds);
seconds = Math.min(seconds * 2, MAX_SLEEP_SECONDS);
}
}
this.logger.trace('ran out of retries');
return undefined;
});
/**
* Returns a list of paths to all files with a given name.
*
* If a prefix is specified, only return paths that match
* the provided prefix.
*
* @param filename The name of the file to find
* @param ref Git reference to search files in
* @param prefix Optional path prefix used to filter results
* @throws {GitHubAPIError} on an API error
*/
this.findFilesByFilenameAndRef = wrapAsync(async (filename, ref, prefix) => {
if (prefix) {
prefix = normalizePrefix(prefix);
}
this.logger.debug(`finding files by filename: ${filename}, ref: ${ref}, prefix: ${prefix}`);
return await this.fileCache.findFilesByFilename(filename, ref, prefix);
});
/**
* Returns a list of paths to all files matching a glob pattern.
*
* If a prefix is specified, only return paths that match
* the provided prefix.
*
* @param glob The glob to match
* @param ref Git reference to search files in
* @param prefix Optional path prefix used to filter results
* @throws {GitHubAPIError} on an API error
*/
this.findFilesByGlobAndRef = wrapAsync(async (glob, ref, prefix) => {
if (prefix) {
prefix = normalizePrefix(prefix);
}
this.logger.debug(`finding files by glob: ${glob}, ref: ${ref}, prefix: ${prefix}`);
return await this.fileCache.findFilesByGlob(glob, ref, prefix);
});
/**
* Open a pull request
*
* @param {PullRequest} pullRequest Pull request data to update
* @param {string} targetBranch The base branch of the pull request
* @param {string} message The commit message for the commit
* @param {Update[]} updates The files to update
* @param {CreatePullRequestOptions} options The pull request options
* @throws {GitHubAPIError} on an API error
*/
this.createPullRequest = wrapAsync(async (pullRequest, targetBranch, message, updates, options) => {
// Update the files for the release if not already supplied
const changes = await this.buildChangeSet(updates, targetBranch);
const prNumber = await (0, code_suggester_1.createPullRequest)(this.octokit, changes, {
upstreamOwner: this.repository.owner,
upstreamRepo: this.repository.repo,
title: pullRequest.title,
branch: pullRequest.headBranchName,
description: pullRequest.body,
primary: targetBranch,
force: true,
fork: !!(options === null || options === void 0 ? void 0 : options.fork),
message,
logger: this.logger,
draft: !!(options === null || options === void 0 ? void 0 : options.draft),
labels: pullRequest.labels,
});
return await this.getPullRequest(prNumber);
});
/**
* Fetch a pull request given the pull number
* @param {number} number The pull request number
* @returns {PullRequest}
*/
this.getPullRequest = wrapAsync(async (number) => {
const response = await this.octokit.pulls.get({
owner: this.repository.owner,
repo: this.repository.repo,
pull_number: number,
});
return {
headBranchName: response.data.head.ref,
baseBranchName: response.data.base.ref,
number: response.data.number,
title: response.data.title,
body: response.data.body || '',
files: [],
labels: response.data.labels
.map(label => label.name)
.filter(name => !!name),
};
});
/**
* Update a pull request's title and body.
* @param {number} number The pull request number
* @param {ReleasePullRequest} releasePullRequest Pull request data to update
* @param {string} targetBranch The target branch of the pull request
* @param {string} options.signoffUser Optional. Commit signoff message
* @param {boolean} options.fork Optional. Whether to open the pull request from
* a fork or not. Defaults to `false`
* @param {PullRequestOverflowHandler} options.pullRequestOverflowHandler Optional.
* Handles extra large pull request body messages.
*/
this.updatePullRequest = wrapAsync(async (number, releasePullRequest, targetBranch, options) => {
// Update the files for the release if not already supplied
const changes = await this.buildChangeSet(releasePullRequest.updates, targetBranch);
let message = releasePullRequest.title.toString();
if (options === null || options === void 0 ? void 0 : options.signoffUser) {
message = (0, signoff_commit_message_1.signoffCommitMessage)(message, options.signoffUser);
}
const title = releasePullRequest.title.toString();
const body = ((options === null || options === void 0 ? void 0 : options.pullRequestOverflowHandler)
? await options.pullRequestOverflowHandler.handleOverflow(releasePullRequest)
: releasePullRequest.body)
.toString()
.slice(0, MAX_ISSUE_BODY_SIZE);
const prNumber = await (0, code_suggester_1.createPullRequest)(this.octokit, changes, {
upstreamOwner: this.repository.owner,
upstreamRepo: this.repository.repo,
title,
branch: releasePullRequest.headRefName,
description: body,
primary: targetBranch,
force: true,
fork: (options === null || options === void 0 ? void 0 : options.fork) === false ? false : true,
message,
logger: this.logger,
draft: releasePullRequest.draft,
});
if (prNumber !== number) {
this.logger.warn(`updated code for ${prNumber}, but update requested for ${number}`);
}
const response = await this.octokit.pulls.update({
owner: this.repository.owner,
repo: this.repository.repo,
pull_number: number,
title: releasePullRequest.title.toString(),
body,
state: 'open',
});
return {
headBranchName: response.data.head.ref,
baseBranchName: response.data.base.ref,
number: response.data.number,
title: response.data.title,
body: response.data.body || '',
files: [],
labels: response.data.labels
.map(label => label.name)
.filter(name => !!name),
};
});
/**
* Returns a list of paths to all files with a given file
* extension.
*
* If a prefix is specified, only return paths that match
* the provided prefix.
*
* @param extension The file extension used to filter results.
* Example: `js`, `java`
* @param ref Git reference to search files in
* @param prefix Optional path prefix used to filter results
* @returns {string[]} List of file paths
* @throws {GitHubAPIError} on an API error
*/
this.findFilesByExtensionAndRef = wrapAsync(async (extension, ref, prefix) => {
if (prefix) {
prefix = normalizePrefix(prefix);
}
return this.fileCache.findFilesByExtension(extension, ref, prefix);
});
/**
* Create a GitHub release
*
* @param {Release} release Release parameters
* @param {ReleaseOptions} options Release option parameters
* @throws {DuplicateReleaseError} if the release tag already exists
* @throws {GitHubAPIError} on other API errors
*/
this.createRelease = wrapAsync(async (release, options = {}) => {
const resp = await this.octokit.repos.createRelease({
name: release.name,
owner: this.repository.owner,
repo: this.repository.repo,
tag_name: release.tag.toString(),
body: release.notes,
draft: !!options.draft,
prerelease: !!options.prerelease,
target_commitish: release.sha,
});
return {
id: resp.data.id,
name: resp.data.name || undefined,
tagName: resp.data.tag_name,
sha: resp.data.target_commitish,
notes: resp.data.body_text ||
resp.data.body ||
resp.data.body_html ||
undefined,
url: resp.data.html_url,
draft: resp.data.draft,
uploadUrl: resp.data.upload_url,
};
}, e => {
if (e instanceof request_error_1.RequestError) {
if (e.status === 422 &&
errors_1.GitHubAPIError.parseErrors(e).some(error => {
return error.code === 'already_exists';
})) {
throw new errors_1.DuplicateReleaseError(e, 'tagName');
}
}
});
/**
* Makes a comment on a issue/pull request.
*
* @param {string} comment - The body of the comment to post.
* @param {number} number - The issue or pull request number.
* @throws {GitHubAPIError} on an API error
*/
this.commentOnIssue = wrapAsync(async (comment, number) => {
this.logger.debug(`adding comment to https://github.com/${this.repository.owner}/${this.repository.repo}/issues/${number}`);
const resp = await this.octokit.issues.createComment({
owner: this.repository.owner,
repo: this.repository.repo,
issue_number: number,
body: comment,
});
return resp.data.html_url;
});
/**
* Removes labels from an issue/pull request.
*
* @param {string[]} labels The labels to remove.
* @param {number} number The issue/pull request number.
*/
this.removeIssueLabels = wrapAsync(async (labels, number) => {
if (labels.length === 0) {
return;
}
this.logger.debug(`removing labels: ${labels} from issue/pull ${number}`);
await Promise.all(labels.map(label => this.octokit.issues.removeLabel({
owner: this.repository.owner,
repo: this.repository.repo,
issue_number: number,
name: label,
})));
});
/**
* Adds label to an issue/pull request.
*
* @param {string[]} labels The labels to add.
* @param {number} number The issue/pull request number.
*/
this.addIssueLabels = wrapAsync(async (labels, number) => {
if (labels.length === 0) {
return;
}
this.logger.debug(`adding labels: ${labels} from issue/pull ${number}`);
await this.octokit.issues.addLabels({
owner: this.repository.owner,
repo: this.repository.repo,
issue_number: number,
labels,
});
});
this.repository = options.repository;
this.octokit = options.octokitAPIs.octokit;
this.request = options.octokitAPIs.request;
this.graphql = options.octokitAPIs.graphql;
this.fileCache = new git_file_utils_1.RepositoryFileCache(this.octokit, this.repository);
this.logger = (_a = options.logger) !== null && _a !== void 0 ? _a : logger_1.logger;
}
static createDefaultAgent(baseUrl, defaultProxy) {
if (!defaultProxy) {
return undefined;
}
const { host, port } = defaultProxy;
if (new URL(baseUrl).protocol.replace(':', '') === 'http') {
return new http_proxy_agent_1.HttpProxyAgent(`http://${host}:${port}`);
}
else {
return new https_proxy_agent_1.HttpsProxyAgent(`https://${host}:${port}`);
}
}
/**
* Build a new GitHub client with auto-detected default branch.
*
* @param {GitHubCreateOptions} options Configuration options
* @param {string} options.owner The repository owner.
* @param {string} options.repo The repository name.
* @param {string} options.defaultBranch Optional. The repository's default branch.
* Defaults to the value fetched via the API.
* @param {string} options.apiUrl Optional. The base url of the GitHub API.
* @param {string} options.graphqlUrl Optional. The base url of the GraphQL API.
* @param {OctokitAPISs} options.octokitAPIs Optional. Override the internal
* client instances with a pre-authenticated instance.
* @param {string} token Optional. A GitHub API token used for authentication.
*/
static async create(options) {
var _a, _b, _c, _d;
const apiUrl = (_a = options.apiUrl) !== null && _a !== void 0 ? _a : exports.GH_API_URL;
const graphqlUrl = (_b = options.graphqlUrl) !== null && _b !== void 0 ? _b : exports.GH_GRAPHQL_URL;
const releasePleaseVersion = require('../../package.json').version;
const apis = (_c = options.octokitAPIs) !== null && _c !== void 0 ? _c : {
octokit: new rest_1.Octokit({
baseUrl: apiUrl,
auth: options.token,
request: {
agent: this.createDefaultAgent(apiUrl, options.proxy),
fetch: options.fetch,
},
}),
request: request_1.request.defaults({
baseUrl: apiUrl,
headers: {
'user-agent': `release-please/${releasePleaseVersion}`,
Authorization: `token ${options.token}`,
},
fetch: options.fetch,
}),
graphql: graphql_1.graphql.defaults({
baseUrl: graphqlUrl,
request: {
agent: this.createDefaultAgent(graphqlUrl, options.proxy),
fetch: options.fetch,
},
headers: {
'user-agent': `release-please/${releasePleaseVersion}`,
Authorization: `token ${options.token}`,
'content-type': 'application/vnd.github.v3+json',
},
}),
};
const opts = {
repository: {
owner: options.owner,
repo: options.repo,
defaultBranch: (_d = options.defaultBranch) !== null && _d !== void 0 ? _d : (await GitHub.defaultBranch(options.owner, options.repo, apis.octokit)),
},
octokitAPIs: apis,
logger: options.logger,
};
return new GitHub(opts);
}
/**
* Returns the default branch for a given repository.
*
* @param {string} owner The GitHub repository owner
* @param {string} repo The GitHub repository name
* @param {OctokitType} octokit An authenticated octokit instance
* @returns {string} Name of the default branch
*/
static async defaultBranch(owner, repo, octokit) {
const { data } = await octokit.repos.get({
repo,
owner,
});
return data.default_branch;
}
/**
* Returns the list of commits to the default branch after the provided filter
* query has been satified.
*
* @param {string} targetBranch Target branch of commit
* @param {CommitFilter} filter Callback function that returns whether a
* commit/pull request matches certain criteria
* @param {CommitIteratorOptions} options Query options
* @param {number} options.maxResults Limit the number of results searched.
* Defaults to unlimited.
* @param {boolean} options.backfillFiles If set, use the REST API for
* fetching the list of touched files in this commit. Defaults to `false`.
* @returns {Commit[]} List of commits to current branch
* @throws {GitHubAPIError} on an API error
*/
async commitsSince(targetBranch, filter, options = {}) {
const commits = [];
const generator = this.mergeCommitIterator(targetBranch, options);
for await (const commit of generator) {
if (filter(commit)) {
break;
}
commits.push(commit);
}
return commits;
}
/**
* Iterate through commit history with a max number of results scanned.
*
* @param {string} targetBranch target branch of commit
* @param {CommitIteratorOptions} options Query options
* @param {number} options.maxResults Limit the number of results searched.
* Defaults to unlimited.
* @param {boolean} options.backfillFiles If set, use the REST API for
* fetching the list of touched files in this commit. Defaults to `false`.
* @yields {Commit}
* @throws {GitHubAPIError} on an API error
*/
async *mergeCommitIterator(targetBranch, options = {}) {
var _a;
const maxResults = (_a = options.maxResults) !== null && _a !== void 0 ? _a : Number.MAX_SAFE_INTEGER;
let cursor = undefined;
let results = 0;
while (results < maxResults) {
const response = await this.mergeCommitsGraphQL(targetBranch, cursor, options);
// no response usually means that the branch can't be found
if (!response) {
break;
}
for (let i = 0; i < response.data.length; i++) {
results += 1;
yield response.data[i];
}
if (!response.pageInfo.hasNextPage) {
break;
}
cursor = response.pageInfo.endCursor;
}
}
async mergeCommitsGraphQL(targetBranch, cursor, options = {}) {
var _a, _b, _c, _d, _e, _f, _g, _h;
var _j;
this.logger.debug(`Fetching merge commits on branch ${targetBranch} with cursor: ${cursor}`);
const query = `query pullRequestsSince($owner: String!, $repo: String!, $num: Int!, $maxFilesChanged: Int, $targetBranch: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
ref(qualifiedName: $targetBranch) {
target {
... on Commit {
history(first: $num, after: $cursor) {
nodes {
associatedPullRequests(first: 10) {
nodes {
number
title
baseRefName
headRefName
labels(first: 10) {
nodes {
name
}
}
body
mergeCommit {
oid
}
files(first: $maxFilesChanged) {
nodes {
path
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
sha: oid
message
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
}
}`;
const params = {
cursor,
owner: this.repository.owner,
repo: this.repository.repo,
num: 25,
targetBranch,
maxFilesChanged: 100, // max is 100
};
const response = await this.graphqlRequest({
query,
...params,
});
if (!response) {
this.logger.warn(`Did not receive a response for query: ${query}`, params);
return null;
}
// if the branch does exist, return null
if (!((_a = response.repository) === null || _a === void 0 ? void 0 : _a.ref)) {
this.logger.warn(`Could not find commits for branch ${targetBranch} - it likely does not exist.`);
return null;
}
const history = response.repository.ref.target.history;
const commits = (history.nodes || []);
// Count the number of pull requests associated with each merge commit. This is
// used in the next step to make sure we only find pull requests with a
// merge commit that contain 1 merged commit.
const mergeCommitCount = {};
for (const commit of commits) {
for (const pr of commit.associatedPullRequests.nodes) {
if ((_b = pr.mergeCommit) === null || _b === void 0 ? void 0 : _b.oid) {
(_c = mergeCommitCount[_j = pr.mergeCommit.oid]) !== null && _c !== void 0 ? _c : (mergeCommitCount[_j] = 0);
mergeCommitCount[pr.mergeCommit.oid]++;
}
}
}
const commitData = [];
for (const graphCommit of commits) {
const commit = {
sha: graphCommit.sha,
message: graphCommit.message,
};
const mergePullRequest = graphCommit.associatedPullRequests.nodes.find(pr => {
return (
// Only match the pull request with a merge commit if there is a
// single merged commit in the PR. This means merge commits and squash
// merges will be matched, but rebase merged PRs will only be matched
// if they contain a single commit. This is so PRs that are rebased
// and merged will have ßSfiles backfilled from each commit instead of
// the whole PR.
pr.mergeCommit &&
pr.mergeCommit.oid === graphCommit.sha &&
mergeCommitCount[pr.mergeCommit.oid] === 1);
});
const pullRequest = mergePullRequest || graphCommit.associatedPullRequests.nodes[0];
if (pullRequest) {
commit.pullRequest = {
sha: commit.sha,
number: pullRequest.number,
baseBranchName: pullRequest.baseRefName,
headBranchName: pullRequest.headRefName,
mergeCommitOid: (_d = pullRequest.mergeCommit) === null || _d === void 0 ? void 0 : _d.oid,
title: pullRequest.title,
body: pullRequest.body,
labels: pullRequest.labels.nodes.map(node => node.name),
files: (((_e = pullRequest.files) === null || _e === void 0 ? void 0 : _e.nodes) || []).map(node => node.path),
};
}
if (mergePullRequest) {
if (((_g = (_f = mergePullRequest.files) === null || _f === void 0 ? void 0 : _f.pageInfo) === null || _g === void 0 ? void 0 : _g.hasNextPage) &&
options.backfillFiles) {
this.logger.info(`PR #${mergePullRequest.number} has many files, backfilling`);
commit.files = await this.getCommitFiles(graphCommit.sha);
}
else {
// We cannot directly fetch files on commits via graphql, only provide file
// information for commits with associated pull requests
commit.files = (((_h = mergePullRequest.files) === null || _h === void 0 ? void 0 : _h.nodes) || []).map(node => node.path);
}
}
else if (options.backfillFiles) {
// In this case, there is no squashed merge commit. This could be a simple
// merge commit, a rebase merge commit, or a direct commit to the branch.
// Fallback to fetching the list of commits from the REST API. In the future
// we can perhaps lazy load these.
commit.files = await this.getCommitFiles(graphCommit.sha);
}
commitData.push(commit);
}
return {
pageInfo: history.pageInfo,
data: commitData,
};
}
/**
* Iterate through merged pull requests with a max number of results scanned.
*
* @param {string} targetBranch The base branch of the pull request
* @param {string} status The status of the pull request
* @param {number} maxResults Limit the number of results searched. Defaults to
* unlimited.
* @param {boolean} includeFiles Whether to fetch the list of files included in
* the pull request. Defaults to `true`.
* @yields {PullRequest}
* @throws {GitHubAPIError} on an API error
*/
async *pullRequestIterator(targetBranch, status = 'MERGED', maxResults = Number.MAX_SAFE_INTEGER, includeFiles = true) {
const generator = includeFiles
? this.pullRequestIteratorWithFiles(targetBranch, status, maxResults)
: this.pullRequestIteratorWithoutFiles(targetBranch, status, maxResults);
for await (const pullRequest of generator) {
yield pullRequest;
}
}
/**
* Helper implementation of pullRequestIterator that includes files via
* the graphQL API.
*
* @param {string} targetBranch The base branch of the pull request
* @param {string} status The status of the pull request
* @param {number} maxResults Limit the number of results searched
*/
async *pullRequestIteratorWithFiles(targetBranch, status = 'MERGED', maxResults = Number.MAX_SAFE_INTEGER) {
let cursor = undefined;
let results = 0;
while (results < maxResults) {
const response = await this.pullRequestsGraphQL(targetBranch, status, cursor);
// no response usually means we ran out of results
if (!response) {
break;
}
for (let i = 0; i < response.data.length; i++) {
results += 1;
yield response.data[i];
}
if (!response.pageInfo.hasNextPage) {
break;
}
cursor = response.pageInfo.endCursor;
}
}
/**
* Helper implementation of pullRequestIterator that excludes files
* via the REST API.
*
* @param {string} targetBranch The base branch of the pull request
* @param {string} status The status of the pull request
* @param {number} maxResults Limit the number of results searched
*/
async *pullRequestIteratorWithoutFiles(targetBranch, status = 'MERGED', maxResults = Number.MAX_SAFE_INTEGER) {
const statusMap = {
OPEN: 'open',
CLOSED: 'closed',
MERGED: 'closed',
};
let results = 0;
for await (const { data: pulls } of this.octokit.paginate.iterator('GET /repos/{owner}/{repo}/pulls', {
state: statusMap[status],
owner: this.repository.owner,
repo: this.repository.repo,
base: targetBranch,
sort: 'updated',
direction: 'desc',
})) {
for (const pull of pulls) {
// The REST API does not have an option for "merged"
// pull requests - they are closed with a `merged_at` timestamp
if (status !== 'MERGED' || pull.merged_at) {
results += 1;
yield {
headBranchName: pull.head.ref,
baseBranchName: pull.base.ref,
number: pull.number,
title: pull.title,
body: pull.body || '',
labels: pull.labels.map(label => label.name),
files: [],
sha: pull.merge_commit_sha || undefined,
};
if (results >= maxResults) {
break;
}
}
}
if (results >= maxResults) {
break;
}
}
}
/**
* Return a list of merged pull requests. The list is not guaranteed to be sorted
* by merged_at, but is generally most recent first.
*
* @param {string} targetBranch - Base branch of the pull request. Defaults to
* the configured default branch.
* @param {number} page - Page of results. Defaults to 1.
* @param {number} perPage - Number of results per page. Defaults to 100.
* @returns {PullRequestHistory | null} - List of merged pull requests
* @throws {GitHubAPIError} on an API error
*/
async pullRequestsGraphQL(targetBranch, states = 'MERGED', cursor) {
var _a;
this.logger.debug(`Fetching ${states} pull requests on branch ${targetBranch} with cursor ${cursor}`);
const response = await this.graphqlRequest({
query: `query mergedPullRequests($owner: String!, $repo: String!, $num: Int!, $maxFilesChanged: Int, $targetBranch: String!, $states: [PullRequestState!], $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequests(first: $num, after: $cursor, baseRefName: $targetBranch, states: $states, orderBy: {field: CREATED_AT, direction: DESC}) {
nodes {
number
title
baseRefName
headRefName
labels(first: 10) {
nodes {
name
}
}
body
mergeCommit {
oid
}
files(first: $maxFilesChanged) {
nodes {
path
}
pageInfo {
endCursor
hasNextPage
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}`,
cursor,
owner: this.repository.owner,
repo: this.repository.repo,
num: 25,
targetBranch,
states,
maxFilesChanged: 64,
});
if (!((_a = response === null || response === void 0 ? void 0 : response.repository) === null || _a === void 0 ? void 0 : _a.pullRequests)) {
this.logger.warn(`Could not find merged pull requests for branch ${targetBranch} - it likely does not exist.`);
return null;
}
const pullRequests = (response.repository.pullRequests.nodes ||
[]);
return {
pageInfo: response.repository.pullRequests.pageInfo,
data: pullRequests.map(pullRequest => {
var _a, _b, _c;
return {
sha: (_a = pullRequest.mergeCommit) === null || _a === void 0 ? void 0 : _a.oid,
number: pullRequest.number,
baseBranchName: pullRequest.baseRefName,
headBranchName: pullRequest.headRefName,
labels: (((_b = pullRequest.labels) === null || _b === void 0 ? void 0 : _b.nodes) || []).map(l => l.name),
title: pullRequest.title,
body: pullRequest.body + '',
files: (((_c = pullRequest.files) === null || _c === void 0 ? void 0 : _c.nodes) || []).map(node => node.path),
};
}),
};
}
/**
* Iterate through releases with a max number of results scanned.
*
* @param {ReleaseIteratorOptions} options Query options
* @param {number} options.maxResults Limit the number of results searched.
* Defaults to unlimited.
* @yields {GitHubRelease}
* @throws {GitHubAPIError} on an API error
*/
async *releaseIterator(options = {}) {
var _a;
const maxResults = (_a = options.maxResults) !== null && _a !== void 0 ? _a : Number.MAX_SAFE_INTEGER;
let results = 0;
let cursor = undefined;
while (true) {
const response = await this.releaseGraphQL(cursor);
if (!response) {
break;
}
for (let i = 0; i < response.data.length; i++) {
if ((results += 1) > maxResults) {
break;
}
yield response.data[i];
}
if (results > maxResults || !response.pageInfo.hasNextPage) {
break;
}
cursor = response.pageInfo.endCursor;
}
}
async releaseGraphQL(cursor) {
this.logger.debug(`Fetching releases with cursor ${cursor}`);
const response = await this.graphqlRequest({
query: `query releases($owner: String!, $repo: String!, $num: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
releases(first: $num, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) {
nodes {
name
tag {
name
}
tagCommit {
oid
}
url
description
isDraft
}
pageInfo {
endCursor
hasNextPage
}
}
}
}`,
cursor,
owner: this.repository.owner,
repo: this.repository.repo,
num: 25,
});
if (!response.repository.releases.nodes.length) {
this.logger.warn('Could not find releases.');
return null;
}
const releases = response.repository.releases.nodes;
return {
pageInfo: response.repository.releases.pageInfo,
data: releases
.filter(release => !!release.tagCommit)
.map(release => {
if (!release.tag || !release.tagCommit) {
this.logger.debug(release);
}
return {
name: release.name || undefined,
tagName: release.tag ? release.tag.name : 'unknown',
sha: release.tagCommit.oid,
notes: release.description,
url: release.url,
draft: release.isDraft,
};
}),
};
}
/**
* Iterate through tags with a max number of results scanned.
*
* @param {TagIteratorOptions} options Query options
* @param {number} options.maxResults Limit the number of results searched.
* Defaults to unlimited.
* @yields {GitHubTag}
* @throws {GitHubAPIError} on an API error
*/
async *tagIterator(options = {}) {
const maxResults = options.maxResults || Number.MAX_SAFE_INTEGER;
let results = 0;
for await (const response of this.octokit.paginate.iterator('GET /repos/{owner}/{repo}/tags', {
owner: this.repository.owner,
repo: this.repository.repo,
})) {
for (const tag of response.data) {
if ((results += 1) > maxResults) {
break;
}
yield {
name: tag.name,
sha: tag.commit.sha,
};
}
if (results > maxResults)
break;
}
}
/**
* Fetch the contents of a file from the configured branch
*
* @param {string} path The path to the file in the repository
* @returns {GitHubFileContents}
* @throws {GitHubAPIError} on other API errors
*/
async getFileContents(path) {
return await this.getFileContentsOnBranch(path, this.repository.defaultBranch);
}
/**
* Fetch the contents of a file
*
* @param {string} path The path to the file in the repository
* @param {string} branch The branch to fetch from
* @returns {GitHubFileContents}
* @throws {FileNotFoundError} if the file cannot be found
* @throws {GitHubAPIError} on other API errors
*/
async getFileContentsOnBranch(path, branch) {
this.logger.debug(`Fetching ${path} from branch ${branch}`);
try {
return await this.fileCache.getFileContents(path, branch);
}
catch (e) {
if (e instanceof git_file_utils_1.FileNotFoundError) {
throw new errors_1.FileNotFoundError(path);
}
throw e;
}
}
async getFileJson(path, branch) {
const content = await this.getFileContentsOnBranch(path, branch);
return JSON.parse(content.parsedContent);
}
/**
* Returns a list of paths to all files with a given name.
*
* If a prefix is specified, only return paths that match
* the provided prefix.
*
* @param filename The name of the file to find
* @param prefix Optional path prefix used to filter results
* @returns {string[]} List of file paths
* @throws {GitHubAPIError} on an API error
*/
async findFilesByFilename(filename, prefix) {
return this.findFilesByFilenameAndRef(filename, this.repository.defaultBranch, prefix);
}
/**
* Returns a list of paths to all files matching a glob pattern.
*
* If a prefix is specified, only return paths that match
* the provided prefix.
*
* @param glob The glob to match
* @param prefix Optional path prefix used to filter results
* @returns {string[]} List of file paths
* @throws {GitHubAPIError} on an API error
*/
async findFilesByGlob(glob, prefix) {
return this.findFilesByGlobAndRef(glob, this.repository.defaultBranch, prefix);
}
/**
* Open a pull request
*
* @deprecated This logic is handled by the Manifest class now as it
* can be more complicated if the release notes are too big
* @param {ReleasePullRequest} releasePullRequest Pull request data to update
* @param {string} targetBranch The base branch of the pull request
* @param {GitHubPR} options The pull request options
* @throws {GitHubAPIError} on an API error
*/
async createReleasePullRequest(releasePullRequest, targetBranch, options) {
let message = releasePullRequest.title.toString();
if (options === null || options === void 0 ? void 0 : options.signoffUser) {
message = (0, signoff_commit_message_1.signoffCommitMessage)(message, options.signoffUser);
}
const pullRequestLabels = (options === null || options === void 0 ? void 0 : options.skipLabeling)
? []
: releasePullRequest.labels;
return await this.createPullRequest({
headBranchName: releasePullRequest.headRefName,
baseBranchName: targetBranch,
number: -1,
title: releasePullRequest.title.toString(),
body: releasePullRequest.body.toString().slice(0, MAX_ISSUE_BODY_SIZE),
labels: pullRequestLabels,
files: [],
}, targetBranch, message, releasePullRequest.updates, {
fork: options === null || options === void 0 ? void 0 : options.fork,
draft: releasePullRequest.draft,
});
}
/**
* Given a set of proposed updates, build a changeset to suggest.
*
* @param {Update[]} updates The proposed updates
* @param {string} defaultBranch The target branch
* @return {Changes} The changeset to suggest.
* @throws {GitHubAPIError} on an API error
*/
async buildChangeSet(updates, defaultBranch) {
const changes = new Map();
for (const update of updates) {
let content;
try {
content = await this.getFileContentsOnBranch(update.path, defaultBranch);
}
catch (err) {
if (!(err instanceof errors_1.FileNotFoundError))
throw err;
// if the file is missing and create = false, just continue
// to the next update, otherwise create the file.
if (!update.createIfMissing) {
this.logger.warn(`file ${update.path} did not exist`);
continue;
}
}
const contentText = content
? Buffer.from(content.content, 'base64').toString('utf8')
: undefined;
const updatedContent = update.updater.updateContent(contentText, this.logger);
if (updatedContent) {
changes.set(update.path, {
content: updatedContent,
originalContent: (content === null || content === void 0 ? void 0 : content.parsedContent) || null,
mode: (content === null || content === void 0 ? void 0 : content.mode) || git_file_utils_1.DEFAULT_FILE_MODE,
});
}
}
return changes;
}
/**
* Returns a list of paths to all files with a given file
* extension.
*
* If a prefix is specified, only return paths that match
* the provided prefix.
*
* @param extension The file extension used to filter results.
* Example: `js`, `java`
* @param prefix Optional path prefix used to filter results
* @returns {string[]} List of file paths
* @throws {GitHubAPIError} on an API error
*/
async findFilesByExtension(extension, prefix) {
return this.findFilesByExtensionAndRef(extension, this.repository.defaultBranch, prefix);
}
/**
* Generate release notes from GitHub at tag
* @param {string} tagName Name of new release tag
* @param {string} targetCommitish Target commitish for new tag
* @param {string} previousTag Optional. Name of previous tag to analyze commits since
*/
async generateReleaseNotes(tagName, targetCommitish, previousTag) {
const resp = await this.octokit.repos.generateReleaseNotes({
owner: this.repository.owner,
repo: this.repository.repo,
tag_name: tagName,
previous_tag_name: previousTag,
target_commitish: targetCommitish,
});
return resp.data.body;
}
/**
* Create a single file on a new branch based on an existing
* branch. This will force-push to that branch.
* @param {string} filename Filename with path in the repository
* @param {string} contents Contents of the file
* @param {string} newBranchName Name of the new branch
* @param {string} baseBranchName Name of the base branch (where
* new branch is forked from)
* @returns {string} HTML URL of the new file
*/
async createFileOnNewBranch(filename, contents, newBranchName, baseBranchName) {
// create or update new branch to match base branch
await this.forkBranch(newBranchName, baseBranchName);
// use the single file upload API
const { data: { content }, } = await this.octokit.repos.createOrUpdateFileContents({
owner: this.repository.owner,
repo: this.repository.repo,
path: filename,
// contents need to be base64 encoded
content: Buffer.from(contents, 'binary').toString('base64'),
message: 'Saving release notes',
branch: newBranchName,
});
if (!(content === null || content === void 0 ? void 0 : content.html_url)) {
throw new Error(`Failed to write to file: ${filename} on branch: ${newBranchName}`);
}
return content.html_url;
}
/**
* Helper to fetch the SHA of a branch
* @param {string} branchName The name of the branch
* @return {string | undefined} Returns the SHA of the branch
* or undefined if it can't be found.
*/
async getBranchSha(branchName) {
this.logger.debug(`Looking up SHA for branch: ${branchName}`);
try {
const { data: { object: { sha }, }, } = await this.octokit.git.getRef({
owner: this.repository.owner,
repo: this.repository.repo,
ref: `heads/${branchName}`,
});
this.logger.debug(`SHA for branch: ${sha}`);
return sha;
}
catch (e) {
if (e instanceof request_error_1.RequestError && e.status === 404) {
this.logger.debug(`Branch: ${branchName} does not exist`);
return undefined;
}
throw e;
}
}
/**
* Helper to fork a branch from an existing branch. Uses `force` so
* it will overwrite the contents of `targetBranchName` to match
* the current contents of `baseBranchName`.
*
* @param {string} targetBranchName The name of the new forked branch
* @param {string} baseB