Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(github-actions): create a local branch manager github action #885

Closed
Closed
Show file tree
Hide file tree
Changes from 2 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
10 changes: 10 additions & 0 deletions .github/local-actions/branch-manager/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
load("//tools:defaults.bzl", "esbuild_checked_in")

esbuild_checked_in(
name = "main",
entry_point = "//.github/local-actions/branch-manager/lib:main.ts",
target = "node16",
deps = [
"//.github/local-actions/branch-manager/lib",
],
)
19 changes: 19 additions & 0 deletions .github/local-actions/branch-manager/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: 'Branch Manager'
description: 'Determine if a provided pull request is able to merge into its target branches'
author: 'Angular'
inputs:
angular-robot-key:
description: 'The private key for the Angular Robot Github app.'
required: true
pr:
description: 'The pull request number'
required: true
repo:
description: 'The name of the repo for the pull request'
required: true
owner:
description: 'The owner of the repo for the pull request'
required: true
runs:
using: 'node16'
main: 'main.js'
28 changes: 28 additions & 0 deletions .github/local-actions/branch-manager/lib/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
load("//tools:defaults.bzl", "ts_library")

package(default_visibility = ["//.github/local-actions/branch-manager:__subpackages__"])

exports_files([
"main.ts",
])

ts_library(
name = "lib",
srcs = glob(
["*.ts"],
exclude = ["*.spec.ts"],
),
deps = [
"//github-actions:utils",
"//ng-dev/pr/common",
"//ng-dev/pr/common:labels",
"//ng-dev/pr/config",
"//ng-dev/pr/merge",
"//ng-dev/utils",
"@npm//@actions/core",
"@npm//@actions/github",
"@npm//@octokit/rest",
"@npm//@types/node",
"@npm//typed-graphqlify",
],
)
161 changes: 161 additions & 0 deletions .github/local-actions/branch-manager/lib/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import * as core from '@actions/core';
import {PullRequestValidationConfig} from '../../../../ng-dev/pr/common/validation/validation-config.js';
import {
assertValidPullRequestConfig,
PullRequestConfig,
} from '../../../../ng-dev/pr/config/index.js';
import {loadAndValidatePullRequest} from '../../../../ng-dev/pr/merge/pull-request.js';
import {AutosquashMergeStrategy} from '../../../../ng-dev/pr/merge/strategies/autosquash-merge.js';
import {
assertValidGithubConfig,
getConfig,
GithubConfig,
setConfig,
} from '../../../../ng-dev/utils/config.js';
import {AuthenticatedGitClient} from '../../../../ng-dev/utils/git/authenticated-git-client.js';
import {
ANGULAR_ROBOT,
getAuthTokenFor,
revokeActiveInstallationToken,
} from '../../../../github-actions/utils.js';
import {MergeConflictsFatalError} from '../../../../ng-dev/pr/merge/failures.js';
import {chdir} from 'process';
import {spawnSync} from 'child_process';

interface CommmitStatus {
state: 'pending' | 'error' | 'failure' | 'success';
description: string;
}

async function main(repo: {owner: string; repo: string}, token: string, pr: number) {
if (isNaN(pr)) {
core.setFailed('The provided pr value was not a number');
return;
devversion marked this conversation as resolved.
Show resolved Hide resolved
}

// Because we want to perform this check in the targetted repository, we first need to check out the repo
// and then move to the directory it is cloned into.
chdir('/tmp');
console.log(
spawnSync('git', [
'clone',
`https://github.com/${repo.owner}/${repo.repo}.git`,
'./branch-manager-repo',
]).output.toString(),
josephperrott marked this conversation as resolved.
Show resolved Hide resolved
);
chdir('/tmp/branch-manager-repo');
devversion marked this conversation as resolved.
Show resolved Hide resolved
josephperrott marked this conversation as resolved.
Show resolved Hide resolved

// Manually define the configuration for the pull request and github to prevent having to
// checkout the repository before defining the config.
// TODO(josephperrott): Load this from the actual repository.
setConfig(<{pullRequest: PullRequestConfig; github: GithubConfig}>{
github: {
mainBranchName: 'main',
josephperrott marked this conversation as resolved.
Show resolved Hide resolved
owner: repo.owner,
name: repo.repo,
},
pullRequest: {
githubApiMerge: false,
},
});
/** The configuration used for the ng-dev tooling. */
const config = await getConfig([assertValidGithubConfig, assertValidPullRequestConfig]);

AuthenticatedGitClient.configure(token);
/** The git client used to perform actions. */
const git = await AuthenticatedGitClient.get();

/** The pull request after being retrieved and validated. */
const pullRequest = await loadAndValidatePullRequest(
{git, config},
pr,
new PullRequestValidationConfig(),
);
core.info('Validated PR information:');
core.info(JSON.stringify(pullRequest));
/** Whether any fatal validation failures were discovered. */
let hasFatalFailures = false;
/** The status information to be pushed as a status to the pull request. */
let statusInfo: CommmitStatus = await (async () => {
// Log validation failures and check for any fatal failures.
if (pullRequest.validationFailures.length !== 0) {
core.info(`Found ${pullRequest.validationFailures.length} failing validation(s)`);
for (const failure of pullRequest.validationFailures) {
hasFatalFailures = !failure.canBeForceIgnored || hasFatalFailures;
await core.group('Validation failures', async () => {
josephperrott marked this conversation as resolved.
Show resolved Hide resolved
core.info(failure.message);
});
}
}

// With any fatal failure the check is not necessary to do.
if (hasFatalFailures) {
core.info('One of the validations was fatal, setting the status as pending for the pr');
return {
description: 'Waiting to check mergeability due to failing status(es)',
state: 'pending',
};
}

try {
git.run(['checkout', 'main']);
/**
* A merge strategy used to perform the merge check.
* Any concrete class implementing MergeStrategy is sufficient as all of our usage is
* defined in the abstract base class.
* */
const strategy = new AutosquashMergeStrategy(git);
await strategy.prepare(pullRequest);
await strategy.check(pullRequest);
core.info('Merge check passes, setting a passing status on the pr');
return {
description: `Merges cleanly to ${pullRequest.targetBranches.join(', ')}`,
state: 'success',
};
} catch (e) {
// As the merge strategy class will express the failures during checks, any thrown error is a
// failure for our merge check.
let description: string;
if (e instanceof MergeConflictsFatalError) {
core.info('Merge conflict found');
description = `Unable to merge into ${e.failedBranches.join(', ')}`;
josephperrott marked this conversation as resolved.
Show resolved Hide resolved
} else {
core.info('Unknown error found when checking merge:');
core.error(e as Error);
description =
'Cannot cleanly merge to all target branches, please update changes or PR target';
}
return {
description,
state: 'failure',
};
}
})();

await git.github.repos.createCommitStatus({
...repo,
state: statusInfo.state,
// Status descriptions are limited to 140 characters.
description: statusInfo.description.substring(0, 139),
josephperrott marked this conversation as resolved.
Show resolved Hide resolved
sha: pullRequest.headSha,
context: 'mergeability',
});
}

/** The token for the angular robot to perform actions. */
const token = await getAuthTokenFor(ANGULAR_ROBOT, true);
josephperrott marked this conversation as resolved.
Show resolved Hide resolved
/** The repository name for the pull request. */
const repo = core.getInput('repo', {required: true, trimWhitespace: true});
/** The owner of the repository for the pull request. */
const owner = core.getInput('owner', {required: true, trimWhitespace: true});
josephperrott marked this conversation as resolved.
Show resolved Hide resolved
/** The pull request number. */
const pr = Number(core.getInput('pr', {required: true, trimWhitespace: true}));

try {
await main({repo, owner}, token, pr).catch((e: Error) => {
core.error(e);
core.setFailed(e.message);
});
} finally {
await revokeActiveInstallationToken(token);
}
Loading