commit 5c1b1b4b19148a58f6ef96f7cef90b716d3bad07
Author: BoYanZh <bomingzh@sjtu.edu.cn>
Date:   Wed Jun 9 20:55:50 2021 +0800

    feat: init

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..861c40e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,296 @@
+.idea
+
+# Created by .ignore support plugin (hsz.mobi)
+### Python template
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+
+# Created by https://www.toptal.com/developers/gitignore/api/vscode,python
+# Edit at https://www.toptal.com/developers/gitignore?templates=vscode,python
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+pytestdebug.log
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+doc/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+pythonenv*
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# profiling data
+.prof
+
+### vscode ###
+.vscode/*
+# !.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# End of https://www.toptal.com/developers/gitignore/api/vscode,python
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..d5e30d8
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,35 @@
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v2.3.0
+    hooks:
+      - id: check-yaml
+      - id: end-of-file-fixer
+      - id: trailing-whitespace
+      - id: requirements-txt-fixer
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: "v0.812"
+    hooks:
+      - id: mypy
+        additional_dependencies:
+          - pydantic
+  - repo: https://github.com/asottile/pyupgrade
+    rev: v2.10.0
+    hooks:
+      - id: pyupgrade
+  - repo: https://github.com/hadialqattan/pycln
+    rev: v0.0.1-beta.3 # Possible releases: https://github.com/hadialqattan/pycln/tags
+    hooks:
+      - id: pycln
+  - repo: https://github.com/PyCQA/bandit
+    rev: '1.7.0'
+    hooks:
+    - id: bandit
+  - repo: https://github.com/PyCQA/isort
+    rev: 5.7.0
+    hooks:
+      - id: isort
+        args: ["--profile", "black", "--filter-files"]
+  - repo: https://github.com/psf/black
+    rev: 19.3b0
+    hooks:
+      - id: black
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ed31a14
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 BoYanZh
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c572533
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# Joint Teapot
+
+## Installation
+
+### Setup venv (Optional)
+
+```bash
+python3 -m venv env
+source env/Scripts/activate
+```
+
+```bash
+pip3 install -e .
+vi .env # configure environment
+```
+
+### For developers
+
+```bash
+pip3 install -r requirements-dev.txt
+pre-commit install
+pytest -svv
+```
diff --git a/joint_teapot/__init__.py b/joint_teapot/__init__.py
new file mode 100644
index 0000000..36bdcfa
--- /dev/null
+++ b/joint_teapot/__init__.py
@@ -0,0 +1,3 @@
+from joint_teapot.canvas import Canvas as Canvas
+from joint_teapot.git import Git as Git
+from joint_teapot.gitea import Gitea as Gitea
diff --git a/joint_teapot/__main__.py b/joint_teapot/__main__.py
new file mode 100644
index 0000000..619e17d
--- /dev/null
+++ b/joint_teapot/__main__.py
@@ -0,0 +1,11 @@
+from joint_teapot import Canvas, Gitea
+
+
+class Teapot:
+    def __init__(self) -> None:
+        self.canvas = Canvas()
+        self.gitea = Gitea()
+
+
+if __name__ == "__main__":
+    teapot = Teapot()
diff --git a/joint_teapot/canvas.py b/joint_teapot/canvas.py
new file mode 100644
index 0000000..9372bec
--- /dev/null
+++ b/joint_teapot/canvas.py
@@ -0,0 +1,36 @@
+from canvasapi import Canvas as PyCanvas
+from canvasapi.group import Group, GroupMembership
+
+from joint_teapot.config import settings
+
+
+class Canvas:
+    def __init__(
+        self,
+        access_token: str = settings.canvas_access_token,
+        courseID: int = settings.course_id,
+    ):
+        self.canvas = PyCanvas("https://umjicanvas.com/", access_token)
+        self.course = self.canvas.get_course(courseID)
+        self.students = self.course.get_users(
+            enrollment_type=["student"], include=["email"]
+        )
+        self.assignments = self.course.get_assignments()
+        self.groups = self.course.get_groups()
+        # for attr in ["sis_login_id", "sortable_name"]:
+        #     assert hasattr(
+        #         self.students[0], attr
+        #     ), f"Unable to gather students' {attr}, please contact the Canvas site admin"
+        group: Group
+        for group in self.groups:
+            membership: GroupMembership
+            print(group.__dict__)
+            for membership in group.get_memberships():
+                print(membership.user_id, end=", ")
+            print("")
+
+
+if __name__ == "__main__":
+    canvas = Canvas()
+    # for student in canvas.students:
+    #     print(student.__dict__)
diff --git a/joint_teapot/config.py b/joint_teapot/config.py
new file mode 100644
index 0000000..868ac3e
--- /dev/null
+++ b/joint_teapot/config.py
@@ -0,0 +1,35 @@
+from functools import lru_cache
+
+from pydantic import BaseSettings
+
+
+class Settings(BaseSettings):
+    """
+    Define the settings (config).
+
+    The selected value is determined as follows (in descending order of priority):
+    1. The command line arguments, e.g., '--db-host' is mapped to 'db-host'
+    2. Environment variables, e.g., '$DB_HOST' is mapped to 'db-host'
+    3. Variables loaded from a dotenv (.env) file
+    4. The default field values for the Settings model
+    """
+
+    # canvas
+    canvas_access_token: str = ""
+    course_id: int = 0
+
+    # gitea
+    gitea_access_token: str = ""
+    org_name: str = ""
+
+    class Config:
+        env_file = ".env"
+        env_file_encoding = "utf-8"
+
+
+@lru_cache()
+def get_settings() -> Settings:
+    return Settings()
+
+
+settings: Settings = get_settings()
diff --git a/joint_teapot/git.py b/joint_teapot/git.py
new file mode 100644
index 0000000..9dd439a
--- /dev/null
+++ b/joint_teapot/git.py
@@ -0,0 +1,5 @@
+import git
+
+
+class Git:
+    ...
diff --git a/joint_teapot/gitea.py b/joint_teapot/gitea.py
new file mode 100644
index 0000000..c0de92f
--- /dev/null
+++ b/joint_teapot/gitea.py
@@ -0,0 +1,161 @@
+import re
+from enum import Enum
+from functools import lru_cache
+from typing import Any, Callable, Dict, List, Optional
+
+import focs_gitea
+from canvasapi.group import Group, GroupMembership
+from canvasapi.paginated_list import PaginatedList
+from canvasapi.user import User
+
+from joint_teapot.config import settings
+from joint_teapot.utils import first
+
+
+class PermissionEnum(Enum):
+    read = "read"
+    write = "write"
+    admin = "admin"
+
+
+def default_repo_name_convertor(user: User) -> Optional[str]:
+    id, name = user.sis_login_id, user.sortable_name
+    eng = re.sub("[\u4e00-\u9fa5]", "", name)
+    eng = "".join([word[0].capitalize() + word[1:] for word in eng.split()])
+    return f"{eng}{id}"
+
+
+class Gitea:
+    def __init__(
+        self,
+        access_token: str = settings.gitea_access_token,
+        org_name: str = settings.org_name,
+    ):
+        self.org_name = org_name
+        configuration = focs_gitea.Configuration()
+        configuration.api_key["access_token"] = access_token
+        self.api_client = focs_gitea.ApiClient(configuration)
+        self.admin_api = focs_gitea.AdminApi(self.api_client)
+        self.miscellaneous_api = focs_gitea.MiscellaneousApi(self.api_client)
+        self.organization_api = focs_gitea.OrganizationApi(self.api_client)
+        self.issue_api = focs_gitea.IssueApi(self.api_client)
+        self.repository_api = focs_gitea.RepositoryApi(self.api_client)
+        self.settings_api = focs_gitea.SettingsApi(self.api_client)
+        self.user_api = focs_gitea.UserApi(self.api_client)
+
+    @lru_cache
+    def __get_team_id_by_name(self, name: str) -> int:
+        res = self.organization_api.team_search(self.org_name, q=str(name), limit=1)
+        return res["data"][0]["id"]
+
+    @lru_cache
+    def __get_username_by_student_id(self, student_id: str) -> str:
+        res = self.user_api.user_search(q=student_id, limit=1)
+        return res["data"][0]["username"]
+
+    def add_canvas_students_to_teams(
+        self, students: PaginatedList, team_names: List[str]
+    ) -> None:
+        for team_name in team_names:
+            team_id = self.__get_team_id_by_name(team_name)
+            for student in students:
+                username = self.__get_username_by_student_id(student.sis_login_id)
+                self.organization_api.org_add_team_member(team_id, username)
+
+    def create_personal_repos_for_canvas_students(
+        self,
+        students: PaginatedList,
+        repo_name_convertor: Callable[
+            [User], Optional[str]
+        ] = default_repo_name_convertor,
+    ) -> None:
+        for student in students:
+            repo_name = repo_name_convertor(student)
+            repo: Dict[str, Any] = self.organization_api.create_org_repo(
+                self.org_name,
+                body={
+                    "auto_init": False,
+                    "default_branch": "master",
+                    "name": repo_name,
+                    "private": True,
+                    "template": False,
+                    "trust_model": "default",
+                },
+            )
+            self.repository_api.repo_add_collaborator(
+                self.org_name,
+                repo["name"],
+                self.__get_username_by_student_id(student.sis_login_id),
+            )
+
+    def create_teams_and_repos_by_canvas_groups(
+        self,
+        students: PaginatedList,
+        groups: PaginatedList,
+        team_name_convertor: Callable[[str], Optional[str]] = lambda name: name,
+        repo_name_convertor: Callable[[str], Optional[str]] = lambda name: name,
+        permission: PermissionEnum = PermissionEnum.write,
+    ) -> None:
+        group: Group
+        for group in groups:
+            team_name = team_name_convertor(group.name)
+            repo_name = repo_name_convertor(group.name)
+            if team_name is None or repo_name is None:
+                continue
+            team: Dict[str, Any] = self.organization_api.org_create_team(
+                self.org_name,
+                body={
+                    "can_create_org_repo": False,
+                    "includes_all_repositories": False,
+                    "name": team_name,
+                    "permission": permission.value,
+                    "units": [
+                        "repo.code",
+                        "repo.issues",
+                        "repo.ext_issues",
+                        "repo.wiki",
+                        "repo.pulls",
+                        "repo.releases",
+                        "repo.projects",
+                        "repo.ext_wiki",
+                    ],
+                },
+            )
+            repo: Dict[str, Any] = self.organization_api.create_org_repo(
+                self.org_name,
+                body={
+                    "auto_init": False,
+                    "default_branch": "master",
+                    "name": repo_name,
+                    "private": True,
+                    "template": False,
+                    "trust_model": "default",
+                },
+            )
+            self.organization_api.org_add_team_repository(
+                team["id"], self.org_name, repo["name"]
+            )
+            membership: GroupMembership
+            for membership in group.get_memberships():
+                student = first(students, lambda s: s.id == membership.user_id)
+                if student is None:
+                    raise Exception(
+                        f"student with user_id {membership.user_id} not found"
+                    )
+                self.organization_api.org_add_team_member(
+                    team["id"], self.__get_username_by_student_id(student.sis_login_id)
+                )
+
+    def get_public_key_of_students(
+        self, students: PaginatedList
+    ) -> List[List[Dict[str, Any]]]:
+        return [
+            self.user_api.user_list_keys(
+                self.__get_username_by_student_id(student.sis_login_id)
+            )
+            for student in students
+        ]
+
+
+if __name__ == "__main__":
+    gitea = Gitea()
diff --git a/joint_teapot/utils.py b/joint_teapot/utils.py
new file mode 100644
index 0000000..020011d
--- /dev/null
+++ b/joint_teapot/utils.py
@@ -0,0 +1,13 @@
+from typing import Callable, Iterable, Optional, TypeVar
+
+_T = TypeVar("_T")
+
+
+def first(
+    iterable: Iterable[_T], condition: Callable[[_T], bool] = lambda x: True
+) -> Optional[_T]:
+    return next((x for x in iterable if condition(x)), None)
+
+
+if __name__ == "__main__":
+    print(first([1, 2, 3, 4], lambda x: x == 5))
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000..76b517b
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,18 @@
+[mypy]
+plugins = pydantic.mypy
+
+follow_imports = silent
+warn_redundant_casts = True
+warn_unused_ignores = True
+disallow_any_generics = True
+check_untyped_defs = True
+no_implicit_reexport = True
+
+# for strict mypy: (this is the tricky one :-))
+disallow_untyped_defs = True
+
+[pydantic-mypy]
+init_forbid_extra = True
+init_typed = True
+warn_required_dynamic_aliases = True
+warn_untyped_fields = True
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..c24fe5b
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+filterwarnings =
+    ignore::DeprecationWarning
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..946de6c
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,2 @@
+pre-commit>=2.10.1
+pytest>=6.2.2
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..6f89ed8
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+canvasapi>=2.2.0
+focs_gitea>=1.0.0
+pydantic[dotenv]>=1.8.1