├── .github └── workflows │ ├── auto-assign-issues.yml │ ├── make-qa.yml │ └── make-test.yml ├── .gitignore ├── .mypy.ini ├── CHANGELOG.md ├── Dockerfile ├── Makefile ├── README.md ├── dev-requirements.txt ├── docs └── configuration.md ├── gitlab2sentry ├── __init__.py ├── exceptions.py ├── resources.py └── utils │ ├── __init__.py │ ├── gitlab_provider.py │ └── sentry_provider.py ├── helm ├── Chart.yaml ├── templates │ ├── cronjob.yaml │ └── secrets.yaml └── values-production.yaml ├── pyproject.toml ├── renovate.json ├── requirements.txt ├── run.py ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── test_gitlab2sentry.py ├── test_gitlab_provider.py └── test_sentry_provider.py /.github/workflows/auto-assign-issues.yml: -------------------------------------------------------------------------------- 1 | name: Issue assignment 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | auto-assign: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 'Auto-assign issue' 12 | uses: pozil/auto-assign-issue@v1.4.0 13 | with: 14 | assignees: thepetk,Solvik 15 | numOfAssignee: 1 16 | -------------------------------------------------------------------------------- /.github/workflows/make-qa.yml: -------------------------------------------------------------------------------- 1 | name: QA 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Set up Python 3.12 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: "3.12" 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -r dev-requirements.txt 24 | 25 | - name: Run qa 26 | run: make qa 27 | 28 | - name: Run mypy 29 | run: make mypy 30 | -------------------------------------------------------------------------------- /.github/workflows/make-test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Set up Python 3.12 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: "3.12" 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -r dev-requirements.txt 24 | 25 | - name: Run tests 26 | run: make test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /venv/ 3 | __pycache__/ -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.12 3 | mypy_path = gitlab2sentry 4 | 5 | [mypy-pytest] 6 | ignore_missing_imports = True 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.0 (2024-10-08) 2 | 3 | ### Feat 4 | 5 | - **mr-label**: Add label parameter to mr creation 6 | 7 | ### Fix 8 | 9 | - **gitlab_provider**: update member retrieval method to include all members 10 | - **gitlab**: ProjectMemberManager has no attribute "all" 11 | 12 | ### Refactor 13 | 14 | - use pydandic settings to handle configuration 15 | - Update gitlab2sentry/utils/gitlab_provider.py 16 | 17 | ## 1.0.2 (2022-12-06) 18 | 19 | ## 1.0.1 (2022-11-03) 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY requirements.txt requirements.txt 6 | RUN pip3 install -r requirements.txt 7 | 8 | RUN groupadd --gid 1000 appuser \ 9 | && useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser 10 | 11 | USER appuser 12 | COPY gitlab2sentry/ gitlab2sentry/ 13 | COPY run.py run.py 14 | 15 | CMD ["python3", "run.py"] 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # project name 2 | PRJ ?= gitlab2sentry 3 | REG ?= your-registry 4 | NS ?= your-namespace 5 | IMG ?= $(REG)/$(NS)/$(PRJ) 6 | TAG ?= $(shell git describe --tags) 7 | 8 | build: 9 | docker build -t $(IMG):$(TAG) . 10 | 11 | push: build 12 | docker push $(IMG):$(TAG) 13 | 14 | test: 15 | pytest 16 | 17 | qa: 18 | isort --profile black . && black . && flake8 19 | 20 | mypy: 21 | mypy gitlab2sentry/ --config-file .mypy.ini --ignore-missing-imports 22 | 23 | run: 24 | # needed env: GITLAB_TOKEN + SENTRY_TOKEN 25 | python3 gitlab2sentry/run.py 26 | 27 | upgrade: push 28 | helm secrets -d vault -n $(NS) upgrade -f helm/values-production.yaml --set cronjob.imageTag=$(TAG) gitlab2sentry ./helm 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitlab2sentry 2 | 3 | Getting a Sentry project for each of your Gitlab repositories is just one MR merge away! 4 | 5 | Gitlab2Sentry will create a Sentry project associated to each of your Gitlab repositories using a Merge Request based automated workflow. 6 | 7 | Any new Gitlab repository you create will be offered a Sentry project if you accept (merge the proposal MR) it with respect to the Gitlab group owning it! 8 | 9 | ## Two-Steps process 10 | 11 | 1. After creating your new project on Gitlab, `gitlab2sentry` will create a first Merge Request asking if you want it to create an associated Sentry project for it. This Merge Request will contain the creation of a `.sentryclirc` file which, if you merge it, will be contributed back the newly created Sentry project `DSN` for this project. 12 | 13 | 2. If you merged the first Merge Request, `gitlab2sentry` will create a second one to update the newly created `.sentryclirc` file with the `DSN` of the sentry project. Moreover, after the merge of the first Merge Request `gitlabsentry` will create a new `sentry project`, update its rate limit and save the `DSN` inside `.sentryclirc`. Once you have merged this second Merge Request everything will be set up! 14 | 15 | **NOTE**: `Gitlab2Sentry` looks only for group projects and searches for MRs having specific keyword inside (check "Configuration" section) 16 | 17 | ## Run locally 18 | 19 | You can install all requirements for this project with: 20 | 21 | ```bash 22 | python3 -m venv venv 23 | pip3 install -r requirements.txt 24 | source venv/bin/activate 25 | ``` 26 | 27 | After the installation of all requirements you have to: 28 | 29 | ```bash 30 | export SENTRY_URL= 31 | export SENTRY_TOKEN= 32 | export SENTRY_ENV= 33 | export GITLAB_TOKEN= 34 | export GITLAB_URL= 35 | python3 run.py 36 | ``` 37 | 38 | ## Deployment 39 | 40 | We prefer to deploy and manage `gitlab2sentry` with `helm`. Inside `helm/` folder you can find an example deployment. 41 | 42 | You can upgrade your deployment with: 43 | 44 | ```bash 45 | make upgrade 46 | ``` 47 | 48 | ## Configuration 49 | 50 | `Gitlab2Sentry` requires some configuration in 3 specific files. 51 | 52 | **[All configuration variables here](./docs/configuration.md)** 53 | 54 | 1. First of all you have to configure the `helm/values-production.yaml` file where everything is configured for the `gitlab2sentry` service. Here you can find a description for every field: 55 | 56 | ```yaml 57 | # Sentry values 58 | - name: SENTRY_TOKEN 59 | valueFrom: 60 | secretKeyRef: 61 | key: SENTRY_TOKEN 62 | name: gitlab2sentry-production 63 | - name: SENTRY_DSN 64 | value: your-sentry-dsn 65 | - name: SENTRY_URL 66 | value: your-sentry-url 67 | - name: SENTRY_ORG_SLUG 68 | value: your-sentry-organization-slug 69 | # Gitlab values 70 | - name: GITLAB_TOKEN 71 | valueFrom: 72 | secretKeyRef: 73 | key: GITLAB_TOKEN 74 | name: your-secret 75 | - name: GITLAB_URL 76 | value: your-gitlab-url 77 | # DSN MR (1) values 78 | - name: GITLAB_DSN_MR_CONTENT 79 | value: the content of your dsn mr 80 | - name: GITLAB_DSN_MR_DESCRIPTION 81 | value: the description of your dsn mr 82 | - name: GITLAB_DSN_MR_BRANCH_NAME 83 | value: your-branch-name 84 | - name: GITLAB_DSN_MR_TITLE 85 | value: "your-dsn-mr-title" 86 | # Sentryclirc MR (2) values 87 | - name: GITLAB_SENTRYCLIRC_MR_CONTENT 88 | value: your-sentryclirc-mr-content 89 | - name: GITLAB_SENTRYCLIRC_MR_DESCRIPTION 90 | value: your-sentryclirc-mr-description 91 | - name: GITLAB_SENTRYCLIRC_MR_BRANCH_NAME 92 | value: your-sentryclirc-mr-branch-name 93 | - name: GITLAB_SENTRYCLIRC_MR_FILEPATH 94 | value: .sentryclirc 95 | - name: GITLAB_SENTRYCLIRC_MR_COMMIT_MSG 96 | value: your-commit-msg 97 | - name: GITLAB_SENTRYCLIRC_MR_TITLE 98 | value: "your sentryclirc mr title" 99 | # Gitlab configuration values 100 | - name: GITLAB_AUTHOR_NAME 101 | value: author-name 102 | - name: GITLAB_AUTHOR_EMAIL 103 | value: your-author-email 104 | - name: GITLAB_GRAPHQL_SUFFIX 105 | value: api/graphql 106 | # - name: GITLAB_MENTIONS 107 | # value: 108 | # - "@all" 109 | - name: GITLAB_MENTIONS_ACCESS_LEVEL 110 | value: 40 # maintainer 111 | - name: GITLAB_CREATION_DAYS_LIMIT 112 | value: 60 # Max days old per project 113 | - name: GITLAB_MR_KEYWORD 114 | value: sentry # key word for searching mrs 115 | - name: GITLAB_REMOVE_SOURCE 116 | value: true # If the mr will remove the source branch 117 | - name: GITLAB_GROUP_IDENTIFIER 118 | value: your-group-identifier # will look only for group projects having this identifier 119 | - name: GITLAB_AIOHTTP_TIMEOUT 120 | value: 60 121 | - name: GITLAB_GRAPHQL_PAGE_LENGTH 122 | value: 100 123 | - name: GITLAB_MR_LABEL_LIST 124 | value: "sentry,your-label" # comma separated list of labels for the mr 125 | ``` 126 | 127 | 2. If you want to follow the `helm` deployment process you will have to fill your details into the `helm/values-production.yaml` and `helm/Chart.yaml`. 128 | 129 | 3. You can update `REG ?= your-registry` and `NS ?= your-namespace` values inside `Makefile`. 130 | 131 | ## Manual run 132 | 133 | If you want to update a specific project (for example if the project has a very big name or is older than the `GITLAB_CREATION_DAYS_LIMIT` value), you can run the `gitlab2sentry` manually. 134 | 135 | - First, you have to `export` all env variables which are listed above in the `helm/values-production.yaml` file. 136 | 137 | - Next you can run the following commands: 138 | 139 | ```python 140 | >>> from gitlab2sentry import Gitlab2Sentry 141 | >>> g2s = Gitlab2Sentry() 142 | >>> g2s.update(full_path="projects_full_path", custom_name="optional_custom_name") 143 | ``` 144 | 145 | ## Contributions & comments welcomed 146 | 147 | Numberly decided to Open Source this project because it saves a lot of time internally to all our developers and helped foster the mass adoption of Sentry in all our Tech teams. We hope this project can benefit someone else. 148 | 149 | Feel free to ask questions, suggest improvements and of course contribute features or fixes you might need! 150 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | black 4 | flake8 5 | isort 6 | mock 7 | mypy 8 | pytest 9 | pytest-mock 10 | types-python-slugify 11 | types-PyYAML 12 | types-requests 13 | pytest-env==1.1.5 14 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration Guide 2 | 3 | This application uses `pydantic`'s `BaseSettings` for configuration, which allows you to set and override parameters using environment variables. Below, you'll find a list of all the configuration options and the expected environment variables. Each configuration setting has a default value, but you can easily override them to suit your deployment needs. 4 | 5 | To configure the application, set the following environment variables: 6 | 7 | | Environment Variable | Description | Default Value | 8 | | ------------------------------- | -------------------------------------------------- | ----------------------------- | 9 | | `DSN_BRANCH_NAME` | Branch name for DSN changes | `auto_add_sentry_dsn` | 10 | | `DSN_MR_CONTENT` | Merge request content for DSN | Custom template (see code) | 11 | | `DSN_MR_DESCRIPTION` | Description for DSN-related merge request | Custom template (see code) | 12 | | `DSN_MR_TITLE` | Title for DSN-related merge request | `[gitlab2sentry] Merge me...` | 13 | | `ENV` | The environment the application is running in | `production` | 14 | | `GITLAB_AUTHOR_EMAIL` | GitLab author email for merge requests | `default-email@example.com` | 15 | | `GITLAB_AUTHOR_NAME` | GitLab author name for merge requests | `Default Author` | 16 | | `GITLAB_GRAPHQL_PAGE_LENGTH` | Page length for GitLab GraphQL queries | `0` | 17 | | `GITLAB_GRAPHQL_SUFFIX` | Suffix for GitLab GraphQL queries | `default-content` | 18 | | `GITLAB_GRAPHQL_TIMEOUT` | Timeout for GitLab GraphQL queries (in seconds) | `10` | 19 | | `GITLAB_GROUP_IDENTIFIER` | Group identifier for GitLab projects | Empty string | 20 | | `GITLAB_MENTIONS_ACCESS_LEVEL` | Access level to mention users in GitLab MRs | `40` | 21 | | `GITLAB_MENTIONS` | GitLab usernames to mention | Empty string | 22 | | `GITLAB_MR_KEYWORD` | Keyword to include in GitLab merge requests | `sentry` | 23 | | `GITLAB_MR_LABEL_LIST` | Labels to assign to GitLab merge requests | `['sentry']` | 24 | | `GITLAB_PROJECT_CREATION_LIMIT` | Limit for creating GitLab projects | `30` | 25 | | `GITLAB_RMV_SRC_BRANCH` | Remove source branch after merge request | `True` | 26 | | `GITLAB_SIGNED_COMMIT` | Whether to use signed commits in GitLab | `False` | 27 | | `GITLAB_TOKEN` | GitLab access token | `default-token` | 28 | | `GITLAB_URL` | Base URL for GitLab service | `http://default-gitlab-url` | 29 | | `SENTRYCLIRC_BRANCH_NAME` | Branch name for Sentry CLI configuration changes | `auto_add_sentry` | 30 | | `SENTRYCLIRC_COM_MSG` | Commit message for `.sentryclirc` update | `Update .sentryclirc` | 31 | | `SENTRYCLIRC_FILEPATH` | Filepath for `.sentryclirc` configuration | `.sentryclirc` | 32 | | `SENTRYCLIRC_MR_CONTENT` | Merge request content for Sentry CLI configuration | Custom template (see code) | 33 | | `SENTRYCLIRC_MR_DESCRIPTION` | Description for Sentry CLI configuration MR | Custom template (see code) | 34 | | `SENTRYCLIRC_MR_TITLE` | Title for Sentry CLI configuration MR | `[gitlab2sentry] Merge me...` | 35 | | `SENTRY_DSN` | Sentry DSN for monitoring | `http://default.sentry.com` | 36 | | `SENTRY_ENV` | Sentry environment name | `production` | 37 | | `SENTRY_ORG_SLUG` | Organization slug for Sentry | `default_org` | 38 | | `SENTRY_TOKEN` | Authentication token for Sentry | `default-token` | 39 | | `SENTRY_URL` | Base URL for Sentry service | `http://default-sentry-url` | 40 | 41 | To override any configuration, simply set the respective environment variable before running the application. For instance: 42 | 43 | ```sh 44 | export SENTRY_DSN="http://your.custom.sentry.dsn" 45 | export GITLAB_URL="http://your.gitlab.url" 46 | ``` 47 | -------------------------------------------------------------------------------- /gitlab2sentry/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from datetime import datetime, timedelta 4 | from typing import Any, Dict, List, Optional 5 | 6 | from slugify import slugify 7 | 8 | from gitlab2sentry.exceptions import SentryProjectCreationFailed 9 | from gitlab2sentry.resources import ( 10 | G2S_STATS, 11 | GRAPHQL_FETCH_PROJECT_QUERY, 12 | GRAPHQL_LIST_PROJECTS_QUERY, 13 | G2SProject, 14 | settings, 15 | ) 16 | from gitlab2sentry.utils import GitlabProvider, SentryProvider 17 | 18 | logging.basicConfig( 19 | level=logging.INFO, 20 | format="%(asctime)s %(name)s %(levelname)s: %(message)s", 21 | datefmt="%Y-%m-%d %H:%M:%S", 22 | ) 23 | 24 | 25 | class Gitlab2Sentry: 26 | def __init__(self): 27 | self.gitlab_provider = self._get_gitlab_provider() 28 | self.sentry_provider = self._get_sentry_provider() 29 | self.run_stats = {key: value for key, value in G2S_STATS} 30 | self.yesterday = datetime.utcnow() - timedelta(hours=24) 31 | self.sentry_groups = set() 32 | 33 | def __str__(self) -> str: 34 | return "" 35 | 36 | def _get_gitlab_provider(self) -> GitlabProvider: 37 | return GitlabProvider(settings.gitlab_url, settings.gitlab_token) 38 | 39 | def _get_sentry_provider(self) -> SentryProvider: 40 | return SentryProvider( 41 | settings.sentry_url, settings.sentry_token, settings.sentry_org_slug 42 | ) 43 | 44 | def _ensure_sentry_group(self, name: str) -> None: 45 | if name not in self.sentry_groups: 46 | self.sentry_provider.ensure_sentry_team(name) 47 | self.sentry_groups.add(name) 48 | 49 | def _has_mrs_enabled(self, g2s_project: G2SProject) -> bool: 50 | if not g2s_project.mrs_enabled: 51 | logging.info( 52 | "{}: [Skipping] Project {} - Does not accept MRs.".format( 53 | self.__str__(), g2s_project.full_path 54 | ) 55 | ) 56 | self.run_stats["mr_disabled"] += 1 57 | return g2s_project.mrs_enabled 58 | 59 | def _is_opened_mr(self, full_path: str, state: Optional[str], label: str) -> bool: 60 | if state and state == "opened": 61 | logging.info( 62 | "{}: [Skipping] Project {} - Has a pending {} MR.".format( 63 | self.__str__(), full_path, label 64 | ) 65 | ) 66 | self.run_stats[f"mr_{label}_waiting"] += 1 67 | return True 68 | else: 69 | return False 70 | 71 | def _opened_dsn_mr_found(self, g2s_project: G2SProject) -> bool: 72 | return self._is_opened_mr( 73 | g2s_project.full_path, g2s_project.dsn_mr_state, "dsn" 74 | ) 75 | 76 | def _opened_sentryclirc_mr_found(self, g2s_project: G2SProject) -> bool: 77 | return self._is_opened_mr( 78 | g2s_project.full_path, 79 | g2s_project.sentryclirc_mr_state, 80 | "sentryclirc", 81 | ) 82 | 83 | def _is_closed_mr(self, full_path: str, state: Optional[str], label: str) -> bool: 84 | if state and state == "closed": 85 | logging.info( 86 | "{}: [Skipping] Project {} - Has a closed {} MR.".format( 87 | self.__str__(), full_path, label 88 | ) 89 | ) 90 | self.run_stats[f"mr_{label}_closed"] += 1 91 | 92 | return True 93 | else: 94 | return False 95 | 96 | def _closed_dsn_mr_found(self, g2s_project: G2SProject) -> bool: 97 | return self._is_closed_mr( 98 | g2s_project.full_path, g2s_project.dsn_mr_state, "dsn" 99 | ) 100 | 101 | def _closed_sentryclirc_mr_found(self, g2s_project: G2SProject) -> bool: 102 | return self._is_closed_mr( 103 | g2s_project.full_path, 104 | g2s_project.sentryclirc_mr_state, 105 | "sentryclirc", 106 | ) 107 | 108 | def _get_mr_states( 109 | self, project_name: str, mr_list: Optional[List[Dict[str, Any]]] 110 | ) -> tuple: 111 | sentryclirc_mr_state, dsn_mr_state = None, None 112 | if mr_list: 113 | for mr in mr_list: 114 | if mr["title"] == settings.sentryclirc_mr_title.format( 115 | project_name=project_name 116 | ): 117 | if not (sentryclirc_mr_state and sentryclirc_mr_state == "opened"): 118 | sentryclirc_mr_state = mr["state"] 119 | elif mr["title"] == settings.dsn_mr_title.format( 120 | project_name=project_name 121 | ): 122 | if not (dsn_mr_state and dsn_mr_state == "opened"): 123 | dsn_mr_state = mr["state"] 124 | else: 125 | pass 126 | return sentryclirc_mr_state, dsn_mr_state 127 | 128 | def _is_group_project(self, group: Optional[Dict[str, Any]]) -> bool: 129 | if group and group.get("name"): 130 | return True 131 | else: 132 | return False 133 | 134 | def _get_sentryclirc_file(self, blob: List[Dict[str, Any]]) -> tuple: 135 | has_sentryclirc_file, has_dsn = False, False 136 | if blob and blob[0]["name"] == settings.sentryclirc_filepath: 137 | has_sentryclirc_file = True 138 | if blob[0].get("rawTextBlob"): 139 | for line in blob[0]["rawTextBlob"].split("\n"): 140 | if line.startswith("dsn"): 141 | has_dsn = True 142 | 143 | return has_sentryclirc_file, has_dsn 144 | 145 | def _has_already_sentry(self, g2s_project: G2SProject) -> bool: 146 | if g2s_project.has_sentryclirc_file and g2s_project.has_dsn: 147 | logging.info( 148 | "{}: [Skipping] Project {} - Has a sentry project.".format( 149 | self.__str__(), g2s_project.full_path 150 | ) 151 | ) 152 | return g2s_project.has_sentryclirc_file and g2s_project.has_dsn 153 | 154 | def _get_g2s_project(self, result: Dict[str, Any]) -> Optional[G2SProject]: 155 | if result.get("repository"): 156 | full_path = result["fullPath"] 157 | group_name = full_path.split("/")[0] 158 | project_name = result["name"] 159 | created_at = result["createdAt"] 160 | mrs_enabled = result["mergeRequestsEnabled"] 161 | id_url = result["id"].split("/")[len(result["id"].split("/")) - 1] 162 | sentryclirc_mr_state, dsn_mr_state = self._get_mr_states( 163 | result["name"], result["mergeRequests"]["nodes"] 164 | ) 165 | has_sentryclirc_file, has_dsn = self._get_sentryclirc_file( 166 | result["repository"]["blobs"]["nodes"] 167 | ) 168 | name_with_namespace = "{} / {}".format(group_name, project_name) 169 | pid = int(id_url.split("/")[len(id_url.split("/")) - 1]) 170 | return G2SProject( 171 | pid, 172 | full_path, 173 | project_name, 174 | group_name, 175 | mrs_enabled, 176 | created_at, 177 | name_with_namespace, 178 | has_sentryclirc_file, 179 | has_dsn, 180 | sentryclirc_mr_state, 181 | dsn_mr_state, 182 | ) 183 | return None 184 | 185 | def _get_paginated_projects(self) -> List[Dict[str, Any]]: 186 | query_start_time = time.time() 187 | logging.info( 188 | "{}: Starting querying all Gitlab group-projects with Graphql at {}/{}".format( # noqa 189 | self.__str__(), settings.gitlab_url, settings.gitlab_graphql_suffix 190 | ) 191 | ) 192 | request_gen = self.gitlab_provider.get_all_projects(GRAPHQL_LIST_PROJECTS_QUERY) 193 | page_results = [page for page in request_gen] 194 | logging.info( 195 | "{}: Fetched {} pages. Total time: {} seconds".format( 196 | self.__str__(), 197 | len(page_results), 198 | round(time.time() - query_start_time, 2), 199 | ) 200 | ) 201 | return page_results 202 | 203 | def _get_gitlab_project(self, full_path: str) -> Optional[G2SProject]: 204 | GRAPHQL_FETCH_PROJECT_QUERY["full_path"] = full_path 205 | logging.info( 206 | "{}: Starting querying for specific Gitlab project with Graphql at {}/{}".format( # noqa 207 | self.__str__(), settings.gitlab_url, settings.gitlab_graphql_suffix 208 | ) 209 | ) 210 | result = self.gitlab_provider.get_project(GRAPHQL_FETCH_PROJECT_QUERY) 211 | return ( 212 | self._get_g2s_project(result.get("project")) 213 | if result.get("project") 214 | else None 215 | ) 216 | 217 | def _get_gitlab_groups(self): 218 | groups = dict() 219 | valid_projects = 0 220 | for page_result in self._get_paginated_projects(): 221 | for result_node in page_result: 222 | result = result_node["node"] 223 | if self._is_group_project(result["group"]): 224 | group_name = result["fullPath"].split("/")[0] 225 | if group_name.startswith(settings.gitlab_group_identifier): 226 | g2s_project = self._get_g2s_project(result) 227 | 228 | if g2s_project: 229 | if not groups.get(group_name): 230 | groups[group_name] = list() 231 | groups[group_name].append(g2s_project) 232 | valid_projects += 1 233 | logging.info( 234 | "{}: Total filtered projects: {}".format(self.__str__(), valid_projects) 235 | ) 236 | return groups 237 | 238 | def _create_sentry_project( 239 | self, 240 | full_path: str, 241 | sentry_group_name: str, 242 | sentry_project_name: str, 243 | sentry_project_slug: str, 244 | ) -> Optional[Dict[str, Any]]: 245 | try: 246 | return self.sentry_provider.get_or_create_project( 247 | sentry_group_name, 248 | sentry_project_name, 249 | sentry_project_slug, 250 | ) 251 | except SentryProjectCreationFailed as creation_err: 252 | logging.error( 253 | "{} Project {} - Failed to create sentry project: {}".format( 254 | self.__str__(), full_path, str(creation_err) 255 | ) 256 | ) 257 | except Exception as err: 258 | logging.warning( 259 | "{} Project {} - Failed to get/create its sentry project: {}".format( 260 | self.__str__(), full_path, str(err) 261 | ) 262 | ) 263 | return None 264 | 265 | def _handle_g2s_project( 266 | self, 267 | g2s_project: G2SProject, 268 | sentry_group_name: str, 269 | custom_name: Optional[str] = None, 270 | ) -> bool: 271 | """ 272 | Creates sentry project for all given gitlab projects. It 273 | follows a two steps process. 274 | 1. Adds the .sentryclirc file to the gitlab repo (via 275 | a mergeRequest). 276 | 2. If the .sentryclirc file is added to master branch ( 277 | or another default branch) it creates the sentry 278 | project and it inserts the dsn inside the .sentryclirc 279 | file. 280 | The flow for creating mrs inlcudes specific cases for creating 281 | or skiping. These cases are: 282 | 1. Project is already in sentry [skip] 283 | 2. Project has MRs disabled [skip] 284 | 3. Project has an opened dsn MR and a .sentryclirc file. 285 | This means that the second MR is pending [skip] 286 | 4. If the .sentryclirc file exists and there is no 287 | opened MR for dsn, creates the dsn MR [create] 288 | 5. If the project has no .sentryclirc file but it 289 | has an MR (closed or opened) [skip] 290 | 6. Project has no .sentryclirc file and no MR for 291 | this. Create the sentryclirc file [create] 292 | """ 293 | if self._has_already_sentry(g2s_project): 294 | return False 295 | 296 | if not self._has_mrs_enabled(g2s_project): 297 | return False 298 | # Case sentryclirc found but 299 | # dsn not found: Pending MR 300 | elif g2s_project.has_sentryclirc_file and not g2s_project.has_dsn: 301 | if self._opened_dsn_mr_found(g2s_project) or self._closed_dsn_mr_found( 302 | g2s_project 303 | ): 304 | return False 305 | else: 306 | 307 | sentry_project_name = ( 308 | custom_name 309 | if custom_name 310 | else "-".join(g2s_project.full_path.split("/")[1:]) 311 | ) 312 | sentry_project_slug = slugify(sentry_project_name).lower() 313 | sentry_project = self._create_sentry_project( 314 | g2s_project.full_path, 315 | sentry_group_name, 316 | sentry_project_name, 317 | sentry_project_slug, 318 | ) 319 | 320 | # If Sentry fails to create project skip 321 | if not sentry_project: 322 | return False 323 | 324 | dsn = self.sentry_provider.set_rate_limit_for_key( 325 | sentry_project["slug"] 326 | ) 327 | 328 | # If fetch of dsn failed skip 329 | if not dsn: 330 | return False 331 | 332 | mr_created = self.gitlab_provider.create_dsn_mr( 333 | g2s_project, dsn, sentry_project_slug 334 | ) 335 | if mr_created: 336 | self.run_stats["mr_dsn_created"] += 1 337 | return True 338 | # Case sentryclirc not found: 339 | # Declined sentryclirc MR or 340 | # need to create one 341 | elif not g2s_project.has_sentryclirc_file: 342 | if self._opened_sentryclirc_mr_found( 343 | g2s_project 344 | ) or self._closed_sentryclirc_mr_found(g2s_project): 345 | return False 346 | else: 347 | mr_created = self.gitlab_provider.create_sentryclirc_mr(g2s_project) 348 | if mr_created: 349 | self.run_stats["mr_sentryclirc_created"] += 1 350 | return True 351 | else: 352 | logging.info( 353 | "{}: Project {} - Not included in Gitlab2Sentry cases".format( 354 | self.__str__(), g2s_project.full_path 355 | ) 356 | ) 357 | self.run_stats["not_in_g2s_cases"] += 1 358 | return False 359 | 360 | def update( 361 | self, full_path: Optional[str] = None, custom_name: Optional[str] = None 362 | ) -> None: 363 | """ 364 | args: full_path 365 | description: Full path of project (e.g. my-team/my-project) 366 | 367 | args: custom_name 368 | description: Specifies a custom name for the project. It only 369 | works if the full_path is specified 370 | 371 | If the fullPath of a specific project is given it will run 372 | the script only for this project. 373 | 374 | If no full_path is provided it will run the script. If 375 | creation_days_limit is provided it will fetch all projects 376 | created after this period. If no it will fetch every project 377 | """ 378 | if full_path: 379 | g2s_project = self._get_gitlab_project(full_path) 380 | if g2s_project: 381 | sentry_group_name = g2s_project.group.split("/")[0].strip() 382 | self._handle_g2s_project( 383 | g2s_project, sentry_group_name, custom_name # type: ignore 384 | ) 385 | else: 386 | logging.info( 387 | "{}: Project with fullPath - {} not found".format( 388 | self.__str__(), full_path 389 | ) 390 | ) 391 | # If no kwarg is given fetch all 392 | else: 393 | groups = self._get_gitlab_groups() 394 | 395 | for group_name in groups.keys(): 396 | sentry_group_name = group_name.split("/")[0].strip() 397 | self._ensure_sentry_group(sentry_group_name) 398 | for g2s_project in groups[group_name]: 399 | # Skip if sentry is installed or 400 | # Project has disabled MRs 401 | self._handle_g2s_project( 402 | g2s_project, sentry_group_name # type: ignore 403 | ) 404 | for key in self.run_stats.keys(): 405 | logging.info( 406 | "{}: RESULTS - {}: {}".format(self.__str__(), key, self.run_stats[key]) 407 | ) 408 | -------------------------------------------------------------------------------- /gitlab2sentry/exceptions.py: -------------------------------------------------------------------------------- 1 | class SentryProjectCreationFailed(Exception): 2 | pass 3 | 4 | 5 | class SentryProjectKeyIDNotFound(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /gitlab2sentry/resources.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from typing import List, Tuple 3 | 4 | from pydantic import Field 5 | from pydantic_settings import BaseSettings 6 | 7 | 8 | class Settings(BaseSettings): 9 | dsn_branch_name: str = Field("auto_add_sentry_dsn") 10 | dsn_mr_content: str = Field( 11 | """ 12 | ## File generated by gitlab2sentry 13 | [defaults] 14 | url = {sentry_url} 15 | dsn = {dsn} 16 | project = {project_slug} 17 | """ 18 | ) 19 | dsn_mr_description: str = Field( 20 | """{mentions} Congrats, your Sentry project has been created, merge this to finalize your Sentry integration of {name_with_namespace} :clap: :cookie:""" # noqa 21 | ) 22 | dsn_mr_title: str = Field( 23 | "[gitlab2sentry] Merge me to add your Sentry DSN to {project_name}" 24 | ) 25 | env: str = Field("production") 26 | gitlab_author_email: str = Field("default-email@example.com") 27 | gitlab_author_name: str = Field("Default Author") 28 | gitlab_graphql_page_length: int = Field(0) 29 | gitlab_graphql_suffix: str = Field("default-content") 30 | gitlab_graphql_timeout: int = Field(10) 31 | gitlab_group_identifier: str = Field("") 32 | gitlab_mentions: str = Field("", examples=["@foo,@bar"]) 33 | gitlab_mentions_access_level: int = Field(40) 34 | gitlab_mr_keyword: str = Field("sentry") 35 | gitlab_mr_label_list: List[str] = Field(["sentry"]) 36 | gitlab_project_creation_limit: int = Field(30) 37 | gitlab_rmv_src_branch: bool = Field(True) 38 | gitlab_signed_commit: bool = Field(False) 39 | gitlab_token: str = Field("default-token") 40 | gitlab_url: str = Field("http://default-gitlab-url") 41 | sentry_dsn: str = Field("http://default.sentry.com") 42 | sentry_env: str = Field("production") 43 | sentry_org_slug: str = Field("default_org") 44 | sentry_token: str = Field("default-token") 45 | sentry_url: str = Field("http://default-sentry-url") 46 | sentryclirc_branch_name: str = Field("auto_add_sentry") 47 | sentryclirc_com_msg: str = Field("Update .sentryclirc") 48 | sentryclirc_filepath: str = Field(".sentryclirc") 49 | sentryclirc_mr_content: str = Field( 50 | """ 51 | ## File generated by gitlab2sentry 52 | [defaults] 53 | url = {sentry_url} 54 | """ 55 | ) 56 | sentryclirc_mr_description: str = Field( 57 | """{mentions} Merge this and it will automatically create a Sentry project for {name_with_namespace} :cookie:""" # noqa 58 | ) 59 | sentryclirc_mr_title: str = Field( 60 | """"[gitlab2sentry] Merge me to add Sentry to {project_name} or close me""" 61 | ) 62 | 63 | 64 | settings = Settings() # type: ignore 65 | 66 | # G2SProject namedtuple configuration 67 | G2SProject = namedtuple( 68 | "G2SProject", 69 | [ 70 | "pid", 71 | "full_path", 72 | "name", 73 | "group", 74 | "mrs_enabled", 75 | "created_at", 76 | "name_with_namespace", 77 | "has_sentryclirc_file", 78 | "has_dsn", 79 | "sentryclirc_mr_state", 80 | "dsn_mr_state", 81 | ], 82 | ) 83 | 84 | # Statistics configuration 85 | G2S_STATS: List[Tuple[str, int]] = [ 86 | ("not_in_g2s_cases", 0), 87 | ("mr_sentryclirc_waiting", 0), 88 | ("mr_dsn_waiting", 0), 89 | ("mr_disabled", 0), 90 | ("mr_sentryclirc_created", 0), 91 | ("mr_dsn_created", 0), 92 | ("mr_sentryclirc_closed", 0), 93 | ("mr_dsn_closed", 0), 94 | ] 95 | 96 | # GraphQL Queries. 97 | GRAPHQL_LIST_PROJECTS_QUERY = { 98 | "name": "PROJECTS_QUERY", 99 | "instance": "projects", 100 | "body": """ 101 | { 102 | projects%s { 103 | edges { 104 | node { 105 | id 106 | fullPath 107 | name 108 | createdAt 109 | mergeRequestsEnabled 110 | group { 111 | name 112 | } 113 | repository { 114 | blobs%s { 115 | nodes { 116 | name 117 | rawTextBlob 118 | } 119 | } 120 | } 121 | mergeRequests%s { 122 | nodes { 123 | id 124 | title 125 | state 126 | } 127 | } 128 | } 129 | } 130 | pageInfo { 131 | endCursor 132 | hasNextPage 133 | } 134 | } 135 | } 136 | """, 137 | } 138 | 139 | GRAPHQL_FETCH_PROJECT_QUERY = { 140 | "name": "PROJECTS_QUERY", 141 | "instance": "projects", 142 | "body": """ 143 | { 144 | project(fullPath: "%s") { 145 | id 146 | fullPath 147 | name 148 | createdAt 149 | mergeRequestsEnabled 150 | group { 151 | name 152 | } 153 | repository { 154 | blobs%s { 155 | nodes { 156 | name 157 | rawTextBlob 158 | } 159 | } 160 | } 161 | mergeRequests%s { 162 | nodes { 163 | id 164 | title 165 | state 166 | } 167 | } 168 | } 169 | } 170 | """, 171 | } 172 | -------------------------------------------------------------------------------- /gitlab2sentry/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .gitlab_provider import * # noqa 2 | from .sentry_provider import * # noqa 3 | -------------------------------------------------------------------------------- /gitlab2sentry/utils/gitlab_provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from datetime import datetime, timedelta 4 | from typing import Any, Dict, Generator, Optional 5 | 6 | import aiohttp 7 | from gitlab import Gitlab 8 | from gitlab.exceptions import GitlabGetError 9 | from gitlab.v4.objects import Project 10 | from gql import Client, gql 11 | from gql.transport.aiohttp import AIOHTTPTransport 12 | from gql.transport.aiohttp import log as websockets_logger 13 | 14 | from gitlab2sentry.resources import G2SProject, settings 15 | 16 | 17 | class GraphQLClient: 18 | def __init__( 19 | self, 20 | url: Optional[str] = settings.gitlab_url, 21 | token: Optional[str] = settings.gitlab_token, 22 | ): 23 | self._client = Client( 24 | transport=self._get_transport(url, token), 25 | fetch_schema_from_transport=True, 26 | execute_timeout=settings.gitlab_graphql_timeout, 27 | ) 28 | websockets_logger.setLevel(logging.WARNING) 29 | 30 | def __str__(self) -> str: 31 | return "" 32 | 33 | def _get_transport( 34 | self, url: Optional[str], token: Optional[str] 35 | ) -> AIOHTTPTransport: 36 | return AIOHTTPTransport( 37 | url="{}/{}".format(url, settings.gitlab_graphql_suffix), 38 | headers={ 39 | "PRIVATE-TOKEN": token, # type: ignore 40 | "Content-Type": "application/json", 41 | }, 42 | ) 43 | 44 | def _query(self, name: str, query: str) -> Dict[str, Any]: 45 | try: 46 | start_time = time.time() 47 | result = self._client.execute(gql(query)) 48 | logging.info( 49 | "{}: Query {} execution_time: {}s".format( # noqa 50 | self.__str__(), name, round(time.time() - start_time, 2) 51 | ) 52 | ) 53 | return result 54 | except aiohttp.client_exceptions.ClientResponseError: 55 | logging.warning("{}: Query {} - Returned 404".format(self.__str__(), name)) 56 | return {} 57 | 58 | def project_fetch_query(self, query_dict: Dict[str, str]) -> Dict[str, Any]: 59 | project_full_path = f"{query_dict['full_path']}" 60 | blobsPaths = '(paths: "{}")'.format(settings.sentryclirc_filepath) 61 | titlesListMRs = '(sourceBranches: ["{}","{}"])'.format( 62 | settings.sentryclirc_branch_name, settings.dsn_branch_name 63 | ) 64 | query = query_dict["body"] % (project_full_path, blobsPaths, titlesListMRs) 65 | return self._query(query_dict["name"], query) 66 | 67 | def project_list_query( 68 | self, query_dict: Dict[str, str], endCursor: str 69 | ) -> Dict[str, Any]: 70 | whereStatement = ' searchNamespaces: true sort: "createdAt_desc"' 71 | edgesStatement = "(first: {}{}{})".format( 72 | settings.gitlab_graphql_page_length, 73 | f' after: "{endCursor}"' if endCursor else "", 74 | whereStatement, 75 | ) 76 | blobsPaths = '(paths: "{}")'.format(settings.sentryclirc_filepath) 77 | titlesListMRs = '(sourceBranches: ["{}","{}"])'.format( 78 | settings.sentryclirc_branch_name, settings.dsn_branch_name 79 | ) 80 | query = query_dict["body"] % (edgesStatement, blobsPaths, titlesListMRs) 81 | return self._query(query_dict["name"], query) 82 | 83 | 84 | class GitlabProvider: 85 | def __init__( 86 | self, 87 | url: Optional[str] = settings.gitlab_url, 88 | token: Optional[str] = settings.gitlab_token, 89 | ) -> None: 90 | self.gitlab = self._get_gitlab(url, token) 91 | self._gql_client = GraphQLClient(url, token) 92 | self.update_limit = self._get_update_limit() 93 | 94 | def __str__(self) -> str: 95 | return "" 96 | 97 | def _get_gitlab(self, url: Optional[str], token: Optional[str]) -> Gitlab: 98 | gitlab = Gitlab(url, private_token=token) 99 | if settings.env != "test": 100 | gitlab.auth() 101 | return gitlab 102 | 103 | def _get_update_limit(self) -> Optional[datetime]: 104 | if settings.gitlab_project_creation_limit: 105 | return datetime.now() - timedelta( 106 | days=settings.gitlab_project_creation_limit 107 | ) 108 | else: 109 | return None 110 | 111 | def _from_iso_to_datetime(self, datetime_str: str) -> datetime: 112 | return datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%SZ") 113 | 114 | def get_project(self, query: Dict[str, Any]): 115 | return self._gql_client.project_fetch_query(query) 116 | 117 | def get_all_projects(self, query: Dict[str, Any], endCursor: str = "") -> Generator: 118 | while True: 119 | result = self._gql_client.project_list_query(query, endCursor) 120 | if ( 121 | result 122 | and result.get(query["instance"], None) 123 | and result[query["instance"]].get("edges", None) 124 | and len(result[query["instance"]]["edges"]) 125 | ): 126 | result_nodes = result[query["instance"]]["edges"] 127 | # Check the last item of the ordered list to se its creation 128 | createdAt = self._from_iso_to_datetime( 129 | result_nodes[len(result_nodes) - 1]["node"]["createdAt"] 130 | ) 131 | if self.update_limit and createdAt < self.update_limit: 132 | yield [ 133 | node 134 | for node in result_nodes 135 | if self._from_iso_to_datetime(node["node"]["createdAt"]) 136 | >= self.update_limit 137 | ] 138 | break 139 | else: 140 | yield result_nodes 141 | if ( 142 | result 143 | and result.get(query["instance"], None) 144 | and result[query["instance"]].get("pageInfo", None) 145 | and result[query["instance"]]["pageInfo"].get("endCursor", None) 146 | and result[query["instance"]]["pageInfo"]["endCursor"] 147 | ): 148 | endCursor = result[query["instance"]]["pageInfo"]["endCursor"] 149 | if not ( 150 | result 151 | and result[query["instance"]].get("pageInfo") 152 | and result[query["instance"]]["pageInfo"].get("hasNextPage") 153 | ): 154 | break 155 | 156 | def _get_or_create_branch(self, branch_name: str, project: Project) -> None: 157 | try: 158 | project.branches.get(branch_name) 159 | logging.warning( 160 | "{}: Branch {} already exists, deleting".format( 161 | self.__str__(), branch_name 162 | ) 163 | ) 164 | project.branches.delete(branch_name) 165 | except GitlabGetError: 166 | pass 167 | 168 | project.branches.create({"branch": branch_name, "ref": project.default_branch}) 169 | 170 | def _get_or_create_sentryclirc( 171 | self, 172 | project: Project, 173 | full_path: str, 174 | branch_name: str, 175 | file_path: str, 176 | content: str, 177 | ) -> None: 178 | try: 179 | f = project.files.get(file_path=file_path, ref=project.default_branch) 180 | f.content = content 181 | f.save(branch=branch_name, commit_message=settings.sentryclirc_com_msg) 182 | except GitlabGetError: 183 | logging.info( 184 | "{}: [Creating] Project {} - File not found for project {}.".format( 185 | self.__str__(), 186 | settings.sentryclirc_filepath, 187 | full_path, 188 | ) 189 | ) 190 | data = { 191 | "author_email": settings.gitlab_author_email, 192 | "author_name": settings.gitlab_author_name, 193 | "branch": branch_name, 194 | "commit_message": settings.sentryclirc_com_msg, 195 | "content": content, 196 | "file_path": file_path, 197 | } 198 | # When commit signing is enabled in GitLab (e.g. via pre-hook), 199 | # commit requires that the author information matches the signer identity 200 | # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/150855 201 | if settings.gitlab_signed_commit: 202 | data.pop("author_email") 203 | data.pop("author_name") 204 | f = project.files.create(data=data) 205 | 206 | def _get_default_mentions(self, project: Project) -> str: 207 | return ", ".join( 208 | [ 209 | f"@{member.username}" 210 | for member in project.members_all.list(iterator=True) 211 | if ( 212 | member.access_level >= settings.gitlab_mentions_access_level 213 | and member.state != "blocked" 214 | ) 215 | ] 216 | ) 217 | 218 | def _get_mr_description( 219 | self, project: Project, msg: str, name_with_namespace: str 220 | ) -> str: 221 | mentions = ( 222 | self._get_default_mentions(project) 223 | if not settings.gitlab_mentions 224 | else ", ".join(settings.gitlab_mentions) 225 | ) 226 | return "\n".join( 227 | [ 228 | line.format( 229 | mentions=mentions, 230 | name_with_namespace=name_with_namespace, 231 | ) 232 | for line in msg.split("\n") 233 | ] 234 | ) 235 | 236 | def _create_mr( 237 | self, 238 | g2s_project: G2SProject, 239 | branch_name: str, 240 | file_path: str, 241 | content: str, 242 | title: str, 243 | ) -> bool: 244 | try: 245 | project = self.gitlab.projects.get(g2s_project.pid) 246 | self._get_or_create_branch(branch_name, project) 247 | self._get_or_create_sentryclirc( 248 | project, g2s_project.full_path, branch_name, file_path, content 249 | ) 250 | project.mergerequests.create( 251 | { 252 | "description": self._get_mr_description( 253 | project, 254 | settings.sentryclirc_mr_description, 255 | g2s_project.name_with_namespace, 256 | ), 257 | "remove_source_branch": settings.gitlab_rmv_src_branch, 258 | "source_branch": branch_name, 259 | "target_branch": project.default_branch, 260 | "title": title, 261 | "labels": settings.gitlab_mr_label_list, 262 | } 263 | ) 264 | return True 265 | except Exception as err: 266 | logging.warning( 267 | "{}: Project {} - Failed to create MR ({}): {}".format( 268 | self.__str__(), 269 | g2s_project.full_path, 270 | branch_name, 271 | str(err), 272 | ) 273 | ) 274 | return False 275 | 276 | def create_sentryclirc_mr(self, g2s_project: G2SProject) -> bool: 277 | logging.info( 278 | "{}: [Creating] Project {} - Needs sentry .sentryclirc MR.".format( 279 | self.__str__(), g2s_project.full_path 280 | ) 281 | ) 282 | return self._create_mr( 283 | g2s_project, 284 | settings.sentryclirc_branch_name, 285 | settings.sentryclirc_filepath, 286 | settings.sentryclirc_mr_content.format(sentry_url=settings.sentry_url), 287 | settings.sentryclirc_mr_title.format(project_name=g2s_project.name), 288 | ) 289 | 290 | def create_dsn_mr( 291 | self, g2s_project: G2SProject, dsn: str, project_slug: str 292 | ) -> bool: 293 | logging.info( 294 | "{}: [Creating] Project {} - Sentry dsn: {}. Needs dsn MR.".format( 295 | self.__str__(), g2s_project.full_path, dsn 296 | ) 297 | ) 298 | return self._create_mr( 299 | g2s_project, 300 | settings.dsn_branch_name, 301 | settings.sentryclirc_filepath, 302 | settings.dsn_mr_content.format( 303 | sentry_url=settings.sentry_url, dsn=dsn, project_slug=project_slug 304 | ), 305 | settings.dsn_mr_title.format(project_name=g2s_project.name), 306 | ) 307 | -------------------------------------------------------------------------------- /gitlab2sentry/utils/sentry_provider.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Any, Dict, Optional, Tuple 4 | 5 | import requests 6 | from requests import Response 7 | from slugify import slugify 8 | 9 | from gitlab2sentry.exceptions import ( 10 | SentryProjectCreationFailed, 11 | SentryProjectKeyIDNotFound, 12 | ) 13 | from gitlab2sentry.resources import settings 14 | 15 | 16 | class SentryAPIClient: 17 | def __init__( 18 | self, 19 | base_url: Optional[str] = settings.sentry_url, 20 | token: Optional[str] = settings.sentry_token, 21 | ): 22 | self.base_url = base_url 23 | self.url = "{}/api/0/{}" 24 | self.headers = {"Authorization": f"Bearer {token}"} 25 | 26 | def __str__(self) -> str: 27 | return "" 28 | 29 | def _get_json(self, response: Response) -> Tuple[int, Any]: 30 | try: 31 | return response.status_code, response.json() 32 | except json.JSONDecodeError as json_error: 33 | logging.warning( 34 | "{}: Error on request suffix: {}".format( 35 | self.__str__(), str(json_error) 36 | ) 37 | ) 38 | return 400, None 39 | 40 | def simple_request( 41 | self, 42 | method: str, 43 | suffix: Optional[str] = None, 44 | data: Optional[Dict[str, Any]] = None, 45 | json_format: bool = False, 46 | ) -> Tuple[int, Any]: 47 | url = self.url.format(self.base_url, suffix) 48 | logging.debug("{} simple {} request to {}".format(self.__str__(), method, url)) 49 | if method == "post": 50 | return self._get_json(requests.post(url, data=data, headers=self.headers)) 51 | elif method == "put": 52 | if json_format: 53 | return self._get_json( 54 | requests.put(url, json=data, headers=self.headers) 55 | ) 56 | return self._get_json(requests.put(url, data=data, headers=self.headers)) 57 | else: 58 | return self._get_json(requests.get(url, headers=self.headers)) 59 | 60 | 61 | class SentryProvider: 62 | def __init__( 63 | self, 64 | url: Optional[str] = settings.sentry_url, 65 | token: Optional[str] = settings.sentry_token, 66 | org_slug: Optional[str] = settings.sentry_org_slug, 67 | ): 68 | self.url = url 69 | self.org_slug = org_slug 70 | self._client = SentryAPIClient(url, token) 71 | 72 | def __str__(self) -> str: 73 | return "" 74 | 75 | def _get_or_create_team(self, team_name: str) -> Optional[Dict[str, Any]]: 76 | team_slug = slugify(team_name) 77 | status_code, result = self._client.simple_request( 78 | "get", "teams/{}/{}/".format(self.org_slug, team_slug) 79 | ) 80 | 81 | if status_code != 200: 82 | return self._client.simple_request( 83 | "post", 84 | "organizations/{}/teams/".format(self.org_slug), 85 | { 86 | "name": team_name, 87 | "slug": team_slug, 88 | }, 89 | )[1] 90 | 91 | if status_code != 201: 92 | return None 93 | 94 | logging.info("{}: Team {} created!".format(self.__str__(), team_name)) 95 | return result 96 | 97 | def get_or_create_project( 98 | self, group_name: str, project_name: str, project_slug: str 99 | ) -> Optional[Dict[str, Any]]: 100 | 101 | status_code, result = self._client.simple_request( 102 | "get", "projects/{}/{}/".format(self.org_slug, project_slug) 103 | ) 104 | # Create if project not found 105 | if status_code == 404: 106 | status_code, result = self._client.simple_request( 107 | "post", 108 | "teams/{}/{}/projects/".format(self.org_slug, group_name), 109 | { 110 | "name": project_name, 111 | "slug": project_slug, 112 | }, 113 | ) 114 | 115 | if status_code == 201: 116 | logging.info( 117 | "{}: [Creating] Sentry project {}".format(self.__str__(), project_name) 118 | ) 119 | elif status_code == 200: 120 | logging.info( 121 | "{}: [Skipping] Sentry project {} exists".format( 122 | self.__str__(), project_name 123 | ) 124 | ) 125 | else: 126 | raise SentryProjectCreationFailed(result) 127 | 128 | return result 129 | 130 | def _get_dsn_and_key_id(self, project_slug: str) -> tuple: 131 | status_code, result = self._client.simple_request( 132 | "get", 133 | "projects/{}/{}/keys/".format(self.org_slug, project_slug), 134 | ) 135 | 136 | if status_code != 200: 137 | return None, None 138 | if ( 139 | result 140 | and len(result) > 0 141 | and result[0].get("dsn", None) 142 | and result[0]["dsn"].get("public", None) 143 | and result[0].get("id", None) 144 | ): 145 | return result[0]["dsn"]["public"], result[0]["id"] 146 | else: 147 | raise SentryProjectKeyIDNotFound(result) 148 | 149 | def set_rate_limit_for_key(self, project_slug: str) -> Optional[str]: 150 | try: 151 | dsn, key = self._get_dsn_and_key_id(project_slug) 152 | status_code, result = self._client.simple_request( 153 | "put", 154 | "projects/{}/{}/keys/{}/".format(self.org_slug, project_slug, key), 155 | {"rateLimit": {"window": 60, "count": 300}}, 156 | json_format=True, 157 | ) 158 | except SentryProjectKeyIDNotFound as key_id_err: 159 | logging.warning( 160 | "{}: Project {} - Sentry key id not found: {}".format( 161 | self.__str__(), 162 | project_slug, 163 | key_id_err, 164 | ) 165 | ) 166 | return None 167 | 168 | if status_code != 200: 169 | return None 170 | return dsn 171 | 172 | def ensure_sentry_team(self, team_name: str) -> bool: 173 | logging.info( 174 | "{}: Ensuring team {} exists on sentry".format(self.__str__(), team_name) 175 | ) 176 | if self._get_or_create_team(team_name): 177 | return True 178 | else: 179 | return False 180 | -------------------------------------------------------------------------------- /helm/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: gitlab2sentry 3 | version: 1.0.1 4 | description: gitlab2sentry helm chart 5 | keywords: 6 | - gitlab 7 | - sentry 8 | sources: 9 | - your-project-source 10 | maintainers: 11 | - name: name-of-the-author 12 | email: email-of-the-author 13 | engine: gotpl 14 | -------------------------------------------------------------------------------- /helm/templates/cronjob.yaml: -------------------------------------------------------------------------------- 1 | {{- range .Values.cronjob.jobs -}} 2 | apiVersion: batch/v1beta1 3 | kind: CronJob 4 | metadata: 5 | name: "{{ .name }}" 6 | {{- if $.Values.global.namespace }} 7 | namespace: "{{ $.Values.global.namespace }}" 8 | {{- else }} 9 | namespace: "{{ $.Chart.Name }}-{{ $.Values.global.env }}" 10 | {{- end }} 11 | annotations: 12 | chart: "{{ $.Chart.Name }}-{{ $.Chart.Version }}" 13 | spec: 14 | schedule: "{{ .schedule }}" 15 | concurrencyPolicy: "{{ $.Values.cronjob.concurrencyPolicy }}" 16 | successfulJobsHistoryLimit: {{ $.Values.cronjob.successfulJobsHistoryLimit }} 17 | failedJobsHistoryLimit: {{ $.Values.cronjob.failedJobsHistoryLimit }} 18 | jobTemplate: 19 | spec: 20 | activeDeadlineSeconds: {{ $.Values.cronjob.activeDeadlineSeconds }} 21 | template: 22 | spec: 23 | {{- if $.Values.cronjob.securityContext }} 24 | securityContext: 25 | {{ toYaml $.Values.cronjob.securityContext | indent 12 }} 26 | {{- end }} 27 | {{- if $.Values.volumes }} 28 | volumes: 29 | {{ toYaml $.Values.volumes | indent 12 }} 30 | {{- end }} 31 | containers: 32 | - name: {{ .name }} 33 | image: "{{ required "The image name must be defined" $.Values.cronjob.image }}:{{ required "The image tag must be defined" $.Values.cronjob.imageTag }}" 34 | imagePullPolicy: "IfNotPresent" 35 | volumeMounts: {{ toYaml .volumeMounts | nindent 14 }} 36 | {{- if .command }} 37 | command: 38 | - {{ .command }} 39 | {{- end }} 40 | {{- range $arg := .args }} 41 | args: 42 | - {{ $arg }} 43 | {{- end }} 44 | {{- if .env }} 45 | env: 46 | {{ toYaml .env | indent 14 }} 47 | {{- end }} 48 | restartPolicy: OnFailure 49 | --- 50 | {{- end -}} 51 | -------------------------------------------------------------------------------- /helm/templates/secrets.yaml: -------------------------------------------------------------------------------- 1 | {{- $name := .Chart.Name -}} 2 | {{- $env := required "You forgot to set env, don't you ?" .Values.global.env -}} 3 | apiVersion: v1 4 | kind: Secret 5 | type: Opaque 6 | metadata: 7 | name: {{ $name }}-{{ $env }} 8 | namespace: {{ .Values.namespace }} 9 | labels: 10 | app.kubernetes.io/managed-by: "helm" 11 | meta.helm.sh/release-name: "gitlab2sentry" 12 | meta.helm.sh/release-namespace: "team-infrastructure" 13 | data: 14 | GITLAB_TOKEN: {{ .Values.secret.gitlab_token | b64enc }} 15 | SENTRY_TOKEN: {{ .Values.secret.sentry_token | b64enc }} -------------------------------------------------------------------------------- /helm/values-production.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | env: production 3 | app: gitlab2sentry 4 | namespace: team-infrastructure 5 | 6 | secret: 7 | gitlab_token: your-vault-gitlab-token-path 8 | sentry_token: your-vault-sentry-token-path 9 | 10 | cronjob: 11 | image: "your-image-registry-path" 12 | imagePullPolicy: "IfNotPresent" 13 | successfulJobsHistoryLimit: 5 14 | failedJobsHistoryLimit: 3 15 | activeDeadlineSeconds: 800 16 | concurrencyPolicy: "Forbid" 17 | startingDeadlineSeconds: 10 18 | securityContext: 19 | runAsUser: your-user-id 20 | jobs: 21 | - name: 'gitlab2sentry' 22 | schedule: your-crontab-schedule 23 | env: 24 | # Sentry values 25 | - name: SENTRY_TOKEN 26 | valueFrom: 27 | secretKeyRef: 28 | key: SENTRY_TOKEN 29 | name: gitlab2sentry-production 30 | - name: SENTRY_DSN 31 | value: your-sentry-dsn 32 | - name: SENTRY_URL 33 | value: your-sentry-url 34 | - name: SENTRY_ORG_SLUG 35 | value: your-sentry-organization-slug 36 | # Gitlab values 37 | - name: GITLAB_TOKEN 38 | valueFrom: 39 | secretKeyRef: 40 | key: GITLAB_TOKEN 41 | name: gitlab2sentry-production 42 | - name: GITLAB_URL 43 | value: your-gitlab-url 44 | # DSN MR (1) values 45 | - name: GITLAB_DSN_MR_CONTENT 46 | value: | 47 | ## File generated by gitlab2sentry 48 | [defaults] 49 | url = {sentry_url} 50 | dsn = {dsn} 51 | project = {project_slug} 52 | - name: GITLAB_DSN_MR_DESCRIPTION 53 | value: | 54 | {mentions} Congrats, your Sentry project has been 55 | created, merge this 56 | to finalize your Sentry integration of 57 | {name_with_namespace} :clap: :cookie: 58 | - name: GITLAB_DSN_MR_BRANCH_NAME 59 | value: auto_add_sentry_dsn 60 | - name: GITLAB_DSN_MR_TITLE 61 | value: "[gitlab2sentry] Merge me to add your sentry DSN to {project_name}" 62 | # Sentryclirc MR (2) values 63 | - name: GITLAB_SENTRYCLIRC_MR_CONTENT 64 | value: | 65 | ## File generated by gitlab2sentry 66 | [defaults] 67 | url = {sentry_url} 68 | - name: GITLAB_SENTRYCLIRC_MR_DESCRIPTION 69 | value: | 70 | {mentions} Merge this and it will automatically 71 | create a Sentry project \n 72 | for {name_with_namespace} :cookie: 73 | - name: GITLAB_SENTRYCLIRC_MR_BRANCH_NAME 74 | value: auto_add_sentry 75 | - name: GITLAB_SENTRYCLIRC_MR_FILEPATH 76 | value: .sentryclirc 77 | - name: GITLAB_SENTRYCLIRC_MR_COMMIT_MSG 78 | value: Update .sentryclirc 79 | - name: GITLAB_SENTRYCLIRC_MR_TITLE 80 | value: "[gitlab2sentry] Merge me to add sentry to {project_name} or close me" 81 | # Gitlab configuration values 82 | - name: GITLAB_AUTHOR_NAME 83 | value: gitlab2sentry 84 | - name: GITLAB_AUTHOR_EMAIL 85 | value: your-author-email 86 | - name: GITLAB_GRAPHQL_SUFFIX 87 | value: api/graphql 88 | # - name: GITLAB_MENTIONS 89 | # value: 90 | # - "@all" 91 | - name: GITLAB_MENTIONS_ACCESS_LEVEL 92 | value: 40 93 | - name: GITLAB_CREATION_DAYS_LIMIT 94 | value: 60 95 | - name: GITLAB_MR_KEYWORD 96 | value: sentry 97 | - name: GITLAB_REMOVE_SOURCE 98 | value: true 99 | - name: GITLAB_GROUP_IDENTIFIER 100 | value: your-group-identifier 101 | - name: GITLAB_AIOHTTP_TIMEOUT 102 | value: 60 103 | - name: GITLAB_GRAPHQL_PAGE_LENGTH 104 | value: 100 105 | - name: GITLAB_MR_LABEL_LIST 106 | value: "sentry,gitlab2sentry" # comma separated list 107 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.commitizen] 2 | name = "cz_conventional_commits" 3 | tag_format = "$version" 4 | version_scheme = "semver" 5 | version = "1.1.0" 6 | update_changelog_on_bump = true 7 | 8 | [tool.pytest_env] 9 | ENV = "test" 10 | SENTRY_ENV = "test" 11 | DSN_MR_CONTENT = """ 12 | ## File generated by gitlab2sentry 13 | [defaults] 14 | url = {sentry_url} 15 | dsn = {dsn} 16 | project = {project_slug} 17 | """ 18 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>team-infrastructure/renovate-configs", 5 | "local>team-infrastructure/renovate-configs:regroupUpdate" 6 | ] 7 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.10.5 2 | awesome-slugify==1.6.5 3 | gql==3.5.0 4 | pydantic-settings==2.5.2 5 | pydantic==2.9.2 6 | python-gitlab==4.10.0 7 | pytz==2022.1 8 | requests==2.32.3 9 | sentry-sdk==2.14.0 10 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | 3 | from gitlab2sentry import Gitlab2Sentry 4 | from gitlab2sentry.resources import SENTRY_DSN, SENTRY_ENV 5 | 6 | if __name__ == "__main__": 7 | sentry_sdk.init( # type: ignore 8 | debug=False, 9 | dsn=SENTRY_DSN, 10 | environment=SENTRY_ENV, 11 | ) 12 | runner = Gitlab2Sentry() 13 | runner.update() 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | ignore = E125,E129,W503,W504 4 | exclude = venv/,.git/ 5 | 6 | [isort] 7 | line_length = 88 8 | indent=' ' 9 | multi_line_output = 3 10 | skip = venv/,.git/ 11 | known_first_party = api,tests 12 | include_trailing_comma = True -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numberly/gitlab2sentry/fda2a53ee3ff85ed08821bc4182789cdc46b480c/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | import pytz 5 | 6 | from gitlab2sentry import Gitlab2Sentry 7 | from gitlab2sentry.resources import G2SProject, settings 8 | from gitlab2sentry.utils.gitlab_provider import GitlabProvider, GraphQLClient 9 | from gitlab2sentry.utils.sentry_provider import SentryProvider 10 | 11 | TEST_PROJECT_NAME = "test" 12 | TEST_GROUP_NAME = f"{settings.gitlab_group_identifier}test" 13 | CURRENT_TIME = datetime.strftime(datetime.now(pytz.UTC), "%Y-%m-%dT%H:%M:%SZ") 14 | OLD_TIME = datetime.strftime( 15 | ( 16 | datetime.now(pytz.UTC) 17 | - timedelta(days=(settings.gitlab_project_creation_limit + 1)) 18 | ), 19 | "%Y-%m-%dT%H:%M:%SZ", 20 | ) 21 | 22 | 23 | @pytest.fixture 24 | def g2s_fixture(): 25 | yield Gitlab2Sentry() 26 | 27 | 28 | @pytest.fixture 29 | def gql_client_fixture(): 30 | yield GraphQLClient() 31 | 32 | 33 | @pytest.fixture 34 | def gitlab_provider_fixture(): 35 | yield GitlabProvider() 36 | 37 | 38 | @pytest.fixture 39 | def sentry_provider_fixture(): 40 | yield SentryProvider() 41 | 42 | 43 | class TestGitlabMember: 44 | def __init__(self, username, access_level, state): 45 | self.username = username 46 | self.access_level = access_level 47 | self.state = state 48 | 49 | 50 | TEST_GITLAB_PROJECT_MEMBERS = [ 51 | TestGitlabMember("active_user", 40, "active"), 52 | TestGitlabMember("blocked_user", 40, "blocked"), 53 | ] 54 | 55 | 56 | class TestGitlabProject: 57 | def __init__(self): 58 | self.members_all = TestGitlabMemberManager() 59 | 60 | 61 | class TestGitlabMemberManager: 62 | def list(self, *args, **kwargs): 63 | return TEST_GITLAB_PROJECT_MEMBERS 64 | 65 | 66 | @pytest.fixture 67 | def gitlab_project_fixture(): 68 | yield TestGitlabProject() 69 | 70 | 71 | def create_test_g2s_project(**kwargs): 72 | return G2SProject( 73 | 1, 74 | f"{TEST_GROUP_NAME}/{TEST_PROJECT_NAME}", 75 | TEST_PROJECT_NAME, 76 | TEST_GROUP_NAME, 77 | kwargs["mrs_enabled"], 78 | CURRENT_TIME, 79 | f"{TEST_GROUP_NAME} / {TEST_PROJECT_NAME}", 80 | kwargs["has_sentryclirc_file"], 81 | kwargs["has_dsn"], 82 | kwargs["sentryclirc_mr_state"], 83 | kwargs["dsn_mr_state"], 84 | ) 85 | 86 | 87 | def create_graphql_json_object(**kwargs): 88 | response_dict = { 89 | "node": { 90 | "id": "gid://gitlab/Project/0001", 91 | "fullPath": f"{TEST_GROUP_NAME}/{TEST_PROJECT_NAME}", 92 | "name": TEST_PROJECT_NAME, 93 | "mergeRequestsEnabled": kwargs["mrs_enabled"], 94 | "group": {"name": kwargs.get("group", TEST_GROUP_NAME)}, 95 | "mergeRequests": {"nodes": []}, 96 | } 97 | } 98 | response_dict["node"]["createdAt"] = kwargs.get("created_at", CURRENT_TIME) 99 | if not kwargs.get("no_repository", False): 100 | response_dict["node"]["repository"] = {"blobs": {"nodes": []}} 101 | 102 | if kwargs["has_sentryclirc_file"]: 103 | if kwargs["has_dsn"]: 104 | blob_item = { 105 | "name": settings.sentryclirc_filepath, 106 | "rawTextBlob": settings.dsn_mr_content.format( 107 | sentry_url=settings.sentry_url, 108 | dsn=settings.sentry_dsn, 109 | project_slug=TEST_PROJECT_NAME, 110 | ), 111 | } 112 | else: 113 | blob_item = { 114 | "name": settings.sentryclirc_filepath, 115 | "rawTextBlob": settings.sentryclirc_mr_content.format( 116 | sentry_url=settings.sentry_url 117 | ), 118 | } 119 | response_dict["node"]["repository"]["blobs"]["nodes"].append(blob_item) 120 | 121 | if kwargs["sentryclirc_mr_state"]: 122 | sentryclirc_mr = { 123 | "id": "gid://gitlab/MergeRequest/0001", 124 | "title": settings.sentryclirc_mr_title.format( 125 | project_name=response_dict["node"]["name"] 126 | ), 127 | "state": kwargs["sentryclirc_mr_state"], 128 | } 129 | response_dict["node"]["mergeRequests"]["nodes"].append(sentryclirc_mr) 130 | 131 | if kwargs["dsn_mr_state"]: 132 | dsn_mr = { 133 | "id": "gid://gitlab/MergeRequest/0001", 134 | "title": settings.dsn_mr_title.format( 135 | project_name=response_dict["node"]["name"] 136 | ), 137 | "state": kwargs["dsn_mr_state"], 138 | } 139 | response_dict["node"]["mergeRequests"]["nodes"].append(dsn_mr) 140 | return response_dict 141 | 142 | 143 | @pytest.fixture 144 | def g2s_new_project(): 145 | yield create_test_g2s_project( 146 | mrs_enabled=True, 147 | has_sentryclirc_file=False, 148 | has_dsn=False, 149 | sentryclirc_mr_state=None, 150 | dsn_mr_state=None, 151 | ) 152 | 153 | 154 | @pytest.fixture 155 | def g2s_disabled_mr_project(): 156 | yield create_test_g2s_project( 157 | mrs_enabled=False, 158 | has_sentryclirc_file=False, 159 | has_dsn=False, 160 | sentryclirc_mr_state=None, 161 | dsn_mr_state=None, 162 | ) 163 | 164 | 165 | @pytest.fixture 166 | def g2s_sentryclirc_mr_closed_project(): 167 | yield create_test_g2s_project( 168 | mrs_enabled=True, 169 | has_sentryclirc_file=False, 170 | has_dsn=False, 171 | sentryclirc_mr_state="closed", 172 | dsn_mr_state=None, 173 | ) 174 | 175 | 176 | @pytest.fixture 177 | def g2s_sentryclirc_mr_open_project(): 178 | yield create_test_g2s_project( 179 | mrs_enabled=True, 180 | has_sentryclirc_file=False, 181 | has_dsn=False, 182 | sentryclirc_mr_state="opened", 183 | dsn_mr_state=None, 184 | ) 185 | 186 | 187 | @pytest.fixture 188 | def g2s_sentryclirc_mr_merged_project(): 189 | yield create_test_g2s_project( 190 | mrs_enabled=True, 191 | has_sentryclirc_file=True, 192 | has_dsn=False, 193 | sentryclirc_mr_state="merged", 194 | dsn_mr_state=None, 195 | ) 196 | 197 | 198 | @pytest.fixture 199 | def g2s_dsn_mr_open_project(): 200 | yield create_test_g2s_project( 201 | mrs_enabled=True, 202 | has_sentryclirc_file=True, 203 | has_dsn=False, 204 | sentryclirc_mr_state="merged", 205 | dsn_mr_state="opened", 206 | ) 207 | 208 | 209 | @pytest.fixture 210 | def g2s_dsn_mr_closed_project(): 211 | yield create_test_g2s_project( 212 | mrs_enabled=True, 213 | has_sentryclirc_file=True, 214 | has_dsn=False, 215 | sentryclirc_mr_state="merged", 216 | dsn_mr_state="closed", 217 | ) 218 | 219 | 220 | @pytest.fixture 221 | def g2s_sentry_project(): 222 | yield create_test_g2s_project( 223 | mrs_enabled=True, 224 | has_sentryclirc_file=True, 225 | has_dsn=True, 226 | sentryclirc_mr_state="merged", 227 | dsn_mr_state="merged", 228 | ) 229 | 230 | 231 | @pytest.fixture 232 | def payload_new_project(): 233 | yield create_graphql_json_object( 234 | mrs_enabled=True, 235 | has_sentryclirc_file=False, 236 | has_dsn=False, 237 | sentryclirc_mr_state=None, 238 | dsn_mr_state=None, 239 | ) 240 | 241 | 242 | @pytest.fixture 243 | def payload_old_project(): 244 | yield create_graphql_json_object( 245 | mrs_enabled=True, 246 | has_sentryclirc_file=False, 247 | has_dsn=False, 248 | sentryclirc_mr_state=None, 249 | dsn_mr_state=None, 250 | created_at=OLD_TIME, 251 | ) 252 | 253 | 254 | @pytest.fixture 255 | def payload_mrs_disabled_project(): 256 | yield create_graphql_json_object( 257 | mrs_enabled=False, 258 | has_sentryclirc_file=False, 259 | has_dsn=False, 260 | sentryclirc_mr_state=None, 261 | dsn_mr_state=None, 262 | ) 263 | 264 | 265 | @pytest.fixture 266 | def payload_no_group_project(): 267 | yield create_graphql_json_object( 268 | mrs_enabled=True, 269 | group=None, 270 | has_sentryclirc_file=False, 271 | has_dsn=False, 272 | sentryclirc_mr_state=None, 273 | dsn_mr_state=None, 274 | ) 275 | 276 | 277 | @pytest.fixture 278 | def payload_no_repository_project(): 279 | yield create_graphql_json_object( 280 | mrs_enabled=True, 281 | no_repository=True, 282 | has_sentryclirc_file=False, 283 | has_dsn=False, 284 | sentryclirc_mr_state=None, 285 | dsn_mr_state=None, 286 | ) 287 | 288 | 289 | @pytest.fixture 290 | def payload_sentryclirc_mr_open_project(): 291 | yield create_graphql_json_object( 292 | mrs_enabled=True, 293 | has_sentryclirc_file=False, 294 | has_dsn=False, 295 | sentryclirc_mr_state="opened", 296 | dsn_mr_state=None, 297 | ) 298 | 299 | 300 | @pytest.fixture 301 | def payload_sentryclirc_mr_closed_project(): 302 | yield create_graphql_json_object( 303 | mrs_enabled=True, 304 | has_sentryclirc_file=False, 305 | has_dsn=False, 306 | sentryclirc_mr_state="closed", 307 | dsn_mr_state=None, 308 | ) 309 | 310 | 311 | @pytest.fixture 312 | def payload_sentryclirc_mr_merged_project(): 313 | yield create_graphql_json_object( 314 | mrs_enabled=True, 315 | has_sentryclirc_file=True, 316 | has_dsn=False, 317 | sentryclirc_mr_state="merged", 318 | dsn_mr_state=None, 319 | ) 320 | 321 | 322 | @pytest.fixture 323 | def payload_dsn_mr_open_project(): 324 | yield create_graphql_json_object( 325 | mrs_enabled=True, 326 | has_sentryclirc_file=True, 327 | has_dsn=False, 328 | sentryclirc_mr_state="merged", 329 | dsn_mr_state="opened", 330 | ) 331 | 332 | 333 | @pytest.fixture 334 | def payload_dsn_mr_closed_project(): 335 | yield create_graphql_json_object( 336 | mrs_enabled=True, 337 | has_sentryclirc_file=True, 338 | has_dsn=False, 339 | sentryclirc_mr_state="merged", 340 | dsn_mr_state="closed", 341 | ) 342 | 343 | 344 | @pytest.fixture 345 | def payload_sentry_project(): 346 | yield create_graphql_json_object( 347 | mrs_enabled=True, 348 | has_sentryclirc_file=True, 349 | has_dsn=True, 350 | sentryclirc_mr_state="merged", 351 | dsn_mr_state="merged", 352 | ) 353 | 354 | GRAPHQL_TEST_QUERY = { 355 | "name": "TEST_QUERY", 356 | "instance": "projects", 357 | "body": """ 358 | { 359 | project(fullPath: "none") { 360 | id 361 | fullPath 362 | name 363 | createdAt 364 | mergeRequestsEnabled 365 | group { 366 | name 367 | } 368 | repository { 369 | blobs { 370 | nodes { 371 | name 372 | rawTextBlob 373 | } 374 | } 375 | } 376 | mergeRequests { 377 | nodes { 378 | id 379 | title 380 | state 381 | } 382 | } 383 | } 384 | } 385 | """, 386 | } 387 | -------------------------------------------------------------------------------- /tests/test_gitlab2sentry.py: -------------------------------------------------------------------------------- 1 | from gitlab2sentry.exceptions import SentryProjectCreationFailed 2 | from gitlab2sentry.resources import settings 3 | from gitlab2sentry.utils import GitlabProvider, SentryProvider 4 | from tests.conftest import TEST_GROUP_NAME 5 | 6 | 7 | def test_get_gitlab_provider(g2s_fixture): 8 | assert isinstance(g2s_fixture._get_gitlab_provider(), GitlabProvider) 9 | 10 | 11 | def test_get_sentry_provider(g2s_fixture): 12 | assert isinstance(g2s_fixture._get_sentry_provider(), SentryProvider) 13 | 14 | 15 | def test_ensure_sentry_group(mocker, g2s_fixture): 16 | mocker.patch.object( 17 | g2s_fixture.sentry_provider, attribute="ensure_sentry_team", return_values=None 18 | ) 19 | g2s_fixture._ensure_sentry_group(TEST_GROUP_NAME) 20 | assert TEST_GROUP_NAME in g2s_fixture.sentry_groups 21 | 22 | 23 | def test_has_mrs_enabled(g2s_fixture, g2s_new_project, g2s_disabled_mr_project): 24 | g2s_fixture.run_stats["mr_disabled"] = 0 25 | assert ( 26 | g2s_fixture._has_mrs_enabled(g2s_new_project) 27 | and not g2s_fixture._has_mrs_enabled(g2s_disabled_mr_project) 28 | and g2s_fixture.run_stats["mr_disabled"] == 1 29 | ) 30 | 31 | 32 | def test_opened_dsn_mr_found(g2s_fixture, g2s_new_project, g2s_dsn_mr_open_project): 33 | g2s_fixture.run_stats["mr_dsn_waiting"] = 0 34 | assert ( 35 | g2s_fixture._opened_dsn_mr_found(g2s_dsn_mr_open_project) 36 | and not g2s_fixture._opened_dsn_mr_found(g2s_new_project) 37 | and g2s_fixture.run_stats["mr_dsn_waiting"] == 1 38 | ) 39 | 40 | 41 | def test_opened_sentryclirc_mr_found( 42 | g2s_fixture, g2s_new_project, g2s_sentryclirc_mr_open_project 43 | ): 44 | g2s_fixture.run_stats["mr_sentryclirc_waiting"] = 0 45 | assert ( 46 | g2s_fixture._opened_sentryclirc_mr_found(g2s_sentryclirc_mr_open_project) 47 | and not g2s_fixture._opened_sentryclirc_mr_found(g2s_new_project) 48 | and g2s_fixture.run_stats["mr_sentryclirc_waiting"] == 1 49 | ) 50 | 51 | 52 | def test_closed_dsn_mr_found(g2s_fixture, g2s_new_project, g2s_dsn_mr_closed_project): 53 | g2s_fixture.run_stats["mr_dsn_closed"] = 0 54 | assert ( 55 | g2s_fixture._closed_dsn_mr_found(g2s_dsn_mr_closed_project) 56 | and not g2s_fixture._closed_dsn_mr_found(g2s_new_project) 57 | and g2s_fixture.run_stats["mr_dsn_closed"] == 1 58 | ) 59 | 60 | 61 | def test_closed_sentryclirc_mr_found( 62 | g2s_fixture, g2s_new_project, g2s_sentryclirc_mr_closed_project 63 | ): 64 | g2s_fixture.run_stats["mr_sentryclirc_closed"] = 0 65 | assert ( 66 | g2s_fixture._closed_sentryclirc_mr_found(g2s_sentryclirc_mr_closed_project) 67 | and not g2s_fixture._closed_sentryclirc_mr_found(g2s_new_project) 68 | and g2s_fixture.run_stats["mr_sentryclirc_closed"] == 1 69 | ) 70 | 71 | 72 | def test_get_mr_states( 73 | g2s_fixture, 74 | payload_new_project, 75 | payload_mrs_disabled_project, 76 | payload_sentryclirc_mr_open_project, 77 | payload_sentryclirc_mr_closed_project, 78 | payload_sentryclirc_mr_merged_project, 79 | payload_dsn_mr_open_project, 80 | payload_dsn_mr_closed_project, 81 | payload_sentry_project, 82 | ): 83 | assert g2s_fixture._get_mr_states( 84 | payload_new_project["node"]["name"], 85 | payload_new_project["node"]["mergeRequests"]["nodes"], 86 | ) == (None, None) 87 | 88 | assert g2s_fixture._get_mr_states( 89 | payload_sentryclirc_mr_open_project["node"]["name"], 90 | payload_sentryclirc_mr_open_project["node"]["mergeRequests"]["nodes"], 91 | ) == ("opened", None) 92 | 93 | assert g2s_fixture._get_mr_states( 94 | payload_mrs_disabled_project["node"]["name"], 95 | payload_mrs_disabled_project["node"]["mergeRequests"]["nodes"], 96 | ) == (None, None) 97 | 98 | assert g2s_fixture._get_mr_states( 99 | payload_sentryclirc_mr_closed_project["node"]["name"], 100 | payload_sentryclirc_mr_closed_project["node"]["mergeRequests"]["nodes"], 101 | ) == ("closed", None) 102 | 103 | assert g2s_fixture._get_mr_states( 104 | payload_sentryclirc_mr_merged_project["node"]["name"], 105 | payload_sentryclirc_mr_merged_project["node"]["mergeRequests"]["nodes"], 106 | ) == ("merged", None) 107 | 108 | assert g2s_fixture._get_mr_states( 109 | payload_dsn_mr_open_project["node"]["name"], 110 | payload_dsn_mr_open_project["node"]["mergeRequests"]["nodes"], 111 | ) == ("merged", "opened") 112 | 113 | assert g2s_fixture._get_mr_states( 114 | payload_dsn_mr_closed_project["node"]["name"], 115 | payload_dsn_mr_closed_project["node"]["mergeRequests"]["nodes"], 116 | ) == ("merged", "closed") 117 | 118 | assert g2s_fixture._get_mr_states( 119 | payload_sentry_project["node"]["name"], 120 | payload_sentry_project["node"]["mergeRequests"]["nodes"], 121 | ) == ("merged", "merged") 122 | 123 | 124 | def test_is_group_project(g2s_fixture, payload_no_group_project, payload_new_project): 125 | assert g2s_fixture._is_group_project( 126 | payload_new_project["node"]["group"] 127 | ) and not g2s_fixture._is_group_project(payload_no_group_project["node"]["group"]) 128 | 129 | 130 | def test_get_sentryclirc_file( 131 | g2s_fixture, 132 | payload_new_project, 133 | payload_sentryclirc_mr_merged_project, 134 | payload_sentry_project, 135 | ): 136 | assert g2s_fixture._get_sentryclirc_file( 137 | payload_new_project["node"]["repository"]["blobs"]["nodes"], 138 | ) == (False, False) 139 | 140 | assert g2s_fixture._get_sentryclirc_file( 141 | payload_sentryclirc_mr_merged_project["node"]["repository"]["blobs"]["nodes"], 142 | ) == (True, False) 143 | 144 | assert g2s_fixture._get_sentryclirc_file( 145 | payload_sentry_project["node"]["repository"]["blobs"]["nodes"], 146 | ) == (True, True) 147 | 148 | 149 | def test_has_already_sentry( 150 | g2s_fixture, 151 | g2s_new_project, 152 | g2s_sentry_project, 153 | ): 154 | assert g2s_fixture._has_already_sentry( 155 | g2s_sentry_project 156 | ) and not g2s_fixture._has_already_sentry(g2s_new_project) 157 | 158 | 159 | def test_get_g2s_project( 160 | g2s_fixture, 161 | g2s_new_project, 162 | g2s_sentry_project, 163 | payload_new_project, 164 | payload_no_repository_project, 165 | payload_sentry_project, 166 | ): 167 | assert ( 168 | g2s_fixture._get_g2s_project(payload_new_project["node"]) 169 | ) == g2s_new_project 170 | 171 | assert not (g2s_fixture._get_g2s_project(payload_no_repository_project["node"])) 172 | 173 | assert ( 174 | g2s_fixture._get_g2s_project(payload_sentry_project["node"]) 175 | ) == g2s_sentry_project 176 | 177 | 178 | def test_get_paginated_projects(g2s_fixture, payload_new_project, mocker): 179 | mocker.patch.object( 180 | g2s_fixture.gitlab_provider, 181 | attribute="get_all_projects", 182 | return_value=[payload_new_project], 183 | ) 184 | assert isinstance(g2s_fixture._get_paginated_projects(), list) 185 | 186 | 187 | def test_get_gitlab_project(g2s_fixture, g2s_new_project, payload_new_project, mocker): 188 | mocker.patch.object( 189 | g2s_fixture.gitlab_provider, attribute="get_project", return_value={} 190 | ) 191 | assert not g2s_fixture._get_gitlab_project(g2s_new_project) 192 | 193 | mocker.patch.object( 194 | g2s_fixture.gitlab_provider, 195 | attribute="get_project", 196 | return_value={"project": payload_new_project["node"]}, 197 | ) 198 | assert g2s_fixture._get_gitlab_project(g2s_new_project.full_path) == g2s_new_project 199 | 200 | 201 | def test_get_gitlab_groups(g2s_fixture, g2s_new_project, payload_new_project, mocker): 202 | mocker.patch.object( 203 | g2s_fixture, 204 | attribute="_get_paginated_projects", 205 | return_value=[[payload_new_project]], 206 | ) 207 | assert g2s_new_project.group in g2s_fixture._get_gitlab_groups().keys() 208 | 209 | 210 | def test_create_sentry_project(g2s_fixture, payload_new_project, mocker): 211 | mocker.patch.object( 212 | g2s_fixture.sentry_provider, 213 | attribute="get_or_create_project", 214 | return_value={"name": TEST_GROUP_NAME}, 215 | ) 216 | assert ( 217 | g2s_fixture._create_sentry_project( 218 | payload_new_project["node"]["fullPath"], 219 | TEST_GROUP_NAME, 220 | payload_new_project["node"]["name"], 221 | payload_new_project["node"]["name"], 222 | )["name"] 223 | == TEST_GROUP_NAME 224 | ) 225 | 226 | mocker.patch.object( 227 | g2s_fixture.sentry_provider, 228 | attribute="get_or_create_project", 229 | side_effect=SentryProjectCreationFailed("error"), 230 | ) 231 | assert not g2s_fixture._create_sentry_project( 232 | payload_new_project["node"]["fullPath"], 233 | TEST_GROUP_NAME, 234 | payload_new_project["node"]["name"], 235 | payload_new_project["node"]["name"], 236 | ) 237 | 238 | mocker.patch.object( 239 | g2s_fixture.sentry_provider, 240 | attribute="get_or_create_project", 241 | side_effect=Exception("error"), 242 | ) 243 | assert not g2s_fixture._create_sentry_project( 244 | payload_new_project["node"]["fullPath"], 245 | TEST_GROUP_NAME, 246 | payload_new_project["node"]["name"], 247 | payload_new_project["node"]["name"], 248 | ) 249 | 250 | 251 | def test_handle_g2s_project( 252 | g2s_fixture, 253 | g2s_new_project, 254 | g2s_disabled_mr_project, 255 | g2s_sentryclirc_mr_open_project, 256 | g2s_sentryclirc_mr_closed_project, 257 | g2s_sentryclirc_mr_merged_project, 258 | g2s_dsn_mr_open_project, 259 | g2s_dsn_mr_closed_project, 260 | g2s_sentry_project, 261 | mocker, 262 | ): 263 | assert not g2s_fixture._handle_g2s_project(g2s_sentry_project, TEST_GROUP_NAME) 264 | assert not g2s_fixture._handle_g2s_project(g2s_disabled_mr_project, TEST_GROUP_NAME) 265 | assert not g2s_fixture._handle_g2s_project(g2s_dsn_mr_open_project, TEST_GROUP_NAME) 266 | assert not g2s_fixture._handle_g2s_project( 267 | g2s_dsn_mr_closed_project, TEST_GROUP_NAME 268 | ) 269 | assert not g2s_fixture._handle_g2s_project( 270 | g2s_sentryclirc_mr_open_project, TEST_GROUP_NAME 271 | ) 272 | assert not g2s_fixture._handle_g2s_project( 273 | g2s_sentryclirc_mr_closed_project, TEST_GROUP_NAME 274 | ) 275 | mocker.patch.object( 276 | g2s_fixture.gitlab_provider, 277 | attribute="create_sentryclirc_mr", 278 | return_value=True, 279 | ) 280 | g2s_fixture.run_stats["mr_sentryclirc_created"] = 0 281 | assert ( 282 | g2s_fixture._handle_g2s_project(g2s_new_project, TEST_GROUP_NAME) 283 | and g2s_fixture.run_stats["mr_sentryclirc_created"] == 1 284 | ) 285 | mocker.patch.object( 286 | g2s_fixture.sentry_provider, 287 | attribute="set_rate_limit_for_key", 288 | return_value=settings.sentry_dsn, 289 | ) 290 | mocker.patch.object( 291 | g2s_fixture, 292 | attribute="_create_sentry_project", 293 | return_value={"name": TEST_GROUP_NAME, "slug": TEST_GROUP_NAME}, 294 | ) 295 | mocker.patch.object( 296 | g2s_fixture.gitlab_provider, attribute="create_dsn_mr", return_value=True 297 | ) 298 | g2s_fixture.run_stats["mr_dsn_created"] = 0 299 | assert ( 300 | g2s_fixture._handle_g2s_project( 301 | g2s_sentryclirc_mr_merged_project, TEST_GROUP_NAME 302 | ) 303 | and g2s_fixture.run_stats["mr_dsn_created"] == 1 304 | ) 305 | 306 | 307 | def test_update(g2s_fixture, g2s_new_project, mocker): 308 | mocker.patch.object( 309 | g2s_fixture, attribute="_get_gitlab_project", return_value=g2s_new_project 310 | ) 311 | mocker.patch.object(g2s_fixture, attribute="_handle_g2s_project", return_value=None) 312 | assert g2s_fixture.update(full_path=g2s_new_project.full_path) is None 313 | 314 | mocker.patch.object( 315 | g2s_fixture, 316 | attribute="_get_gitlab_groups", 317 | return_value={TEST_GROUP_NAME: g2s_new_project}, 318 | ) 319 | mocker.patch.object( 320 | g2s_fixture, attribute="_ensure_sentry_group", return_value=None 321 | ) 322 | mocker.patch.object(g2s_fixture, attribute="_handle_g2s_project", return_value=None) 323 | assert g2s_fixture.update() is None 324 | -------------------------------------------------------------------------------- /tests/test_gitlab_provider.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import aiohttp 4 | from gitlab import Gitlab 5 | from gql.transport.aiohttp import AIOHTTPTransport 6 | 7 | from gitlab2sentry.resources import ( 8 | GRAPHQL_FETCH_PROJECT_QUERY, 9 | GRAPHQL_LIST_PROJECTS_QUERY, 10 | settings, 11 | ) 12 | from tests.conftest import CURRENT_TIME, GRAPHQL_TEST_QUERY 13 | 14 | 15 | def test_get_transport(gql_client_fixture): 16 | assert isinstance( 17 | gql_client_fixture._get_transport(settings.gitlab_url, settings.gitlab_token), 18 | AIOHTTPTransport, 19 | ) 20 | 21 | 22 | def test_query(gql_client_fixture, payload_new_project, mocker): 23 | mocker.patch.object( 24 | gql_client_fixture._client, 25 | attribute="execute", 26 | return_value=[payload_new_project], 27 | ) 28 | assert gql_client_fixture._query( 29 | payload_new_project["node"]["name"], GRAPHQL_TEST_QUERY["body"] 30 | ) 31 | mocker.patch.object( 32 | gql_client_fixture._client, 33 | attribute="execute", 34 | side_effect=aiohttp.client_exceptions.ClientResponseError(None, None), 35 | ) 36 | assert not gql_client_fixture._query( 37 | payload_new_project["node"]["name"], GRAPHQL_TEST_QUERY["body"] 38 | ) 39 | 40 | 41 | def test_project_fetch_query(gql_client_fixture, payload_new_project, mocker): 42 | mocker.patch.object( 43 | gql_client_fixture._client, 44 | attribute="execute", 45 | return_value=[payload_new_project], 46 | ) 47 | assert ( 48 | gql_client_fixture.project_fetch_query(GRAPHQL_FETCH_PROJECT_QUERY)[0] 49 | == payload_new_project 50 | ) 51 | 52 | 53 | def test_project_list_query(gql_client_fixture, payload_new_project, mocker): 54 | mocker.patch.object( 55 | gql_client_fixture._client, 56 | attribute="execute", 57 | return_value=[payload_new_project], 58 | ) 59 | assert ( 60 | gql_client_fixture.project_list_query(GRAPHQL_LIST_PROJECTS_QUERY, None)[0] 61 | == payload_new_project 62 | ) 63 | 64 | 65 | def test_get_gitlab(gitlab_provider_fixture): 66 | assert isinstance( 67 | gitlab_provider_fixture._get_gitlab(settings.gitlab_url, settings.gitlab_token), 68 | Gitlab, 69 | ) 70 | 71 | 72 | def test_get_update_limit(gitlab_provider_fixture): 73 | if settings.gitlab_project_creation_limit: 74 | assert ( 75 | datetime.now() - gitlab_provider_fixture._get_update_limit() 76 | ).days - settings.gitlab_project_creation_limit <= 1 77 | else: 78 | assert not gitlab_provider_fixture._get_update_limit() 79 | 80 | 81 | def test_from_iso_to_datetime(gitlab_provider_fixture): 82 | assert isinstance( 83 | gitlab_provider_fixture._from_iso_to_datetime(CURRENT_TIME), datetime 84 | ) 85 | 86 | 87 | def test_get_default_mentions(gitlab_provider_fixture, gitlab_project_fixture): 88 | _mentioned_members = gitlab_provider_fixture._get_default_mentions( 89 | gitlab_project_fixture 90 | ).split(", ") 91 | _project_non_blocked_members = [ 92 | member 93 | for member in gitlab_project_fixture.members_all.list() 94 | if member.state != "blocked" 95 | ] 96 | assert len(_mentioned_members) == len(_project_non_blocked_members) 97 | 98 | 99 | def test_get_project(gitlab_provider_fixture, mocker): 100 | mocker.patch.object( 101 | gitlab_provider_fixture._gql_client, 102 | attribute="project_fetch_query", 103 | return_value=True, 104 | ) 105 | assert gitlab_provider_fixture.get_project(GRAPHQL_FETCH_PROJECT_QUERY) is True 106 | 107 | 108 | def test_get_all_projects( 109 | gitlab_provider_fixture, payload_new_project, payload_old_project, mocker 110 | ): 111 | mocker.patch.object( 112 | gitlab_provider_fixture._gql_client, 113 | attribute="project_list_query", 114 | side_effect=[ 115 | { 116 | GRAPHQL_LIST_PROJECTS_QUERY["instance"]: { 117 | "edges": [payload_new_project], 118 | "pageInfo": {"endCursor": "first-cursor", "hasNextPage": True}, 119 | } 120 | }, 121 | { 122 | GRAPHQL_LIST_PROJECTS_QUERY["instance"]: { 123 | "edges": [payload_new_project], 124 | "pageInfo": {"endCursor": None, "hasNextPage": False}, 125 | } 126 | }, 127 | ], 128 | ) 129 | assert ( 130 | len( 131 | [ 132 | result_page 133 | for result_page in gitlab_provider_fixture.get_all_projects( 134 | GRAPHQL_LIST_PROJECTS_QUERY 135 | ) 136 | ] 137 | ) 138 | == 2 139 | ) 140 | 141 | mocker.patch.object( 142 | gitlab_provider_fixture._gql_client, 143 | attribute="project_list_query", 144 | side_effect=[ 145 | { 146 | GRAPHQL_LIST_PROJECTS_QUERY["instance"]: { 147 | "edges": [payload_old_project], 148 | "pageInfo": {"endCursor": "first-cursor", "hasNextPage": True}, 149 | } 150 | }, 151 | { 152 | GRAPHQL_LIST_PROJECTS_QUERY["instance"]: { 153 | "edges": [payload_old_project], 154 | "pageInfo": {"endCursor": None, "hasNextPage": False}, 155 | } 156 | }, 157 | ], 158 | ) 159 | assert ( 160 | len( 161 | [ 162 | result_page 163 | for result_page in gitlab_provider_fixture.get_all_projects( 164 | GRAPHQL_LIST_PROJECTS_QUERY 165 | ) 166 | ] 167 | ) 168 | == 1 169 | ) 170 | -------------------------------------------------------------------------------- /tests/test_sentry_provider.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from requests import Response 5 | 6 | from gitlab2sentry.exceptions import ( 7 | SentryProjectCreationFailed, 8 | SentryProjectKeyIDNotFound, 9 | ) 10 | from gitlab2sentry.resources import settings 11 | from tests.conftest import TEST_GROUP_NAME, TEST_PROJECT_NAME 12 | 13 | STATUS_CODE, DETAIL = 400, b'{"msg": "error_details"}' 14 | 15 | 16 | def mocked_response(status_code): 17 | response = Response() 18 | response._content = DETAIL 19 | response.status_code = status_code 20 | return response 21 | 22 | 23 | def test_get_json(sentry_provider_fixture): 24 | assert sentry_provider_fixture._client._get_json(mocked_response(STATUS_CODE)) == ( 25 | STATUS_CODE, 26 | json.loads(DETAIL.decode()), 27 | ) 28 | 29 | 30 | def test_simple_request(sentry_provider_fixture, mocker): 31 | mocker.patch("requests.post", return_value=mocked_response(200)) 32 | assert sentry_provider_fixture._client.simple_request("post", "", None) 33 | mocker.patch("requests.put", return_value=mocked_response(200)) 34 | assert sentry_provider_fixture._client.simple_request("put", "", None) 35 | 36 | mocker.patch("requests.get", return_value=mocked_response(200)) 37 | assert sentry_provider_fixture._client.simple_request("get", "", None) 38 | 39 | 40 | def test_get_or_create_team(sentry_provider_fixture, mocker): 41 | mocker.patch("requests.post", return_value=mocked_response(404)) 42 | mocker.patch("requests.get", return_value=mocked_response(201)) 43 | assert sentry_provider_fixture._get_or_create_team(TEST_GROUP_NAME) == json.loads( 44 | DETAIL.decode() 45 | ) 46 | 47 | mocker.patch("requests.post", return_value=mocked_response(200)) 48 | assert sentry_provider_fixture._get_or_create_team(TEST_GROUP_NAME) == json.loads( 49 | DETAIL.decode() 50 | ) 51 | 52 | mocker.patch("requests.post", return_value=mocked_response(404)) 53 | mocker.patch("requests.get", return_value=mocked_response(200)) 54 | assert sentry_provider_fixture._get_or_create_team(TEST_GROUP_NAME) is None 55 | 56 | 57 | def test_get_or_create_project(sentry_provider_fixture, mocker): 58 | mocker.patch("requests.post", return_value=mocked_response(404)) 59 | mocker.patch("requests.get", return_value=mocked_response(201)) 60 | assert sentry_provider_fixture.get_or_create_project( 61 | TEST_GROUP_NAME, TEST_PROJECT_NAME, TEST_PROJECT_NAME 62 | ) == json.loads(DETAIL.decode()) 63 | 64 | mocker.patch("requests.get", return_value=mocked_response(200)) 65 | assert sentry_provider_fixture.get_or_create_project( 66 | TEST_GROUP_NAME, TEST_PROJECT_NAME, TEST_PROJECT_NAME 67 | ) == json.loads(DETAIL.decode()) 68 | 69 | mocker.patch("requests.get", return_value=mocked_response(400)) 70 | with pytest.raises(SentryProjectCreationFailed): 71 | assert sentry_provider_fixture.get_or_create_project( 72 | TEST_GROUP_NAME, TEST_PROJECT_NAME, TEST_PROJECT_NAME 73 | ) 74 | 75 | 76 | def test_get_dsn_and_key_id(sentry_provider_fixture, mocker): 77 | response = Response() 78 | detail = b"[{}]" 79 | decoded_detail = json.loads(detail.decode()) 80 | response._content = detail 81 | response.status_code = 400 82 | 83 | mocker.patch("requests.get", return_value=response) 84 | assert sentry_provider_fixture._get_dsn_and_key_id(TEST_PROJECT_NAME) == ( 85 | None, 86 | None, 87 | ) 88 | 89 | response = Response() 90 | detail = b"[{}]" 91 | decoded_detail = json.loads(detail.decode()) 92 | response._content = detail 93 | response.status_code = 200 94 | 95 | mocker.patch("requests.get", return_value=response) 96 | with pytest.raises(SentryProjectKeyIDNotFound): 97 | assert sentry_provider_fixture._get_dsn_and_key_id(TEST_PROJECT_NAME) 98 | 99 | response = Response() 100 | detail = b'[{"dsn":{"public": "test-dsn"}, "id": "test_id"}]' 101 | decoded_detail = json.loads(detail.decode()) 102 | response._content = detail 103 | response.status_code = 200 104 | 105 | mocker.patch("requests.get", return_value=response) 106 | assert sentry_provider_fixture._get_dsn_and_key_id(TEST_PROJECT_NAME) == ( 107 | decoded_detail[0]["dsn"]["public"], 108 | decoded_detail[0]["id"], 109 | ) 110 | 111 | 112 | def test_set_rate_limit_for_key(sentry_provider_fixture, mocker): 113 | mocker.patch.object( 114 | sentry_provider_fixture, 115 | attribute="_get_dsn_and_key_id", 116 | return_value=(settings.sentry_dsn, "result"), 117 | ) 118 | mocker.patch.object( 119 | sentry_provider_fixture._client, 120 | attribute="simple_request", 121 | return_value=(200, "result"), 122 | ) 123 | assert ( 124 | sentry_provider_fixture.set_rate_limit_for_key(TEST_PROJECT_NAME) 125 | == settings.sentry_dsn 126 | ) 127 | 128 | mocker.patch.object( 129 | sentry_provider_fixture._client, 130 | attribute="simple_request", 131 | return_value=(400, "result"), 132 | ) 133 | assert sentry_provider_fixture.set_rate_limit_for_key(TEST_PROJECT_NAME) is None 134 | 135 | mocker.patch.object( 136 | sentry_provider_fixture, 137 | attribute="_get_dsn_and_key_id", 138 | side_effect=SentryProjectKeyIDNotFound(), 139 | ) 140 | assert sentry_provider_fixture.set_rate_limit_for_key(TEST_PROJECT_NAME) is None 141 | 142 | 143 | def test_ensure_sentry_team(sentry_provider_fixture, mocker): 144 | mocker.patch.object( 145 | sentry_provider_fixture, attribute="_get_or_create_team", return_value=True 146 | ) 147 | assert sentry_provider_fixture.ensure_sentry_team(TEST_GROUP_NAME) 148 | 149 | mocker.patch.object( 150 | sentry_provider_fixture, attribute="_get_or_create_team", return_value=False 151 | ) 152 | assert not sentry_provider_fixture.ensure_sentry_team(TEST_GROUP_NAME) 153 | --------------------------------------------------------------------------------