From 9b239c9552ebb1189ee75dcddc42c2fc60c05af5 Mon Sep 17 00:00:00 2001 From: Andrea Panattoni Date: Thu, 26 Mar 2026 14:44:05 +0100 Subject: [PATCH] Support GH Personal Access Token For local testing, using a Github Personal Access Token is simpler than creating a GH Application. Add `--github-token` argument Signed-off-by: Andrea Panattoni --- README.md | 24 +++++++++++++++---- src/merge_bot/cli.py | 47 ++++++++++++++++++++++++++++++-------- src/merge_bot/merge_bot.py | 28 ++++++++++++++++------- src/merge_bot/test.py | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1500742..dcd393c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ## Requirements - Python 3.8 or higher -- Access to GitHub App credentials +- Access to GitHub App credentials or a Personal Access Token - Optional: Slack webhook credentials for notifications ## Installation @@ -30,12 +30,19 @@ pip install -r requirements.txt `merge-bot` provides several CLI options to customize its behavior. Below is the general usage syntax: +Using GitHub App credentials: ```bash python merge-bot.py --source --dest --merge \ --bot-name --bot-email --github-app-key \ --github-cloner-key [options] ``` +Using a Personal Access Token: +```bash +python merge-bot.py --source --dest --merge \ + --bot-name --bot-email --github-token [options] +``` + ### CLI Options | Option | Short | Required | Description | @@ -46,14 +53,17 @@ python merge-bot.py --source --dest --me | `--bot-name` | | Yes | The name used in git commits. | | `--bot-email` | | Yes | The email used in git commits. | | `--working-dir` | | No | The directory where repositories will be cloned. Defaults to `.`. | +| `--github-token` | | No* | The path to a file containing a GitHub Personal Access Token. Alternative to GitHub App auth. | | `--github-app-id` | | No | The GitHub App ID. Defaults to `118774`. | -| `--github-app-key` | | Yes | The path to the GitHub App private key. | +| `--github-app-key` | | No* | The path to the GitHub App private key. | | `--github-cloner-id` | | No | The GitHub cloner App ID. Defaults to `121614`. | -| `--github-cloner-key` | | Yes | The path to the GitHub cloner App private key. | +| `--github-cloner-key` | | No* | The path to the GitHub cloner App private key. | | `--slack-webhook` | | No | Path to Slack webhook credentials for notifications. | | `--update-go-modules` | | No | Update and vendor Go modules in a separate commit. | | `--run-make` | | No | Run `make merge-bot` and include changes in a separate commit. | +\* Either `--github-token` or both `--github-app-key` and `--github-cloner-key` must be provided. + ### Examples 1. **Basic Merge**: @@ -70,7 +80,13 @@ python merge-bot.py --source --dest --me --github-cloner-key /path/to/cloner.key --update-go-modules ``` -3. **Merge and Run `make`**: +3. **Basic Merge with Personal Access Token**: + ```bash + python merge-bot.py --source https://example.com/upstream.git:main --dest my-org/my-repo:dev --merge my-org/my-repo:merge \ + --bot-name "Merge Bot" --bot-email "bot@example.com" --github-token /path/to/token + ``` + +4. **Merge and Run `make`**: ```bash python merge-bot.py --source https://example.com/upstream.git:main --dest my-org/my-repo:dev --merge my-org/my-repo:merge \ --bot-name "Merge Bot" --bot-email "bot@example.com" --github-app-key /path/to/app.key \ diff --git a/src/merge_bot/cli.py b/src/merge_bot/cli.py index 8fb3ee3..e49b725 100755 --- a/src/merge_bot/cli.py +++ b/src/merge_bot/cli.py @@ -128,30 +128,42 @@ def parse_cli_arguments(testing_args=None): help="The working directory where the git repos will be cloned.", default=".", ) - parser.add_argument( + auth_group = parser.add_argument_group( + "authentication", + "Either --github-token or both --github-app-key and " + "--github-cloner-key must be provided.", + ) + auth_group.add_argument( + "--github-token", + type=str, + required=False, + help="The path to a file containing a GitHub Personal Access Token. " + "When provided, --github-app-key and --github-cloner-key are not required.", + ) + auth_group.add_argument( "--github-app-id", type=int, required=False, help="The app ID of the GitHub app to use.", default=118774, # shiftstack-merge-bot ) - parser.add_argument( + auth_group.add_argument( "--github-app-key", type=str, - required=True, + required=False, help="The path to a github app private key.", ) - parser.add_argument( + auth_group.add_argument( "--github-cloner-id", type=int, required=False, help="The app ID of the GitHub cloner app to use.", default=121614, # shiftstack-merge-bot-cloner ) - parser.add_argument( + auth_group.add_argument( "--github-cloner-key", type=str, - required=True, + required=False, help="The path to a github app private key.", ) parser.add_argument( @@ -180,17 +192,31 @@ def parse_cli_arguments(testing_args=None): else: args = parser.parse_args() + if not args.github_token and not (args.github_app_key and args.github_cloner_key): + parser.error( + "Either --github-token or both --github-app-key and " + "--github-cloner-key must be provided." + ) + return args def main(): args = parse_cli_arguments() - with open(args.github_app_key, "r") as f: - gh_app_key = f.read().strip().encode() + gh_token = None + gh_app_key = None + gh_cloner_key = None + + if args.github_token: + with open(args.github_token, "r") as f: + gh_token = f.read().strip() + else: + with open(args.github_app_key, "r") as f: + gh_app_key = f.read().strip().encode() - with open(args.github_cloner_key, "r") as f: - gh_cloner_key = f.read().strip().encode() + with open(args.github_cloner_key, "r") as f: + gh_cloner_key = f.read().strip().encode() slack_webhook = None if args.slack_webhook is not None: @@ -211,6 +237,7 @@ def main(): slack_webhook, update_go_modules=args.update_go_modules, run_make=args.run_make, + gh_token=gh_token, ) if success: diff --git a/src/merge_bot/merge_bot.py b/src/merge_bot/merge_bot.py index 6aae9c2..0082247 100755 --- a/src/merge_bot/merge_bot.py +++ b/src/merge_bot/merge_bot.py @@ -302,6 +302,11 @@ def create_pr(g, dest_repo, dest, source, merge): return pr.json()["html_url"], True +def github_token_login(token): + logging.info("Logging to GitHub with personal access token") + return github3.login(token=token) + + def github_app_login(gh_app_id, gh_app_key): logging.info("Logging to GitHub") g = github3.GitHub() @@ -422,20 +427,27 @@ def run( slack_webhook, update_go_modules=False, run_make=False, + gh_token=None, ): logging.basicConfig( format="%(levelname)s - %(message)s", stream=sys.stdout, level=logging.INFO ) - # App credentials for accessing the destination and opening a PR - gh_app = github_app_login(gh_app_id, gh_app_key) - gh_app = github_login_for_repo(gh_app, dest.ns, dest.name, gh_app_id, gh_app_key) + if gh_token: + gh_app = github_token_login(gh_token) + gh_cloner_app = github_token_login(gh_token) + else: + # App credentials for accessing the destination and opening a PR + gh_app = github_app_login(gh_app_id, gh_app_key) + gh_app = github_login_for_repo( + gh_app, dest.ns, dest.name, gh_app_id, gh_app_key + ) - # App credentials for writing to the merge repo - gh_cloner_app = github_app_login(gh_cloner_id, gh_cloner_key) - gh_cloner_app = github_login_for_repo( - gh_cloner_app, merge.ns, merge.name, gh_cloner_id, gh_cloner_key - ) + # App credentials for writing to the merge repo + gh_cloner_app = github_app_login(gh_cloner_id, gh_cloner_key) + gh_cloner_app = github_login_for_repo( + gh_cloner_app, merge.ns, merge.name, gh_cloner_id, gh_cloner_key + ) try: dest_repo = gh_app.repository(dest.ns, dest.name) diff --git a/src/merge_bot/test.py b/src/merge_bot/test.py index 7898661..78fa9e6 100644 --- a/src/merge_bot/test.py +++ b/src/merge_bot/test.py @@ -20,6 +20,19 @@ "run-make": None, } +valid_token_args = { + "source": "https://opendev.org/openstack/kuryr-kubernetes:master", + "dest": "openshift/kuryr-kubernetes:master", + "merge": "shiftstack/kuryr-kubernetes:merge-bot-master", + "bot-name": "test", + "bot-email": "test@email.com", + "working-dir": "tmp", + "github-token": "/credentials/gh-token", + "slack-webhook": "/credentials/slack-webhook", + "update-go-modules": None, + "run-make": None, +} + def args_dict_to_list(args_dict): args = [] @@ -77,6 +90,34 @@ def test_valid_cli_argmuents(self): self.assertEqual(args.update_go_modules, True) self.assertEqual(args.run_make, True) + def test_valid_cli_arguments_with_token(self): + args = cli.parse_cli_arguments(args_dict_to_list(valid_token_args)) + + self.assertEqual( + args.source.url, "https://opendev.org/openstack/kuryr-kubernetes" + ) + self.assertEqual(args.source.branch, "master") + self.assertEqual(args.dest.ns, "openshift") + self.assertEqual(args.dest.name, "kuryr-kubernetes") + self.assertEqual(args.dest.branch, "master") + self.assertEqual(args.merge.ns, "shiftstack") + self.assertEqual(args.merge.name, "kuryr-kubernetes") + self.assertEqual(args.merge.branch, "merge-bot-master") + self.assertEqual(args.github_token, "/credentials/gh-token") + self.assertIsNone(args.github_app_key) + self.assertIsNone(args.github_cloner_key) + + def test_missing_auth(self): + no_auth_args = { + "source": "https://opendev.org/openstack/kuryr-kubernetes:master", + "dest": "openshift/kuryr-kubernetes:master", + "merge": "shiftstack/kuryr-kubernetes:merge-bot-master", + "bot-name": "test", + "bot-email": "test@email.com", + } + with self.assertRaises(SystemExit): + cli.parse_cli_arguments(args_dict_to_list(no_auth_args)) + def test_invalid_branch(self): for branch in ("dest", "source", "merge"): invalid_args = valid_args.copy()