# This file is part of GAnarchy - decentralized project hub # Copyright (C) 2020 Soni L. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Core logic of GAnarchy. """ import hashlib import hmac from pathlib import Path import re from urllib import parse import ganarchy.git import ganarchy.dirs import ganarchy.data GIT = ganarchy.git.GitCache(Path(ganarchy.dirs.CACHE_HOME)/'ganarchy-cache.git') class Repo: """A GAnarchy repo. Args: dbconn (ganarchy.db.Database): The database connection. project_commit (str): The project commit. url (str): The git URL. branch (str): The branch. head_commit (str): The last known head commit. Attributes: branch (str or None): The remote git branch. branchname (str): The local git branch. """ # TODO fill in Attributes. def __init__(self, dbconn, project_commit, url, branch, head_commit): self.url = url self.branch = branch self.project_commit = project_commit self.errormsg = None self.erroring = False self.message = None self.hash = None self.branchname = None self.head = None if not self._check_branch(GIT): return if not branch: self.branchname = "gan" + hashlib.sha256(url.encode("utf-8")).hexdigest() self.head = "HEAD" else: self.branchname = "gan" + hmac.new(branch.encode("utf-8"), url.encode("utf-8"), "sha256").hexdigest() self.head = "refs/heads/" + branch if head_commit: self.hash = head_commit else: try: # FIXME should we even do this? self.hash = GIT.get_hash(self.branchname) except ganarchy.git.GitError: self.erroring = True self.refresh_metadata(GIT) def _check_branch(self, work_repo): """Checks if ``self.branch`` is a valid git branch name, or None. Sets ``self.errormsg`` and ``self.erroring`` accordingly. Returns: bool: True if valid, False otherwise. """ if not self.branch: return True try: work_repo.check_branchname(self.branch) return True except ganarchy.git.GitError as e: self.erroring = True self.errormsg = e return False def refresh_metadata(self, work_repo): """Refreshes repo metadata. """ if not self._check_branch(work_repo): return try: self.message = work_repo.get_commit_message(self.branchname) except ganarchy.git.GitError as e: self.erroring = True self.errormsg = e # FIXME maybe this shouldn't be "public"? # reasoning: this update() isn't reflected in the db. # but this might be handy for dry runs. # alternatively: change the return to be the new head commit, # and update things accordingly. def update(self, work_repo, *, dry_run=False): """Updates the git repo, returning a commit count. Args: dry_run (bool): To simulate an update without doing anything. In particular, without fetching commits. """ if not self._check_branch(work_repo): return None if not dry_run: try: work_repo.force_fetch(self.url, self.head, self.branchname) except ganarchy.git.GitError as e: # This may error for various reasons, but some # are important: dead links, etc self.erroring = True self.errormsg = e return None pre_hash = self.hash try: post_hash = work_repo.get_hash(self.branchname) except ganarchy.git.GitError as e: # This should never happen, but maybe there's some edge cases? # TODO check self.erroring = True self.errormsg = e return None self.hash = post_hash if not pre_hash: pre_hash = post_hash count = work_repo.get_count(pre_hash, post_hash) try: work_repo.check_history(self.branchname, self.project_commit) self.refresh_metadata(work_repo) return count except ganarchy.git.GitError as e: self.erroring = True self.errormsg = e return None class Project: """A GAnarchy project. Args: dbconn (ganarchy.db.Database): The database connection. project_commit (str): The project commit. Attributes: commit (str): The project commit. repos (list, optional): Repos associated with this project. title (str, optional): Title of the project. description (str, optional): Description of the project. commit_body (str, optional): Raw commit message for title and description. exists (bool): Whether the project exists in our git cache. """ def __init__(self, dbconn, dblock, project_commit): self.commit = project_commit self.refresh_metadata(GIT) self.repos = None self._dbconn = dbconn self._dblock = dblock def load_repos(self): """Loads the repos into this project. If repos have already been loaded, re-loads them. """ repos = [] with self._dblock: for url, branch, head_commit in self._dbconn.list_repobranches(self.commit): repos.append( Repo(self._dbconn, self.commit, url, branch, head_commit) ) self.repos = repos def refresh_metadata(self, work_repo): """Refreshes project metadata. """ try: project = work_repo.get_commit_message(self.commit) project_title, project_desc = (lambda x: x.groups() if x is not None else ('', None))(re.fullmatch('^\\[Project\\]\s+(.+?)(?:\n\n(.+))?$', project, flags=re.ASCII|re.DOTALL|re.IGNORECASE)) if not project_title.strip(): # FIXME project_title, project_desc = ("Error parsing project commit",)*2 # if project_desc: # FIXME # project_desc = project_desc.strip() self.exists = True self.commit_body = project self.title = project_title self.description = project_desc except ganarchy.git.GitError: self.exists = False self.commit_body = None self.title = None self.description = None def update(self, work_repo, *, dry_run=False): """Updates the project and its repos. """ # TODO? check if working correctly results = [] if self.repos is not None: for repo in self.repos: results.append((repo, repo.update(work_repo, dry_run=dry_run))) self.refresh_metadata(work_repo) if self.repos is not None: results.sort(key=lambda x: x[1] or -1, reverse=True) if not dry_run: entries = [] for (repo, count) in results: if count is not None: entries.append(( self.commit, repo.url, repo.branch, repo.hash, count )) with self._dblock: self._dbconn.insert_activities(entries) return results class GAnarchy: """A GAnarchy instance. Args: dbconn (ganarchy.db.Database): The database connection. config (ganarchy.data.DataSource): The (effective) config. Attributes: base_url (str): Instance base URL. title (str): Instance title. projects (list, optional): Projects associated with this instance. """ def __init__(self, dbconn, dblock, config): self.title = None self.base_url = None self.projects = None self._dbconn = dbconn self._dblock = dblock self._config = config self.load_metadata() def load_metadata(self): """Loads instance metadata from config. If instance metadata has already been loaded, re-loads it. """ try: base_url = self._config.get_property_value( ganarchy.data.DataProperty.INSTANCE_BASE_URL ) except LookupError: # FIXME use a more appropriate error type raise ValueError try: title = self._config.get_property_value( ganarchy.data.DataProperty.INSTANCE_TITLE ) except LookupError: title = "GAnarchy on " + parse.urlparse(base_url).hostname self.title = title self.base_url = base_url def load_projects(self): """Loads the projects into this GAnarchy instance. If projects have already been loaded, re-loads them. """ projects = [] with self._dblock: for project in self._dbconn.list_projects(): projects.append(Project(self._dbconn, self._dblock, project)) projects.sort(key=lambda p: p.title or "") # sort projects by title self.projects = projects