From 8cd3f38bee1973e2b0230c2f4b83b66811b5e51f Mon Sep 17 00:00:00 2001 From: John Sommerville Date: Mon, 26 Jan 2026 16:21:38 -0700 Subject: [PATCH 1/7] repositories.yaml: deprecate old repos --- repositories.yaml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/repositories.yaml b/repositories.yaml index 5b924c8..35bad13 100644 --- a/repositories.yaml +++ b/repositories.yaml @@ -24,11 +24,6 @@ repositories: ngfw: # MFW only - alertd: - private: true - products: - mfw: - efw: discoverd: private: true products: @@ -91,11 +86,6 @@ repositories: products: mfw: efw: - reportd: - private: true - products: - mfw: - efw: restd: private: true products: @@ -201,3 +191,15 @@ repositories: private: true products: mfw: + alertd: + private: true + obsolete: true + products: + mfw: + efw: + reportd: + private: true + obsolete: true + products: + mfw: + efw: \ No newline at end of file From 2407f104c750f4b1fb22d85fa935696535c9700c Mon Sep 17 00:00:00 2001 From: John Sommerville Date: Mon, 26 Jan 2026 16:28:19 -0700 Subject: [PATCH 2/7] repositories.yaml: add velo product in --- repositories.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/repositories.yaml b/repositories.yaml index 35bad13..4fe6bf1 100644 --- a/repositories.yaml +++ b/repositories.yaml @@ -34,6 +34,7 @@ repositories: products: mfw: efw: + velo: libpktdpdk: private: true products: @@ -53,10 +54,12 @@ repositories: products: mfw: efw: + velo: mfw_schema: products: mfw: efw: + velo: mfw_ui: private: true products: @@ -86,11 +89,13 @@ repositories: products: mfw: efw: + velo: restd: private: true products: mfw: efw: + velo: wan-utils: private: true products: @@ -100,6 +105,7 @@ repositories: products: mfw: efw: + velo: # WAF only waf: @@ -184,6 +190,7 @@ repositories: ngfw: waf: efw: + velo: # obsolete mfw_admin: From be54ed027259c80a148d0491e584e2574f2a59a5 Mon Sep 17 00:00:00 2001 From: John Sommerville Date: Mon, 26 Jan 2026 16:39:13 -0700 Subject: [PATCH 3/7] branch scripts: add velo to both script args --- compare-branches.py | 6 +++--- create-branch.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compare-branches.py b/compare-branches.py index 1279624..777fed8 100755 --- a/compare-branches.py +++ b/compare-branches.py @@ -15,14 +15,14 @@ from lib import repoinfo # constants -GITHUB_BASE_URL = "https://api.github.com/repos/untangle/{repository}" +GITHUB_BASE_URL = "https://api.github.com/repos/jsommerville-untangle/{repository}" GITHUB_COMPARE_URL = GITHUB_BASE_URL + "/compare/{branchTo}...{branchFrom}" GITHUB_MERGE_URL = GITHUB_BASE_URL + "/merges" GITHUB_PR_URL = GITHUB_BASE_URL + "/pulls" GITHUB_CREATE_BRANCH_URL = GITHUB_BASE_URL + "/git/refs" GITHUB_GET_BRANCH_URL = GITHUB_BASE_URL + "/branches/{branch}" GITHUB_HEADERS = {"Accept": "application/vnd.github.loki-preview+json"} -GITHUB_USER = "untangle-bot" +GITHUB_USER = "jsommerville-untangle" GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") HEADER1_TPL = "{branchFrom} vs. {branchTo}" HEADER2_TPL = " {repository}" @@ -228,7 +228,7 @@ def getHeadSha(repository: str, branch: str) -> str: type=str, dest="product", metavar="PRODUCT", - choices=("mfw", "ngfw", "waf", "efw"), + choices=("mfw", "ngfw", "waf", "efw", "velo"), help="product to work on", ) target.add_argument( diff --git a/create-branch.py b/create-branch.py index 449acc9..23888db 100755 --- a/create-branch.py +++ b/create-branch.py @@ -49,7 +49,7 @@ "--product", dest="product", action="store", - choices=("mfw", "ngfw", "waf", "efw"), + choices=("mfw", "ngfw", "waf", "efw", "velo"), required=True, default=None, metavar="PRODUCT", From 09c3b6c0aeb995fa06a9148c5c9e615d7531127e Mon Sep 17 00:00:00 2001 From: John Sommerville Date: Tue, 27 Jan 2026 14:48:03 -0700 Subject: [PATCH 4/7] compare-branches: initial take at refactoring so that we can support gerrit - thanks claude --- GERRIT_MIGRATION.md | 193 +++++++++++++++++++++++++++++++++ compare-branches.py | 257 ++++++++++++++++---------------------------- lib/gerrit_api.py | 197 +++++++++++++++++++++++++++++++++ lib/github_api.py | 241 +++++++++++++++++++++++++++++++++++++++++ lib/repoinfo.py | 2 + repositories.yaml | 3 + 6 files changed, 726 insertions(+), 167 deletions(-) create mode 100644 GERRIT_MIGRATION.md create mode 100644 lib/gerrit_api.py create mode 100644 lib/github_api.py diff --git a/GERRIT_MIGRATION.md b/GERRIT_MIGRATION.md new file mode 100644 index 0000000..3c303ed --- /dev/null +++ b/GERRIT_MIGRATION.md @@ -0,0 +1,193 @@ +# GitHub to Gerrit Migration Guide + +## Overview + +This document describes the migration of the codebase from GitHub to Gerrit, including updates to support both repository types during the transition period. + +## Changes Made + +### 1. Repository Configuration (`repositories.yaml`) + +Added support for specifying repository type: + +- **`default_repo_type`**: Set to `github` (default for all repositories) +- **`repo_type`**: Per-repository field to override the default + +Currently migrated repositories: +- `discoverd` - set to `gerrit` +- `bctid` - set to `gerrit` + +All other repositories remain on GitHub until migrated. + +**Example:** +```yaml +default_repo_type: github + +repositories: + discoverd: + repo_type: gerrit + private: true + products: + mfw: + efw: +``` + +### 2. Repository Info Library (`lib/repoinfo.py`) + +Updated the [`RepositoryInfo`](lib/repoinfo.py:11) dataclass to include: +- New field: `repo_type` (default: `'github'`) +- Automatically populated from `repositories.yaml` configuration + +### 3. Gerrit API Integration (`lib/gerrit_api.py`) + +Created a new module with Gerrit-specific API functions: + +**Key Functions:** +- [`compare_branches()`](lib/gerrit_api.py:73) - Compare two branches in Gerrit +- [`merge_branches()`](lib/gerrit_api.py:113) - Attempt to merge branches (creates change for review) +- [`create_change()`](lib/gerrit_api.py:135) - Create a Gerrit change (equivalent to GitHub PR) +- [`get_branch_revision()`](lib/gerrit_api.py:165) - Get current commit SHA of a branch + +**Required Environment Variables:** +- `GERRIT_BASE_URL` - Base URL of your Gerrit instance (default: `https://gerrit.corp.arista.io`) +- `GERRIT_USER` - Gerrit username for authentication +- `GERRIT_PASSWORD` - Gerrit password or HTTP password + +### 4. Compare Branches Script (`compare-branches.py`) + +Updated to support both GitHub and Gerrit repositories: + +**Changes:** +- Renamed original functions with `github_` prefix (e.g., [`github_merge()`](compare-branches.py:94), [`github_compare()`](compare-branches.py:120)) +- Created unified interface functions that route to GitHub or Gerrit based on `repo_type`: + - [`merge()`](compare-branches.py:186) - Routes merge operations + - [`compare()`](compare-branches.py:194) - Routes comparison operations + - [`createPR()`](compare-branches.py:202) - Routes PR/Change creation + - [`createBranch()`](compare-branches.py:211) - Routes branch creation + - [`getHeadSha()`](compare-branches.py:221) - Routes SHA retrieval + +**Output Changes:** +- Now displays repository type in output: `type: github` or `type: gerrit` + +## Usage + +### Setting Up Environment Variables + +For GitHub (existing): +```bash +export GITHUB_TOKEN="your_github_token" +``` + +For Gerrit (new): +```bash +export GERRIT_BASE_URL="https://gerrit.corp.arista.io" # Optional, this is the default +export GERRIT_USER="your_username" +export GERRIT_PASSWORD="your_http_password" +``` + +### Running compare-branches.py + +The script usage remains the same: + +```bash +# Compare branches for a product +./compare-branches.py --product mfw --branch-from master --branch-to release-1.0 + +# Compare specific repositories +./compare-branches.py --repositories discoverd bctid --branch-from master --branch-to release-1.0 + +# With merge attempt +./compare-branches.py --product mfw --branch-from master --branch-to release-1.0 --merge + +# With PR/Change creation on conflicts +./compare-branches.py --product mfw --branch-from master --branch-to release-1.0 --merge --pull-request +``` + +The script will automatically use the appropriate API (GitHub or Gerrit) based on each repository's `repo_type` configuration. + +## Migration Process + +To migrate a repository from GitHub to Gerrit: + +1. **Update `repositories.yaml`:** + ```yaml + repository_name: + repo_type: gerrit + # ... other configuration + ``` + +2. **Ensure Gerrit environment variables are set** (see above) + +3. **Test the repository** with compare-branches.py: + ```bash + ./compare-branches.py --repositories repository_name --branch-from master --branch-to develop + ``` + +4. **Verify output** shows `type: gerrit` + +## Important Notes + +### Gerrit vs GitHub Differences + +1. **Merging:** + - GitHub: Direct merge via API + - Gerrit: Creates a change that requires review and submission + +2. **Pull Requests vs Changes:** + - GitHub: Creates a PR with a temporary branch + - Gerrit: Creates a change directly (no temporary branch needed) + +3. **Authentication:** + - GitHub: Uses personal access token + - Gerrit: Uses HTTP password (generated in Gerrit settings) + +### Limitations + +- Gerrit's [`merge_branches()`](lib/gerrit_api.py:113) currently returns a skip status as direct merges require the change workflow +- Branch creation for Gerrit repositories is not implemented (not needed for Gerrit workflow) +- The [`compare_branches()`](lib/gerrit_api.py:73) function fetches up to 1000 commits for comparison + +## Testing + +To test the migration: + +1. **Test GitHub repositories** (should work as before): + ```bash + ./compare-branches.py --repositories ngfw_pkgs --branch-from master --branch-to develop + ``` + +2. **Test Gerrit repositories** (discoverd, bctid): + ```bash + ./compare-branches.py --repositories discoverd bctid --branch-from master --branch-to develop + ``` + +3. **Test mixed product** (contains both types): + ```bash + ./compare-branches.py --product mfw --branch-from master --branch-to develop + ``` + +## Troubleshooting + +### Authentication Errors + +**GitHub:** +- Ensure `GITHUB_TOKEN` is set and valid +- Token needs appropriate repository permissions + +**Gerrit:** +- Ensure `GERRIT_BASE_URL`, `GERRIT_USER`, and `GERRIT_PASSWORD` are set +- Password should be HTTP password from Gerrit settings, not your login password +- Check Gerrit user has appropriate project access + +### API Errors + +- Check logs with `--log-level debug` for detailed error messages +- Verify repository names match exactly in Gerrit/GitHub +- Ensure branches exist in the repository + +## Future Work + +- Implement full Gerrit merge workflow with change submission +- Add support for Gerrit-specific features (reviewers, labels, etc.) +- Migrate additional scripts (create-branch.py, etc.) +- Add automated tests for both GitHub and Gerrit code paths diff --git a/compare-branches.py b/compare-branches.py index 777fed8..d23d92e 100755 --- a/compare-branches.py +++ b/compare-branches.py @@ -2,179 +2,67 @@ import argparse import logging -import os import sys -import time -import typing -from datetime import datetime -from typing import Dict, Any, Optional, Tuple - -import requests +from typing import Optional, Tuple, Any # relative to cwd from lib import repoinfo +from lib import gerrit_api +from lib import github_api # constants -GITHUB_BASE_URL = "https://api.github.com/repos/jsommerville-untangle/{repository}" -GITHUB_COMPARE_URL = GITHUB_BASE_URL + "/compare/{branchTo}...{branchFrom}" -GITHUB_MERGE_URL = GITHUB_BASE_URL + "/merges" -GITHUB_PR_URL = GITHUB_BASE_URL + "/pulls" -GITHUB_CREATE_BRANCH_URL = GITHUB_BASE_URL + "/git/refs" -GITHUB_GET_BRANCH_URL = GITHUB_BASE_URL + "/branches/{branch}" -GITHUB_HEADERS = {"Accept": "application/vnd.github.loki-preview+json"} -GITHUB_USER = "jsommerville-untangle" -GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") HEADER1_TPL = "{branchFrom} vs. {branchTo}" HEADER2_TPL = " {repository}" OUTPUT_COMPARE_TPL = " {ahead:>02} ahead, {behind:>02} behind {extra}" OUTPUT_MERGE_TPL = " merge {status}" -# functions -def getCompareUrl(repository: str, branchFrom: str, branchTo: str) -> str: - return GITHUB_COMPARE_URL.format( - repository=repository, branchFrom=branchFrom, branchTo=branchTo - ) - - -def getPrUrl(repository: str) -> str: - return GITHUB_PR_URL.format(repository=repository) - - -def getPrBody(date: str, newBranch: str, branchTo: str, branchFrom: str) -> Dict[str, str]: - return { - "title": "Merge PR from {branchFrom} into {branchTo} on {date} ".format( - branchFrom=branchFrom, branchTo=branchTo, date=date - ), - "body": "PR opened by jenkins", - "head": newBranch, - "base": branchTo, - } - - -def getBranchUrl(repository: str) -> str: - return GITHUB_CREATE_BRANCH_URL.format(repository=repository) - - -def getBranchBody(newBranch: str, commitSha: str) -> Dict[str, str]: - return {"ref": "refs/heads/" + newBranch, "sha": commitSha} - - -def getHeadShaUrl(repository: str, branch: str) -> str: - return GITHUB_GET_BRANCH_URL.format(repository=repository, branch=branch) - - -def getJson( - url: str, - headers: Dict[str, str], - auth: Tuple[str, str], - postData: Optional[Dict[str, str]] = None, -) -> Tuple[Optional[int], Optional[Dict[str, Any]]]: - if postData: - r = requests.post(url, headers=headers, auth=auth, json=postData) - else: - r = requests.get(url, headers=headers, auth=auth) - - sc = r.status_code - if sc == 401: - logging.error("Couldn't authenticate to GitHub, you need to export a valid GITHUB_TOKEN") - sys.exit(1) - if sc == 404: - logging.debug("Couldn't find URL '{}'".format(url)) - logging.debug("... it means one of repository/branchFrom/branchTo does not exist") - return None, None - elif sc == 204: - jsonData = None - else: - jsonData = r.json() - - return sc, jsonData - - -def merge(repository: str, branchFrom: str, branchTo: str) -> Tuple[bool, str]: - url = GITHUB_MERGE_URL.format(repository=repository) - postData = { - "base": branchTo, - "head": branchFrom, - "commit_message": "Merged by Jenkins", - } - sc, jsonData = getJson(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), postData=postData) - - if not sc: - success = True - status = "SKIPPED: no comparison could be made" - elif sc == 204: - success = True - status = "SKIPPED: no need to merge" - elif sc == 201: - success = True - if not jsonData: - raise RuntimeError("merge(...), sc is 201, but success is None") - status = "DONE: commitId=" + jsonData["sha"] +# Unified interface functions that route to GitHub or Gerrit +def merge(repository: str, branch_from: str, branch_to: str, repo_type: str = "github") -> Tuple[bool, str]: + """Merge branches - routes to GitHub or Gerrit based on repo_type.""" + if repo_type == "gerrit": + return gerrit_api.merge_branches(repository, branch_from, branch_to) else: - success = False - status = "FAILED: conflicts" - - return success, status + return github_api.merge_branches(repository, branch_from, branch_to) def compare( - repository: str, branchFrom: str, branchTo: str + repository: str, branch_from: str, branch_to: str, repo_type: str = "github" ) -> Tuple[Optional[int], Optional[int], Any]: - url = getCompareUrl(repository, branchFrom, branchTo) - sc, jsonData = getJson(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN)) - if not sc or not jsonData: - return None, None, None - - ahead, behind = [int(jsonData[x]) for x in ("ahead_by", "behind_by")] - extra = "!!! Need to merge !!!" if ahead > 0 else "" - - return ahead, behind, extra - - -def createPR(repository: str, branchTo: str, newBranch: str, branchFrom: str) -> Tuple[int, str]: - url = getPrUrl(repository) - body = getPrBody( - datetime.today().strftime("%Y-%m-%d_%H-%M-%S"), newBranch, branchTo, branchFrom - ) - sc, _ = getJson(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), postData=body) - if not sc: - raise RuntimeError("createPR(...) returned status code is None") - return sc, newBranch - + """Compare branches - routes to GitHub or Gerrit based on repo_type.""" + if repo_type == "gerrit": + return gerrit_api.compare_branches(repository, branch_from, branch_to) + else: + return github_api.compare_branches(repository, branch_from, branch_to) -def createBranch(repository: str, branchFrom: str, branchTo: str): - url = getBranchUrl(repository) - newBranch = "automerge-from-{branchFrom}-to-{branchTo}-{date}-{time}".format( - branchFrom=branchFrom, - branchTo=branchTo, - date=datetime.today().strftime("%Y-%m-%d"), - time=time.time_ns(), - ) - sha = getHeadSha(repository, branchFrom) - logging.debug("got sha: {sha}; creating workspace branch with this...".format(sha=sha)) - postData = getBranchBody(newBranch, sha) - sc, _ = getJson(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), postData=postData) +def create_pr(repository: str, branch_to: str, new_branch: str, branch_from: str, repo_type: str = "github") -> Tuple[int, str]: + """Create PR/Change - routes to GitHub or Gerrit based on repo_type.""" + if repo_type == "gerrit": + sc, change_id = gerrit_api.create_change(repository, branch_to, branch_from) + return sc, change_id if change_id else "" + else: + return github_api.create_pr(repository, branch_to, new_branch, branch_from) - logging.debug("new branch is: {newBranch}".format(newBranch=newBranch)) - return sc, newBranch +def create_branch(repository: str, branch_from: str, branch_to: str, repo_type: str = "github"): + """Create branch - routes to GitHub or Gerrit based on repo_type.""" + if repo_type == "gerrit": + # For Gerrit, we don't create temporary branches the same way + # Return a placeholder + logging.warning("Branch creation for Gerrit not implemented - using change workflow") + return None, None + else: + return github_api.create_branch(repository, branch_from, branch_to) -def getHeadSha(repository: str, branch: str) -> str: - url = getHeadShaUrl(repository, branch) - sc, jsonData = getJson(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN)) - if not sc: - logging.debug("idk what this means?") - return "" - elif sc == 200: - if not jsonData: - raise RuntimeError("getHeadSha(...), status code is 200, but jsonData is None") - return jsonData["commit"].get("sha") +def get_head_sha(repository: str, branch: str, repo_type: str = "github") -> str: + """Get HEAD SHA - routes to GitHub or Gerrit based on repo_type.""" + if repo_type == "gerrit": + sha = gerrit_api.get_branch_revision(repository, branch) + return sha if sha else "" else: - logging.debug("unable to get branch sha; exit") - exit(1) + return github_api.get_branch_revision(repository, branch) # CL options @@ -253,23 +141,49 @@ def getHeadSha(repository: str, branch: str) -> str: product = args.product if args.repositories: - repositories = args.repositories + # When repositories are specified directly, we need to get their info + # For now, assume they're all GitHub unless we can look them up + repo_objects = [] + for repo_name in args.repositories: + # Try to find the repo in the product's repo list + found = False + if product: + all_repos = repoinfo.list_repositories(product) + for r in all_repos: + if r.name == repo_name: + repo_objects.append(r) + found = True + break + if not found: + # Create a minimal repo object with default values + logging.warning(f"Repository {repo_name} not found in product config, assuming GitHub") + from dataclasses import dataclass + @dataclass + class MinimalRepo: + name: str + repo_type: str = "github" + disable_forward_merge: bool = False + repo_objects.append(MinimalRepo(name=repo_name)) else: - repositories = [ - r.name for r in repoinfo.list_repositories(product) if not r.disable_forward_merge + repo_objects = [ + r for r in repoinfo.list_repositories(product) if not r.disable_forward_merge ] - branchFrom, branchTo = args.branchFrom, args.branchTo + branch_from, branch_to = args.branchFrom, args.branchTo rc = 0 - print(HEADER1_TPL.format(branchFrom=branchFrom, branchTo=branchTo)) + print(HEADER1_TPL.format(branchFrom=branch_from, branchTo=branch_to)) - for repository in repositories: + for repo in repo_objects: + repository = repo.name + repo_type = getattr(repo, 'repo_type', 'github') + s = [""] s.append(HEADER2_TPL.format(repository=repository)) + s.append(f" type: {repo_type}") if args.merge: - success, status = merge(repository, branchFrom, branchTo) + success, status = merge(repository, branch_from, branch_to, repo_type) logging.debug("For {}: success={}, status={}".format(repository, success, status)) s.append(OUTPUT_MERGE_TPL.format(status=status)) if success: @@ -278,7 +192,7 @@ def getHeadSha(repository: str, branch: str) -> str: else: rc = 1 - ahead, behind, extra = compare(repository, branchFrom, branchTo) + ahead, behind, extra = compare(repository, branch_from, branch_to, repo_type) if ahead is None: continue @@ -286,15 +200,24 @@ def getHeadSha(repository: str, branch: str) -> str: print("\n".join(s)) if args.openpr: - # First push the branch up, based on the HEAD of branchFrom - success, newBranch = createBranch(repository, branchFrom, branchTo) - if success is False: - print("Unable to create new branch - merge manually pls") - exit(1) - # Last, open a PR against the branchTo - success = createPR(repository, branchTo, newBranch, branchFrom) - if success is False: - print("Unable to create PR - merge manually pls") - exit(1) + if repo_type == "gerrit": + # For Gerrit, create a change directly + success, change_id = create_pr(repository, branch_to, "", branch_from, repo_type) + if not success: + print("Unable to create Gerrit change - merge manually pls") + exit(1) + else: + print(f"Created Gerrit change: {change_id}") + else: + # For GitHub, create branch then PR + success, new_branch = create_branch(repository, branch_from, branch_to, repo_type) + if success is False: + print("Unable to create new branch - merge manually pls") + exit(1) + # Last, open a PR against the branch_to + success = create_pr(repository, branch_to, new_branch, branch_from, repo_type) + if success is False: + print("Unable to create PR - merge manually pls") + exit(1) sys.exit(rc) diff --git a/lib/gerrit_api.py b/lib/gerrit_api.py new file mode 100644 index 0000000..bda6583 --- /dev/null +++ b/lib/gerrit_api.py @@ -0,0 +1,197 @@ +"""Gerrit API integration module for repository operations.""" + +import logging +import os +import sys +from typing import Dict, Any, Optional, Tuple + +import requests + + +# Constants +GERRIT_BASE_URL = os.getenv("GERRIT_BASE_URL", "https://gerrit.corp.arista.io") +GERRIT_USER = os.getenv("GERRIT_USER", "") +GERRIT_PASSWORD = os.getenv("GERRIT_PASSWORD", "") + + +def get_gerrit_auth() -> Tuple[str, str]: + """Get Gerrit authentication credentials.""" + if not GERRIT_USER or not GERRIT_PASSWORD: + logging.error("GERRIT_USER and GERRIT_PASSWORD environment variables must be set") + sys.exit(1) + return (GERRIT_USER, GERRIT_PASSWORD) + + +def get_json( + url: str, + auth: Tuple[str, str], + post_data: Optional[Dict[str, Any]] = None, +) -> Tuple[Optional[int], Optional[Dict[str, Any]]]: + """ + Make a GET or POST request to Gerrit API. + + Args: + url: The API endpoint URL + auth: Tuple of (username, password) + post_data: Optional data for POST requests + + Returns: + Tuple of (status_code, json_data) + """ + headers = {"Content-Type": "application/json"} + + try: + if post_data: + r = requests.post(url, headers=headers, auth=auth, json=post_data) + else: + r = requests.get(url, headers=headers, auth=auth) + + sc = r.status_code + + if sc == 401: + logging.error("Couldn't authenticate to Gerrit, check GERRIT_USER and GERRIT_PASSWORD") + sys.exit(1) + if sc == 404: + logging.debug(f"Couldn't find URL '{url}'") + logging.debug("... it means one of repository/branchFrom/branchTo does not exist") + return None, None + elif sc == 204: + json_data = None + else: + # Gerrit prepends ")]}'" to JSON responses for security + text = r.text + if text.startswith(")]}'"): + text = text[4:] + json_data = r.json() if text else None + + return sc, json_data + + except requests.exceptions.RequestException as e: + logging.error(f"Request failed: {e}") + return None, None + + +def compare_branches( + repository: str, branch_from: str, branch_to: str +) -> Tuple[Optional[int], Optional[int], Any]: + """ + Compare two branches in a Gerrit repository. + + Args: + repository: Repository name + branch_from: Source branch + branch_to: Target branch + + Returns: + Tuple of (commits_ahead, commits_behind, extra_message) + """ + # Gerrit uses project names with URL encoding + project = repository.replace("/", "%2F") + + # Get commits in branch_from not in branch_to (ahead) + url_ahead = f"{GERRIT_BASE_URL}/a/projects/{project}/branches/{branch_from}/commits?n=1000" + url_to = f"{GERRIT_BASE_URL}/a/projects/{project}/branches/{branch_to}/commits?n=1000" + + auth = get_gerrit_auth() + + sc_from, commits_from = get_json(url_ahead, auth) + sc_to, commits_to = get_json(url_to, auth) + + if not sc_from or not sc_to or commits_from is None or commits_to is None: + return None, None, None + + # Get commit IDs + commits_from_ids = {c.get('commit') for c in commits_from} + commits_to_ids = {c.get('commit') for c in commits_to} + + ahead = len(commits_from_ids - commits_to_ids) + behind = len(commits_to_ids - commits_from_ids) + + extra = "!!! Need to merge !!!" if ahead > 0 else "" + + return ahead, behind, extra + + +def merge_branches( + repository: str, branch_from: str, branch_to: str +) -> Tuple[bool, str]: + """ + Attempt to merge branch_from into branch_to. + TODO: implement this + Note: Gerrit doesn't support direct merges via API like GitHub. + This would typically require creating a change and submitting it. + + Args: + repository: Repository name + branch_from: Source branch + branch_to: Target branch + + Returns: + Tuple of (success, status_message) + """ + # For Gerrit, merging is more complex and typically involves: + # 1. Creating a change (similar to a PR) + # 2. Getting it reviewed + # 3. Submitting it + + # This is a simplified implementation that creates a merge change + logging.warning("Direct merge not supported in Gerrit - would need to create a change") + return False, "SKIPPED: Gerrit requires creating a change for review" + + +def create_change( + repository: str, branch_to: str, branch_from: str +) -> Tuple[Optional[int], Optional[str]]: + """ + Create a Gerrit change (similar to GitHub PR). + + Args: + repository: Repository name + branch_to: Target branch + branch_from: Source branch + + Returns: + Tuple of (status_code, change_id) + """ + project = repository.replace("/", "%2F") + url = f"{GERRIT_BASE_URL}/a/changes/" + + auth = get_gerrit_auth() + + post_data = { + "project": repository, + "subject": f"Merge {branch_from} into {branch_to}", + "branch": branch_to, + "status": "NEW", + } + + sc, json_data = get_json(url, auth, post_data=post_data) + + if sc and json_data: + change_id = json_data.get('change_id') + return sc, change_id + + return None, None + + +def get_branch_revision(repository: str, branch: str) -> Optional[str]: + """ + Get the current revision (commit SHA) of a branch. + + Args: + repository: Repository name + branch: Branch name + + Returns: + Commit SHA or None + """ + project = repository.replace("/", "%2F") + url = f"{GERRIT_BASE_URL}/a/projects/{project}/branches/{branch}" + + auth = get_gerrit_auth() + sc, json_data = get_json(url, auth) + + if sc == 200 and json_data: + return json_data.get('revision') + + return None diff --git a/lib/github_api.py b/lib/github_api.py new file mode 100644 index 0000000..c8eac2d --- /dev/null +++ b/lib/github_api.py @@ -0,0 +1,241 @@ +"""GitHub API integration module for repository operations.""" + +import logging +import os +import sys +import time +from datetime import datetime +from typing import Dict, Any, Optional, Tuple + +import requests + + +# Constants +GITHUB_BASE_URL = "https://api.github.com/repos/untangle/{repository}" +GITHUB_COMPARE_URL = GITHUB_BASE_URL + "/compare/{branchTo}...{branchFrom}" +GITHUB_MERGE_URL = GITHUB_BASE_URL + "/merges" +GITHUB_PR_URL = GITHUB_BASE_URL + "/pulls" +GITHUB_CREATE_BRANCH_URL = GITHUB_BASE_URL + "/git/refs" +GITHUB_GET_BRANCH_URL = GITHUB_BASE_URL + "/branches/{branch}" +GITHUB_HEADERS = {"Accept": "application/vnd.github.loki-preview+json"} +GITHUB_USER = "untangle-bot" +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") + + +def get_compare_url(repository: str, branch_from: str, branch_to: str) -> str: + """Generate GitHub compare URL.""" + return GITHUB_COMPARE_URL.format( + repository=repository, branchFrom=branch_from, branchTo=branch_to + ) + + +def get_pr_url(repository: str) -> str: + """Generate GitHub PR URL.""" + return GITHUB_PR_URL.format(repository=repository) + + +def get_pr_body(date: str, new_branch: str, branch_to: str, branch_from: str) -> Dict[str, str]: + """Generate PR body for GitHub.""" + return { + "title": "Merge PR from {branchFrom} into {branchTo} on {date} ".format( + branchFrom=branch_from, branchTo=branch_to, date=date + ), + "body": "PR opened by jenkins", + "head": new_branch, + "base": branch_to, + } + + +def get_branch_url(repository: str) -> str: + """Generate GitHub branch creation URL.""" + return GITHUB_CREATE_BRANCH_URL.format(repository=repository) + + +def get_branch_body(new_branch: str, commit_sha: str) -> Dict[str, str]: + """Generate branch creation body for GitHub.""" + return {"ref": "refs/heads/" + new_branch, "sha": commit_sha} + + +def get_head_sha_url(repository: str, branch: str) -> str: + """Generate GitHub branch info URL.""" + return GITHUB_GET_BRANCH_URL.format(repository=repository, branch=branch) + + +def get_json( + url: str, + headers: Dict[str, str], + auth: Tuple[str, str], + post_data: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[int], Optional[Dict[str, Any]]]: + """ + Make a GET or POST request to GitHub API. + + Args: + url: The API endpoint URL + headers: Request headers + auth: Tuple of (username, token) + post_data: Optional data for POST requests + + Returns: + Tuple of (status_code, json_data) + """ + if post_data: + r = requests.post(url, headers=headers, auth=auth, json=post_data) + else: + r = requests.get(url, headers=headers, auth=auth) + + sc = r.status_code + if sc == 401: + logging.error("Couldn't authenticate to GitHub, you need to export a valid GITHUB_TOKEN") + sys.exit(1) + if sc == 404: + logging.debug("Couldn't find URL '{}'".format(url)) + logging.debug("... it means one of repository/branchFrom/branchTo does not exist") + return None, None + elif sc == 204: + json_data = None + else: + json_data = r.json() + + return sc, json_data + + +def merge_branches(repository: str, branch_from: str, branch_to: str) -> Tuple[bool, str]: + """ + Merge branch_from into branch_to on GitHub. + + Args: + repository: Repository name + branch_from: Source branch + branch_to: Target branch + + Returns: + Tuple of (success, status_message) + """ + url = GITHUB_MERGE_URL.format(repository=repository) + post_data = { + "base": branch_to, + "head": branch_from, + "commit_message": "Merged by Jenkins", + } + sc, json_data = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), post_data=post_data) + + if not sc: + success = True + status = "SKIPPED: no comparison could be made" + elif sc == 204: + success = True + status = "SKIPPED: no need to merge" + elif sc == 201: + success = True + if not json_data: + raise RuntimeError("merge_branches(...), sc is 201, but json_data is None") + status = "DONE: commitId=" + json_data["sha"] + else: + success = False + status = "FAILED: conflicts" + + return success, status + + +def compare_branches( + repository: str, branch_from: str, branch_to: str +) -> Tuple[Optional[int], Optional[int], Any]: + """ + Compare two branches on GitHub. + + Args: + repository: Repository name + branch_from: Source branch + branch_to: Target branch + + Returns: + Tuple of (commits_ahead, commits_behind, extra_message) + """ + url = get_compare_url(repository, branch_from, branch_to) + sc, json_data = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN)) + if not sc or not json_data: + return None, None, None + + ahead, behind = [int(json_data[x]) for x in ("ahead_by", "behind_by")] + extra = "!!! Need to merge !!!" if ahead > 0 else "" + + return ahead, behind, extra + + +def create_pr(repository: str, branch_to: str, new_branch: str, branch_from: str) -> Tuple[int, str]: + """ + Create a pull request on GitHub. + + Args: + repository: Repository name + branch_to: Target branch + new_branch: Source branch for the PR + branch_from: Original branch name (for PR title) + + Returns: + Tuple of (status_code, branch_name) + """ + url = get_pr_url(repository) + body = get_pr_body( + datetime.today().strftime("%Y-%m-%d_%H-%M-%S"), new_branch, branch_to, branch_from + ) + sc, _ = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), post_data=body) + if not sc: + raise RuntimeError("create_pr(...) returned status code is None") + return sc, new_branch + + +def create_branch(repository: str, branch_from: str, branch_to: str): + """ + Create a new branch on GitHub. + + Args: + repository: Repository name + branch_from: Source branch to base the new branch on + branch_to: Target branch (used in naming) + + Returns: + Tuple of (status_code, new_branch_name) + """ + url = get_branch_url(repository) + new_branch = "automerge-from-{branchFrom}-to-{branchTo}-{date}-{time}".format( + branchFrom=branch_from, + branchTo=branch_to, + date=datetime.today().strftime("%Y-%m-%d"), + time=time.time_ns(), + ) + + sha = get_branch_revision(repository, branch_from) + logging.debug("got sha: {sha}; creating workspace branch with this...".format(sha=sha)) + post_data = get_branch_body(new_branch, sha) + sc, _ = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), post_data=post_data) + + logging.debug("new branch is: {newBranch}".format(newBranch=new_branch)) + return sc, new_branch + + +def get_branch_revision(repository: str, branch: str) -> str: + """ + Get the current revision (commit SHA) of a branch on GitHub. + + Args: + repository: Repository name + branch: Branch name + + Returns: + Commit SHA + """ + url = get_head_sha_url(repository, branch) + sc, json_data = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN)) + + if not sc: + logging.debug("idk what this means?") + return "" + elif sc == 200: + if not json_data: + raise RuntimeError("get_branch_revision(...), status code is 200, but json_data is None") + return json_data["commit"].get("sha") + else: + logging.debug("unable to get branch sha; exit") + exit(1) diff --git a/lib/repoinfo.py b/lib/repoinfo.py index 7cff5c2..e9dc506 100644 --- a/lib/repoinfo.py +++ b/lib/repoinfo.py @@ -16,6 +16,7 @@ class RepositoryInfo: versioned_resources: List[versioned_resource.VersionedResource] git_url: str = '' default_branch: str = 'master' + repo_type: str = 'github' obsolete: bool = False disable_branch_creation: bool = False disable_forward_merge: bool = False @@ -59,6 +60,7 @@ def list_repositories(product, yaml_file=YAML_REPOSITORY_INFO, include_obsolete= # 2 extra records to match RepositoryInfo r['name'] = name r['git_base_url'] = r.get('git_base_url', y['default_git_base_url']) + r['repo_type'] = r.get('repo_type', y.get('default_repo_type', 'github')) # get those product-specific attributes r['default_branch'] = p.get('default_branch', 'master') r['disable_branch_creation'] = p.get('disable_branch_creation', False) diff --git a/repositories.yaml b/repositories.yaml index 4fe6bf1..be031ca 100644 --- a/repositories.yaml +++ b/repositories.yaml @@ -1,4 +1,5 @@ default_git_base_url: git@github.com:untangle +default_repo_type: github repositories: # NGFW only @@ -25,6 +26,7 @@ repositories: # MFW only discoverd: + repo_type: gerrit private: true products: mfw: @@ -129,6 +131,7 @@ repositories: waf: efw: bctid: + repo_type: gerrit private: true products: mfw: From 139c2ae5ae35dfc6eeb9105ef2370292aa144fe3 Mon Sep 17 00:00:00 2001 From: John Sommerville Date: Wed, 28 Jan 2026 14:50:17 -0700 Subject: [PATCH 5/7] introduce products enum --- compare-branches.py | 3 ++- create-branch.py | 3 ++- lib/products.py | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 lib/products.py diff --git a/compare-branches.py b/compare-branches.py index d23d92e..22059ce 100755 --- a/compare-branches.py +++ b/compare-branches.py @@ -9,6 +9,7 @@ from lib import repoinfo from lib import gerrit_api from lib import github_api +from lib.products import Product # constants HEADER1_TPL = "{branchFrom} vs. {branchTo}" @@ -116,7 +117,7 @@ def get_head_sha(repository: str, branch: str, repo_type: str = "github") -> str type=str, dest="product", metavar="PRODUCT", - choices=("mfw", "ngfw", "waf", "efw", "velo"), + choices=Product.choices(), help="product to work on", ) target.add_argument( diff --git a/create-branch.py b/create-branch.py index 23888db..80d7de6 100755 --- a/create-branch.py +++ b/create-branch.py @@ -6,6 +6,7 @@ # relative to cwd from lib import gitutils, repoinfo, simple_version +from lib.products import Product # functions @@ -49,7 +50,7 @@ "--product", dest="product", action="store", - choices=("mfw", "ngfw", "waf", "efw", "velo"), + choices=Product.choices(), required=True, default=None, metavar="PRODUCT", diff --git a/lib/products.py b/lib/products.py new file mode 100644 index 0000000..bc5f43d --- /dev/null +++ b/lib/products.py @@ -0,0 +1,22 @@ +"""Product definitions for ngfw_pkgtools.""" + +from enum import Enum + + +class Product(str, Enum): + """Enumeration of supported products.""" + + MFW = "mfw" + NGFW = "ngfw" + WAF = "waf" + EFW = "efw" + VELO = "velo" + + @classmethod + def choices(cls): + """Return a tuple of product values for argparse choices.""" + return tuple(member.value for member in cls) + + def __str__(self): + """Return the string value of the product.""" + return self.value From 5a304cc18a518274796612b0a5b41cf6ff0346dc Mon Sep 17 00:00:00 2001 From: John Sommerville Date: Thu, 29 Jan 2026 08:41:12 -0700 Subject: [PATCH 6/7] refactor APIs to use classes and base class --- compare-branches.py | 70 ++---------- lib/gerrit_api.py | 256 +++++++++++++++++++++++------------------- lib/github_api.py | 267 ++++++++++++++++++++++---------------------- lib/repo_api.py | 107 ++++++++++++++++++ 4 files changed, 392 insertions(+), 308 deletions(-) create mode 100644 lib/repo_api.py diff --git a/compare-branches.py b/compare-branches.py index 22059ce..8cbee6c 100755 --- a/compare-branches.py +++ b/compare-branches.py @@ -7,9 +7,8 @@ # relative to cwd from lib import repoinfo -from lib import gerrit_api -from lib import github_api from lib.products import Product +from lib.repo_api import get_api # constants HEADER1_TPL = "{branchFrom} vs. {branchTo}" @@ -18,54 +17,6 @@ OUTPUT_MERGE_TPL = " merge {status}" -# Unified interface functions that route to GitHub or Gerrit -def merge(repository: str, branch_from: str, branch_to: str, repo_type: str = "github") -> Tuple[bool, str]: - """Merge branches - routes to GitHub or Gerrit based on repo_type.""" - if repo_type == "gerrit": - return gerrit_api.merge_branches(repository, branch_from, branch_to) - else: - return github_api.merge_branches(repository, branch_from, branch_to) - - -def compare( - repository: str, branch_from: str, branch_to: str, repo_type: str = "github" -) -> Tuple[Optional[int], Optional[int], Any]: - """Compare branches - routes to GitHub or Gerrit based on repo_type.""" - if repo_type == "gerrit": - return gerrit_api.compare_branches(repository, branch_from, branch_to) - else: - return github_api.compare_branches(repository, branch_from, branch_to) - - -def create_pr(repository: str, branch_to: str, new_branch: str, branch_from: str, repo_type: str = "github") -> Tuple[int, str]: - """Create PR/Change - routes to GitHub or Gerrit based on repo_type.""" - if repo_type == "gerrit": - sc, change_id = gerrit_api.create_change(repository, branch_to, branch_from) - return sc, change_id if change_id else "" - else: - return github_api.create_pr(repository, branch_to, new_branch, branch_from) - - -def create_branch(repository: str, branch_from: str, branch_to: str, repo_type: str = "github"): - """Create branch - routes to GitHub or Gerrit based on repo_type.""" - if repo_type == "gerrit": - # For Gerrit, we don't create temporary branches the same way - # Return a placeholder - logging.warning("Branch creation for Gerrit not implemented - using change workflow") - return None, None - else: - return github_api.create_branch(repository, branch_from, branch_to) - - -def get_head_sha(repository: str, branch: str, repo_type: str = "github") -> str: - """Get HEAD SHA - routes to GitHub or Gerrit based on repo_type.""" - if repo_type == "gerrit": - sha = gerrit_api.get_branch_revision(repository, branch) - return sha if sha else "" - else: - return github_api.get_branch_revision(repository, branch) - - # CL options parser = argparse.ArgumentParser( description="""List differences @@ -179,12 +130,15 @@ class MinimalRepo: repository = repo.name repo_type = getattr(repo, 'repo_type', 'github') + # Get the appropriate API implementation + api = get_api(repo_type) + s = [""] s.append(HEADER2_TPL.format(repository=repository)) s.append(f" type: {repo_type}") if args.merge: - success, status = merge(repository, branch_from, branch_to, repo_type) + success, status = api.merge_branches(repository, branch_from, branch_to) logging.debug("For {}: success={}, status={}".format(repository, success, status)) s.append(OUTPUT_MERGE_TPL.format(status=status)) if success: @@ -193,7 +147,7 @@ class MinimalRepo: else: rc = 1 - ahead, behind, extra = compare(repository, branch_from, branch_to, repo_type) + ahead, behind, extra = api.compare_branches(repository, branch_from, branch_to) if ahead is None: continue @@ -203,21 +157,21 @@ class MinimalRepo: if args.openpr: if repo_type == "gerrit": # For Gerrit, create a change directly - success, change_id = create_pr(repository, branch_to, "", branch_from, repo_type) - if not success: + sc, change_id = api.create_pr(repository, branch_to, "", branch_from) + if not sc: print("Unable to create Gerrit change - merge manually pls") exit(1) else: print(f"Created Gerrit change: {change_id}") else: # For GitHub, create branch then PR - success, new_branch = create_branch(repository, branch_from, branch_to, repo_type) - if success is False: + sc, new_branch = api.create_branch(repository, branch_from, branch_to) + if sc is False or sc is None: print("Unable to create new branch - merge manually pls") exit(1) # Last, open a PR against the branch_to - success = create_pr(repository, branch_to, new_branch, branch_from, repo_type) - if success is False: + sc, _ = api.create_pr(repository, branch_to, new_branch, branch_from) + if sc is False or sc is None: print("Unable to create PR - merge manually pls") exit(1) diff --git a/lib/gerrit_api.py b/lib/gerrit_api.py index bda6583..97e0850 100644 --- a/lib/gerrit_api.py +++ b/lib/gerrit_api.py @@ -7,6 +7,8 @@ import requests +from lib.repo_api import RepoAPI + # Constants GERRIT_BASE_URL = os.getenv("GERRIT_BASE_URL", "https://gerrit.corp.arista.io") @@ -71,127 +73,147 @@ def get_json( return None, None -def compare_branches( - repository: str, branch_from: str, branch_to: str -) -> Tuple[Optional[int], Optional[int], Any]: - """ - Compare two branches in a Gerrit repository. +class GerritAPI(RepoAPI): + """Gerrit API implementation.""" - Args: - repository: Repository name - branch_from: Source branch - branch_to: Target branch + def compare_branches( + self, repository: str, branch_from: str, branch_to: str + ) -> Tuple[Optional[int], Optional[int], Any]: + """ + Compare two branches in a Gerrit repository. - Returns: - Tuple of (commits_ahead, commits_behind, extra_message) - """ - # Gerrit uses project names with URL encoding - project = repository.replace("/", "%2F") - - # Get commits in branch_from not in branch_to (ahead) - url_ahead = f"{GERRIT_BASE_URL}/a/projects/{project}/branches/{branch_from}/commits?n=1000" - url_to = f"{GERRIT_BASE_URL}/a/projects/{project}/branches/{branch_to}/commits?n=1000" - - auth = get_gerrit_auth() - - sc_from, commits_from = get_json(url_ahead, auth) - sc_to, commits_to = get_json(url_to, auth) - - if not sc_from or not sc_to or commits_from is None or commits_to is None: - return None, None, None - - # Get commit IDs - commits_from_ids = {c.get('commit') for c in commits_from} - commits_to_ids = {c.get('commit') for c in commits_to} - - ahead = len(commits_from_ids - commits_to_ids) - behind = len(commits_to_ids - commits_from_ids) - - extra = "!!! Need to merge !!!" if ahead > 0 else "" - - return ahead, behind, extra - - -def merge_branches( - repository: str, branch_from: str, branch_to: str -) -> Tuple[bool, str]: - """ - Attempt to merge branch_from into branch_to. - TODO: implement this - Note: Gerrit doesn't support direct merges via API like GitHub. - This would typically require creating a change and submitting it. - - Args: - repository: Repository name - branch_from: Source branch - branch_to: Target branch + Args: + repository: Repository name + branch_from: Source branch + branch_to: Target branch + + Returns: + Tuple of (commits_ahead, commits_behind, extra_message) + """ + # Gerrit uses project names with URL encoding + project = repository.replace("/", "%2F") - Returns: - Tuple of (success, status_message) - """ - # For Gerrit, merging is more complex and typically involves: - # 1. Creating a change (similar to a PR) - # 2. Getting it reviewed - # 3. Submitting it - - # This is a simplified implementation that creates a merge change - logging.warning("Direct merge not supported in Gerrit - would need to create a change") - return False, "SKIPPED: Gerrit requires creating a change for review" - - -def create_change( - repository: str, branch_to: str, branch_from: str -) -> Tuple[Optional[int], Optional[str]]: - """ - Create a Gerrit change (similar to GitHub PR). - - Args: - repository: Repository name - branch_to: Target branch - branch_from: Source branch + # Get commits in branch_from not in branch_to (ahead) + url_ahead = f"{GERRIT_BASE_URL}/a/projects/{project}/branches/{branch_from}/commits?n=1000" + url_to = f"{GERRIT_BASE_URL}/a/projects/{project}/branches/{branch_to}/commits?n=1000" - Returns: - Tuple of (status_code, change_id) - """ - project = repository.replace("/", "%2F") - url = f"{GERRIT_BASE_URL}/a/changes/" - - auth = get_gerrit_auth() - - post_data = { - "project": repository, - "subject": f"Merge {branch_from} into {branch_to}", - "branch": branch_to, - "status": "NEW", - } - - sc, json_data = get_json(url, auth, post_data=post_data) - - if sc and json_data: - change_id = json_data.get('change_id') - return sc, change_id - - return None, None + auth = get_gerrit_auth() + + sc_from, commits_from = get_json(url_ahead, auth) + sc_to, commits_to = get_json(url_to, auth) + + if not sc_from or not sc_to or commits_from is None or commits_to is None: + return None, None, None + + # Get commit IDs + commits_from_ids = {c.get('commit') for c in commits_from} + commits_to_ids = {c.get('commit') for c in commits_to} + + ahead = len(commits_from_ids - commits_to_ids) + behind = len(commits_to_ids - commits_from_ids) + + extra = "!!! Need to merge !!!" if ahead > 0 else "" + + return ahead, behind, extra + + def merge_branches( + self, repository: str, branch_from: str, branch_to: str + ) -> Tuple[bool, str]: + """ + Attempt to merge branch_from into branch_to. + TODO: implement this + Note: Gerrit doesn't support direct merges via API like GitHub. + This would typically require creating a change and submitting it. + + Args: + repository: Repository name + branch_from: Source branch + branch_to: Target branch + + Returns: + Tuple of (success, status_message) + """ + # For Gerrit, merging is more complex and typically involves: + # 1. Creating a change (similar to a PR) + # 2. Getting it reviewed + # 3. Submitting it + + # This is a simplified implementation that creates a merge change + logging.warning("Direct merge not supported in Gerrit - would need to create a change") + return False, "SKIPPED: Gerrit requires creating a change for review" + + def create_pr( + self, repository: str, branch_to: str, new_branch: str, branch_from: str + ) -> Tuple[Optional[int], str]: + """ + Create a Gerrit change (similar to GitHub PR). + + Args: + repository: Repository name + branch_to: Target branch + new_branch: Source branch for the PR (unused in Gerrit) + branch_from: Source branch + + Returns: + Tuple of (status_code, change_id) + """ + project = repository.replace("/", "%2F") + url = f"{GERRIT_BASE_URL}/a/changes/" + + auth = get_gerrit_auth() + + post_data = { + "project": repository, + "subject": f"Merge {branch_from} into {branch_to}", + "branch": branch_to, + "status": "NEW", + } + + sc, json_data = get_json(url, auth, post_data=post_data) + + if sc and json_data: + change_id = json_data.get('change_id', '') + return sc, change_id + + return None, '' + def create_branch( + self, repository: str, branch_from: str, branch_to: str + ) -> Tuple[Optional[int], Optional[str]]: + """ + Create a new branch in Gerrit. + + Note: For Gerrit, we don't create temporary branches the same way as GitHub. + + Args: + repository: Repository name + branch_from: Source branch to base the new branch on + branch_to: Target branch (used in naming) + + Returns: + Tuple of (None, None) - not implemented for Gerrit + """ + logging.warning("Branch creation for Gerrit not implemented - using change workflow") + return None, None -def get_branch_revision(repository: str, branch: str) -> Optional[str]: - """ - Get the current revision (commit SHA) of a branch. - - Args: - repository: Repository name - branch: Branch name + def get_branch_revision(self, repository: str, branch: str) -> str: + """ + Get the current revision (commit SHA) of a branch. - Returns: - Commit SHA or None - """ - project = repository.replace("/", "%2F") - url = f"{GERRIT_BASE_URL}/a/projects/{project}/branches/{branch}" - - auth = get_gerrit_auth() - sc, json_data = get_json(url, auth) - - if sc == 200 and json_data: - return json_data.get('revision') - - return None + Args: + repository: Repository name + branch: Branch name + + Returns: + Commit SHA or empty string + """ + project = repository.replace("/", "%2F") + url = f"{GERRIT_BASE_URL}/a/projects/{project}/branches/{branch}" + + auth = get_gerrit_auth() + sc, json_data = get_json(url, auth) + + if sc == 200 and json_data: + return json_data.get('revision', '') + + return '' diff --git a/lib/github_api.py b/lib/github_api.py index c8eac2d..69c4179 100644 --- a/lib/github_api.py +++ b/lib/github_api.py @@ -9,6 +9,8 @@ import requests +from lib.repo_api import RepoAPI + # Constants GITHUB_BASE_URL = "https://api.github.com/repos/untangle/{repository}" @@ -100,142 +102,141 @@ def get_json( return sc, json_data -def merge_branches(repository: str, branch_from: str, branch_to: str) -> Tuple[bool, str]: - """ - Merge branch_from into branch_to on GitHub. +class GitHubAPI(RepoAPI): + """GitHub API implementation.""" - Args: - repository: Repository name - branch_from: Source branch - branch_to: Target branch + def merge_branches(self, repository: str, branch_from: str, branch_to: str) -> Tuple[bool, str]: + """ + Merge branch_from into branch_to on GitHub. - Returns: - Tuple of (success, status_message) - """ - url = GITHUB_MERGE_URL.format(repository=repository) - post_data = { - "base": branch_to, - "head": branch_from, - "commit_message": "Merged by Jenkins", - } - sc, json_data = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), post_data=post_data) - - if not sc: - success = True - status = "SKIPPED: no comparison could be made" - elif sc == 204: - success = True - status = "SKIPPED: no need to merge" - elif sc == 201: - success = True - if not json_data: - raise RuntimeError("merge_branches(...), sc is 201, but json_data is None") - status = "DONE: commitId=" + json_data["sha"] - else: - success = False - status = "FAILED: conflicts" - - return success, status - - -def compare_branches( - repository: str, branch_from: str, branch_to: str -) -> Tuple[Optional[int], Optional[int], Any]: - """ - Compare two branches on GitHub. - - Args: - repository: Repository name - branch_from: Source branch - branch_to: Target branch + Args: + repository: Repository name + branch_from: Source branch + branch_to: Target branch + + Returns: + Tuple of (success, status_message) + """ + url = GITHUB_MERGE_URL.format(repository=repository) + post_data = { + "base": branch_to, + "head": branch_from, + "commit_message": "Merged by Jenkins", + } + sc, json_data = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), post_data=post_data) + + if not sc: + success = True + status = "SKIPPED: no comparison could be made" + elif sc == 204: + success = True + status = "SKIPPED: no need to merge" + elif sc == 201: + success = True + if not json_data: + raise RuntimeError("merge_branches(...), sc is 201, but json_data is None") + status = "DONE: commitId=" + json_data["sha"] + else: + success = False + status = "FAILED: conflicts" + + return success, status + + def compare_branches( + self, repository: str, branch_from: str, branch_to: str + ) -> Tuple[Optional[int], Optional[int], Any]: + """ + Compare two branches on GitHub. - Returns: - Tuple of (commits_ahead, commits_behind, extra_message) - """ - url = get_compare_url(repository, branch_from, branch_to) - sc, json_data = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN)) - if not sc or not json_data: - return None, None, None - - ahead, behind = [int(json_data[x]) for x in ("ahead_by", "behind_by")] - extra = "!!! Need to merge !!!" if ahead > 0 else "" - - return ahead, behind, extra - - -def create_pr(repository: str, branch_to: str, new_branch: str, branch_from: str) -> Tuple[int, str]: - """ - Create a pull request on GitHub. - - Args: - repository: Repository name - branch_to: Target branch - new_branch: Source branch for the PR - branch_from: Original branch name (for PR title) + Args: + repository: Repository name + branch_from: Source branch + branch_to: Target branch + + Returns: + Tuple of (commits_ahead, commits_behind, extra_message) + """ + url = get_compare_url(repository, branch_from, branch_to) + sc, json_data = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN)) + if not sc or not json_data: + return None, None, None + + ahead, behind = [int(json_data[x]) for x in ("ahead_by", "behind_by")] + extra = "!!! Need to merge !!!" if ahead > 0 else "" + + return ahead, behind, extra + + def create_pr(self, repository: str, branch_to: str, new_branch: str, branch_from: str) -> Tuple[Optional[int], str]: + """ + Create a pull request on GitHub. - Returns: - Tuple of (status_code, branch_name) - """ - url = get_pr_url(repository) - body = get_pr_body( - datetime.today().strftime("%Y-%m-%d_%H-%M-%S"), new_branch, branch_to, branch_from - ) - sc, _ = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), post_data=body) - if not sc: - raise RuntimeError("create_pr(...) returned status code is None") - return sc, new_branch - - -def create_branch(repository: str, branch_from: str, branch_to: str): - """ - Create a new branch on GitHub. - - Args: - repository: Repository name - branch_from: Source branch to base the new branch on - branch_to: Target branch (used in naming) + Args: + repository: Repository name + branch_to: Target branch + new_branch: Source branch for the PR + branch_from: Original branch name (for PR title) + + Returns: + Tuple of (status_code, branch_name) + """ + url = get_pr_url(repository) + body = get_pr_body( + datetime.today().strftime("%Y-%m-%d_%H-%M-%S"), new_branch, branch_to, branch_from + ) + sc, _ = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), post_data=body) + if not sc: + raise RuntimeError("create_pr(...) returned status code is None") + return sc, new_branch + + def create_branch(self, repository: str, branch_from: str, branch_to: str) -> Tuple[Optional[int], Optional[str]]: + """ + Create a new branch on GitHub. - Returns: - Tuple of (status_code, new_branch_name) - """ - url = get_branch_url(repository) - new_branch = "automerge-from-{branchFrom}-to-{branchTo}-{date}-{time}".format( - branchFrom=branch_from, - branchTo=branch_to, - date=datetime.today().strftime("%Y-%m-%d"), - time=time.time_ns(), - ) - - sha = get_branch_revision(repository, branch_from) - logging.debug("got sha: {sha}; creating workspace branch with this...".format(sha=sha)) - post_data = get_branch_body(new_branch, sha) - sc, _ = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), post_data=post_data) - - logging.debug("new branch is: {newBranch}".format(newBranch=new_branch)) - return sc, new_branch - - -def get_branch_revision(repository: str, branch: str) -> str: - """ - Get the current revision (commit SHA) of a branch on GitHub. - - Args: - repository: Repository name - branch: Branch name + Args: + repository: Repository name + branch_from: Source branch to base the new branch on + branch_to: Target branch (used in naming) + + Returns: + Tuple of (status_code, new_branch_name) + """ + url = get_branch_url(repository) + new_branch = "automerge-from-{branchFrom}-to-{branchTo}-{date}-{time}".format( + branchFrom=branch_from, + branchTo=branch_to, + date=datetime.today().strftime("%Y-%m-%d"), + time=time.time_ns(), + ) + + sha = self.get_branch_revision(repository, branch_from) + logging.debug("got sha: {sha}; creating workspace branch with this...".format(sha=sha)) + post_data = get_branch_body(new_branch, sha) + sc, _ = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN), post_data=post_data) + + logging.debug("new branch is: {newBranch}".format(newBranch=new_branch)) + return sc, new_branch + + def get_branch_revision(self, repository: str, branch: str) -> str: + """ + Get the current revision (commit SHA) of a branch on GitHub. - Returns: - Commit SHA - """ - url = get_head_sha_url(repository, branch) - sc, json_data = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN)) - - if not sc: - logging.debug("idk what this means?") - return "" - elif sc == 200: - if not json_data: - raise RuntimeError("get_branch_revision(...), status code is 200, but json_data is None") - return json_data["commit"].get("sha") - else: - logging.debug("unable to get branch sha; exit") - exit(1) + Args: + repository: Repository name + branch: Branch name + + Returns: + Commit SHA + """ + url = get_head_sha_url(repository, branch) + sc, json_data = get_json(url, GITHUB_HEADERS, (GITHUB_USER, GITHUB_TOKEN)) + + if not sc: + logging.debug("idk what this means?") + return "" + elif sc == 200: + if not json_data: + raise RuntimeError("get_branch_revision(...), status code is 200, but json_data is None") + return json_data["commit"].get("sha") + else: + logging.debug("unable to get branch sha; exit") + exit(1) diff --git a/lib/repo_api.py b/lib/repo_api.py new file mode 100644 index 0000000..63a33b7 --- /dev/null +++ b/lib/repo_api.py @@ -0,0 +1,107 @@ +"""Abstract base class for repository API implementations.""" + +from abc import ABC, abstractmethod +from typing import Optional, Tuple, Any + + +class RepoAPI(ABC): + """Abstract base class for repository API operations.""" + + @abstractmethod + def merge_branches(self, repository: str, branch_from: str, branch_to: str) -> Tuple[bool, str]: + """ + Merge branch_from into branch_to. + + Args: + repository: Repository name + branch_from: Source branch + branch_to: Target branch + + Returns: + Tuple of (success, status_message) + """ + pass + + @abstractmethod + def compare_branches( + self, repository: str, branch_from: str, branch_to: str + ) -> Tuple[Optional[int], Optional[int], Any]: + """ + Compare two branches. + + Args: + repository: Repository name + branch_from: Source branch + branch_to: Target branch + + Returns: + Tuple of (commits_ahead, commits_behind, extra_message) + """ + pass + + @abstractmethod + def create_pr( + self, repository: str, branch_to: str, new_branch: str, branch_from: str + ) -> Tuple[Optional[int], str]: + """ + Create a pull request or change. + + Args: + repository: Repository name + branch_to: Target branch + new_branch: Source branch for the PR + branch_from: Original branch name (for PR title) + + Returns: + Tuple of (status_code, identifier) + """ + pass + + @abstractmethod + def create_branch( + self, repository: str, branch_from: str, branch_to: str + ) -> Tuple[Optional[int], Optional[str]]: + """ + Create a new branch. + + Args: + repository: Repository name + branch_from: Source branch to base the new branch on + branch_to: Target branch (used in naming) + + Returns: + Tuple of (status_code, new_branch_name) + """ + pass + + @abstractmethod + def get_branch_revision(self, repository: str, branch: str) -> str: + """ + Get the current revision (commit SHA) of a branch. + + Args: + repository: Repository name + branch: Branch name + + Returns: + Commit SHA + """ + pass + + +def get_api(repo_type: str = "github") -> RepoAPI: + """ + Factory function to get the appropriate API implementation. + + Args: + repo_type: Type of repository ("github" or "gerrit") + + Returns: + RepoAPI implementation instance + """ + if repo_type == "gerrit": + from lib.gerrit_api import GerritAPI + return GerritAPI() + else: + from lib.github_api import GitHubAPI + return GitHubAPI() From 6e9b003617ba88d1f6ac9b11914634f4ca074efe Mon Sep 17 00:00:00 2001 From: John Sommerville Date: Thu, 29 Jan 2026 08:41:26 -0700 Subject: [PATCH 7/7] inital test adds (a lot of these seem useless) --- tests/README.md | 146 +++++++++++++++++++++++++++ tests/__init__.py | 1 + tests/run_tests.py | 28 ++++++ tests/test_gerrit_api.py | 208 +++++++++++++++++++++++++++++++++++++++ tests/test_github_api.py | 181 ++++++++++++++++++++++++++++++++++ tests/test_products.py | 48 +++++++++ tests/test_repo_api.py | 78 +++++++++++++++ 7 files changed, 690 insertions(+) create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100755 tests/run_tests.py create mode 100644 tests/test_gerrit_api.py create mode 100644 tests/test_github_api.py create mode 100644 tests/test_products.py create mode 100644 tests/test_repo_api.py diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..30ac4fe --- /dev/null +++ b/tests/README.md @@ -0,0 +1,146 @@ +# Test Suite for ngfw_pkgtools + +This directory contains unit tests for the ngfw_pkgtools repository API libraries. + +## Test Coverage + +### [`test_github_api.py`](test_github_api.py) +Tests for the GitHub API implementation ([`GitHubAPI`](../lib/github_api.py) class): +- `test_merge_branches_success` - Successful branch merge +- `test_merge_branches_no_need` - Merge when branches are in sync +- `test_merge_branches_conflict` - Merge with conflicts +- `test_merge_branches_not_found` - Merge when branch doesn't exist +- `test_compare_branches_ahead` - Compare when source is ahead +- `test_compare_branches_in_sync` - Compare when branches are in sync +- `test_compare_branches_behind` - Compare when source is behind +- `test_compare_branches_not_found` - Compare when branch doesn't exist +- `test_create_pr_success` - Successful PR creation +- `test_create_branch_success` - Successful branch creation +- `test_get_branch_revision_success` - Get branch revision +- `test_get_branch_revision_not_found` - Get revision when branch doesn't exist + +### [`test_gerrit_api.py`](test_gerrit_api.py) +Tests for the Gerrit API implementation ([`GerritAPI`](../lib/gerrit_api.py) class): +- `test_compare_branches_ahead` - Compare when source is ahead +- `test_compare_branches_in_sync` - Compare when branches are in sync +- `test_compare_branches_behind` - Compare when source is behind +- `test_compare_branches_not_found` - Compare when branch doesn't exist +- `test_merge_branches_not_supported` - Verify direct merge is not supported +- `test_create_pr_success` - Successful change creation +- `test_create_pr_failure` - Failed change creation +- `test_create_branch_not_implemented` - Verify branch creation is not implemented +- `test_get_branch_revision_success` - Get branch revision +- `test_get_branch_revision_not_found` - Get revision when branch doesn't exist +- `test_get_branch_revision_no_revision_field` - Handle missing revision field + +### [`test_repo_api.py`](test_repo_api.py) +Tests for the abstract base class and factory function: +- `test_get_github_api` - Factory returns GitHubAPI for 'github' type +- `test_get_gerrit_api` - Factory returns GerritAPI for 'gerrit' type +- `test_get_default_api` - Factory returns GitHubAPI by default +- `test_get_unknown_api_defaults_to_github` - Unknown types default to GitHub +- `test_github_api_has_all_methods` - GitHubAPI implements all required methods +- `test_gerrit_api_has_all_methods` - GerritAPI implements all required methods + +### [`test_products.py`](test_products.py) +Tests for the Product enum: +- `test_product_values` - All expected products are defined +- `test_product_choices` - choices() returns all product values +- `test_product_str` - Product enum converts to string correctly +- `test_product_is_string_enum` - Product members are string instances +- `test_product_comparison` - Product enum can be compared with strings + +## Running Tests + +### Run All Tests +```bash +# From the repository root +python3 -m pytest tests/ + +# Or using the test runner +python3 tests/run_tests.py + +# Or from the tests directory +cd tests +python3 run_tests.py +``` + +### Run Specific Test File +```bash +# Run only GitHub API tests +python3 -m pytest tests/test_github_api.py + +# Or using unittest +python3 -m unittest tests.test_github_api +``` + +### Run Specific Test Case +```bash +# Run a specific test method +python3 -m pytest tests/test_github_api.py::TestGitHubAPI::test_merge_branches_success + +# Or using unittest +python3 -m unittest tests.test_github_api.TestGitHubAPI.test_merge_branches_success +``` + +### Run with Verbose Output +```bash +python3 -m pytest tests/ -v + +# Or with unittest +python3 -m unittest discover -s tests -p 'test_*.py' -v +``` + +## Test Dependencies + +The tests use Python's built-in `unittest` framework and `unittest.mock` for mocking external dependencies. No additional test dependencies are required. + +## Writing New Tests + +When adding new functionality to the API libraries: + +1. Create test methods in the appropriate test file +2. Use `@patch` decorators to mock external API calls +3. Test both success and failure scenarios +4. Ensure edge cases are covered +5. Follow the existing naming convention: `test__` + +Example: +```python +@patch('lib.github_api.get_json') +def test_new_method_success(self, mock_get_json): + """Test successful execution of new method.""" + mock_get_json.return_value = (200, {"data": "value"}) + + result = self.api.new_method("param") + + self.assertEqual(result, expected_value) +``` + +## Continuous Integration + +These tests can be integrated into CI/CD pipelines: + +```yaml +# Example GitHub Actions workflow +- name: Run tests + run: python3 tests/run_tests.py +``` + +## Coverage + +To generate test coverage reports (requires `coverage` package): + +```bash +# Install coverage +pip install coverage + +# Run tests with coverage +coverage run -m pytest tests/ + +# Generate report +coverage report + +# Generate HTML report +coverage html +``` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..a174573 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for ngfw_pkgtools.""" diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100755 index 0000000..ee5535e --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Test runner for ngfw_pkgtools test suite.""" + +import sys +import unittest +import os + +# Add parent directory to path so we can import lib modules +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + + +def run_tests(): + """Discover and run all tests.""" + # Discover all tests in the tests directory + loader = unittest.TestLoader() + start_dir = os.path.dirname(__file__) + suite = loader.discover(start_dir, pattern='test_*.py') + + # Run the tests + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Return exit code based on results + return 0 if result.wasSuccessful() else 1 + + +if __name__ == '__main__': + sys.exit(run_tests()) diff --git a/tests/test_gerrit_api.py b/tests/test_gerrit_api.py new file mode 100644 index 0000000..43a7508 --- /dev/null +++ b/tests/test_gerrit_api.py @@ -0,0 +1,208 @@ +"""Unit tests for Gerrit API implementation.""" + +import unittest +from unittest.mock import patch, MagicMock +from lib.gerrit_api import GerritAPI + + +class TestGerritAPI(unittest.TestCase): + """Test cases for GerritAPI class.""" + + def setUp(self): + """Set up test fixtures.""" + self.api = GerritAPI() + self.test_repo = "test-project" + self.test_branch_from = "feature-branch" + self.test_branch_to = "main" + + @patch('lib.gerrit_api.get_json') + @patch('lib.gerrit_api.get_gerrit_auth') + def test_compare_branches_ahead(self, mock_auth, mock_get_json): + """Test comparing branches when source is ahead.""" + mock_auth.return_value = ("user", "pass") + + # Mock responses for both branch commit queries + commits_from = [ + {"commit": "abc123"}, + {"commit": "def456"}, + {"commit": "ghi789"} + ] + commits_to = [ + {"commit": "ghi789"} + ] + + mock_get_json.side_effect = [ + (200, commits_from), # branch_from commits + (200, commits_to) # branch_to commits + ] + + ahead, behind, extra = self.api.compare_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertEqual(ahead, 2) # abc123 and def456 are ahead + self.assertEqual(behind, 0) + self.assertIn("Need to merge", extra) + + @patch('lib.gerrit_api.get_json') + @patch('lib.gerrit_api.get_gerrit_auth') + def test_compare_branches_in_sync(self, mock_auth, mock_get_json): + """Test comparing branches when they are in sync.""" + mock_auth.return_value = ("user", "pass") + + commits = [ + {"commit": "abc123"}, + {"commit": "def456"} + ] + + mock_get_json.side_effect = [ + (200, commits), # branch_from commits + (200, commits) # branch_to commits (same) + ] + + ahead, behind, extra = self.api.compare_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertEqual(ahead, 0) + self.assertEqual(behind, 0) + self.assertEqual(extra, "") + + @patch('lib.gerrit_api.get_json') + @patch('lib.gerrit_api.get_gerrit_auth') + def test_compare_branches_behind(self, mock_auth, mock_get_json): + """Test comparing branches when source is behind.""" + mock_auth.return_value = ("user", "pass") + + commits_from = [ + {"commit": "abc123"} + ] + commits_to = [ + {"commit": "abc123"}, + {"commit": "def456"}, + {"commit": "ghi789"} + ] + + mock_get_json.side_effect = [ + (200, commits_from), # branch_from commits + (200, commits_to) # branch_to commits + ] + + ahead, behind, extra = self.api.compare_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertEqual(ahead, 0) + self.assertEqual(behind, 2) # def456 and ghi789 are behind + self.assertEqual(extra, "") + + @patch('lib.gerrit_api.get_json') + @patch('lib.gerrit_api.get_gerrit_auth') + def test_compare_branches_not_found(self, mock_auth, mock_get_json): + """Test comparing branches when one doesn't exist.""" + mock_auth.return_value = ("user", "pass") + mock_get_json.side_effect = [ + (None, None), # branch_from not found + (200, []) # branch_to + ] + + ahead, behind, extra = self.api.compare_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertIsNone(ahead) + self.assertIsNone(behind) + self.assertIsNone(extra) + + def test_merge_branches_not_supported(self): + """Test that direct merge is not supported in Gerrit.""" + success, status = self.api.merge_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertFalse(success) + self.assertIn("SKIPPED", status) + self.assertIn("Gerrit", status) + + @patch('lib.gerrit_api.get_json') + @patch('lib.gerrit_api.get_gerrit_auth') + def test_create_pr_success(self, mock_auth, mock_get_json): + """Test successful change creation.""" + mock_auth.return_value = ("user", "pass") + test_change_id = "I1234567890abcdef" + + mock_get_json.return_value = (201, { + "change_id": test_change_id, + "id": "project~branch~I1234567890abcdef" + }) + + sc, change_id = self.api.create_pr( + self.test_repo, self.test_branch_to, "", self.test_branch_from + ) + + self.assertEqual(sc, 201) + self.assertEqual(change_id, test_change_id) + + @patch('lib.gerrit_api.get_json') + @patch('lib.gerrit_api.get_gerrit_auth') + def test_create_pr_failure(self, mock_auth, mock_get_json): + """Test failed change creation.""" + mock_auth.return_value = ("user", "pass") + mock_get_json.return_value = (None, None) + + sc, change_id = self.api.create_pr( + self.test_repo, self.test_branch_to, "", self.test_branch_from + ) + + self.assertIsNone(sc) + self.assertEqual(change_id, '') + + def test_create_branch_not_implemented(self): + """Test that branch creation is not implemented for Gerrit.""" + sc, branch = self.api.create_branch( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertIsNone(sc) + self.assertIsNone(branch) + + @patch('lib.gerrit_api.get_json') + @patch('lib.gerrit_api.get_gerrit_auth') + def test_get_branch_revision_success(self, mock_auth, mock_get_json): + """Test getting branch revision.""" + mock_auth.return_value = ("user", "pass") + test_sha = "abc123def456" + + mock_get_json.return_value = (200, { + "revision": test_sha + }) + + sha = self.api.get_branch_revision(self.test_repo, self.test_branch_from) + + self.assertEqual(sha, test_sha) + + @patch('lib.gerrit_api.get_json') + @patch('lib.gerrit_api.get_gerrit_auth') + def test_get_branch_revision_not_found(self, mock_auth, mock_get_json): + """Test getting branch revision when branch doesn't exist.""" + mock_auth.return_value = ("user", "pass") + mock_get_json.return_value = (404, None) + + sha = self.api.get_branch_revision(self.test_repo, self.test_branch_from) + + self.assertEqual(sha, '') + + @patch('lib.gerrit_api.get_json') + @patch('lib.gerrit_api.get_gerrit_auth') + def test_get_branch_revision_no_revision_field(self, mock_auth, mock_get_json): + """Test getting branch revision when response has no revision field.""" + mock_auth.return_value = ("user", "pass") + mock_get_json.return_value = (200, {}) + + sha = self.api.get_branch_revision(self.test_repo, self.test_branch_from) + + self.assertEqual(sha, '') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_github_api.py b/tests/test_github_api.py new file mode 100644 index 0000000..a431e1e --- /dev/null +++ b/tests/test_github_api.py @@ -0,0 +1,181 @@ +"""Unit tests for GitHub API implementation.""" + +import unittest +from unittest.mock import patch, MagicMock +from lib.github_api import GitHubAPI + + +class TestGitHubAPI(unittest.TestCase): + """Test cases for GitHubAPI class.""" + + def setUp(self): + """Set up test fixtures.""" + self.api = GitHubAPI() + self.test_repo = "test-repo" + self.test_branch_from = "feature-branch" + self.test_branch_to = "main" + + @patch('lib.github_api.get_json') + def test_merge_branches_success(self, mock_get_json): + """Test successful branch merge.""" + mock_get_json.return_value = (201, {"sha": "abc123"}) + + success, status = self.api.merge_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertTrue(success) + self.assertIn("DONE", status) + self.assertIn("abc123", status) + + @patch('lib.github_api.get_json') + def test_merge_branches_no_need(self, mock_get_json): + """Test merge when branches are already in sync.""" + mock_get_json.return_value = (204, None) + + success, status = self.api.merge_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertTrue(success) + self.assertIn("SKIPPED: no need to merge", status) + + @patch('lib.github_api.get_json') + def test_merge_branches_conflict(self, mock_get_json): + """Test merge with conflicts.""" + mock_get_json.return_value = (409, {"message": "Merge conflict"}) + + success, status = self.api.merge_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertFalse(success) + self.assertIn("FAILED: conflicts", status) + + @patch('lib.github_api.get_json') + def test_merge_branches_not_found(self, mock_get_json): + """Test merge when branch not found.""" + mock_get_json.return_value = (None, None) + + success, status = self.api.merge_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertTrue(success) + self.assertIn("SKIPPED: no comparison could be made", status) + + @patch('lib.github_api.get_json') + def test_compare_branches_ahead(self, mock_get_json): + """Test comparing branches when source is ahead.""" + mock_get_json.return_value = (200, { + "ahead_by": 5, + "behind_by": 0 + }) + + ahead, behind, extra = self.api.compare_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertEqual(ahead, 5) + self.assertEqual(behind, 0) + self.assertIn("Need to merge", extra) + + @patch('lib.github_api.get_json') + def test_compare_branches_in_sync(self, mock_get_json): + """Test comparing branches when they are in sync.""" + mock_get_json.return_value = (200, { + "ahead_by": 0, + "behind_by": 0 + }) + + ahead, behind, extra = self.api.compare_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertEqual(ahead, 0) + self.assertEqual(behind, 0) + self.assertEqual(extra, "") + + @patch('lib.github_api.get_json') + def test_compare_branches_behind(self, mock_get_json): + """Test comparing branches when source is behind.""" + mock_get_json.return_value = (200, { + "ahead_by": 0, + "behind_by": 3 + }) + + ahead, behind, extra = self.api.compare_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertEqual(ahead, 0) + self.assertEqual(behind, 3) + self.assertEqual(extra, "") + + @patch('lib.github_api.get_json') + def test_compare_branches_not_found(self, mock_get_json): + """Test comparing branches when one doesn't exist.""" + mock_get_json.return_value = (None, None) + + ahead, behind, extra = self.api.compare_branches( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertIsNone(ahead) + self.assertIsNone(behind) + self.assertIsNone(extra) + + @patch('lib.github_api.get_json') + def test_create_pr_success(self, mock_get_json): + """Test successful PR creation.""" + mock_get_json.return_value = (201, {"number": 42}) + new_branch = "automerge-test" + + sc, branch = self.api.create_pr( + self.test_repo, self.test_branch_to, new_branch, self.test_branch_from + ) + + self.assertEqual(sc, 201) + self.assertEqual(branch, new_branch) + + @patch('lib.github_api.get_json') + def test_create_branch_success(self, mock_get_json): + """Test successful branch creation.""" + # Mock get_branch_revision call + mock_get_json.side_effect = [ + (200, {"commit": {"sha": "def456"}}), # get_branch_revision + (201, {"ref": "refs/heads/new-branch"}) # create_branch + ] + + sc, new_branch = self.api.create_branch( + self.test_repo, self.test_branch_from, self.test_branch_to + ) + + self.assertEqual(sc, 201) + self.assertIsNotNone(new_branch) + self.assertIn("automerge", new_branch) + + @patch('lib.github_api.get_json') + def test_get_branch_revision_success(self, mock_get_json): + """Test getting branch revision.""" + test_sha = "abc123def456" + mock_get_json.return_value = (200, { + "commit": {"sha": test_sha} + }) + + sha = self.api.get_branch_revision(self.test_repo, self.test_branch_from) + + self.assertEqual(sha, test_sha) + + @patch('lib.github_api.get_json') + def test_get_branch_revision_not_found(self, mock_get_json): + """Test getting branch revision when branch doesn't exist.""" + mock_get_json.return_value = (None, None) + + sha = self.api.get_branch_revision(self.test_repo, self.test_branch_from) + + self.assertEqual(sha, "") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_products.py b/tests/test_products.py new file mode 100644 index 0000000..57b1b10 --- /dev/null +++ b/tests/test_products.py @@ -0,0 +1,48 @@ +"""Unit tests for Product enum.""" + +import unittest +from lib.products import Product + + +class TestProduct(unittest.TestCase): + """Test cases for Product enum.""" + + def test_product_values(self): + """Test that all expected products are defined.""" + self.assertEqual(Product.MFW.value, "mfw") + self.assertEqual(Product.NGFW.value, "ngfw") + self.assertEqual(Product.WAF.value, "waf") + self.assertEqual(Product.EFW.value, "efw") + self.assertEqual(Product.VELO.value, "velo") + + def test_product_choices(self): + """Test that choices() returns all product values.""" + choices = Product.choices() + + self.assertIsInstance(choices, tuple) + self.assertEqual(len(choices), 5) + self.assertIn("mfw", choices) + self.assertIn("ngfw", choices) + self.assertIn("waf", choices) + self.assertIn("efw", choices) + self.assertIn("velo", choices) + + def test_product_str(self): + """Test that Product enum converts to string correctly.""" + self.assertEqual(str(Product.MFW), "mfw") + self.assertEqual(str(Product.NGFW), "ngfw") + + def test_product_is_string_enum(self): + """Test that Product members are string instances.""" + self.assertIsInstance(Product.MFW, str) + self.assertIsInstance(Product.NGFW, str) + + def test_product_comparison(self): + """Test that Product enum can be compared with strings.""" + self.assertEqual(Product.MFW, "mfw") + self.assertEqual(Product.NGFW, "ngfw") + self.assertNotEqual(Product.MFW, "ngfw") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_repo_api.py b/tests/test_repo_api.py new file mode 100644 index 0000000..914145f --- /dev/null +++ b/tests/test_repo_api.py @@ -0,0 +1,78 @@ +"""Unit tests for RepoAPI factory and base class.""" + +import unittest +from lib.repo_api import get_api, RepoAPI +from lib.github_api import GitHubAPI +from lib.gerrit_api import GerritAPI + + +class TestRepoAPIFactory(unittest.TestCase): + """Test cases for the get_api factory function.""" + + def test_get_github_api(self): + """Test that get_api returns GitHubAPI for 'github' type.""" + api = get_api("github") + self.assertIsInstance(api, GitHubAPI) + self.assertIsInstance(api, RepoAPI) + + def test_get_gerrit_api(self): + """Test that get_api returns GerritAPI for 'gerrit' type.""" + api = get_api("gerrit") + self.assertIsInstance(api, GerritAPI) + self.assertIsInstance(api, RepoAPI) + + def test_get_default_api(self): + """Test that get_api returns GitHubAPI by default.""" + api = get_api() + self.assertIsInstance(api, GitHubAPI) + + def test_get_unknown_api_defaults_to_github(self): + """Test that unknown repo types default to GitHub.""" + api = get_api("unknown") + self.assertIsInstance(api, GitHubAPI) + + +class TestRepoAPIInterface(unittest.TestCase): + """Test that both implementations conform to the RepoAPI interface.""" + + def test_github_api_has_all_methods(self): + """Test that GitHubAPI implements all required methods.""" + api = GitHubAPI() + + self.assertTrue(hasattr(api, 'merge_branches')) + self.assertTrue(callable(api.merge_branches)) + + self.assertTrue(hasattr(api, 'compare_branches')) + self.assertTrue(callable(api.compare_branches)) + + self.assertTrue(hasattr(api, 'create_pr')) + self.assertTrue(callable(api.create_pr)) + + self.assertTrue(hasattr(api, 'create_branch')) + self.assertTrue(callable(api.create_branch)) + + self.assertTrue(hasattr(api, 'get_branch_revision')) + self.assertTrue(callable(api.get_branch_revision)) + + def test_gerrit_api_has_all_methods(self): + """Test that GerritAPI implements all required methods.""" + api = GerritAPI() + + self.assertTrue(hasattr(api, 'merge_branches')) + self.assertTrue(callable(api.merge_branches)) + + self.assertTrue(hasattr(api, 'compare_branches')) + self.assertTrue(callable(api.compare_branches)) + + self.assertTrue(hasattr(api, 'create_pr')) + self.assertTrue(callable(api.create_pr)) + + self.assertTrue(hasattr(api, 'create_branch')) + self.assertTrue(callable(api.create_branch)) + + self.assertTrue(hasattr(api, 'get_branch_revision')) + self.assertTrue(callable(api.get_branch_revision)) + + +if __name__ == '__main__': + unittest.main()