260 lines
		
	
	
	
		
			7.8 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			260 lines
		
	
	
	
		
			7.8 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
| #!/usr/bin/env python3
 | |
| # SPDX-FileCopyrightText: 2021 Luca Beltrame <lbeltrame@kde.org>
 | |
| # SPDX-License-Identifier: BSD-3-clause
 | |
| 
 | |
| import argparse
 | |
| from datetime import date
 | |
| from functools import lru_cache
 | |
| import logging
 | |
| import json
 | |
| from pathlib import Path
 | |
| import subprocess
 | |
| from typing import Union, Dict, List, Tuple
 | |
| from urllib.parse import quote
 | |
| 
 | |
| import git
 | |
| from git.repo import Repo
 | |
| from jinja2 import BaseLoader, Environment
 | |
| import requests
 | |
| import sarge
 | |
| 
 | |
| 
 | |
| API_URL = "https://invent.kde.org/api/v4/projects/"
 | |
| OBS_URL = "https://api.opensuse.org/trigger/runservice"
 | |
| MATRIX_COMMANDER = "/usr/local/bin/matrix-commander.py"
 | |
| CREDENTIALS_FILE = "/etc/matrix-commander/credentials.json"
 | |
| STORE_DIR = "/var/lib/matrix-commander/store"
 | |
| REPO_TEMPLATE = "https://invent.kde.org/{}"
 | |
| 
 | |
| MESSAGE_TEMPLATE = """
 | |
| ## OBS package update report
 | |
| 
 | |
| Updated at {{ date.today().strftime('%Y-%m-%d') }}
 | |
| {% for repository, update in repositories.items() %}
 | |
| 
 | |
| ### {{ repository }}
 | |
| 
 | |
| Examined {{ update | length }} packages changed upstream:
 | |
| 
 | |
| {% for package, remote, state in update %}
 | |
| {% if state != "error" %}
 | |
| - {{ package }} updated to [{{ state[0:8] }}](https://commits.kde.org/{{ remote }}/{{ state }})
 | |
| {% else %}
 | |
| - {{ package }} not updated (source service error)
 | |
| {% endif %}
 | |
| {% endfor %}
 | |
| {% endfor %}
 | |
| """
 | |
| 
 | |
| 
 | |
| def project_exists(project: str) -> bool:
 | |
|     # We want / to get quoted, so put safe to ""
 | |
|     project_name = quote(project, safe="")
 | |
|     request = requests.get(API_URL + project_name)
 | |
| 
 | |
|     if request:
 | |
|         return True
 | |
| 
 | |
|     return False
 | |
| 
 | |
| 
 | |
| def get_remote_hash(url: str, branch: str = "master") -> str:
 | |
| 
 | |
|     gitcmd = git.cmd.Git()
 | |
|     try:
 | |
|         revision = gitcmd.ls_remote(url, f"refs/heads/{branch}",
 | |
|                                     quiet=True, refs=True)
 | |
|     except git.exc.GitCommandError:
 | |
|         # Catch API errors
 | |
|         return
 | |
| 
 | |
|     if not revision:
 | |
|         return
 | |
|     git_hash, branch = revision.split("\t")
 | |
|     return git_hash
 | |
| 
 | |
| 
 | |
| def trigger_update(repository: str, package_name: str,
 | |
|                    token: str) -> Union[requests.Response, bool]:
 | |
| 
 | |
|     header = {"Authorization": f"Token {token}"}
 | |
|     parameters = {"project": repository, "package": package_name}
 | |
| 
 | |
|     logging.info("Updating package %s", package_name)
 | |
|     result = requests.post(OBS_URL, params=parameters, headers=header)
 | |
| 
 | |
|     if not result:
 | |
|         logging.error(
 | |
|             "Error during service run, package %s, error code %s, url %s",
 | |
|             package_name, result.status_code, result.url)
 | |
|         return False
 | |
| 
 | |
|     logging.debug("Package %s complete", package_name)
 | |
|     return result
 | |
| 
 | |
| 
 | |
| class RepoUpdater:
 | |
| 
 | |
|     def __init__(self, config: str, cache_file: str, token_file: str) -> None:
 | |
| 
 | |
|         self.cache = cache_file
 | |
|         self.token = token_file
 | |
| 
 | |
|         if not Path(cache_file).exists():
 | |
|             logging.debug("File cache not found, not loading")
 | |
|             self._data: Dict[str, Dict[str, str]] = dict()
 | |
|         else:
 | |
|             with open(cache_file) as handle:
 | |
|                 self._data = json.load(handle)
 | |
| 
 | |
|         with open(config, "r") as mapping:
 | |
|             repo_data = json.load(mapping)
 | |
|             self.config = repo_data
 | |
| 
 | |
|     @property
 | |
|     def repositories(self) -> List[str]:
 | |
|         return self._data.keys()
 | |
| 
 | |
|     def update_repository(self, repository) -> List[Tuple[str, str, str]]:
 | |
| 
 | |
|         if self._data.get(repository) is None:
 | |
|             logging.debug("No prior data - initializing empty")
 | |
|             self._data[repository] = dict()
 | |
| 
 | |
|         logging.debug("Updating list of repositories")
 | |
|         to_update = self.get_updated_remotes(repository)
 | |
| 
 | |
|         if not to_update:
 | |
|             logging.debug(f"Nothing to update for {repository}")
 | |
|             return
 | |
| 
 | |
|         logging.info(f"Found {len(to_update)} updated repositories")
 | |
| 
 | |
|         updated = list()
 | |
|         logging.info("Updating packages for %s", repository)
 | |
| 
 | |
|         for package in to_update:
 | |
|             remote_name = self.config[repository][package]["kde"]
 | |
|             if trigger_update(repository, package, self.token):
 | |
|                 remote_hash = to_update[package]
 | |
|                 self._data[repository][remote_name] = remote_hash
 | |
|                 self.save_cache()
 | |
|                 updated.append((package, remote_name, remote_hash))
 | |
|             else:
 | |
|                 updated.append((package, remote_name, "error"))
 | |
| 
 | |
|         return updated
 | |
| 
 | |
|     @lru_cache(maxsize=200)
 | |
|     def get_updated_remotes(self, repository: str) -> Dict[str, str]:
 | |
| 
 | |
|         to_update = dict()
 | |
| 
 | |
|         repodata = self.config[repository]
 | |
|         for repo, data in repodata.items():
 | |
| 
 | |
|             if not data:
 | |
|                 logging.warning("Repository %s missing configuration, skipping",
 | |
|                                 repo)
 | |
|                 continue
 | |
| 
 | |
|             kde_name = data.get("kde")
 | |
|             branch = data.get("branch")
 | |
|             if kde_name is None or branch is None:
 | |
|                 logging.warning("Repository %s missing configuration, skipping",
 | |
|                                 repo)
 | |
|                 continue
 | |
| 
 | |
|             url = REPO_TEMPLATE.format(kde_name)
 | |
| 
 | |
|             if not project_exists(kde_name):
 | |
|                 logging.warning("Repository %s not found, skipping",
 | |
|                                 kde_name)
 | |
|                 continue
 | |
| 
 | |
|             local_hash = self._data[repository].get(kde_name, "")
 | |
|             remote_hash = get_remote_hash(url, branch)
 | |
| 
 | |
|             if remote_hash is None:
 | |
|                 logging.error("Failed to update repo at URL %s", url)
 | |
|                 continue
 | |
| 
 | |
|             if local_hash != remote_hash:
 | |
|                 logging.debug("Hash doesn't match, marking as changed")
 | |
|                 to_update[repo] = remote_hash
 | |
| 
 | |
|         return to_update
 | |
| 
 | |
|     def save_cache(self) -> None:
 | |
|         logging.debug("Saving JSON cache")
 | |
|         with open(self.cache, "w") as handle:
 | |
|             json.dump(self._data, handle, indent=4)
 | |
| 
 | |
| 
 | |
| def notify_matrix(update_data: Dict[str, List[Tuple[str, str]]]) -> None:
 | |
| 
 | |
|     template = Environment(loader=BaseLoader, lstrip_blocks=True,
 | |
|                            trim_blocks=True).from_string(MESSAGE_TEMPLATE)
 | |
| 
 | |
| 
 | |
|     message = template.render(repositories=update_data, date=date)
 | |
| 
 | |
|     cmd = [MATRIX_COMMANDER, "--markdown", "--log-level", "ERROR",
 | |
|            "--credentials", CREDENTIALS_FILE, "--store", STORE_DIR,
 | |
|            "-m", message]
 | |
|     logging.debug("Sending Matrix notification")
 | |
|     sarge.run(cmd)
 | |
| 
 | |
| 
 | |
| def commit_changes(cache_file: str, repo_home: str) -> None:
 | |
| 
 | |
|     repo = Repo(repo_home)
 | |
|     repo.index.add([cache_file])
 | |
|     repo.index.commit("[OBS unstable update bot] Update caches")
 | |
|     origin = repo.remotes["origin"]
 | |
|     origin.pull(rebase=True)
 | |
|     origin.push()
 | |
| 
 | |
| 
 | |
| def main() -> None:
 | |
| 
 | |
|     parser = argparse.ArgumentParser()
 | |
|     parser.add_argument(
 | |
|         "--cache-file", help="Location of the cache file",
 | |
|         default=Path.home() / ".local/share/obs_repo_cache.json")
 | |
|     parser.add_argument(
 | |
|         "--repo-root", help="Location of the git repository root")
 | |
|     parser.add_argument("mapping_file", help="KDE:OBS repository mapping file")
 | |
|     parser.add_argument("token", help="Authorization token file")
 | |
|     parser.add_argument("--debug", help="Show debugging output",
 | |
|                         action="store_true")
 | |
|     options = parser.parse_args()
 | |
| 
 | |
|     level = logging.INFO if not options.debug else logging.DEBUG
 | |
| 
 | |
|     logging.basicConfig(format='%(levelname)s - %(message)s',
 | |
|                         level=level)
 | |
| 
 | |
|     cache_file = options.cache_file
 | |
| 
 | |
|     with open(options.token) as handle:
 | |
|         token = handle.read().strip()
 | |
| 
 | |
|     updater = RepoUpdater(options.mapping_file, cache_file, token)
 | |
| 
 | |
|     updated_data = dict()
 | |
| 
 | |
|     for repo in updater.repositories:
 | |
|         updated = updater.update_repository(repo)
 | |
|         updated_data[repo] = updated
 | |
| 
 | |
|     if options.repo_root is not None:
 | |
|         logging.info("Committing changes")
 | |
|         commit_changes(cache_file, options.repo_root)
 | |
| 
 | |
|     notify_matrix(updated_data)
 | |
|     logging.info("Complete")
 | |
| 
 | |
| 
 | |
| if __name__ == "__main__":
 | |
|     main()
 |