├── git_sage ├── __init__.py ├── cmdline │ ├── __init__.py │ ├── __main__.py │ ├── main.py │ ├── application.py │ ├── logger.py │ ├── parser.py │ └── merge_command.py ├── github │ ├── __init__.py │ ├── pr_table.py │ ├── sage_github.py │ └── sage_pr.py ├── repo │ ├── __init__.yaml │ ├── wrap_lines.py │ ├── sage_repository.py │ └── release_merge.py ├── util │ ├── __init__.py │ ├── limited_iter.py │ └── uniq.py └── config │ ├── config.py │ └── loader.py ├── git_sage_test ├── __init__.py └── config │ ├── __init__.py │ └── test_loader.py ├── Makefile.d ├── test.mk ├── install.mk └── lint.mk ├── .gitignore ├── requirements.txt ├── Makefile ├── config ├── isort.cfg ├── mypy.ini └── flake8-test └── git-sage /git_sage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_sage/cmdline/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_sage/github/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_sage/repo/__init__.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_sage/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_sage_test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_sage/util/limited_iter.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_sage_test/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_sage/cmdline/__main__.py: -------------------------------------------------------------------------------- 1 | from git_sage.cmdline.main import main 2 | 3 | if __name__ == '__main__': 4 | main() 5 | -------------------------------------------------------------------------------- /Makefile.d/test.mk: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: test 3 | test: \ 4 | test-unit 5 | 6 | 7 | 8 | .PHONY: test-unit 9 | test-unit: 10 | $(TOOL)/python -m unittest discover 11 | 12 | -------------------------------------------------------------------------------- /Makefile.d/install.mk: -------------------------------------------------------------------------------- 1 | .PHONY: install-tools 2 | 3 | 4 | install-tools: tools/bin/activate 5 | 6 | 7 | tools/bin/activate: 8 | python -m venv tools 9 | ./tools/bin/pip install -r requirements.txt 10 | 11 | -------------------------------------------------------------------------------- /git_sage/config/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Final 3 | 4 | 5 | @dataclass(frozen=True) 6 | class GitSageConfig(object): 7 | 8 | repository: str 9 | access_token: str 10 | 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.pyc 3 | *.bak 4 | *.sav 5 | ~$* 6 | \#*\# 7 | .#* 8 | .tramp_history 9 | /images 10 | /trash/ 11 | /doc/_build 12 | /doc/apidoc 13 | /.cache/ 14 | /local/ 15 | /scripts/.bundle 16 | /tmp 17 | **/.mypy_cache/** 18 | 19 | 20 | 21 | config.yaml 22 | /tools/ 23 | /Sage/ -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pygithub 2 | pygit2 3 | aiofiles 4 | aiohttp 5 | aiohttp-devtools 6 | asynctest 7 | flake8 8 | ipython 9 | isort 10 | mypy 11 | pydantic 12 | pytest 13 | python-engineio 14 | requests 15 | sockjs 16 | types-aiofiles 17 | watchgod 18 | pyyaml 19 | types-pyyaml 20 | beautifultable 21 | -------------------------------------------------------------------------------- /git_sage/repo/wrap_lines.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | 4 | def wrap_lines(text: str) -> str: 5 | text = text.strip() 6 | accumulator = [] 7 | for line in text.splitlines(): 8 | line = '\n'.join(textwrap.wrap(line, 72)) 9 | accumulator.append(line) 10 | return '\n'.join(accumulator) 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export REPO_ROOT:=$(shell git rev-parse --show-toplevel) 2 | export TOOL:=$(REPO_ROOT)/tools/bin 3 | 4 | .PHONY: all 5 | all: test lint 6 | 7 | 8 | include Makefile.d/install.mk 9 | include Makefile.d/lint.mk 10 | include Makefile.d/test.mk 11 | 12 | 13 | 14 | .PHONY: shell 15 | shell: 16 | $(TOOL)/ipython 17 | 18 | -------------------------------------------------------------------------------- /config/isort.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | line_length=100 3 | multi_line_output=4 4 | known_third_party = babel,bravado_core,cloudstorage,Crypto,dateutil,elasticsearch,elasticsearch_dsl,faker,functools32,google,icalendar,jinja2,jsonschema,jwt,lxml,markdown,markupsafe,oauth2client,oauthlib,pexpect,PIL,polib,protorpc,python_jwt,pytz,requests,six,webapp2,webapp2_extras,webob,webtest, 5 | known_first_party = app 6 | known_app_test = app_test,nose_focus 7 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,APP_TEST,LOCALFOLDER 8 | -------------------------------------------------------------------------------- /git_sage_test/config/test_loader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import unittest 4 | 5 | from git_sage.config.config import GitSageConfig 6 | from git_sage.config.loader import config_file_path, config_loader 7 | 8 | basedir = os.path.dirname(os.path.dirname(__file__)) 9 | 10 | log = logging.getLogger('git-sage.test') 11 | 12 | class TestConfigLoader(unittest.TestCase): 13 | 14 | def test_config_loader(self) -> None: 15 | config_yaml = config_file_path() 16 | config = config_loader(config_yaml) 17 | print(config) 18 | -------------------------------------------------------------------------------- /Makefile.d/lint.mk: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: lint 3 | lint: \ 4 | lint-isort \ 5 | lint-mypy \ 6 | lint-flake8 7 | 8 | 9 | .PHONY: lint-mypy 10 | lint-mypy: 11 | $(TOOL)/mypy --config-file config/mypy.ini -p git_sage 12 | $(TOOL)/mypy --config-file config/mypy.ini -p git_sage_test 13 | 14 | 15 | .PHONY: lint-flake8 16 | lint-flake8: 17 | $(TOOL)/flake8 --statistics --config config/flake8-test git_sage git_sage_test 18 | 19 | 20 | .PHONY: lint-isort 21 | lint-isort: 22 | $(TOOL)/isort --settings config/isort.cfg git_sage git_sage_test 23 | 24 | 25 | -------------------------------------------------------------------------------- /git-sage: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # isort:skip_file 3 | 4 | import os 5 | import sys 6 | import subprocess 7 | 8 | print('Sage Git Releasemanagement') 9 | 10 | basedir = os.path.dirname(os.path.realpath(__file__)) 11 | print('basedir', basedir) 12 | os.environ['PYTHONPATH'] = basedir 13 | 14 | print('argv', sys.argv) 15 | 16 | python = os.path.join(basedir, 'tools', 'bin', 'python') 17 | os.execv( 18 | python, 19 | [ 20 | python, 21 | '-m', 22 | 'git_sage.cmdline', 23 | *sys.argv[1:] 24 | ], 25 | ) 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /git_sage/config/loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from git_sage.config.config import GitSageConfig 4 | from yaml import Loader, load 5 | 6 | 7 | def config_file_path() -> str: 8 | """ 9 | Find the config file 10 | """ 11 | basedir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 12 | config_yaml = os.path.join(basedir, 'config.yaml') 13 | return config_yaml 14 | 15 | 16 | def config_loader(filename: str) -> GitSageConfig: 17 | """ 18 | Load the config file 19 | """ 20 | with open(filename, 'r') as f: 21 | data = load(f, Loader=Loader) 22 | return GitSageConfig( 23 | repository=data['repository'], 24 | access_token=data['access_token'], 25 | ) 26 | -------------------------------------------------------------------------------- /git_sage/cmdline/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from git_sage.cmdline.application import Application 5 | from git_sage.cmdline.logger import log 6 | from git_sage.cmdline.parser import cmdline_parser 7 | 8 | 9 | def main() -> None: 10 | parser = cmdline_parser() 11 | args = parser.parse_args(sys.argv[1:]) 12 | print(args) 13 | if args.log is not None: 14 | level = getattr(logging, args.log) 15 | log.setLevel(level=level) 16 | # logging.basicConfig(level=logging.DEBUG) 17 | app = Application() 18 | if args.subcommand == 'todo': 19 | app.todo_cmd(args.limit, args.only_blocker) 20 | if args.subcommand == 'merge': 21 | app.merge_cmd(args.pr_number, args.exclude, args.limit, args.only_blocker) 22 | -------------------------------------------------------------------------------- /config/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | mypy_path = stubs 4 | 5 | plugins = pydantic.mypy 6 | 7 | 8 | # strictness flags 9 | check_untyped_defs = True 10 | disallow_any_generics = True 11 | disallow_incomplete_defs = True 12 | disallow_subclassing_any = True 13 | disallow_untyped_calls = True 14 | disallow_untyped_decorators = True 15 | disallow_untyped_defs = True 16 | follow_imports = silent 17 | no_implicit_optional = True 18 | no_implicit_reexport = True 19 | show_error_codes = True 20 | strict_equality = True 21 | strict_optional = True 22 | warn_redundant_casts = True 23 | warn_return_any = True 24 | warn_unused_configs = True 25 | warn_unused_ignores = True 26 | 27 | 28 | # Testsuite 29 | 30 | [mypy-nose_focus] 31 | ignore_missing_imports = True 32 | 33 | 34 | [mypy-pygit2] 35 | ignore_missing_imports = True 36 | 37 | 38 | [mypy-beautifultable] 39 | ignore_missing_imports = True 40 | -------------------------------------------------------------------------------- /git_sage/util/uniq.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, print_function, unicode_literals 2 | 3 | from typing import Iterable, List, Set, TypeVar 4 | 5 | T = TypeVar('T') 6 | 7 | 8 | def uniq(iterable: Iterable[T]) -> List[T]: 9 | """ 10 | Return unique items without changing order 11 | 12 | Given an iterable producing possibly duplicate items, this function returns the list of distinct 13 | items in the iterable, without changing their order. Only subsequent duplicates are removed. 14 | 15 | Args: 16 | iterable: iterable whose values are hashable 17 | Returns: 18 | list: elements of the collection with duplicates removed. The order is preserved, except 19 | that later duplicate elements are dropped. 20 | """ 21 | result = [] 22 | seen = set() # type: Set[T] 23 | for item in iterable: 24 | if item in seen: 25 | continue 26 | result.append(item) 27 | seen.add(item) 28 | return result 29 | -------------------------------------------------------------------------------- /git_sage/github/pr_table.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import cached_property 3 | from typing import Final, Iterable, List 4 | 5 | from beautifultable import BeautifulTable 6 | from git_sage.github.sage_pr import SagePullRequest 7 | 8 | log = logging.getLogger('git-sage') 9 | 10 | 11 | class PullRequestTable(object): 12 | 13 | def __init__(self, pulls: Iterable[SagePullRequest]) -> None: 14 | self.pulls: Final[Iterable[SagePullRequest]] = pulls 15 | 16 | @cached_property 17 | def table(self) -> BeautifulTable: 18 | table = BeautifulTable() 19 | table.set_style(BeautifulTable.STYLE_DOTTED) 20 | table.columns.header = ['Number', 'Review', 'Title'] 21 | table.columns.width = [8, 10, 40] 22 | table.columns.width_exceed_policy = BeautifulTable.WEP_ELLIPSIS 23 | return table 24 | 25 | def _format(self, spr: SagePullRequest) -> List[str]: 26 | return [ 27 | str(spr.pr.number), 28 | 'approved' if spr.is_positive_review else '', 29 | spr.pr.title, 30 | ] 31 | 32 | def _lines(self) -> Iterable[List[str]]: 33 | for pr in self.pulls: 34 | yield self._format(pr) 35 | 36 | def stream_print(self) -> None: 37 | for line in self.table.stream(self._lines()): 38 | print(line) 39 | -------------------------------------------------------------------------------- /git_sage/cmdline/application.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import cached_property 3 | from typing import Final, List 4 | 5 | from git_sage.cmdline.merge_command import MergeCommand 6 | from git_sage.config.config import GitSageConfig 7 | from git_sage.config.loader import config_file_path, config_loader 8 | from git_sage.github.sage_github import SageGithub 9 | from git_sage.repo.sage_repository import SageRepository 10 | 11 | log = logging.getLogger('git-sage') 12 | 13 | 14 | class Application(object): 15 | 16 | def __init__(self) -> None: 17 | pass 18 | 19 | @cached_property 20 | def config(self) -> GitSageConfig: 21 | config_yaml = config_file_path() 22 | return config_loader(config_yaml) 23 | 24 | @cached_property 25 | def sage(self) -> SageRepository: 26 | return SageRepository( 27 | config=self.config, 28 | ) 29 | 30 | @cached_property 31 | def github(self) -> SageGithub: 32 | return SageGithub( 33 | config=self.config, 34 | ) 35 | 36 | def todo_cmd(self, limit: int, only_blocker: bool) -> None: 37 | """ 38 | Print the next open pull requests 39 | """ 40 | self.github.print_table(limit, only_blocker) 41 | 42 | def merge_cmd(self, 43 | pr_numbers: list[int], 44 | exclude: list[int], 45 | limit: int, 46 | only_blocker: bool) -> None: 47 | cmd = MergeCommand(self.sage, self.github) 48 | cmd(pr_numbers, exclude, limit, only_blocker) 49 | -------------------------------------------------------------------------------- /git_sage/github/sage_github.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import cached_property 3 | from typing import Final, Iterable, List, Optional 4 | 5 | from git_sage.config.config import GitSageConfig 6 | from git_sage.github.pr_table import PullRequestTable 7 | from git_sage.github.sage_pr import SagePullRequest 8 | from github import Github 9 | from github.PullRequest import PullRequest 10 | from github.Repository import Repository 11 | 12 | log = logging.getLogger('git-sage') 13 | 14 | 15 | class SageGithub(object): 16 | 17 | def __init__(self, 18 | config: GitSageConfig 19 | ) -> None: 20 | self.config: Final[GitSageConfig] = config 21 | 22 | @cached_property 23 | def gh(self) -> Github: 24 | return Github(self.config.access_token) 25 | 26 | @cached_property 27 | def repo(self) -> Repository: 28 | return self.gh.get_repo('sagemath/sage') # 'vbraun/sage') 29 | 30 | def get_pull(self, pr_number: int) -> SagePullRequest: 31 | pr = self.repo.get_pull(pr_number) 32 | return SagePullRequest(pr) 33 | 34 | def pull_requests(self, limit: Optional[int], only_blocker: bool) -> Iterable[SagePullRequest]: 35 | pulls = self.repo.get_pulls( 36 | state='open', 37 | sort='created', 38 | direction='asc', 39 | ) 40 | count: int = 0 41 | for pr in pulls: 42 | if (limit is not None) and (count >= limit): 43 | return 44 | spr = SagePullRequest(pr) 45 | if only_blocker and not spr.is_blocker: 46 | continue 47 | yield spr 48 | count += 1 49 | 50 | def print_table(self, limit: int, only_blocker: bool) -> None: 51 | table = PullRequestTable(self.pull_requests(limit, only_blocker)) 52 | table.stream_print() 53 | -------------------------------------------------------------------------------- /git_sage/cmdline/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Customized Python logger 3 | """ 4 | ############################################################################## 5 | # Copyright (C) 2014 Volker Braun 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | ############################################################################## 20 | 21 | 22 | import logging 23 | 24 | 25 | def make_logger(name: str, doctest_mode: bool = False) -> logging.Logger: 26 | logger = logging.getLogger(name) 27 | if len(logger.handlers) == 0: 28 | if doctest_mode: 29 | formatter = logging.Formatter('[%(name)s] %(levelname)s: %(message)s') 30 | else: 31 | formatter = logging.Formatter('%(asctime)s [%(name)s] %(levelname)s: %(message)s', 32 | datefmt='%H:%M:%S') 33 | handler = logging.StreamHandler() 34 | handler.setFormatter(formatter) 35 | logger.addHandler(handler) 36 | logger.setLevel(logging.WARNING) 37 | 38 | return logger 39 | 40 | 41 | def enable_doctest_mode() -> None: 42 | global logger 43 | log = make_logger('git-sage', True) 44 | 45 | 46 | log = make_logger('git-sage') 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /git_sage/github/sage_pr.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import cached_property 3 | from typing import Final 4 | 5 | from github.PullRequest import PullRequest 6 | 7 | log = logging.getLogger('git-sage') 8 | 9 | 10 | class SagePullRequest(object): 11 | 12 | def __init__(self, pr: PullRequest) -> None: 13 | self.pr: Final[PullRequest] = pr 14 | 15 | @cached_property 16 | def is_positive_review(self) -> bool: 17 | """ 18 | Use the 's: positive review' and 's: needs work' labels 19 | """ 20 | is_needs_work = False 21 | is_positive_review = False 22 | for label in self.pr.labels: 23 | log.debug(f'pr {self.pr.number} label: {label.name}') 24 | is_positive_review = is_positive_review or (label.name == 's: positive review') 25 | is_needs_work = is_needs_work or (label.name == 's: needs work') 26 | return is_positive_review and not is_needs_work 27 | 28 | @cached_property 29 | def is_blocker(self) -> bool: 30 | """ 31 | Use the 's: blocker / 1' label 32 | """ 33 | is_blocker = False 34 | for label in self.pr.labels: 35 | log.debug(f'pr {self.pr.number} label: {label.name}') 36 | is_blocker = is_blocker or (label.name == 'p: blocker / 1') 37 | return is_blocker 38 | 39 | 40 | # """ 41 | # Need at least one approve, and no pending change request 42 | # """ 43 | # approved = False 44 | # for review in self.pr.get_reviews(): 45 | # log.debug(f'considering review {review.state}: {review}') 46 | # if review.state == 'REQUEST_CHANGES': 47 | # return False 48 | # approved = approved or (review.state == 'APPROVED') 49 | # log.debug(f'pr {self.pr.number} positive review: {approved}') 50 | # return approved 51 | 52 | 53 | -------------------------------------------------------------------------------- /config/flake8-test: -------------------------------------------------------------------------------- 1 | # flake8 configuration file for tests 2 | # 3 | # Errors cause failures in the "make test" target 4 | 5 | 6 | [flake8] 7 | 8 | hang_closing = False 9 | 10 | ### Ignore 11 | # W291,W293,W391: whitespace related 12 | # F401: Unused import 13 | 14 | ignore = 15 | W291,W293,W391, 16 | # Whitespace related 17 | F401, 18 | # Unused import 19 | E266, 20 | # Too many leading '#' for block comment 21 | E123, 22 | # Closing bracket does not match indentation of opening bracket's line 23 | E402, 24 | # Module level import not at top of file (TODO: cleanup) 25 | E129, 26 | # Visually indented line with same indent as next logical line 27 | E221, 28 | # multiple spaces before operator (TODO: cleanup) 29 | E225, 30 | # missing whitespace around operator (TODO: cleanup) 31 | E241, 32 | # multiple spaces after ',' (TODO: cleanup) 33 | E261, 34 | # at least two spaces before inline comment (TODO: cleanup) 35 | E302, 36 | # expected 2 blank lines, found 1 (TODO: cleanup) 37 | E303, 38 | # too many blank lines (3) (TODO: cleanup) 39 | E305, 40 | # expected 2 blank lines after class or function definition, found 3 (TODO: cleanup) 41 | E501, 42 | # line too long (724 > 200 characters) (TODO: cleanup) 43 | E711, 44 | # comparison to None should be 'if cond is None:' (TODO: cleanup) 45 | E712, 46 | # comparison to True should be 'if cond is True:' or 'if cond:' (TODO: cleanup) 47 | #F811, 48 | # redefinition of unused 'to_json' from line 35 (TODO: cleanup) 49 | F821, 50 | # undefined name 'InvalidChecksum' (TODO: cleanup) 51 | F841, 52 | # local variable 'event_type' is assigned to but never used (TODO: cleanup) 53 | W503, 54 | # line break before binary operator (TODO: cleanup) 55 | W504, 56 | # line break after binary operator (TODO: cleanup) 57 | W605 58 | # invalid escape sequence '\?' (TODO: cleanup) 59 | 60 | 61 | # max-line-length = 120 62 | max-line-length = 200 63 | 64 | # exclude = 65 | -------------------------------------------------------------------------------- /git_sage/cmdline/parser.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import sys 4 | 5 | log = logging.getLogger('git-sage') 6 | 7 | 8 | description = ''' 9 | Sage Release Management CLI 10 | ''' 11 | 12 | 13 | def cmdline_parser() -> argparse.ArgumentParser: 14 | parser = argparse.ArgumentParser(description=description) 15 | parser.add_argument( 16 | '--log', dest='log', default=None, 17 | help='one of [DEBUG, INFO, ERROR, WARNING, CRITICAL]') 18 | subparsers = parser.add_subparsers( 19 | dest='subcommand') 20 | 21 | parser_todo = subparsers.add_parser( 22 | 'todo', 23 | help='list pull requests that are ready to merge') 24 | parser_todo.add_argument( 25 | '-l', '--limit', 26 | dest='limit', 27 | type=int, 28 | help='Limit number of PRs', 29 | default=10) 30 | parser_todo.add_argument( 31 | '-b', '--only-blocker', 32 | dest='only_blocker', 33 | help='only list blockers', 34 | default=False, 35 | action='store_true') 36 | 37 | parser_merge = subparsers.add_parser( 38 | 'merge', 39 | help='merge pull requests') 40 | parser_merge.add_argument( 41 | '-x', '--exclude', 42 | dest='exclude', 43 | nargs='+', 44 | type=int, 45 | help='Exclude PRs', 46 | default=(), 47 | ) 48 | parser_merge.add_argument( 49 | '-l', '--limit', 50 | dest='limit', 51 | type=int, 52 | help='Auto-merge this many outstanding PRs', 53 | default=0) 54 | parser_merge.add_argument( 55 | '-b', '--only-blocker', 56 | dest='only_blocker', 57 | help='only list blockers', 58 | default=False, 59 | action='store_true') 60 | parser_merge.add_argument( 61 | 'pr_number', 62 | nargs='*', 63 | type=int, 64 | help='Number(s) of Github PR to merge', 65 | default=[] 66 | ) 67 | 68 | # subparsers = parser.add_subparsers( 69 | # dest='subparser', 70 | # help='sub-command help') 71 | return parser 72 | -------------------------------------------------------------------------------- /git_sage/cmdline/merge_command.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from itertools import islice 3 | from math import inf 4 | from typing import Final, FrozenSet, List 5 | 6 | from git_sage.github.sage_github import SageGithub 7 | from git_sage.repo.release_merge import MergeConflictException 8 | from git_sage.repo.sage_repository import SageRepository 9 | 10 | log = logging.getLogger('git-sage') 11 | 12 | 13 | class MergeCommand(object): 14 | 15 | def __init__(self, sage: SageRepository, github: SageGithub) -> None: 16 | self.sage: Final[SageRepository] = sage 17 | self.github: Final[SageGithub] = github 18 | 19 | def _merge_specific_pr_numbers(self, pr_numbers: List[int], force: bool) -> None: 20 | for pr_number in pr_numbers: 21 | spr = self.github.get_pull(pr_number) 22 | if not (force or spr.is_positive_review): 23 | raise ValueError(f'pr {pr_number} does not have positive review') 24 | print(f'Merging {spr.pr}') 25 | self.sage.merge_pr(spr.pr) 26 | 27 | def _merge_all(self, 28 | limit: int, 29 | exclude: FrozenSet[int], 30 | only_blocker: bool) -> None: 31 | for spr in islice(self.github.pull_requests(None, only_blocker), limit): 32 | if spr.pr in exclude: 33 | continue 34 | if not spr.is_positive_review: 35 | continue 36 | print(f'Merging {spr.pr}') 37 | try: 38 | self.sage.merge_pr(spr.pr) 39 | except MergeConflictException as exc: 40 | print(f'Merge failed: {exc}') 41 | 42 | def __call__(self, 43 | pr_numbers: list[int], 44 | exclude: list[int], 45 | limit: int, 46 | only_blocker: bool, 47 | ) -> None: 48 | exclude_set = frozenset(exclude) 49 | if pr_numbers: 50 | filtered = [pr for pr in pr_numbers if pr not in exclude_set] 51 | self._merge_specific_pr_numbers(filtered, True) 52 | else: 53 | self._merge_all(limit, exclude_set, only_blocker) 54 | -------------------------------------------------------------------------------- /git_sage/repo/sage_repository.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from functools import cached_property 4 | from typing import Final, Optional, cast 5 | 6 | import pygit2 7 | from git_sage.config.config import GitSageConfig 8 | from git_sage.repo.release_merge import ReleaseMerge 9 | from github.PullRequest import PullRequest 10 | from pygit2 import Remote, RemoteCallbacks, Repository 11 | 12 | log = logging.getLogger('git-sage') 13 | 14 | 15 | class SageRepositoryException(Exception): 16 | pass 17 | 18 | 19 | class SageRepository(object): 20 | 21 | def __init__(self, 22 | config: GitSageConfig 23 | ) -> None: 24 | self.config: Final[GitSageConfig] = config 25 | self._verify_remotes() 26 | 27 | @cached_property 28 | def callbacks(self) -> RemoteCallbacks: 29 | keypair = pygit2.KeypairFromAgent('git') 30 | callbacks = pygit2.RemoteCallbacks(credentials=keypair) 31 | return callbacks 32 | 33 | @cached_property 34 | def repo(self) -> Repository: 35 | path = self._path_from_local() or self._path_from_cwd() 36 | if not path: 37 | raise SageRepositoryException('sage git repo not found') 38 | repo = Repository(path) 39 | return repo 40 | 41 | def _path_from_cwd(self) -> Optional[str]: 42 | log.debug('searching sage repo in cwd') 43 | path = pygit2.discover_repository(os.getcwd()) 44 | return cast(Optional[str], path) 45 | 46 | def _path_from_local(self) -> Optional[str]: 47 | repo_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 48 | log.debug(f'searching sage repo in {repo_root}') 49 | sage_repo = os.path.join(repo_root, 'Sage') 50 | log.debug(f'using sage repo: {sage_repo}') 51 | path = pygit2.discover_repository(sage_repo) 52 | return cast(Optional[str], path) 53 | 54 | @cached_property 55 | def origin(self) -> Remote: 56 | try: 57 | return self.repo.remotes['origin'] 58 | except KeyError: 59 | raise SageRepositoryException('origin remote not defined') 60 | 61 | @cached_property 62 | def github(self) -> Remote: 63 | try: 64 | return self.repo.remotes['github'] 65 | except KeyError: 66 | raise SageRepositoryException('github remote not defined') 67 | 68 | def _verify_remotes(self) -> None: 69 | origin_url = ['git@github.com:vbraun/sage.git'] 70 | github_url = ['git@github.com:sagemath/sage.git', 'https://github.com/sagemath/sage.git'] 71 | if self.origin.url not in origin_url: 72 | raise SageRepositoryException(f'origin remote should be {origin_url}') 73 | if self.github.url not in github_url: 74 | raise SageRepositoryException(f'github remote should be {github_url}') 75 | 76 | def merge_pr(self, pr: PullRequest) -> None: 77 | release = ReleaseMerge(self.repo, self.github, self.callbacks, pr) 78 | release.merge_commit() 79 | 80 | -------------------------------------------------------------------------------- /git_sage/repo/release_merge.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import cached_property 3 | from typing import Final, List 4 | 5 | from git_sage.repo.wrap_lines import wrap_lines 6 | from git_sage.util.uniq import uniq 7 | from github.NamedUser import NamedUser 8 | from github.PullRequest import PullRequest 9 | from pygit2 import Branch, Commit, GitError, Remote, RemoteCallbacks, Repository 10 | from pygit2.enums import ResetMode # type: ignore 11 | 12 | log = logging.getLogger('git-sage') 13 | 14 | 15 | TEMPLATE = """ 16 | gh-{number}: {title} 17 | 18 | {description} 19 | 20 | URL: {url} 21 | Reported by: {user} 22 | Reviewer(s): {reviewers} 23 | """ 24 | 25 | 26 | def user_name(user: NamedUser) -> str: 27 | return user.name or user.login 28 | 29 | 30 | class MergeConflictException(Exception): 31 | pass 32 | 33 | 34 | class ReleaseMerge(object): 35 | 36 | def __init__(self, 37 | repo: Repository, 38 | github: Remote, 39 | callbacks: RemoteCallbacks, 40 | pr: PullRequest, 41 | ) -> None: 42 | self.repo: Final[Repository] = repo 43 | self.github: Final[Remote] = github 44 | self.callbacks: Final[RemoteCallbacks] = callbacks 45 | self.pr: Final[PullRequest] = pr 46 | 47 | @cached_property 48 | def reviewer_names(self) -> List[str]: 49 | """ 50 | Ordered and deduplicated reviewer names 51 | """ 52 | deduplicated = uniq( 53 | user_name(review.user) for review in self.pr.get_reviews()) 54 | return sorted(deduplicated, key=lambda name: name.lower()) 55 | 56 | @cached_property 57 | def message(self) -> str: 58 | return TEMPLATE.format( 59 | number=self.pr.number, 60 | title=self.pr.title, 61 | description=wrap_lines(self.pr.body or ''), 62 | url=self.pr.html_url, 63 | user=user_name(self.pr.user), 64 | reviewers=', '.join(self.reviewer_names), 65 | ) 66 | 67 | @property 68 | def branch_tip(self) -> Commit: 69 | number = self.pr.number 70 | name = f'refs/pull/{number}/head' 71 | log.debug(f'pr branch name should be {name}') 72 | self.github.fetch([name], callbacks=self.callbacks) 73 | return self.repo.revparse_single('FETCH_HEAD') 74 | 75 | def merge_commit(self) -> Commit: 76 | """ 77 | Add the release commit to the repository, and return the merge commit 78 | """ 79 | log.info(f'Merging {self.pr}') 80 | head = self.repo.head 81 | branch_tip = self.branch_tip.oid 82 | self.repo.merge(branch_tip) 83 | user = self.repo.default_signature 84 | try: 85 | tree = self.repo.index.write_tree() 86 | except GitError as error: 87 | log.error(error) 88 | self.repo.reset(head.target, ResetMode.HARD) 89 | raise MergeConflictException(self.pr.number) 90 | message = self.message 91 | merge_commit = self.repo.create_commit( 92 | 'HEAD', user, user, message, tree, 93 | [self.repo.head.target, branch_tip]) 94 | if self.repo.index.conflicts: 95 | raise MergeConflictException(self.pr.number) 96 | self.repo.state_cleanup() 97 | return merge_commit 98 | --------------------------------------------------------------------------------