diff --git a/src/github-api.ts b/src/github-api.ts index 8c72c1032..b7efb6b00 100644 --- a/src/github-api.ts +++ b/src/github-api.ts @@ -21,6 +21,7 @@ import {PullRequest} from './pull-request'; import {Repository} from './repository'; import {Release} from './release'; import { + ScmChangeSet, ScmRelease, ScmReleaseIteratorOptions, ScmReleaseOptions, @@ -651,6 +652,112 @@ export class GitHubApi { } ); + /** + * Apply file changes as a single commit on top of an existing branch via + * the Git Database API. The branch ref is force-updated. + * + * Empty change sets are a no-op. + * + * @param branchName The branch to push to (must already exist). + * @param message Commit message. + * @param changes File-level changes to apply. + * @returns The new commit SHA, or undefined if there were no changes. + * @throws {ConfigurationError} if the branch does not exist. + * @throws {GitHubAPIError} on any other API error. + */ + commitAndPushChanges = wrapAsync( + async ( + branchName: string, + message: string, + changes: ScmChangeSet + ): Promise => { + if (changes.size === 0) { + this.logger.debug( + `No file changes to push to branch ${branchName}; skipping commit` + ); + return undefined; + } + const owner = this.repository.owner; + const repo = this.repository.repo; + const branchSha = await this.getBranchSha(branchName); + if (!branchSha) { + throw new ConfigurationError( + `Branch ${branchName} does not exist`, + 'core', + `${owner}/${repo}` + ); + } + const { + data: { + tree: {sha: baseTreeSha}, + }, + } = await this.octokit.git.getCommit({ + owner, + repo, + commit_sha: branchSha, + }); + const treeEntries: { + path: string; + mode: '100644' | '100755' | '040000' | '160000' | '120000'; + type: 'blob'; + sha: string | null; + }[] = []; + for (const [filePath, diff] of changes) { + if (diff.content === null) { + treeEntries.push({ + path: filePath, + mode: diff.mode, + type: 'blob', + sha: null, + }); + } else { + const { + data: {sha: blobSha}, + } = await this.octokit.git.createBlob({ + owner, + repo, + content: Buffer.from(diff.content).toString('base64'), + encoding: 'base64', + }); + treeEntries.push({ + path: filePath, + mode: diff.mode, + type: 'blob', + sha: blobSha, + }); + } + } + const { + data: {sha: newTreeSha}, + } = await this.octokit.git.createTree({ + owner, + repo, + base_tree: baseTreeSha, + tree: treeEntries, + }); + const { + data: {sha: newCommitSha}, + } = await this.octokit.git.createCommit({ + owner, + repo, + message, + tree: newTreeSha, + parents: [branchSha], + }); + await this.octokit.git.updateRef({ + owner, + repo, + ref: `heads/${branchName}`, + sha: newCommitSha, + force: true, + }); + this.logger.debug( + `Pushed commit ${newCommitSha} (${treeEntries.length} file change(s)) to ${branchName}` + ); + return newCommitSha; + } + ); + /** * Create a GitHub release * diff --git a/src/github.ts b/src/github.ts index 07035fd04..fe4334398 100644 --- a/src/github.ts +++ b/src/github.ts @@ -792,24 +792,12 @@ export class GitHub implements Scm { ) .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, + // Force-push the new tree onto the PR's head branch, then PATCH the PR's title/body. + await this.gitHubApi.commitAndPushChanges( + releasePullRequest.headRefName, message, - logger: this.logger, - draft: releasePullRequest.draft, - }); - if (prNumber !== number) { - this.logger.warn( - `updated code for ${prNumber}, but update requested for ${number}` - ); - } + changes + ); return this.gitHubApi.updatePullRequest(number, title, body); } diff --git a/src/local-github.ts b/src/local-github.ts index 0ee726d35..b3c5d25ca 100644 --- a/src/local-github.ts +++ b/src/local-github.ts @@ -804,28 +804,12 @@ export class LocalGitHub implements Scm { .toString() .slice(0, MAX_ISSUE_BODY_SIZE); - const prNumber = await suggesterCreatePullRequest( - this.gitHubApi.octokit, - changes, - { - upstreamOwner: this.repository.owner, - upstreamRepo: this.repository.repo, - title, - branch: pullRequest.headRefName, - description: body, - primary: targetBranch, - force: true, - fork: options?.fork === false ? false : true, - message, - logger: this.logger, - draft: pullRequest.draft, - } + // Force-push the new tree onto the PR's head branch, then PATCH the PR's title/body. + await this.gitHubApi.commitAndPushChanges( + pullRequest.headRefName, + message, + changes ); - if (prNumber !== number) { - this.logger.warn( - `updated code for ${prNumber}, but update requested for ${number}` - ); - } return this.gitHubApi.updatePullRequest(number, title, body); } diff --git a/test/github.ts b/test/github.ts index e25ec03ed..4e55b21e8 100644 --- a/test/github.ts +++ b/test/github.ts @@ -1214,5 +1214,113 @@ describe('GitHub', () => { sinon.assert.calledOnce(handleOverflowStub); req.done(); }); + + it('pushes file changes via the Git Data API and PATCHes the PR (no code-suggester)', async () => { + // Stub buildChangeSet so we don't have to nock all of file fetches. + const branch = 'release-please--branches--main--components--xdeployment'; + const changes = new Map(); + changes.set('CHANGELOG.md', { + mode: '100644', + content: '# new changelog\n', + originalContent: '# old changelog\n', + }); + sandbox.stub(github, 'buildChangeSet').resolves(changes); + + // If code-suggester is reached we'd see /pulls?head=... or POST /pulls; + // the absence of a matching nock would error the test if it happened. + const createPullRequestSpy = sandbox.spy( + codeSuggester, + 'createPullRequest' + ); + + // Git Data API call sequence: getRef -> getCommit -> createBlob -> + // createTree -> createCommit -> updateRef. + req = req + .get(`/repos/fake/fake/git/ref/heads%2F${encodeURIComponent(branch)}`) + .reply(200, {object: {sha: 'parent-sha'}}) + .get('/repos/fake/fake/git/commits/parent-sha') + .reply(200, {tree: {sha: 'base-tree-sha'}}) + .post('/repos/fake/fake/git/blobs') + .reply(201, {sha: 'blob-sha'}) + .post('/repos/fake/fake/git/trees', body => { + expect(body.base_tree).to.eql('base-tree-sha'); + expect(body.tree).to.have.lengthOf(1); + expect(body.tree[0]).to.deep.include({ + path: 'CHANGELOG.md', + mode: '100644', + type: 'blob', + sha: 'blob-sha', + }); + return true; + }) + .reply(201, {sha: 'new-tree-sha'}) + .post('/repos/fake/fake/git/commits', body => { + expect(body.tree).to.eql('new-tree-sha'); + expect(body.parents).to.eql(['parent-sha']); + return true; + }) + .reply(201, {sha: 'new-commit-sha'}) + .patch( + `/repos/fake/fake/git/refs/heads%2F${encodeURIComponent(branch)}`, + body => { + expect(body.sha).to.eql('new-commit-sha'); + expect(body.force).to.eql(true); + return true; + } + ) + .reply(200, {object: {sha: 'new-commit-sha'}}) + .patch('/repos/fake/fake/pulls/123') + .reply(200, { + number: 123, + title: 'updated-title', + body: 'updated body', + labels: [], + head: {ref: branch}, + base: {ref: 'main'}, + }); + + const pullRequest = { + title: PullRequestTitle.ofTargetBranch('main'), + body: new PullRequestBody(mockReleaseData(1)), + labels: [], + headRefName: branch, + draft: false, + updates: [], + }; + const result = await github.updatePullRequest(123, pullRequest, 'main'); + expect(result.number).to.eql(123); + sinon.assert.notCalled(createPullRequestSpy); + req.done(); + }); + + it('skips the commit step when there are no file changes', async () => { + sandbox.stub(github, 'buildChangeSet').resolves(new Map()); + const createPullRequestSpy = sandbox.spy( + codeSuggester, + 'createPullRequest' + ); + + req = req.patch('/repos/fake/fake/pulls/123').reply(200, { + number: 123, + title: 'updated-title', + body: 'updated body', + labels: [], + head: {ref: 'release-please--branches--main'}, + base: {ref: 'main'}, + }); + + const pullRequest = { + title: PullRequestTitle.ofTargetBranch('main'), + body: new PullRequestBody(mockReleaseData(1)), + labels: [], + headRefName: 'release-please--branches--main', + draft: false, + updates: [], + }; + const result = await github.updatePullRequest(123, pullRequest, 'main'); + expect(result.number).to.eql(123); + sinon.assert.notCalled(createPullRequestSpy); + req.done(); + }); }); });