# Copyright (C) 2024 Cetmix OÜ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import giturlparse from odoo import _, api, fields, models from odoo.exceptions import ValidationError class CxTowerGitRemote(models.Model): """ Git Remote. Implements single git remote. Eg a branch or a pull request. """ _name = "cx.tower.git.remote" _inherit = [ "cx.tower.reference.mixin", "cx.tower.yaml.mixin", ] _description = "Cetmix Tower Git Remote" _order = "sequence, name" # Used to detect git ssh urls GIT_SSH_URL_PATTERN = r"^[\w\.-]+@[\w\.-]+:.*\.git$" GIT_HTTPS_URL_PATTERN = r"^https://.*\.git$" GIT_GIT_URL_PATTERN = r"^git://.*\.git$" active = fields.Boolean(related="source_id.active", store=True, readonly=True) enabled = fields.Boolean( default=True, help="Enable in configuration and exported to files" ) sequence = fields.Integer(default=10) name = fields.Char(compute="_compute_name", store=True, default="remote") source_id = fields.Many2one( comodel_name="cx.tower.git.source", required=True, ondelete="cascade", auto_join=True, ) git_project_id = fields.Many2one( comodel_name="cx.tower.git.project", related="source_id.git_project_id", store=True, readonly=True, ) repo_id = fields.Many2one( comodel_name="cx.tower.git.repo", string="Repository", required=True, ondelete="restrict", help="If selected, the remote URL will be filled from the" " repo settings based on the remote protocol", ) repo_provider = fields.Selection( related="repo_id.provider", readonly=True, ) # -- Repo related fields url_protocol = fields.Selection( string="Protocol", selection=[ ("ssh", "SSH"), ("https", "HTTPS"), ("git", "GIT"), ], required=True, default=lambda self: self._get_default_url_protocol(), ) is_private = fields.Boolean( string="Private", help="Repository is private", related="repo_id.is_private", store=True, readonly=True, ) head_type = fields.Selection( selection=[ ("branch", "Branch"), ("pr", "Pull/Merge Request"), ("commit", "Commit"), ], required=True, ) head = fields.Char( help="Git remote head. Link to branch, PR, commit or commit hash.", required=True, index=True, ) def _get_default_url_protocol(self): """Default URL protocol for new remote. Returns: Char: Default URL protocol. """ return "https" @api.depends("source_id", "sequence") def _compute_name(self): """ Compute remote name. By default all remotes are named `remote_` where position is the position of the remote in the source. Eg first remote is `remote_1`, second is `remote_2`, etc. """ for remote in self: if remote.source_id: for index, source_remote in enumerate(remote.source_id.remote_ids): source_remote.name = f"remote_{index + 1}" @api.onchange("head") def onchange_head(self): """ Extract head number from head url and set it as head. """ for remote in self: if remote.head and "/" in remote.head: remote.head = self._sanitize_head(remote.head) @api.model_create_multi def create(self, vals_list): # Sanitize head for vals in vals_list: head = vals.get("head") if head and "/" in head: vals["head"] = self._sanitize_head(head) res = super().create(vals_list) # Export project to related files and templates res._update_related_files_and_templates() return res def write(self, vals): # Sanitize head if "head" in vals: head = vals["head"] if head and "/" in head: vals["head"] = self._sanitize_head(head) res = super().write(vals) # Update related files and templates on update self._update_related_files_and_templates() return res def unlink(self): """ Override to update related files and templates on unlink """ related_files = self.mapped("git_project_id").mapped("git_project_rel_ids") related_templates = self.mapped("git_project_id").mapped( "git_project_file_template_rel_ids" ) res = super().unlink() # Update related files and templates on unlink if related_files: related_files._save_to_file() if related_templates: related_templates._save_to_file_template() return res def _sanitize_head(self, head): """Sanitize head. Extract head number from head url and set it as head. Args: head (Char): Head to sanitize Returns: Char: Sanitized head """ if head and "/" in head: return head.split("/")[-1].strip() return head @api.model def get_head_data(self): """ This method is used to get values for the dropdown dynamic widget. It is designed for integrations with repo providers using APIs. Returns: List: List of tuples(selection, name) eg [('18.0', '18.0'), ('main', 'main'), ('develop', 'develop')] """ values = [ ("18.0", "18.0"), ("main", "Main"), ("develop", "Develop"), ("17.0", "17.0"), ] return values def _update_related_files_and_templates(self): # Update related files on update related_files = self.mapped("git_project_id").mapped("git_project_rel_ids") if related_files: related_files._save_to_file() related_templates = self.mapped("git_project_id").mapped( "git_project_file_template_rel_ids" ) if related_templates: related_templates._save_to_file_template() # ------------------------------ # Reference mixin methods # ------------------------------ def _get_pre_populated_model_data(self): res = super()._get_pre_populated_model_data() res.update({"cx.tower.git.remote": ["cx.tower.git.source", "source_id"]}) return res # ------------------------------ # YAML mixin methods # ------------------------------ def _get_fields_for_yaml(self): res = super()._get_fields_for_yaml() res += [ "name", "enabled", "sequence", "repo_id", "head", "head_type", ] return res # ------------------------------ # Git Aggregator related methods # ------------------------------ def _git_aggregator_prepare_url(self): """Prepare url for git aggregator Returns: Char: Prepared url for git aggregator """ self.ensure_one() if not self.repo_id: raise ValidationError(_("Repository is required")) if not self.repo_id.url: raise ValidationError(_("Repository URL is not set")) url = self.repo_id.url prepared_url = giturlparse.parse(url).urls.get(self.url_protocol, url) # If repo is public or is not using HTTPS protocol return URL as is if not self.is_private or self.url_protocol != "https": return prepared_url if self.repo_provider == "github": prepared_url = self._git_aggregator_prepare_url_github(prepared_url) elif self.repo_provider == "gitlab": prepared_url = self._git_aggregator_prepare_url_gitlab(prepared_url) elif self.repo_provider == "bitbucket": prepared_url = self._git_aggregator_prepare_url_bitbucket(prepared_url) return prepared_url def _git_aggregator_prepare_url_github(self, url): """Prepare url for git aggregator for private Github repo using https protocol. Args: url (Char): URL to prepare Returns: Char: Prepared url for git aggregator """ self.ensure_one() # This is how final url will look like # https://$GITHUB_TOKEN:x-oauth-basic@github.com/soem_org/some_private_repo.git url_without_protocol = url.replace("https://", "") url = f"https://$GITHUB_TOKEN:x-oauth-basic@{url_without_protocol}" return url def _git_aggregator_prepare_url_gitlab(self, url): """Prepare url for git aggregator for private GitLab repo using https protocol. Args: url (Char): URL to prepare Returns: Char: Prepared url for git aggregator """ self.ensure_one() # This is how final url will look like # https://:@.git url_without_protocol = url.replace("https://", "") url = f"https://$GITLAB_TOKEN_NAME:$GITLAB_TOKEN@{url_without_protocol}" return url def _git_aggregator_prepare_url_bitbucket(self, url): """Prepare url for git aggregator for private Github repo using https protocol. Args: url (Char): URL to prepare Returns: Char: Prepared url for git aggregator """ self.ensure_one() # This is how final url will look like # https://x-token-auth:{access_token}@bitbucket.org/user/repo.git # From https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/ url_without_protocol = url.replace("https://", "") url = f"https://x-token-auth:$BITBUCKET_TOKEN@{url_without_protocol}" return url def _git_aggregator_prepare_head(self): """Prepare head for git aggregator Returns: Char: Prepared head for git aggregator """ self.ensure_one() if self.repo_provider == "github": return self._git_aggregator_prepare_head_github() if self.repo_provider == "gitlab": return self._git_aggregator_prepare_head_gitlab() if self.repo_provider == "bitbucket": return self._git_aggregator_prepare_head_bitbucket() return self.head def _git_aggregator_prepare_head_github(self): """Prepare head for git aggregator for Github. Returns: Char: Prepared head for git aggregator """ # Extract branch name, PR/MR or commit number from head head_number = self.head.split("/")[-1] if not head_number: raise ValidationError( _("Git Aggregator: " "Head number is empty in %(head)s", head=self.head) ) # PR/MR if self.head_type == "pr": return f"refs/pull/{head_number}/head" # Commit if self.head_type in ["commit", "branch"]: return f"{head_number}" # Fallback to original head return self.head def _git_aggregator_prepare_head_gitlab(self): """Prepare head for git aggregator for GitLab. Returns: Char: Prepared head for git aggregator """ # Extract branch name, PR/MR or commit number from head head_number = self.head.split("/")[-1] if not head_number: raise ValidationError( _("Git Aggregator: " "Head number is empty in %(head)s", head=self.head) ) # PR/MR if self.head_type == "pr": return f"merge-requests/{head_number}/head" # Commit # https://gitlab.com/cetmix/test/-/tree/17.0-test-branch?ref_type=heads if self.head_type in ["commit", "branch"]: head_parts = head_number.split("?") return f"{head_parts[0]}" # Fallback to original head return self.head def _git_aggregator_prepare_head_bitbucket(self): """Prepare head for git aggregator for Bitbucket. Returns: Char: Prepared head for git aggregator """ # Extract branch name, PR/MR or commit number from head head_number = self.head.split("/")[-1] if not head_number: raise ValidationError( _("Git Aggregator: " "Head number is empty in %(head)s", head=self.head) ) # PR/MR if self.head_type == "pr": raise ValidationError( _( "Git Aggregator: " "Bitbucket does not support" " fetching PRs. Please use branch instead.\n\n" "Source: %(src)s\n" "URL: %(url)s\n" "Head: %(head)s", src=self.source_id.name, url=self.repo_id.url, head=self.head, ) ) # Commit if self.head_type in ["commit", "branch"]: return f"{head_number}" # Fallback to original head return self.head