Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/github-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {PullRequest} from './pull-request';
import {Repository} from './repository';
import {Release} from './release';
import {
ScmChangeSet,
ScmRelease,
ScmReleaseIteratorOptions,
ScmReleaseOptions,
Expand Down Expand Up @@ -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<string | undefined> => {
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
*
Expand Down
22 changes: 5 additions & 17 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
26 changes: 5 additions & 21 deletions src/local-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
108 changes: 108 additions & 0 deletions test/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading