diff --git a/__snapshots__/cli.js b/__snapshots__/cli.js index 426623100..841c70db5 100644 --- a/__snapshots__/cli.js +++ b/__snapshots__/cli.js @@ -25,6 +25,10 @@ Options: --repo-url GitHub URL to generate release for [required] --dry-run Prepare but do not take action [boolean] [default: false] + --local Whether to use local clone + [boolean] [default: false] + --local-path Path to existing local clone [string] + --local-clone-depth Depth of local clone [number] --include-v-in-tags include "v" in tag versions [boolean] [default: true] --monorepo-tags include library name in tags and release @@ -97,6 +101,9 @@ Options: on [string] --repo-url GitHub URL to generate release for [required] --dry-run Prepare but do not take action[boolean] [default: false] + --local Whether to use local clone [boolean] [default: false] + --local-path Path to existing local clone [string] + --local-clone-depth Depth of local clone [number] --label comma-separated list of labels to add to from release PR [default: "autorelease: pending"] --skip-labeling skip application of labels to pull requests @@ -138,6 +145,9 @@ Options: on [string] --repo-url GitHub URL to generate release for [required] --dry-run Prepare but do not take action[boolean] [default: false] + --local Whether to use local clone [boolean] [default: false] + --local-path Path to existing local clone [string] + --local-clone-depth Depth of local clone [number] --draft mark release as a draft. no tag is created but tag_name and target_commitish are associated with the release for future tag creation upon "un-drafting" the release. @@ -187,6 +197,10 @@ Options: --repo-url GitHub URL to generate release for[required] --dry-run Prepare but do not take action [boolean] [default: false] + --local Whether to use local clone + [boolean] [default: false] + --local-path Path to existing local clone [string] + --local-clone-depth Depth of local clone [number] --release-as override the semantically determined release version [string] --bump-minor-pre-major should we bump the semver minor prior to the diff --git a/package.json b/package.json index cc71ce57c..793ddeb88 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "./build/src/index.js", "bin": "./build/src/bin/release-please.js", "scripts": { - "test": "cross-env ENVIRONMENT=test LC_ALL=en c8 mocha --node-option no-experimental-fetch --recursive --timeout=5000 build/test", + "test": "cross-env ENVIRONMENT=test LC_ALL=en c8 mocha --recursive --timeout=5000 build/test", "docs": "echo add docs tests", "test:snap": "cross-env SNAPSHOT_UPDATE=1 LC_ALL=en npm test", "clean": "gts clean", diff --git a/src/bin/release-please.ts b/src/bin/release-please.ts index e07069379..b9c935a9f 100644 --- a/src/bin/release-please.ts +++ b/src/bin/release-please.ts @@ -16,7 +16,8 @@ import {coerceOption} from '../util/coerce-option'; import * as yargs from 'yargs'; -import {GitHub, GH_API_URL, GH_GRAPHQL_URL} from '../github'; +import {GitHub} from '../github'; +import {GH_API_URL, GH_GRAPHQL_URL} from '../github-api'; import {Manifest, ManifestOptions, ROOT_PROJECT_PATH} from '../manifest'; import {ChangelogSection, buildChangelogSections} from '../changelog-notes'; import {logger, setLogger, CheckpointLogger} from '../util/logger'; @@ -30,6 +31,8 @@ import { } from '../factory'; import {Bootstrapper} from '../bootstrapper'; import {createPatch} from 'diff'; +import {Scm} from '../scm'; +import {LocalGitHub} from '../local-github'; // eslint-disable-next-line @typescript-eslint/no-var-requires const parseGithubRepoUrl = require('parse-github-repo-url'); @@ -49,6 +52,9 @@ interface GitHubArgs { apiUrl?: string; graphqlUrl?: string; fork?: boolean; + local?: boolean; + localPath?: string; + localCloneDepth?: number; // deprecated in favor of targetBranch defaultBranch?: string; @@ -187,6 +193,19 @@ function gitHubOptions(yargs: yargs.Argv): yargs.Argv { type: 'boolean', default: false, }) + .option('local', { + describe: 'Whether to use local clone', + type: 'boolean', + default: false, + }) + .option('local-path', { + describe: 'Path to existing local clone', + type: 'string', + }) + .option('local-clone-depth', { + describe: 'Depth of local clone', + type: 'number', + }) .middleware(_argv => { const argv = _argv as GitHubArgs; // allow secrets to be loaded from file path @@ -817,16 +836,29 @@ const debugConfigCommand: yargs.CommandModule<{}, DebugConfigArgs> = { }, }; -async function buildGitHub(argv: GitHubArgs): Promise { +async function buildGitHub(argv: GitHubArgs): Promise { const [owner, repo] = parseGithubRepoUrl(argv.repoUrl); - const github = await GitHub.create({ - owner, - repo, - token: argv.token!, - apiUrl: argv.apiUrl, - graphqlUrl: argv.graphqlUrl, - }); - return github; + if (argv.local) { + const localGitHub = await LocalGitHub.create({ + owner, + repo, + token: argv.token!, + apiUrl: argv.apiUrl, + graphqlUrl: argv.graphqlUrl, + localRepoPath: argv.localPath, + cloneDepth: argv.localCloneDepth, + }); + return localGitHub; + } else { + const github = await GitHub.create({ + owner, + repo, + token: argv.token!, + apiUrl: argv.apiUrl, + graphqlUrl: argv.graphqlUrl, + }); + return github; + } } export const parser = yargs diff --git a/src/bootstrapper.ts b/src/bootstrapper.ts index 6c31a33ad..ccf38b151 100644 --- a/src/bootstrapper.ts +++ b/src/bootstrapper.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GitHub} from './github'; +import {Scm} from './scm'; import { DEFAULT_RELEASE_PLEASE_MANIFEST, DEFAULT_RELEASE_PLEASE_CONFIG, @@ -30,13 +30,13 @@ interface BootstrapPullRequest extends PullRequest { } export class Bootstrapper { - private github: GitHub; + private github: Scm; private targetBranch: string; private manifestFile: string; private configFile: string; private initialVersion: Version; constructor( - github: GitHub, + github: Scm, targetBranch: string, manifestFile: string = DEFAULT_RELEASE_PLEASE_MANIFEST, configFile: string = DEFAULT_RELEASE_PLEASE_CONFIG, diff --git a/src/changelog-notes/github.ts b/src/changelog-notes/github.ts index a79ddeeab..2b0a87edc 100644 --- a/src/changelog-notes/github.ts +++ b/src/changelog-notes/github.ts @@ -14,11 +14,11 @@ import {ChangelogNotes, BuildNotesOptions} from '../changelog-notes'; import {ConventionalCommit} from '../commit'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; export class GitHubChangelogNotes implements ChangelogNotes { - private github: GitHub; - constructor(github: GitHub) { + private github: Scm; + constructor(github: Scm) { this.github = github; } async buildNotes( diff --git a/src/factories/changelog-notes-factory.ts b/src/factories/changelog-notes-factory.ts index 9113137b3..75942a53f 100644 --- a/src/factories/changelog-notes-factory.ts +++ b/src/factories/changelog-notes-factory.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {ChangelogNotes, ChangelogSection} from '../changelog-notes'; import {GitHubChangelogNotes} from '../changelog-notes/github'; import {DefaultChangelogNotes} from '../changelog-notes/default'; @@ -22,7 +22,7 @@ export type ChangelogNotesType = string; export interface ChangelogNotesFactoryOptions { type: ChangelogNotesType; - github: GitHub; + github: Scm; changelogSections?: ChangelogSection[]; commitPartial?: string; headerPartial?: string; diff --git a/src/factories/plugin-factory.ts b/src/factories/plugin-factory.ts index 5f2756e51..fc7754912 100644 --- a/src/factories/plugin-factory.ts +++ b/src/factories/plugin-factory.ts @@ -19,7 +19,7 @@ import { SentenceCasePluginConfig, GroupPriorityPluginConfig, } from '../manifest'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {ManifestPlugin} from '../plugin'; import {LinkedVersions} from '../plugins/linked-versions'; import {CargoWorkspace} from '../plugins/cargo-workspace'; @@ -34,7 +34,7 @@ import {WorkspacePluginOptions} from '../plugins/workspace'; export interface PluginFactoryOptions { type: PluginType; - github: GitHub; + github: Scm; targetBranch: string; repositoryConfig: RepositoryConfig; manifestPath: string; diff --git a/src/factories/versioning-strategy-factory.ts b/src/factories/versioning-strategy-factory.ts index ba69b0714..c2484813b 100644 --- a/src/factories/versioning-strategy-factory.ts +++ b/src/factories/versioning-strategy-factory.ts @@ -18,7 +18,7 @@ import {AlwaysBumpPatch} from '../versioning-strategies/always-bump-patch'; import {AlwaysBumpMinor} from '../versioning-strategies/always-bump-minor'; import {AlwaysBumpMajor} from '../versioning-strategies/always-bump-major'; import {ServicePackVersioningStrategy} from '../versioning-strategies/service-pack'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {ConfigurationError} from '../errors'; import {PrereleaseVersioningStrategy} from '../versioning-strategies/prerelease'; @@ -30,7 +30,7 @@ export interface VersioningStrategyFactoryOptions { bumpPatchForMinorPreMajor?: boolean; prereleaseType?: string; prerelease?: boolean; - github: GitHub; + github: Scm; } export type VersioningStrategyBuilder = ( diff --git a/src/factory.ts b/src/factory.ts index 025cf1e48..44f828505 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -15,7 +15,7 @@ import {ConfigurationError} from './errors'; import {buildChangelogNotes} from './factories/changelog-notes-factory'; import {buildVersioningStrategy} from './factories/versioning-strategy-factory'; -import {GitHub} from './github'; +import {Scm} from './scm'; import {ReleaserConfig} from './manifest'; import {BaseStrategyOptions} from './strategies/base'; import {Bazel} from './strategies/bazel'; @@ -61,7 +61,7 @@ export type ReleaseType = string; export type ReleaseBuilder = (options: BaseStrategyOptions) => Strategy; export interface StrategyFactoryOptions extends ReleaserConfig { - github: GitHub; + github: Scm; path?: string; targetBranch?: string; } diff --git a/src/github-api.ts b/src/github-api.ts new file mode 100644 index 000000000..8c72c1032 --- /dev/null +++ b/src/github-api.ts @@ -0,0 +1,977 @@ +// Copyright 2026 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. + +import {Octokit} from '@octokit/rest'; +import {request} from '@octokit/request'; +import {RequestError} from '@octokit/request-error'; +import {Logger} from 'code-suggester/build/src/types'; + +import {PullRequest} from './pull-request'; +import {Repository} from './repository'; +import {Release} from './release'; +import { + ScmRelease, + ScmReleaseIteratorOptions, + ScmReleaseOptions, + ScmCommitIteratorOptions, +} from './scm'; +import { + GitHubAPIError, + DuplicateReleaseError, + ConfigurationError, +} from './errors'; +import {logger as defaultLogger} from './util/logger'; + +import {graphql} from '@octokit/graphql'; +import {HttpsProxyAgent} from 'https-proxy-agent'; +import {HttpProxyAgent} from 'http-proxy-agent'; + +export const GH_API_URL = 'https://api.github.com'; +export const GH_GRAPHQL_URL = 'https://api.github.com'; + +export type OctokitType = InstanceType; + +// Extract some types from the `request` package. +type RequestBuilderType = typeof request; +type DefaultFunctionType = RequestBuilderType['defaults']; +type RequestFunctionType = ReturnType; + +export interface OctokitAPIs { + graphql: Function; + request: RequestFunctionType; + octokit: OctokitType; +} + +export interface ProxyOption { + host: string; + port: number; +} + +export interface GitHubCreateOptions { + owner: string; + repo: string; + defaultBranch?: string; + apiUrl?: string; + graphqlUrl?: string; + octokitAPIs?: OctokitAPIs; + token?: string; + logger?: Logger; + proxy?: ProxyOption; + fetch?: any; +} + +export interface GitHubApiOptions { + repository: Repository; + octokitAPIs: OctokitAPIs; + logger?: Logger; +} + +export interface GraphQLCommit { + sha: string; + message: string; + associatedPullRequests: { + nodes: GraphQLPullRequest[]; + }; +} + +export interface GraphQLPullRequest { + number: number; + title: string; + body: string; + baseRefName: string; + headRefName: string; + labels: { + nodes: { + name: string; + }[]; + }; + mergeCommit?: { + oid: string; + }; + files: { + nodes: { + path: string; + }[]; + pageInfo: { + hasNextPage: boolean; + }; + }; +} + +export interface PullRequestHistory { + pageInfo: { + hasNextPage: boolean; + endCursor: string | undefined; + }; + data: PullRequest[]; +} + +export interface CommitHistory { + pageInfo: { + hasNextPage: boolean; + endCursor: string | undefined; + }; + data: ScmRelease[]; // Wait, ScmRelease? Let's check CommitHistory in github.ts +} + +export type CommitIteratorOptions = ScmCommitIteratorOptions; +export interface GraphQLRelease { + name: string; + tag: { + name: string; + }; + tagCommit: { + oid: string; + }; + url: string; + description: string; + isDraft: boolean; +} + +export interface ReleaseHistory { + pageInfo: { + hasNextPage: boolean; + endCursor: string | undefined; + }; + data: ScmRelease[]; +} + +export type ReleaseIteratorOptions = ScmReleaseIteratorOptions; + +export const MAX_SLEEP_SECONDS = 20; +export const MAX_ISSUE_BODY_SIZE = 65536; + +export class GitHubApi { + readonly repository: Repository; + readonly octokitAPIs: OctokitAPIs; + octokit: OctokitType; + private graphql: Function; + private logger: Logger; + + constructor(options: GitHubApiOptions) { + this.repository = options.repository; + this.octokitAPIs = options.octokitAPIs; + this.octokit = options.octokitAPIs.octokit; + this.graphql = options.octokitAPIs.graphql; + this.logger = options.logger ?? defaultLogger; + } + + static createDefaultAgent(baseUrl: string, defaultProxy?: ProxyOption) { + if (!defaultProxy) { + return undefined; + } + + const {host, port} = defaultProxy; + if (new URL(baseUrl).protocol.replace(':', '') === 'http') { + return new HttpProxyAgent(`http://${host}:${port}`); + } else { + return new HttpsProxyAgent(`https://${host}:${port}`); + } + } + + static async create(options: GitHubCreateOptions): Promise { + const apiUrl = options.apiUrl ?? GH_API_URL; + const graphqlUrl = options.graphqlUrl ?? GH_GRAPHQL_URL; + const releasePleaseVersion = require('../../package.json').version; + const apis = options.octokitAPIs ?? { + octokit: new Octokit({ + baseUrl: apiUrl, + auth: options.token, + request: { + agent: this.createDefaultAgent(apiUrl, options.proxy), + fetch: options.fetch, + }, + }), + request: request.defaults({ + baseUrl: apiUrl, + headers: { + 'user-agent': `release-please/${releasePleaseVersion}`, + Authorization: `token ${options.token}`, + }, + fetch: options.fetch, + }), + graphql: 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: + options.defaultBranch ?? + (await GitHubApi.defaultBranch( + options.owner, + options.repo, + apis.octokit + )), + }, + octokitAPIs: apis, + logger: options.logger, + }; + return new GitHubApi(opts); + } + + static async defaultBranch( + owner: string, + repo: string, + octokit: OctokitType + ): Promise { + const {data} = await octokit.repos.get({ + repo, + owner, + }); + return data.default_branch; + } + + private graphqlRequest = wrapAsync( + async ( + opts: { + [key: string]: string | number | null | undefined; + }, + options?: { + maxRetries?: number; + } + ) => { + let maxRetries = options?.maxRetries ?? 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 as GitHubAPIError).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 sleepInMs(1000 * seconds); + seconds = Math.min(seconds * 2, MAX_SLEEP_SECONDS); + } + } + this.logger.trace('ran out of retries'); + return undefined; + } + ); + + /** + * Iterate through merged pull requests with a max number of results scanned. + * + * @param {string} targetBranch Target branch of commit. + * @param {string} status The status of the pull request. Defaults to 'MERGED'. + * @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: string, + status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', + maxResults: number = Number.MAX_SAFE_INTEGER, + includeFiles = true + ): AsyncGenerator { + 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 + */ + private async *pullRequestIteratorWithFiles( + targetBranch: string, + status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', + maxResults: number = Number.MAX_SAFE_INTEGER + ): AsyncGenerator { + let cursor: string | undefined = undefined; + let results = 0; + while (results < maxResults) { + const response: PullRequestHistory | null = + 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 + */ + private async *pullRequestIteratorWithoutFiles( + targetBranch: string, + status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', + maxResults: number = Number.MAX_SAFE_INTEGER + ): AsyncGenerator { + const statusMap: Record = { + 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: any) => 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 + */ + private async pullRequestsGraphQL( + targetBranch: string, + states: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', + cursor?: string + ): Promise { + 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 (!response?.repository?.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 || + []) as GraphQLPullRequest[]; + return { + pageInfo: response.repository.pullRequests.pageInfo, + data: pullRequests.map(pullRequest => { + return { + sha: pullRequest.mergeCommit?.oid, // already filtered non-merged + number: pullRequest.number, + baseBranchName: pullRequest.baseRefName, + headBranchName: pullRequest.headRefName, + labels: (pullRequest.labels?.nodes || []).map(l => l.name), + title: pullRequest.title, + body: pullRequest.body + '', + files: (pullRequest.files?.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 scanned. + * Defaults to unlimited. + * @yields {ScmRelease} + * @throws {GitHubAPIError} on an API error + */ + async *releaseIterator(options: ReleaseIteratorOptions = {}) { + const maxResults = options.maxResults ?? Number.MAX_SAFE_INTEGER; + let results = 0; + let cursor: string | undefined = undefined; + while (true) { + const response: ReleaseHistory | null = 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; + } + } + + private async releaseGraphQL( + cursor?: string + ): Promise { + 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 as GraphQLRelease[]; + 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, + } as ScmRelease; + }), + } as ReleaseHistory; + } + + createPullRequest = wrapAsync( + async ( + pullRequest: PullRequest, + targetBranch: string, + options?: {draft?: boolean} + ) => { + const pullResponseData = ( + await this.octokit.pulls.create({ + owner: this.repository.owner, + repo: this.repository.repo, + title: pullRequest.title, + head: `${this.repository.owner}:${pullRequest.headBranchName}`, + base: targetBranch, + body: pullRequest.body, + maintainer_can_modify: true, + draft: !!options?.draft, + }) + ).data; + + this.logger.info( + `Successfully opened pull request available at url: ${pullResponseData.html_url}.` + ); + return await this.getPullRequest(pullResponseData.number); + } + ); + + /** + * Fetch a pull request given the pull number + * @param {number} number The pull request number + * @returns {PullRequest} + */ + getPullRequest = wrapAsync(async (number: number): Promise => { + 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: any) => label.name) + .filter((name: any) => !!name) as string[], + }; + }); + + updatePullRequest = wrapAsync( + async (number: number, title: string, body: string) => { + const response = await this.octokit.pulls.update({ + owner: this.repository.owner, + repo: this.repository.repo, + pull_number: number, + title, + 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: any) => label.name) + .filter((name: any) => !!name) as string[], + }; + } + ); + + /** + * Create a GitHub release + * + * @param {Release} release Release parameters + * @param {ScmReleaseOptions} options Release option parameters + * @throws {DuplicateReleaseError} if the release tag already exists + * @throws {GitHubAPIError} on other API errors + */ + createRelease = wrapAsync( + async ( + release: Release, + options: ScmReleaseOptions = {} + ): Promise => { + if (options.forceTag) { + try { + await this.octokit.git.createRef({ + owner: this.repository.owner, + repo: this.repository.repo, + ref: `refs/tags/${release.tag.toString()}`, + sha: release.sha, + }); + } catch (err) { + // ignore if tag already exists + if ((err as RequestError).status === 422) { + this.logger.debug( + `Tag ${release.tag.toString()} already exists, skipping tag creation` + ); + } else { + throw err; + } + } + } + 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 RequestError) { + if ( + e.status === 422 && + GitHubAPIError.parseErrors(e).some(error => { + return error.code === 'already_exists'; + }) + ) { + throw new 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 + */ + commentOnIssue = wrapAsync( + async (comment: string, number: number): Promise => { + 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. + */ + removeIssueLabels = wrapAsync( + async (labels: string[], number: number): Promise => { + 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. + */ + addIssueLabels = wrapAsync( + async (labels: string[], number: number): Promise => { + 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, + }); + } + ); + + /** + * 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: string, + targetCommitish: string, + previousTag?: string + ): Promise { + 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: string, + contents: string, + newBranchName: string, + baseBranchName: string + ): Promise { + // 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, + }); + return content?.html_url || ''; + } + + /** + * Fork a branch from a base branch. + */ + private async forkBranch( + targetBranchName: string, + baseBranchName: string + ): Promise { + const baseBranchSha = await this.getBranchSha(baseBranchName); + if (!baseBranchSha) { + throw new ConfigurationError( + `Unable to find base branch: ${baseBranchName}`, + 'core', + `${this.repository.owner}/${this.repository.repo}` + ); + } + if (await this.getBranchSha(targetBranchName)) { + const branchSha = await this.updateBranchSha( + targetBranchName, + baseBranchSha + ); + this.logger.debug( + `Updated ${targetBranchName} to match ${baseBranchName} at ${branchSha}` + ); + return branchSha; + } else { + const branchSha = await this.createNewBranch( + targetBranchName, + baseBranchSha + ); + this.logger.debug( + `Created ${targetBranchName} from ${baseBranchName} at ${branchSha}` + ); + return branchSha; + } + } + + /** + * Helper to fetch the SHA of a branch + */ + private async getBranchSha(branchName: string): Promise { + 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 RequestError && e.status === 404) { + this.logger.debug(`Branch: ${branchName} does not exist`); + return undefined; + } + throw e; + } + } + + /** + * Helper to create a new branch from a given SHA. + */ + private async createNewBranch( + branchName: string, + branchSha: string + ): Promise { + this.logger.debug(`Creating new branch: ${branchName} at ${branchSha}`); + const { + data: { + object: {sha}, + }, + } = await this.octokit.git.createRef({ + owner: this.repository.owner, + repo: this.repository.repo, + ref: `refs/heads/${branchName}`, + sha: branchSha, + }); + this.logger.debug(`New branch: ${branchName} at ${sha}`); + return sha; + } + + /** + * Helper to update branch SHA. + */ + private async updateBranchSha( + branchName: string, + branchSha: string + ): Promise { + this.logger.debug(`Updating branch ${branchName} to ${branchSha}`); + const { + data: { + object: {sha}, + }, + } = await this.octokit.git.updateRef({ + owner: this.repository.owner, + repo: this.repository.repo, + ref: `heads/${branchName}`, + sha: branchSha, + force: true, + }); + this.logger.debug(`Updated branch: ${branchName} to ${sha}`); + return sha; + } +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const wrapAsync = , V>( + fn: (...args: T) => Promise, + errorHandler?: (e: Error) => void +) => { + return async (...args: T): Promise => { + try { + return await fn(...args); + } catch (e) { + if (errorHandler) { + errorHandler(e as GitHubAPIError); + } + if (e instanceof RequestError) { + throw new GitHubAPIError(e); + } + throw e; + } + }; +}; + +export const sleepInMs = (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)); diff --git a/src/github.ts b/src/github.ts index 29f1d52b4..e77e46a60 100644 --- a/src/github.ts +++ b/src/github.ts @@ -12,25 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {createPullRequest} from 'code-suggester'; import {PullRequest} from './pull-request'; import {Commit} from './commit'; import {Octokit} from '@octokit/rest'; import {request} from '@octokit/request'; -import {graphql} from '@octokit/graphql'; import {RequestError} from '@octokit/request-error'; -import { - GitHubAPIError, - DuplicateReleaseError, - FileNotFoundError, - ConfigurationError, -} from './errors'; +import {createPullRequest as suggesterCreatePullRequest} from 'code-suggester'; +import {GitHubAPIError, FileNotFoundError} from './errors'; const MAX_ISSUE_BODY_SIZE = 65536; const MAX_SLEEP_SECONDS = 20; -export const GH_API_URL = 'https://api.github.com'; -export const GH_GRAPHQL_URL = 'https://api.github.com'; + type OctokitType = InstanceType; import {logger as defaultLogger} from './util/logger'; @@ -39,6 +32,7 @@ import {ReleasePullRequest} from './release-pull-request'; import {Update} from './update'; import {Release} from './release'; import {ROOT_PROJECT_PATH} from './manifest'; +import {GitHubApi, GitHubCreateOptions} from './github-api'; import {signoffCommitMessage} from './util/signoff-commit-message'; import { RepositoryFileCache, @@ -47,10 +41,19 @@ import { FileNotFoundError as MissingFileError, } from '@google-automations/git-file-utils'; import {Logger} from 'code-suggester/build/src/types'; -import {HttpsProxyAgent} from 'https-proxy-agent'; -import {HttpProxyAgent} from 'http-proxy-agent'; -import {PullRequestOverflowHandler} from './util/pull-request-overflow-handler'; import {mergeUpdates} from './updaters/composite'; +import { + Scm, + ScmChangeSet, + ScmCommitIteratorOptions, + ScmReleaseIteratorOptions, + ScmTagIteratorOptions, + ScmCreatePullRequestOptions, + ScmReleaseOptions, + ScmRelease, + ScmTag, + ScmUpdatePullRequestOptions, +} from './scm'; // Extract some types from the `request` package. type RequestBuilderType = typeof request; @@ -68,24 +71,6 @@ export interface GitHubOptions { logger?: Logger; } -interface ProxyOption { - host: string; - port: number; -} - -interface GitHubCreateOptions { - owner: string; - repo: string; - defaultBranch?: string; - apiUrl?: string; - graphqlUrl?: string; - octokitAPIs?: OctokitAPIs; - token?: string; - logger?: Logger; - proxy?: ProxyOption; - fetch?: any; -} - type CommitFilter = (commit: Commit) => boolean; interface GraphQLCommit { @@ -120,19 +105,6 @@ interface GraphQLPullRequest { }; } -interface GraphQLRelease { - name: string; - tag: { - name: string; - }; - tagCommit: { - oid: string; - }; - url: string; - description: string; - isDraft: boolean; -} - interface CommitHistory { pageInfo: { hasNextPage: boolean; @@ -141,184 +113,49 @@ interface CommitHistory { data: Commit[]; } -interface PullRequestHistory { - pageInfo: { - hasNextPage: boolean; - endCursor: string | undefined; - }; - data: PullRequest[]; -} - -interface ReleaseHistory { - pageInfo: { - hasNextPage: boolean; - endCursor: string | undefined; - }; - data: GitHubRelease[]; -} - -interface CommitIteratorOptions { - maxResults?: number; - backfillFiles?: boolean; - batchSize?: number; -} - -interface ReleaseIteratorOptions { - maxResults?: number; -} - -interface TagIteratorOptions { - maxResults?: number; -} +type CommitIteratorOptions = ScmCommitIteratorOptions; +type ReleaseIteratorOptions = ScmReleaseIteratorOptions; +type TagIteratorOptions = ScmTagIteratorOptions; -export interface ReleaseOptions { - draft?: boolean; - prerelease?: boolean; - forceTag?: boolean; -} +export type ReleaseOptions = ScmReleaseOptions; +export type GitHubRelease = ScmRelease; +export type GitHubTag = ScmTag; +export type ChangeSet = ScmChangeSet; -export interface GitHubRelease { - id: number; - name?: string; - tagName: string; - sha: string; - notes?: string; - url: string; - draft?: boolean; - uploadUrl?: string; -} +type CreatePullRequestOptions = ScmCreatePullRequestOptions; -export interface GitHubTag { - name: string; - sha: string; -} - -interface FileDiff { - readonly mode: '100644' | '100755' | '040000' | '160000' | '120000'; - readonly content: string | null; - readonly originalContent: string | null; -} -export type ChangeSet = Map; - -interface CreatePullRequestOptions { - fork?: boolean; - draft?: boolean; -} - -export class GitHub { +export class GitHub implements Scm { readonly repository: Repository; private octokit: OctokitType; - private request: RequestFunctionType; private graphql: Function; private fileCache: RepositoryFileCache; private logger: Logger; + private gitHubApi: GitHubApi; private constructor(options: GitHubOptions) { this.repository = options.repository; this.octokit = options.octokitAPIs.octokit; - this.request = options.octokitAPIs.request; this.graphql = options.octokitAPIs.graphql; this.fileCache = new RepositoryFileCache(this.octokit, this.repository); this.logger = options.logger ?? defaultLogger; + this.gitHubApi = new GitHubApi({ + repository: this.repository, + octokitAPIs: options.octokitAPIs, + logger: this.logger, + }); } - static createDefaultAgent(baseUrl: string, defaultProxy?: ProxyOption) { - if (!defaultProxy) { - return undefined; - } - - const {host, port} = defaultProxy; - if (new URL(baseUrl).protocol.replace(':', '') === 'http') { - return new HttpProxyAgent(`http://${host}:${port}`); - } else { - return new HttpsProxyAgent(`https://${host}:${port}`); - } + getGitHubApi(): GitHubApi { + return this.gitHubApi; } - /** - * 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: GitHubCreateOptions): Promise { - const apiUrl = options.apiUrl ?? GH_API_URL; - const graphqlUrl = options.graphqlUrl ?? GH_GRAPHQL_URL; - const releasePleaseVersion = require('../../package.json').version; - const apis = options.octokitAPIs ?? { - octokit: new Octokit({ - baseUrl: apiUrl, - auth: options.token, - request: { - agent: this.createDefaultAgent(apiUrl, options.proxy), - fetch: options.fetch, - }, - }), - request: request.defaults({ - baseUrl: apiUrl, - headers: { - 'user-agent': `release-please/${releasePleaseVersion}`, - Authorization: `token ${options.token}`, - }, - fetch: options.fetch, - }), - graphql: 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: - options.defaultBranch ?? - (await GitHub.defaultBranch( - options.owner, - options.repo, - apis.octokit - )), - }, - octokitAPIs: apis, + const gitHubApi = await GitHubApi.create(options); + return new GitHub({ + repository: gitHubApi.repository, + octokitAPIs: gitHubApi.octokitAPIs, 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: string, - repo: string, - octokit: OctokitType - ): Promise { - const {data} = await octokit.repos.get({ - repo, - owner, }); - return data.default_branch; } /** @@ -651,189 +488,12 @@ export class GitHub { maxResults: number = Number.MAX_SAFE_INTEGER, includeFiles = true ): AsyncGenerator { - 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 - */ - private async *pullRequestIteratorWithFiles( - targetBranch: string, - status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', - maxResults: number = Number.MAX_SAFE_INTEGER - ): AsyncGenerator { - let cursor: string | undefined = undefined; - let results = 0; - while (results < maxResults) { - const response: PullRequestHistory | null = - 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 - */ - private async *pullRequestIteratorWithoutFiles( - targetBranch: string, - status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', - maxResults: number = Number.MAX_SAFE_INTEGER - ): AsyncGenerator { - const statusMap: Record = { - 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 - */ - private async pullRequestsGraphQL( - targetBranch: string, - states: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', - cursor?: string - ): Promise { - 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, + yield* this.gitHubApi.pullRequestIterator( targetBranch, - states, - maxFilesChanged: 64, - }); - if (!response?.repository?.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 || - []) as GraphQLPullRequest[]; - return { - pageInfo: response.repository.pullRequests.pageInfo, - data: pullRequests.map(pullRequest => { - return { - sha: pullRequest.mergeCommit?.oid, // already filtered non-merged - number: pullRequest.number, - baseBranchName: pullRequest.baseRefName, - headBranchName: pullRequest.headRefName, - labels: (pullRequest.labels?.nodes || []).map(l => l.name), - title: pullRequest.title, - body: pullRequest.body + '', - files: (pullRequest.files?.nodes || []).map(node => node.path), - }; - }), - }; + status, + maxResults, + includeFiles + ); } /** @@ -846,82 +506,7 @@ export class GitHub { * @throws {GitHubAPIError} on an API error */ async *releaseIterator(options: ReleaseIteratorOptions = {}) { - const maxResults = options.maxResults ?? Number.MAX_SAFE_INTEGER; - let results = 0; - let cursor: string | undefined = undefined; - while (true) { - const response: ReleaseHistory | null = 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; - } - } - - private async releaseGraphQL( - cursor?: string - ): Promise { - 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 as GraphQLRelease[]; - 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, - } as GitHubRelease; - }), - } as ReleaseHistory; + yield* this.gitHubApi.releaseIterator(options); } /** @@ -1066,6 +651,7 @@ export class GitHub { prefix ); } + /** * Returns a list of paths to all files matching a glob pattern. * @@ -1089,52 +675,6 @@ export class GitHub { } ); - /** - * 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: ReleasePullRequest, - targetBranch: string, - options?: { - signoffUser?: string; - fork?: boolean; - skipLabeling?: boolean; - } - ): Promise { - let message = releasePullRequest.title.toString(); - if (options?.signoffUser) { - message = signoffCommitMessage(message, options.signoffUser); - } - const pullRequestLabels: string[] = 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?.fork, - draft: releasePullRequest.draft, - } - ); - } - /** * Open a pull request * @@ -1145,71 +685,53 @@ export class GitHub { * @param {CreatePullRequestOptions} options The pull request options * @throws {GitHubAPIError} on an API error */ - createPullRequest = wrapAsync( - async ( - pullRequest: PullRequest, - targetBranch: string, - message: string, - updates: Update[], - options?: CreatePullRequestOptions - ): Promise => { - // Update the files for the release if not already supplied - const changes = await this.buildChangeSet(updates, targetBranch); - const prNumber = await createPullRequest(this.octokit, changes, { - upstreamOwner: this.repository.owner, - upstreamRepo: this.repository.repo, + async createPullRequest( + pullRequest: PullRequest, + targetBranch: string, + message: string, + updates: Update[], + options?: CreatePullRequestOptions + ): Promise { + const changes = await this.buildChangeSet(updates, targetBranch); + const prNumber = await suggesterCreatePullRequest(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?.fork, + message, + logger: this.logger, + draft: !!options?.draft, + labels: pullRequest.labels, + }); + if (prNumber === 0) { + this.logger.warn( + 'no code changes detected, skipping pull request creation' + ); + return { + headBranchName: pullRequest.headBranchName, + baseBranchName: targetBranch, + number: 0, title: pullRequest.title, - branch: pullRequest.headBranchName, - description: pullRequest.body, - primary: targetBranch, - force: true, - fork: !!options?.fork, - message, - logger: this.logger, - draft: !!options?.draft, + body: pullRequest.body, labels: pullRequest.labels, - }); - if (prNumber === 0) { - this.logger.warn( - 'no code changes detected, skipping pull request creation' - ); - return { - headBranchName: pullRequest.headBranchName, - baseBranchName: targetBranch, - number: 0, - title: pullRequest.title, - body: pullRequest.body, - labels: pullRequest.labels, - files: [], - }; - } - return await this.getPullRequest(prNumber); + files: [], + }; } - ); + return await this.getPullRequest(prNumber); + } /** * Fetch a pull request given the pull number * @param {number} number The pull request number * @returns {PullRequest} */ - getPullRequest = wrapAsync(async (number: number): Promise => { - 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) as string[], - }; - }); + async getPullRequest(number: number): Promise { + return await this.gitHubApi.getPullRequest(number); + } /** * Update a pull request's title and body. @@ -1222,75 +744,51 @@ export class GitHub { * @param {PullRequestOverflowHandler} options.pullRequestOverflowHandler Optional. * Handles extra large pull request body messages. */ - updatePullRequest = wrapAsync( - async ( - number: number, - releasePullRequest: ReleasePullRequest, - targetBranch: string, - options?: { - signoffUser?: string; - fork?: boolean; - pullRequestOverflowHandler?: PullRequestOverflowHandler; - } - ): Promise => { - // Update the files for the release if not already supplied - const changes = await this.buildChangeSet( - releasePullRequest.updates, - targetBranch + async updatePullRequest( + number: number, + releasePullRequest: ReleasePullRequest, + targetBranch: string, + options?: ScmUpdatePullRequestOptions + ): Promise { + const changes = await this.buildChangeSet( + releasePullRequest.updates, + targetBranch + ); + + let message = releasePullRequest.title.toString(); + if (options?.signoffUser) { + message = signoffCommitMessage(message, options.signoffUser); + } + const title = releasePullRequest.title.toString(); + const body = ( + options?.pullRequestOverflowHandler + ? await options.pullRequestOverflowHandler.handleOverflow( + releasePullRequest + ) + : releasePullRequest.body + ) + .toString() + .slice(0, MAX_ISSUE_BODY_SIZE); + const prNumber = await suggesterCreatePullRequest(this.octokit, changes, { + upstreamOwner: this.repository.owner, + upstreamRepo: this.repository.repo, + title, + branch: releasePullRequest.headRefName, + description: body, + primary: targetBranch, + force: true, + fork: 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}` ); - let message = releasePullRequest.title.toString(); - if (options?.signoffUser) { - message = signoffCommitMessage(message, options.signoffUser); - } - const title = releasePullRequest.title.toString(); - const body = ( - options?.pullRequestOverflowHandler - ? await options.pullRequestOverflowHandler.handleOverflow( - releasePullRequest - ) - : releasePullRequest.body - ) - .toString() - .slice(0, MAX_ISSUE_BODY_SIZE); - const prNumber = await createPullRequest(this.octokit, changes, { - upstreamOwner: this.repository.owner, - upstreamRepo: this.repository.repo, - title, - branch: releasePullRequest.headRefName, - description: body, - primary: targetBranch, - force: true, - fork: 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) as string[], - }; } - ); + return this.gitHubApi.updatePullRequest(number, title, body); + } /** * Given a set of proposed updates, build a changeset to suggest. @@ -1402,68 +900,12 @@ export class GitHub { * @throws {DuplicateReleaseError} if the release tag already exists * @throws {GitHubAPIError} on other API errors */ - createRelease = wrapAsync( - async ( - release: Release, - options: ReleaseOptions = {} - ): Promise => { - if (options.forceTag) { - try { - await this.octokit.git.createRef({ - owner: this.repository.owner, - repo: this.repository.repo, - ref: `refs/tags/${release.tag.toString()}`, - sha: release.sha, - }); - } catch (err) { - // ignore if tag already exists - if ((err as RequestError).status === 422) { - this.logger.debug( - `Tag ${release.tag.toString()} already exists, skipping tag creation` - ); - } else { - throw err; - } - } - } - 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 RequestError) { - if ( - e.status === 422 && - GitHubAPIError.parseErrors(e).some(error => { - return error.code === 'already_exists'; - }) - ) { - throw new DuplicateReleaseError(e, 'tagName'); - } - } - } - ); + async createRelease( + release: Release, + options: ReleaseOptions = {} + ): Promise { + return await this.gitHubApi.createRelease(release, options); + } /** * Makes a comment on a issue/pull request. @@ -1472,20 +914,9 @@ export class GitHub { * @param {number} number - The issue or pull request number. * @throws {GitHubAPIError} on an API error */ - commentOnIssue = wrapAsync( - async (comment: string, number: number): Promise => { - 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; - } - ); + async commentOnIssue(comment: string, number: number): Promise { + return await this.gitHubApi.commentOnIssue(comment, number); + } /** * Removes labels from an issue/pull request. @@ -1493,24 +924,9 @@ export class GitHub { * @param {string[]} labels The labels to remove. * @param {number} number The issue/pull request number. */ - removeIssueLabels = wrapAsync( - async (labels: string[], number: number): Promise => { - 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, - }) - ) - ); - } - ); + async removeIssueLabels(labels: string[], number: number): Promise { + return await this.gitHubApi.removeIssueLabels(labels, number); + } /** * Adds label to an issue/pull request. @@ -1518,20 +934,9 @@ export class GitHub { * @param {string[]} labels The labels to add. * @param {number} number The issue/pull request number. */ - addIssueLabels = wrapAsync( - async (labels: string[], number: number): Promise => { - 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, - }); - } - ); + async addIssueLabels(labels: string[], number: number): Promise { + return await this.gitHubApi.addIssueLabels(labels, number); + } /** * Generate release notes from GitHub at tag @@ -1544,14 +949,11 @@ export class GitHub { targetCommitish: string, previousTag?: string ): Promise { - 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; + return await this.gitHubApi.generateReleaseNotes( + tagName, + targetCommitish, + previousTag + ); } /** @@ -1570,151 +972,12 @@ export class GitHub { newBranchName: string, baseBranchName: string ): Promise { - // 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?.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. - */ - private async getBranchSha(branchName: string): Promise { - 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 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} baseBranchName The base branch from which to fork. - * @returns {string} The branch SHA - * @throws {ConfigurationError} if the base branch cannot be found. - */ - private async forkBranch( - targetBranchName: string, - baseBranchName: string - ): Promise { - const baseBranchSha = await this.getBranchSha(baseBranchName); - if (!baseBranchSha) { - // this is highly unlikely to be thrown as we will have - // already attempted to read from the branch - throw new ConfigurationError( - `Unable to find base branch: ${baseBranchName}`, - 'core', - `${this.repository.owner}/${this.repository.repo}` - ); - } - // see if newBranchName exists - if (await this.getBranchSha(targetBranchName)) { - // branch already exists, update it to the match the base branch - const branchSha = await this.updateBranchSha( - targetBranchName, - baseBranchSha - ); - this.logger.debug( - `Updated ${targetBranchName} to match ${baseBranchName} at ${branchSha}` - ); - return branchSha; - } else { - // branch does not exist, create a new branch from the base branch - const branchSha = await this.createNewBranch( - targetBranchName, - baseBranchSha - ); - this.logger.debug( - `Forked ${targetBranchName} from ${baseBranchName} at ${branchSha}` - ); - return branchSha; - } - } - - /** - * Helper to create a new branch from a given SHA. - * @param {string} branchName The new branch name - * @param {string} branchSha The SHA of the branch - * @returns {string} The SHA of the new branch - */ - private async createNewBranch( - branchName: string, - branchSha: string - ): Promise { - this.logger.debug(`Creating new branch: ${branchName} at ${branchSha}`); - const { - data: { - object: {sha}, - }, - } = await this.octokit.git.createRef({ - owner: this.repository.owner, - repo: this.repository.repo, - ref: `refs/heads/${branchName}`, - sha: branchSha, - }); - this.logger.debug(`New branch: ${branchName} at ${sha}`); - return sha; - } - - private async updateBranchSha( - branchName: string, - branchSha: string - ): Promise { - this.logger.debug(`Updating branch ${branchName} to ${branchSha}`); - const { - data: { - object: {sha}, - }, - } = await this.octokit.git.updateRef({ - owner: this.repository.owner, - repo: this.repository.repo, - ref: `heads/${branchName}`, - sha: branchSha, - force: true, - }); - this.logger.debug(`Updated branch: ${branchName} to ${sha}`); - return sha; + return await this.gitHubApi.createFileOnNewBranch( + filename, + contents, + newBranchName, + baseBranchName + ); } } diff --git a/src/local-github.ts b/src/local-github.ts new file mode 100644 index 000000000..f1dcfb43a --- /dev/null +++ b/src/local-github.ts @@ -0,0 +1,1033 @@ +// Copyright 2026 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. + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as child_process from 'child_process'; +import * as util from 'util'; +import * as readline from 'readline'; + +const execFile = util.promisify(child_process.execFile); +const mkdtemp = fs.promises.mkdtemp; + +import { + Scm, + ScmRelease, + ScmTag, + ScmCommitIteratorOptions, + ScmReleaseIteratorOptions, + ScmTagIteratorOptions, + ScmCreatePullRequestOptions, + ScmUpdatePullRequestOptions, + ScmReleaseOptions, + ScmChangeSet, +} from './scm'; +import {FileNotFoundError} from './errors'; +import {Repository} from './repository'; +import {ROOT_PROJECT_PATH} from './manifest'; +import {Commit} from './commit'; +import {PullRequest} from './pull-request'; +import {ReleasePullRequest} from './release-pull-request'; +import {Update} from './update'; +import {Release} from './release'; +import { + GitHubFileContents, + DEFAULT_FILE_MODE, +} from '@google-automations/git-file-utils'; +import {mergeUpdates} from './updaters/composite'; +import { + GitHubApi, + MAX_ISSUE_BODY_SIZE, + GitHubCreateOptions, +} from './github-api'; +import {Logger} from 'code-suggester/build/src/types'; +import {logger as defaultLogger} from './util/logger'; + +export interface LocalGitHubCreateOptions extends GitHubCreateOptions { + cloneDepth?: number; + localRepoPath?: string; +} + +/** + * LocalGitHub implements the Scm interface using a local git clone + * where possible, and falling back to the GitHub API for other operations. + */ +export class LocalGitHub implements Scm { + readonly repository: Repository; + private cloneDir: string; + private gitHubApi: GitHubApi; + private logger: Logger; + + constructor( + repository: Repository, + gitHubApi: GitHubApi, + cloneDir: string, + options?: {logger?: Logger} + ) { + this.repository = repository; + this.gitHubApi = gitHubApi; + this.cloneDir = cloneDir; + this.logger = options?.logger ?? defaultLogger; + } + + static async create(options: LocalGitHubCreateOptions): Promise { + const gitHubApi = await GitHubApi.create(options); + const logger = options.logger ?? defaultLogger; + + let repoDir: string; + if (options.localRepoPath) { + repoDir = options.localRepoPath; + let isGitRepo = false; + try { + await execFile('git', ['rev-parse', '--is-inside-work-tree'], { + cwd: repoDir, + }); + isGitRepo = true; + } catch (err) { + isGitRepo = false; + } + + if (!isGitRepo) { + logger.info( + `Path ${repoDir} is not a git clone. Cloning repository...` + ); + const url = `https://github.com/${gitHubApi.repository.owner}/${gitHubApi.repository.repo}.git`; + const args = ['clone', '--', url, repoDir]; + if (options.cloneDepth) { + args.splice(1, 0, '--depth', options.cloneDepth.toString()); + } + logger.debug(`Executing: git ${args.join(' ')}`); + await execFile('git', args); + } else { + logger.info(`Using existing local repository at ${repoDir}...`); + } + + const branch = gitHubApi.repository.defaultBranch; + logger.debug('Executing: git fetch origin'); + await execFile('git', ['fetch', 'origin'], {cwd: repoDir}); + + logger.debug(`Executing: git checkout ${branch}`); + await execFile('git', ['checkout', branch], {cwd: repoDir}); + + logger.debug(`Executing: git reset --hard origin/${branch}`); + await execFile('git', ['reset', '--hard', `origin/${branch}`], { + cwd: repoDir, + }); + } else { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'release-please-')); + logger.info(`Cloning repository to ${tempDir}...`); + const url = `https://github.com/${gitHubApi.repository.owner}/${gitHubApi.repository.repo}.git`; + + const args = ['clone', '--', url, tempDir]; + if (options.cloneDepth) { + args.splice(1, 0, '--depth', options.cloneDepth.toString()); + } + + logger.debug(`Executing: git ${args.join(' ')}`); + await execFile('git', args); + repoDir = tempDir; + } + + return new LocalGitHub(gitHubApi.repository, gitHubApi, repoDir, { + logger: options.logger, + }); + } + + /** + * 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: string): Promise { + return await this.getFileContentsOnBranch( + path, + this.repository.defaultBranch + ); + } + + private async execGitStream( + args: string[], + callback: (line: string) => void + ): Promise { + return new Promise((resolve, reject) => { + const child = child_process.spawn('git', args, {cwd: this.cloneDir}); + let stderr = ''; + child.stderr.on('data', data => { + stderr += data; + }); + + const rl = readline.createInterface({ + input: child.stdout, + crlfDelay: Infinity, + }); + + rl.on('line', callback); + + child.on('close', code => { + if (code !== 0) { + reject(new Error(`Command failed ${code}: ${stderr}`)); + } else { + resolve(); + } + }); + }); + } + + private async ensureRef(ref: string): Promise { + try { + await execFile('git', ['rev-parse', '--verify', ref], { + cwd: this.cloneDir, + }); + return ref; + } catch (err) { + this.logger.debug( + `Ref ${ref} not found locally, trying to fetch from origin...` + ); + try { + await execFile('git', ['fetch', 'origin', '--', ref], { + cwd: this.cloneDir, + }); + return 'FETCH_HEAD'; + } catch (fetchErr) { + throw err; // Throw original error if fetch fails + } + } + } + + /** + * 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: string, + branch: string + ): Promise { + this.logger.debug( + `Fetching file contents for file ${path} on branch ${branch}` + ); + + const ref = await this.ensureRef(branch); + const lsTreeResult = await execFile('git', ['ls-tree', ref, path], { + cwd: this.cloneDir, + }); + + if (!lsTreeResult.stdout.trim()) { + throw new FileNotFoundError(path); + } + + const [info] = lsTreeResult.stdout.split('\t'); + const [mode, , sha] = info.split(' '); + + const {stdout} = await execFile('git', ['show', `${ref}:${path}`], { + cwd: this.cloneDir, + maxBuffer: 100 * 1024 * 1024, + }); + + return { + content: Buffer.from(stdout).toString('base64'), + parsedContent: stdout, + sha, + mode, + }; + } + + async getFileJson(path: string, branch: string): Promise { + 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: string, + prefix?: string + ): Promise { + return this.findFilesByFilenameAndRef( + filename, + this.repository.defaultBranch, + prefix + ); + } + + /** + * 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 + */ + async findFilesByFilenameAndRef( + filename: string, + ref: string, + prefix?: string + ): Promise { + this.logger.debug( + `Looking in local clone for file ${filename} with ref ${ref} and prefix '${prefix}'` + ); + + let normalizedPrefix = prefix + ? prefix.replace(/^[/\\]/, '').replace(/[/\\]$/, '') + : ''; + if (normalizedPrefix === ROOT_PROJECT_PATH) { + normalizedPrefix = ''; + } + + const treePath = normalizedPrefix ? `${normalizedPrefix}/` : '.'; + + const resolvedRef = await this.ensureRef(ref); + this.logger.trace( + `Executing stream: git ls-tree -r --name-only ${resolvedRef} ${treePath}` + ); + const matchedPaths: string[] = []; + await this.execGitStream( + ['ls-tree', '-r', '--name-only', resolvedRef, treePath], + line => { + const trimmed = line.trim(); + if (trimmed && path.posix.basename(trimmed) === filename) { + matchedPaths.push(trimmed); + } + } + ); + + if (normalizedPrefix) { + return matchedPaths + .map(p => { + if (p === normalizedPrefix) return ''; + if (p.startsWith(`${normalizedPrefix}/`)) { + return p.slice(normalizedPrefix.length + 1); + } + return p; + }) + .filter(p => p !== ''); + } + return matchedPaths; + } + + /** + * 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: string, prefix?: string): Promise { + return this.findFilesByGlobAndRef( + glob, + 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 ref Git reference to search files in + * @param prefix Optional path prefix used to filter results + * @throws {GitHubAPIError} on an API error + */ + async findFilesByGlobAndRef( + glob: string, + ref: string, + prefix?: string + ): Promise { + this.logger.debug( + `Looking in local clone for file matching glob ${glob} with ref ${ref} and prefix '${prefix}'` + ); + + let normalizedPrefix = prefix + ? prefix.replace(/^[/\\]/, '').replace(/[/\\]$/, '') + : ''; + if (normalizedPrefix === ROOT_PROJECT_PATH) { + normalizedPrefix = ''; + } + + const treePath = normalizedPrefix ? `${normalizedPrefix}/` : '.'; + + const resolvedRef = await this.ensureRef(ref); + const files: string[] = []; + const dirs = new Set(); + await this.execGitStream( + ['ls-tree', '-r', '--name-only', resolvedRef, treePath], + line => { + const trimmed = line.trim(); + if (trimmed) { + files.push(trimmed); + let dir = path.posix.dirname(trimmed); + while (dir !== '.' && dir !== '/') { + dirs.add(dir); + dir = path.posix.dirname(dir); + } + } + } + ); + + const allPaths = [...files, ...dirs]; + + // Make paths relative to prefix if provided + let relativePaths = allPaths; + if (normalizedPrefix) { + relativePaths = allPaths + .map(p => { + if (p === normalizedPrefix) return ''; + if (p.startsWith(`${normalizedPrefix}/`)) { + return p.slice(normalizedPrefix.length + 1); + } + return p; + }) + .filter(p => p !== ''); + } + + const regex = globToRegex(glob); + return relativePaths.filter(p => regex.test(p)); + } + + /** + * 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: string, + prefix?: string + ): Promise { + return this.findFilesByExtensionAndRef( + extension, + this.repository.defaultBranch, + prefix + ); + } + + /** + * 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 + */ + async findFilesByExtensionAndRef( + extension: string, + ref: string, + prefix?: string + ): Promise { + this.logger.debug( + `Looking in local clone for file matching extension ${extension} with ref ${ref} and prefix '${prefix}'` + ); + + let normalizedPrefix = prefix + ? prefix.replace(/^[/\\]/, '').replace(/[/\\]$/, '') + : ''; + if (normalizedPrefix === ROOT_PROJECT_PATH) { + normalizedPrefix = ''; + } + + const treePath = normalizedPrefix ? `${normalizedPrefix}/` : '.'; + + const resolvedRef = await this.ensureRef(ref); + const matchedPaths: string[] = []; + await this.execGitStream( + ['ls-tree', '-r', '--name-only', resolvedRef, treePath], + line => { + const trimmed = line.trim(); + if (trimmed && trimmed.endsWith(`.${extension}`)) { + matchedPaths.push(trimmed); + } + } + ); + + if (normalizedPrefix) { + return matchedPaths + .map(p => { + if (p === normalizedPrefix) return ''; + if (p.startsWith(`${normalizedPrefix}/`)) { + return p.slice(normalizedPrefix.length + 1); + } + return p; + }) + .filter(p => p !== ''); + } + return matchedPaths; + } + + /** + * 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: string, + filter: (commit: Commit) => boolean, + options?: ScmCommitIteratorOptions + ): Promise { + const commits: Commit[] = []; + 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: string, + options?: ScmCommitIteratorOptions + ): AsyncGenerator { + this.logger.debug( + `Looking in local clone for commits on branch ${targetBranch}` + ); + + const backfillFiles = options?.backfillFiles ?? true; + + let format = '---COMMIT_START---%n%H%n%B'; + if (backfillFiles) { + format += '%n---FILES_START---'; + } + + const ref = await this.ensureRef(targetBranch); + const args = ['log', ref, `--pretty=format:${format}`]; + if (backfillFiles) { + args.push('--name-only'); + } + if (options?.maxResults) { + args.push('-n', options.maxResults.toString()); + } + + const {stdout} = await execFile('git', args, { + cwd: this.cloneDir, + maxBuffer: 100 * 1024 * 1024, + }); + + const blocks = stdout.split('---COMMIT_START---\n'); + for (const block of blocks) { + if (!block.trim()) continue; + + let commitInfo = block; + let files: string[] = []; + + if (backfillFiles) { + const parts = block.split('\n---FILES_START---\n'); + commitInfo = parts[0]; + if (parts[1]) { + files = parts[1] + .split('\n') + .map((f: string) => f.trim()) + .filter((f: string) => f); + } + } + + const lines = commitInfo.split('\n'); + const sha = lines[0].trim(); + const message = lines.slice(1).join('\n').trim(); + + if (!sha) continue; + + const commit: Commit = { + sha, + message, + files: backfillFiles ? files : undefined, + }; + + const subject = lines[1] ? lines[1].trim() : ''; + let prNumber: number | undefined; + let headBranchName = ''; + + const squashMatch = subject.match(/\s\(#(\d+)\)$/); + const mergeMatch = subject.match(/^Merge pull request #(\d+) from (.*)$/); + + if (squashMatch) { + prNumber = parseInt(squashMatch[1], 10); + } else if (mergeMatch) { + prNumber = parseInt(mergeMatch[1], 10); + headBranchName = mergeMatch[2].trim(); + } + + if (prNumber) { + commit.pullRequest = { + sha, + number: prNumber, + title: subject.replace(/\s\(#(\d+)\)$/, ''), + body: message, + labels: [], + files: backfillFiles ? files : [], + baseBranchName: targetBranch, + headBranchName, + }; + } + + yield commit; + } + } + + /** + * 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: string, + status?: 'OPEN' | 'CLOSED' | 'MERGED', + maxResults?: number, + includeFiles?: boolean + ): AsyncGenerator { + yield* this.gitHubApi.pullRequestIterator( + targetBranch, + status, + maxResults, + includeFiles + ); + } + + /** + * 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?: ScmReleaseIteratorOptions + ): AsyncGenerator { + yield* this.gitHubApi.releaseIterator(options); + } + + /** + * 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?: ScmTagIteratorOptions + ): AsyncGenerator { + const {stdout} = await execFile( + 'git', + [ + 'for-each-ref', + '--sort=-version:refname', + 'refs/tags', + '--format=%(refname:short)|%(objectname)|%(*objectname)', + ], + {cwd: this.cloneDir} + ); + + const maxResults = options?.maxResults || Number.MAX_SAFE_INTEGER; + let results = 0; + + for (const line of stdout.split('\n')) { + if (!line) continue; + const [name, objectSha, commitSha] = line.split('|'); + const sha = commitSha || objectSha; + if (sha) { + yield {name, sha}; + results++; + if (results >= maxResults) break; + } + } + } + + private async applyEditsAndPush( + branch: string, + targetBranch: string, + message: string, + changes: ScmChangeSet + ): Promise { + this.logger.debug(`Applying edits and pushing to ${branch}`); + + // Checkout/Reset PR branch + await execFile('git', ['fetch', 'origin', '--', targetBranch], { + cwd: this.cloneDir, + }); + await execFile( + 'git', + ['checkout', '-B', branch, `origin/${targetBranch}`], + { + cwd: this.cloneDir, + } + ); + + // Write file edits + for (const [filePath, fileUpdate] of changes.entries()) { + const fullPath = path.join(this.cloneDir, filePath); + await fs.promises.mkdir(path.dirname(fullPath), {recursive: true}); + if (fileUpdate.content !== null) { + await fs.promises.writeFile(fullPath, fileUpdate.content); + } else { + await fs.promises.unlink(fullPath).catch(() => {}); + } + if (fileUpdate.mode) { + await fs.promises.chmod(fullPath, parseInt(fileUpdate.mode, 8)); + } + } + + // Commit changes + const msgFile = path.join( + os.tmpdir(), + `release-please-commit-msg-${process.pid}-${Date.now()}` + ); + await fs.promises.writeFile(msgFile, message); + await execFile('git', ['add', '.'], {cwd: this.cloneDir}); + + try { + await execFile('git', ['commit', '-F', msgFile], {cwd: this.cloneDir}); + } catch (err) { + const error = err as {stdout?: string; stderr?: string}; + if (error.stdout && error.stdout.includes('nothing to commit')) { + this.logger.debug('Nothing to commit'); + } else { + throw err; + } + } finally { + await fs.promises.unlink(msgFile).catch(() => {}); + } + + // Push transit + await execFile('git', ['push', '-f', 'origin', branch], { + cwd: this.cloneDir, + }); + } + + /** + * 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 + */ + async createPullRequest( + pullRequest: PullRequest, + targetBranch: string, + message: string, + updates: Update[], + options?: ScmCreatePullRequestOptions + ): Promise { + const changes = await this.buildChangeSet(updates, targetBranch); + await this.applyEditsAndPush( + pullRequest.headBranchName, + targetBranch, + message, + changes + ); + this.logger.info('Creating pull request via GitHub API...'); + return await this.gitHubApi.createPullRequest( + pullRequest, + targetBranch, + options + ); + } + + /** + * 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. + */ + async updatePullRequest( + number: number, + pullRequest: ReleasePullRequest, + targetBranch: string, + options?: ScmUpdatePullRequestOptions + ): Promise { + const changes = await this.buildChangeSet( + pullRequest.updates, + targetBranch + ); + const message = pullRequest.title.toString(); + await this.applyEditsAndPush( + pullRequest.headRefName, + targetBranch, + message, + changes + ); + const body = ( + options?.pullRequestOverflowHandler + ? await options.pullRequestOverflowHandler.handleOverflow(pullRequest) + : pullRequest.body + ) + .toString() + .slice(0, MAX_ISSUE_BODY_SIZE); + const pullResponseData = ( + await this.gitHubApi.octokit.pulls.update({ + owner: this.repository.owner, + repo: this.repository.repo, + pull_number: number, + title: pullRequest.title.toString(), + body, + state: 'open', + }) + ).data; + return { + headBranchName: pullResponseData.head.ref, + baseBranchName: pullResponseData.base.ref, + number: pullResponseData.number, + title: pullResponseData.title, + body: pullResponseData.body || '', + files: [], + labels: pullResponseData.labels + .map((label: any) => label.name) + .filter((name: any) => !!name) as string[], + }; + } + + async getPullRequest(number: number): Promise { + return await this.gitHubApi.getPullRequest(number); + } + + /** + * 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 + */ + async createRelease( + release: Release, + options?: ScmReleaseOptions + ): Promise { + return await this.gitHubApi.createRelease(release, options); + } + + /** + * 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 + */ + async commentOnIssue(comment: string, number: number): Promise { + return await this.gitHubApi.commentOnIssue(comment, number); + } + + /** + * Removes labels from an issue/pull request. + * + * @param {string[]} labels The labels to remove. + * @param {number} number The issue/pull request number. + */ + async removeIssueLabels(labels: string[], number: number): Promise { + return await this.gitHubApi.removeIssueLabels(labels, number); + } + + /** + * Adds label to an issue/pull request. + * + * @param {string[]} labels The labels to add. + * @param {number} number The issue/pull request number. + */ + async addIssueLabels(labels: string[], number: number): Promise { + return await this.gitHubApi.addIssueLabels(labels, number); + } + + /** + * 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: string, + targetCommitish: string, + previousTag?: string + ): Promise { + return await this.gitHubApi.generateReleaseNotes( + tagName, + targetCommitish, + previousTag + ); + } + + /** + * 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: string, + contents: string, + newBranchName: string, + baseBranchName: string + ): Promise { + return await this.gitHubApi.createFileOnNewBranch( + filename, + contents, + newBranchName, + baseBranchName + ); + } + + /** + * 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: Update[], + defaultBranch: string + ): Promise { + const mergedUpdates = mergeUpdates(updates); + const changes = new Map(); + for (const update of mergedUpdates) { + let content: GitHubFileContents | undefined; + try { + content = await this.getFileContentsOnBranch( + update.path, + defaultBranch + ); + } catch (err) { + if (!(err instanceof FileNotFoundError)) throw err; + if (!update.createIfMissing) { + console.warn(`file ${update.path} did not exist`); + continue; + } + } + const newContents = update.updater.updateContent( + content ? content.parsedContent : undefined + ); + if (newContents) { + changes.set(update.path, { + content: newContents, + originalContent: content ? content.parsedContent : null, + mode: content ? content.mode : DEFAULT_FILE_MODE, + }); + } + } + return changes; + } +} + +function globToRegex(glob: string): RegExp { + let reg = ''; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (i + 1 < glob.length && glob[i + 1] === '*') { + if (i + 2 < glob.length && glob[i + 2] === '/') { + reg += '(?:.*\\/)?'; + i += 2; + } else { + reg += '.*'; + i++; + } + } else { + reg += '[^/]*'; + } + } else if (c === '?') { + reg += '[^/]'; + } else if ( + ['.', '+', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\'].includes(c) + ) { + reg += '\\' + c; + } else { + reg += c; + } + i++; + } + return new RegExp(`^${reg}$`); +} diff --git a/src/manifest.ts b/src/manifest.ts index f0b46a05b..b77525f64 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -13,7 +13,7 @@ // limitations under the License. import {ChangelogSection} from './changelog-notes'; -import {GitHub, GitHubRelease, GitHubTag} from './github'; +import {Scm, ScmRelease, ScmTag} from './scm'; import {Version, VersionsMap} from './version'; import {Commit, parseConventionalCommits} from './commit'; import {PullRequest} from './pull-request'; @@ -294,7 +294,7 @@ const DEFAULT_COMMIT_BATCH_SIZE = 10; export const MANIFEST_PULL_REQUEST_TITLE_PATTERN = 'chore: release ${branch}'; -export interface CreatedRelease extends GitHubRelease { +export interface CreatedRelease extends ScmRelease { id: number; path: string; version: string; @@ -306,7 +306,7 @@ export interface CreatedRelease extends GitHubRelease { export class Manifest { private repository: Repository; - private github: GitHub; + private github: Scm; readonly repositoryConfig: RepositoryConfig; readonly releasedVersions: ReleasedVersions; private targetBranch: string; @@ -365,7 +365,7 @@ export class Manifest { * pull request. Defaults to `[autorelease: tagged]` */ constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, releasedVersions: ReleasedVersions, @@ -431,7 +431,7 @@ export class Manifest { * @returns {Manifest} */ static async fromManifest( - github: GitHub, + github: Scm, targetBranch: string, configFile: string = DEFAULT_RELEASE_PLEASE_CONFIG, manifestFile: string = DEFAULT_RELEASE_PLEASE_MANIFEST, @@ -487,7 +487,7 @@ export class Manifest { * @returns {Manifest} */ static async fromConfig( - github: GitHub, + github: Scm, targetBranch: string, config: ReleaserConfig, manifestOptions?: ManifestOptions, @@ -904,8 +904,8 @@ export class Manifest { return releasesByPath; } - private async getAllTags(): Promise> { - const allTags: Record = {}; + private async getAllTags(): Promise> { + const allTags: Record = {}; for await (const tag of this.github.tagIterator()) { allTags[tag.name] = tag; } @@ -1437,7 +1437,7 @@ function extractReleaserConfig( * @param {string} releaseAs Optional. Override release-as and use the given version */ async function parseConfig( - github: GitHub, + github: Scm, configFile: string, branch: string, onlyPath?: string, @@ -1491,7 +1491,7 @@ async function parseConfig( * @throws {ConfigurationError} if missing the manifest config file */ async function fetchManifestConfig( - github: GitHub, + github: Scm, configFile: string, branch: string ): Promise { @@ -1524,7 +1524,7 @@ async function fetchManifestConfig( * @returns {Record} */ async function parseReleasedVersions( - github: GitHub, + github: Scm, manifestFile: string, branch: string ): Promise { @@ -1549,7 +1549,7 @@ async function parseReleasedVersions( * @throws {ConfigurationError} if missing the manifest config file */ async function fetchReleasedVersions( - github: GitHub, + github: Scm, manifestFile: string, branch: string ): Promise> { @@ -1592,7 +1592,7 @@ function isPublishedVersion(strategy: Strategy, version: Version): boolean { * @param {string} prefix Limit the release to a specific component. */ async function latestReleaseVersion( - github: GitHub, + github: Scm, targetBranch: string, releaseFilter: (version: Version) => boolean, config: ReleaserConfig, diff --git a/src/plugin.ts b/src/plugin.ts index f7973626f..74048846b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GitHub} from './github'; +import {Scm} from './scm'; import {CandidateReleasePullRequest, RepositoryConfig} from './manifest'; import {Strategy} from './strategy'; import {Commit, ConventionalCommit} from './commit'; @@ -26,12 +26,12 @@ import {logger as defaultLogger, Logger} from './util/logger'; * or update existing files. */ export abstract class ManifestPlugin { - readonly github: GitHub; + readonly github: Scm; readonly targetBranch: string; readonly repositoryConfig: RepositoryConfig; protected logger: Logger; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, logger: Logger = defaultLogger diff --git a/src/plugins/group-priority.ts b/src/plugins/group-priority.ts index e0677fa47..00e0dac7b 100644 --- a/src/plugins/group-priority.ts +++ b/src/plugins/group-priority.ts @@ -13,7 +13,7 @@ // limitations under the License. import {ManifestPlugin} from '../plugin'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {RepositoryConfig, CandidateReleasePullRequest} from '../manifest'; /** @@ -33,7 +33,7 @@ export class GroupPriority extends ManifestPlugin { * @param {string[]} groups List of group names ordered with highest priority first */ constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, groups: string[] diff --git a/src/plugins/linked-versions.ts b/src/plugins/linked-versions.ts index f3d059bf7..96edbc0a6 100644 --- a/src/plugins/linked-versions.ts +++ b/src/plugins/linked-versions.ts @@ -14,7 +14,7 @@ import {ManifestPlugin} from '../plugin'; import {RepositoryConfig, CandidateReleasePullRequest} from '../manifest'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {Logger} from '../util/logger'; import {Strategy} from '../strategy'; import {Commit, parseConventionalCommits} from '../commit'; @@ -41,7 +41,7 @@ export class LinkedVersions extends ManifestPlugin { readonly merge: boolean; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, groupName: string, diff --git a/src/plugins/maven-workspace.ts b/src/plugins/maven-workspace.ts index 0586ec2d6..f08cad3ad 100644 --- a/src/plugins/maven-workspace.ts +++ b/src/plugins/maven-workspace.ts @@ -33,7 +33,7 @@ import {PullRequestTitle} from '../util/pull-request-title'; import {PullRequestBody} from '../util/pull-request-body'; import {BranchName} from '../util/branch-name'; import {logger as defaultLogger, Logger} from '../util/logger'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {JavaSnapshot} from '../versioning-strategies/java-snapshot'; import {AlwaysBumpPatch} from '../versioning-strategies/always-bump-patch'; import {ConventionalCommit} from '../commit'; @@ -79,7 +79,7 @@ const XPATH_PROJECT_DEPENDENCY_MANAGEMENT_DEPENDENCIES = export class MavenWorkspace extends WorkspacePlugin { readonly considerAllArtifacts: boolean; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, options: MavenWorkspacePluginOptions = {} diff --git a/src/plugins/merge.ts b/src/plugins/merge.ts index 811ac3f72..18c476ac9 100644 --- a/src/plugins/merge.ts +++ b/src/plugins/merge.ts @@ -24,7 +24,7 @@ import {PullRequestBody, ReleaseData} from '../util/pull-request-body'; import {BranchName} from '../util/branch-name'; import {Update} from '../update'; import {mergeUpdates} from '../updaters/composite'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; export interface MergeOptions { pullRequestTitlePattern?: string; @@ -50,7 +50,7 @@ export class Merge extends ManifestPlugin { private forceMerge: boolean; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, options: MergeOptions = {} @@ -129,7 +129,7 @@ export class Merge extends ManifestPlugin { candidates.map(candidate => candidate.config.releaseType) ); const releaseType = - releaseTypes.size === 1 ? releaseTypes.values().next().value : 'simple'; + releaseTypes.size === 1 ? releaseTypes.values().next().value! : 'simple'; return [ { path: ROOT_PROJECT_PATH, diff --git a/src/plugins/node-workspace.ts b/src/plugins/node-workspace.ts index 7424ab64d..eec1a2e71 100644 --- a/src/plugins/node-workspace.ts +++ b/src/plugins/node-workspace.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {CandidateReleasePullRequest, RepositoryConfig} from '../manifest'; import {PackageLockJson} from '../updaters/node/package-lock-json'; import {Version, VersionsMap} from '../version'; @@ -77,7 +77,7 @@ export class NodeWorkspace extends WorkspacePlugin { readonly updatePeerDependencies: boolean; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, options: NodeWorkspaceOptions = {} diff --git a/src/plugins/sentence-case.ts b/src/plugins/sentence-case.ts index f257706cc..80f70fb39 100644 --- a/src/plugins/sentence-case.ts +++ b/src/plugins/sentence-case.ts @@ -13,7 +13,7 @@ // limitations under the License. import {ManifestPlugin} from '../plugin'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {RepositoryConfig} from '../manifest'; import {ConventionalCommit} from '../commit'; @@ -27,7 +27,7 @@ const SPECIAL_WORDS = ['gRPC', 'npm']; export class SentenceCase extends ManifestPlugin { specialWords: Set; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, specialWords?: Array diff --git a/src/plugins/workspace.ts b/src/plugins/workspace.ts index 2386377dc..5e58b73b7 100644 --- a/src/plugins/workspace.ts +++ b/src/plugins/workspace.ts @@ -22,7 +22,7 @@ import { import {logger as defaultLogger, Logger} from '../util/logger'; import {VersionsMap, Version} from '../version'; import {Merge} from './merge'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {ReleasePleaseManifest} from '../updaters/release-please-manifest'; export type DependencyGraph = Map>; @@ -59,7 +59,7 @@ export abstract class WorkspacePlugin extends ManifestPlugin { private manifestPath: string; private merge: boolean; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, options: WorkspacePluginOptions = {} diff --git a/src/scm.ts b/src/scm.ts new file mode 100644 index 000000000..73e114460 --- /dev/null +++ b/src/scm.ts @@ -0,0 +1,169 @@ +// Copyright 2026 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. + +import {Repository} from './repository'; +import {Commit} from './commit'; +import {PullRequest} from './pull-request'; +import {ReleasePullRequest} from './release-pull-request'; +import {Update} from './update'; +import {Release} from './release'; +import {GitHubFileContents} from '@google-automations/git-file-utils'; +import {PullRequestOverflowHandler} from './util/pull-request-overflow-handler'; + +export interface ScmFileDiff { + readonly mode: '100644' | '100755' | '040000' | '160000' | '120000'; + readonly content: string | null; + readonly originalContent: string | null; +} + +export type ScmChangeSet = Map; + +export interface ScmCommitIteratorOptions { + maxResults?: number; + backfillFiles?: boolean; + batchSize?: number; +} + +export interface ScmReleaseIteratorOptions { + maxResults?: number; +} + +export interface ScmTagIteratorOptions { + maxResults?: number; +} + +export interface ScmCreatePullRequestOptions { + fork?: boolean; + draft?: boolean; +} + +export interface ScmUpdatePullRequestOptions { + signoffUser?: string; + fork?: boolean; + pullRequestOverflowHandler?: PullRequestOverflowHandler; +} + +export interface ScmReleaseOptions { + draft?: boolean; + prerelease?: boolean; + forceTag?: boolean; +} + +export interface ScmRelease { + id: number; + name?: string; + tagName: string; + sha: string; + notes?: string; + url: string; + draft?: boolean; + uploadUrl?: string; +} + +export interface ScmTag { + name: string; + sha: string; +} + +export interface Scm { + readonly repository: Repository; + + getFileContents(path: string): Promise; + getFileContentsOnBranch( + path: string, + branch: string + ): Promise; + getFileJson(path: string, branch: string): Promise; + + findFilesByFilename(filename: string, prefix?: string): Promise; + findFilesByFilenameAndRef( + filename: string, + ref: string, + prefix?: string + ): Promise; + findFilesByGlob(glob: string, prefix?: string): Promise; + findFilesByGlobAndRef( + glob: string, + ref: string, + prefix?: string + ): Promise; + findFilesByExtension(extension: string, prefix?: string): Promise; + findFilesByExtensionAndRef( + extension: string, + ref: string, + prefix?: string + ): Promise; + + commitsSince( + targetBranch: string, + filter: (commit: Commit) => boolean, + options?: ScmCommitIteratorOptions + ): Promise; + mergeCommitIterator( + targetBranch: string, + options?: ScmCommitIteratorOptions + ): AsyncGenerator; + pullRequestIterator( + targetBranch: string, + status?: 'OPEN' | 'CLOSED' | 'MERGED', + maxResults?: number, + includeFiles?: boolean + ): AsyncGenerator; + releaseIterator( + options?: ScmReleaseIteratorOptions + ): AsyncGenerator; + tagIterator( + options?: ScmTagIteratorOptions + ): AsyncGenerator; + + createPullRequest( + pullRequest: PullRequest, + targetBranch: string, + message: string, + updates: Update[], + options?: ScmCreatePullRequestOptions + ): Promise; + updatePullRequest( + number: number, + pullRequest: ReleasePullRequest, + targetBranch: string, + options?: ScmUpdatePullRequestOptions + ): Promise; + getPullRequest(number: number): Promise; + + createRelease( + release: Release, + options?: ScmReleaseOptions + ): Promise; + commentOnIssue(comment: string, number: number): Promise; + removeIssueLabels(labels: string[], number: number): Promise; + addIssueLabels(labels: string[], number: number): Promise; + + generateReleaseNotes( + tagName: string, + targetCommitish: string, + previousTag?: string + ): Promise; + createFileOnNewBranch( + filename: string, + contents: string, + newBranchName: string, + baseBranchName: string + ): Promise; + + buildChangeSet( + updates: Update[], + defaultBranch: string + ): Promise; +} diff --git a/src/strategies/base.ts b/src/strategies/base.ts index 952c666cc..44855ef11 100644 --- a/src/strategies/base.ts +++ b/src/strategies/base.ts @@ -13,7 +13,7 @@ // limitations under the License. import {Strategy, BuildReleaseOptions, BumpReleaseOptions} from '../strategy'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {VersioningStrategy} from '../versioning-strategy'; import {Repository} from '../repository'; import {ChangelogNotes, ChangelogSection} from '../changelog-notes'; @@ -57,7 +57,7 @@ export interface BaseStrategyOptions { path?: string; bumpMinorPreMajor?: boolean; bumpPatchForMinorPreMajor?: boolean; - github: GitHub; + github: Scm; component?: string; packageName?: string; versioningStrategy?: VersioningStrategy; @@ -96,7 +96,7 @@ export interface BaseStrategyOptions { */ export abstract class BaseStrategy implements Strategy { readonly path: string; - protected github: GitHub; + protected github: Scm; protected logger: Logger; protected component?: string; private packageName?: string; diff --git a/src/util/pull-request-overflow-handler.ts b/src/util/pull-request-overflow-handler.ts index 43bc85245..1d364b7d7 100644 --- a/src/util/pull-request-overflow-handler.ts +++ b/src/util/pull-request-overflow-handler.ts @@ -13,7 +13,7 @@ // limitations under the License. import {PullRequestBody} from './pull-request-body'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {PullRequest} from '../pull-request'; import {Logger, logger as defaultLogger} from './logger'; import {URL} from 'url'; @@ -62,9 +62,9 @@ export interface PullRequestOverflowHandler { export class FilePullRequestOverflowHandler implements PullRequestOverflowHandler { - private github: GitHub; + private github: Scm; private logger: Logger; - constructor(github: GitHub, logger: Logger = defaultLogger) { + constructor(github: Scm, logger: Logger = defaultLogger) { this.github = github; this.logger = logger; } diff --git a/test/github.ts b/test/github.ts index 242bf17b8..e25ec03ed 100644 --- a/test/github.ts +++ b/test/github.ts @@ -21,8 +21,10 @@ import {readFileSync} from 'fs'; import {resolve} from 'path'; import * as snapshot from 'snap-shot-it'; import * as sinon from 'sinon'; +import * as codeSuggester from 'code-suggester'; -import {GH_API_URL, GitHub, GitHubRelease} from '../src/github'; +import {GitHub, GitHubRelease} from '../src/github'; +import {GitHubApi, GH_API_URL} from '../src/github-api'; import {PullRequest} from '../src/pull-request'; import {TagName} from '../src/util/tag-name'; import {Version} from '../src/version'; @@ -35,8 +37,6 @@ import { import {fail} from 'assert'; import {PullRequestBody} from '../src/util/pull-request-body'; import {PullRequestTitle} from '../src/util/pull-request-title'; -import * as codeSuggester from 'code-suggester'; -import {RawContent} from '../src/updaters/raw-content'; import {ReleasePleaseManifest} from '../src/updaters/release-please-manifest'; import {HttpsProxyAgent} from 'https-proxy-agent'; import {HttpProxyAgent} from 'http-proxy-agent'; @@ -102,12 +102,12 @@ describe('GitHub', () => { }); it('default agent is undefined when no proxy option passed ', () => { - expect(GitHub.createDefaultAgent('test_url')).eq(undefined); + expect(GitHubApi.createDefaultAgent('test_url')).eq(undefined); }); it('should return a https agent', () => { expect( - GitHub.createDefaultAgent(GH_API_URL, { + GitHubApi.createDefaultAgent(GH_API_URL, { host: 'http://proxy.com', port: 3000, }) @@ -116,7 +116,7 @@ describe('GitHub', () => { it('should throw error when baseUrl is an invalid url', () => { expect(() => { - GitHub.createDefaultAgent('invalid_url', { + GitHubApi.createDefaultAgent('invalid_url', { host: 'http://proxy.com', port: 3000, }); @@ -125,7 +125,7 @@ describe('GitHub', () => { it('should return a http agent', () => { expect( - GitHub.createDefaultAgent('http://www.github.com', { + GitHubApi.createDefaultAgent('http://www.github.com', { host: 'http://proxy.com', port: 3000, }) @@ -1012,136 +1012,6 @@ describe('GitHub', () => { }); }); - describe('createReleasePullRequest', () => { - it('should update file', async () => { - const createPullRequestStub = sandbox - .stub(codeSuggester, 'createPullRequest') - .resolves(1); - sandbox - .stub(github, 'getFileContentsOnBranch') - .withArgs('existing-file', 'main') - .resolves({ - sha: 'abc123', - content: 'somecontent', - parsedContent: 'somecontent', - mode: '100644', - }); - sandbox.stub(github, 'getPullRequest').withArgs(1).resolves({ - title: 'created title', - headBranchName: 'release-please--branches--main', - baseBranchName: 'main', - number: 1, - body: 'some body', - labels: [], - files: [], - }); - const pullRequest = await github.createReleasePullRequest( - { - title: PullRequestTitle.ofTargetBranch('main'), - body: new PullRequestBody([]), - labels: [], - headRefName: 'release-please--branches--main', - draft: false, - updates: [ - { - path: 'existing-file', - createIfMissing: false, - updater: new RawContent('some content'), - }, - ], - }, - 'main' - ); - expect(pullRequest.number).to.eql(1); - sinon.assert.calledOnce(createPullRequestStub); - const changes = createPullRequestStub.getCall(0).args[1]; - expect(changes).to.not.be.undefined; - expect(changes!.size).to.eql(1); - expect(changes!.get('existing-file')).to.not.be.undefined; - }); - it('should handle missing files', async () => { - const createPullRequestStub = sandbox - .stub(codeSuggester, 'createPullRequest') - .resolves(1); - sandbox - .stub(github, 'getFileContentsOnBranch') - .withArgs('missing-file', 'main') - .rejects(new FileNotFoundError('missing-file')); - sandbox.stub(github, 'getPullRequest').withArgs(1).resolves({ - title: 'created title', - headBranchName: 'release-please--branches--main', - baseBranchName: 'main', - number: 1, - body: 'some body', - labels: [], - files: [], - }); - const pullRequest = await github.createReleasePullRequest( - { - title: PullRequestTitle.ofTargetBranch('main'), - body: new PullRequestBody([]), - labels: [], - headRefName: 'release-please--branches--main', - draft: false, - updates: [ - { - path: 'missing-file', - createIfMissing: false, - updater: new RawContent('some content'), - }, - ], - }, - 'main' - ); - expect(pullRequest.number).to.eql(1); - sinon.assert.calledOnce(createPullRequestStub); - const changes = createPullRequestStub.getCall(0).args[1]; - expect(changes).to.not.be.undefined; - expect(changes!.size).to.eql(0); - }); - it('should create missing file', async () => { - const createPullRequestStub = sandbox - .stub(codeSuggester, 'createPullRequest') - .resolves(1); - sandbox - .stub(github, 'getFileContentsOnBranch') - .withArgs('missing-file', 'main') - .rejects(new FileNotFoundError('missing-file')); - sandbox.stub(github, 'getPullRequest').withArgs(1).resolves({ - title: 'created title', - headBranchName: 'release-please--branches--main', - baseBranchName: 'main', - number: 1, - body: 'some body', - labels: [], - files: [], - }); - const pullRequest = await github.createReleasePullRequest( - { - title: PullRequestTitle.ofTargetBranch('main'), - body: new PullRequestBody([]), - labels: [], - headRefName: 'release-please--branches--main', - draft: false, - updates: [ - { - path: 'missing-file', - createIfMissing: true, - updater: new RawContent('some content'), - }, - ], - }, - 'main' - ); - expect(pullRequest.number).to.eql(1); - sinon.assert.calledOnce(createPullRequestStub); - const changes = createPullRequestStub.getCall(0).args[1]; - expect(changes).to.not.be.undefined; - expect(changes!.size).to.eql(1); - expect(changes!.get('missing-file')).to.not.be.undefined; - }); - }); - describe('createPullRequest', () => { it('should not call getPullRequest when no code changes detected', async () => { const createPullRequestStub = sandbox diff --git a/test/local-github.ts b/test/local-github.ts new file mode 100644 index 000000000..1f35f5599 --- /dev/null +++ b/test/local-github.ts @@ -0,0 +1,166 @@ +// Copyright 2026 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. + +import {expect} from 'chai'; +import {describe, it, before} from 'mocha'; +import {LocalGitHub} from '../src/local-github'; + +describe('LocalGitHub', () => { + let localGitHub: LocalGitHub; + + before(async () => { + localGitHub = await LocalGitHub.create({ + owner: 'googleapis', + repo: 'release-please', + defaultBranch: 'main', + cloneDepth: 100, + }); + }); + + describe('getFileContentsOnBranch', () => { + it('reads file content correctly', async () => { + const contents = await localGitHub.getFileContentsOnBranch( + 'package.json', + 'main' + ); + expect(contents).to.not.be.undefined; + expect(contents.parsedContent).to.include('"name": "release-please"'); + expect(contents.sha).to.not.be.undefined; + }); + + it('reads file content correctly from a branch', async () => { + const contents = await localGitHub.getFileContentsOnBranch( + 'package.json', + '12.x' + ); + expect(contents).to.not.be.undefined; + expect(contents.parsedContent).to.include('"name": "release-please"'); + expect(contents.sha).to.not.be.undefined; + }); + + it('reads file content correctly from a tag', async () => { + const contents = await localGitHub.getFileContentsOnBranch( + 'package.json', + 'v17.4.0' + ); + expect(contents).to.not.be.undefined; + expect(contents.parsedContent).to.include('"name": "release-please"'); + expect(contents.sha).to.not.be.undefined; + }); + + it('throws FileNotFoundError when file does not exist', async () => { + try { + await localGitHub.getFileContentsOnBranch( + 'non-existent-file.txt', + 'main' + ); + throw new Error('Expected FileNotFoundError to be thrown'); + } catch (err) { + const error = err as Error; + expect(error.name).to.equal('FileNotFoundError'); + } + }); + + it('throws FileNotFoundError when file does not exist on a branch', async () => { + try { + await localGitHub.getFileContentsOnBranch( + 'non-existent-file.txt', + '12.x' + ); + throw new Error('Expected FileNotFoundError to be thrown'); + } catch (err) { + const error = err as Error; + expect(error.name).to.equal('FileNotFoundError'); + } + }); + }); + + describe('findFilesByFilenameAndRef', () => { + it('finds files by filename', async () => { + const files = await localGitHub.findFilesByFilenameAndRef( + 'package.json', + 'main' + ); + expect(files).to.include('package.json'); + }); + + it('finds files by filename on a branch', async () => { + const files = await localGitHub.findFilesByFilenameAndRef( + 'package.json', + '12.x' + ); + expect(files).to.include('package.json'); + }); + }); + + describe('findFilesByGlobAndRef', () => { + it('finds files by glob', async () => { + const files = await localGitHub.findFilesByGlobAndRef('*.json', 'main'); + expect(files).to.include('package.json'); + }); + + it('finds files by glob on a branch', async () => { + const files = await localGitHub.findFilesByGlobAndRef('*.json', '12.x'); + expect(files).to.include('package.json'); + }); + }); + + describe('findFilesByExtensionAndRef', () => { + it('finds files by extension', async () => { + const files = await localGitHub.findFilesByExtensionAndRef( + 'json', + 'main' + ); + expect(files).to.include('package.json'); + }); + + it('finds files by extension on a branch', async () => { + const files = await localGitHub.findFilesByExtensionAndRef( + 'json', + '12.x' + ); + expect(files).to.include('package.json'); + }); + }); + + describe('mergeCommitIterator', () => { + it('iterates over commits', async () => { + const generator = localGitHub.mergeCommitIterator('main', { + maxResults: 5, + }); + const commits = []; + for await (const commit of generator) { + commits.push(commit); + } + expect(commits.length).to.be.greaterThan(0); + expect(commits.length).to.be.lessThanOrEqual(5); + expect(commits[0].sha).to.not.be.undefined; + expect(commits[0].message).to.not.be.undefined; + }); + }); + + describe('tagIterator', () => { + it('iterates over tags', async () => { + const generator = localGitHub.tagIterator({maxResults: 5}); + const tags = []; + for await (const tag of generator) { + tags.push(tag); + } + expect(tags.length).to.be.greaterThan(0); + expect(tags.length).to.be.lessThanOrEqual(5); + expect(tags[0].name).to.not.be.undefined; + expect(tags[0].sha).to.not.be.undefined; + }); + }); +}); diff --git a/test/manifest.ts b/test/manifest.ts index eb34cb944..2906eb5e7 100644 --- a/test/manifest.ts +++ b/test/manifest.ts @@ -3776,15 +3776,18 @@ describe('Manifest', () => { .resolves(buildGitHubFileRaw('some-content')); stubSuggesterWithSnapshot(sandbox, this.test!.fullTitle()); mockPullRequests(github, []); - sandbox.stub(github, 'getPullRequest').withArgs(22).resolves({ - number: 22, - title: 'pr title1', - body: 'pr body1', - headBranchName: 'release-please/branches/main', - baseBranchName: 'main', - labels: [], - files: [], - }); + sandbox + .stub((github as any).gitHubApi, 'getPullRequest') + .withArgs(22) + .resolves({ + number: 22, + title: 'pr title1', + body: 'pr body1', + headBranchName: 'release-please/branches/main', + baseBranchName: 'main', + labels: [], + files: [], + }); const manifest = new Manifest( github, 'main', @@ -3840,7 +3843,7 @@ describe('Manifest', () => { .resolves(buildGitHubFileRaw('some-content-2')); mockPullRequests(github, []); sandbox - .stub(github, 'getPullRequest') + .stub((github as any).gitHubApi, 'getPullRequest') .withArgs(123) .resolves({ number: 123, @@ -3968,15 +3971,18 @@ describe('Manifest', () => { .resolves(buildGitHubFileRaw('some-content')); stubSuggesterWithSnapshot(sandbox, this.test!.fullTitle()); mockPullRequests(github, []); - sandbox.stub(github, 'getPullRequest').withArgs(22).resolves({ - number: 22, - title: 'pr title1', - body: 'pr body1', - headBranchName: 'release-please/branches/main', - baseBranchName: 'main', - labels: [], - files: [], - }); + sandbox + .stub((github as any).gitHubApi, 'getPullRequest') + .withArgs(22) + .resolves({ + number: 22, + title: 'pr title1', + body: 'pr body1', + headBranchName: 'release-please/branches/main', + baseBranchName: 'main', + labels: [], + files: [], + }); const manifest = new Manifest( github, 'main', @@ -4031,15 +4037,18 @@ describe('Manifest', () => { .resolves(buildGitHubFileRaw('some-content')); stubSuggesterWithSnapshot(sandbox, this.test!.fullTitle()); mockPullRequests(github, []); - sandbox.stub(github, 'getPullRequest').withArgs(22).resolves({ - number: 22, - title: 'pr title1', - body: 'pr body1', - headBranchName: 'release-please/branches/main', - baseBranchName: 'main', - labels: [], - files: [], - }); + sandbox + .stub((github as any).gitHubApi, 'getPullRequest') + .withArgs(22) + .resolves({ + number: 22, + title: 'pr title1', + body: 'pr body1', + headBranchName: 'release-please/branches/main', + baseBranchName: 'main', + labels: [], + files: [], + }); const manifest = new Manifest( github, 'main',