├── requirements.txt ├── pytest.ini ├── environment.yml ├── dev-requirements.txt ├── .gitignore ├── err_stash.plug ├── .pre-commit-config.yaml ├── .travis.yml ├── LICENSE ├── CHANGELOG.md ├── README.md ├── tests.py └── err_stash.py /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs 2 | stashy 3 | pygithub 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | -p no:logging 4 | --cov=err_stash.py 5 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: err-stash 2 | dependencies: 3 | - python >=3 4 | - stashy 5 | - pygithub 6 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | errbot 3 | pytest >=3.3 4 | pytest-cov 5 | pytest-mock 6 | attrs 7 | pre-commit 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | __pycache__/ 3 | *.pyc 4 | default.ini 5 | /plugins/ 6 | /data/ 7 | config.py 8 | errbot.log 9 | .coverage 10 | -------------------------------------------------------------------------------- /err_stash.plug: -------------------------------------------------------------------------------- 1 | [Core] 2 | Name = Stash 3 | Module = err_stash 4 | 5 | [Documentation] 6 | Description = ESSS Stash workflow helper 7 | 8 | [Python] 9 | Version = 3 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: '^($|.*\.bin)' 2 | repos: 3 | - repo: https://github.com/ambv/black 4 | rev: 18.6b4 5 | hooks: 6 | - id: black 7 | args: [--safe, --quiet] 8 | language_version: python3.6 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v1.3.0 11 | hooks: 12 | - id: trailing-whitespace 13 | - id: end-of-file-fixer 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | 4 | install: 5 | - pip install --upgrade pip setuptools 6 | - pip install -r dev-requirements.txt 7 | 8 | jobs: 9 | include: 10 | - script: python -m pytest tests.py 11 | python: "3.7" 12 | name: "Python 3.7" 13 | 14 | - script: python -m pytest tests.py 15 | python: "3.6" 16 | name: "Python 3.6" 17 | 18 | - script: pre-commit run --all-files --show-diff-on-failure 19 | python: "3.6" 20 | name: "Linting" 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 ESSS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | **1.5.1** 2 | 3 | - Fix a bug when getting the commit hash of a Branch in GitHub, causing the `delete-branch` command fail 4 | 5 | **1.5.0** 6 | 7 | - Add new command `delete-branch `. 8 | - Fixed a bug where `err-stash` would mistakenly create a Stash link when suggesting to create a PR for a Github branch. 9 | 10 | **1.4.0** 11 | 12 | - Add support to merge and close Github pull requests. 13 | 14 | **1.3.2** 15 | 16 | - Bug fix when try to merge branch to other than `master`. 17 | 18 | **1.3.1** 19 | 20 | - Bug fix when try to merge branches and one of them does not have PR open. 21 | 22 | **1.3.0** 23 | 24 | - Modify merge message to be easier to know the target branch. 25 | 26 | **1.2.0** 27 | 28 | - Implement `--force` option to merge PRs with different target branches. 29 | 30 | **1.1.2** 31 | 32 | - Fix bug where the fact that a PROJECT might not have a REPOSITORY/BRANCH generated an error. 33 | 34 | **1.1.1** 35 | 36 | - Add `STASH_PROJECTS` to configuration template variable. 37 | - Change `STASH_PROJECTS` defaults to `None`. 38 | 39 | **1.1.0** 40 | 41 | - Support multiple projects using `STASH_PROJECTS` configuration variable. 42 | - Add `!stash version` command to display the changelog. 43 | 44 | 45 | **1.0.0** 46 | 47 | 48 | First version. 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # err-stash 2 | 3 | [![Travis branch](https://img.shields.io/travis/ESSS/err-stash/master.svg)](https://travis-ci.org/ESSS/err-stash/) 4 | 5 | [errbot plugin](http://errbot.io/en/latest/) to interact with Stash. 6 | 7 | # Usage 8 | 9 | Talk with the bot for help: 10 | 11 | ``` 12 | !help Stash 13 | ``` 14 | 15 | # Development 16 | 17 | Clone: 18 | 19 | ``` 20 | git clone git@github.com:ESSS/err-stash.git 21 | cd err-stash 22 | ``` 23 | 24 | Create a **pure** virtual environment with Python 3.6 and activate it. Using `conda`: 25 | 26 | ``` 27 | conda create -n py36 python=3.6 28 | W:\Miniconda\envs\py36\python.exe -m venv .env36 29 | .env36\Scripts\activate 30 | ``` 31 | 32 | **It is important to use a pure virtual environment and not a conda environment** otherwise 33 | `pip install` might break `conda`. 34 | 35 | Install dependencies: 36 | 37 | ``` 38 | pip install -r dev-requirements.txt 39 | ``` 40 | 41 | Run tests: 42 | 43 | ``` 44 | pytest tests.py 45 | ``` 46 | 47 | ## Run bot in text mode 48 | 49 | Create a bot for local development: 50 | 51 | ``` 52 | errbot --init 53 | ``` 54 | 55 | And edit the generated `config.py`: 56 | 57 | * Change `@CHANGE_ME` to your username. 58 | * Change `BOT_EXTRA_PLUGIN_DIR` to point to the current directory. 59 | 60 | Start it up with: 61 | 62 | ``` 63 | errbot -T 64 | ``` 65 | 66 | Execute to configure the bot: 67 | 68 | ``` 69 | !plugin config Stash { 70 | 'STASH_URL': 'https://eden.esss.co/stash', 71 | 'STASH_PROJECTS': ['ESSS'], 72 | 'GITHUB_ORGANIZATIONS': ['ESSS'], 73 | } 74 | ``` 75 | 76 | Copy and paste this configuration, probably the default is enough for you. 77 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import OrderedDict 3 | 4 | import attr 5 | import github 6 | import pytest 7 | import stashy 8 | import stashy.errors 9 | from github import GithubException 10 | 11 | import err_stash 12 | from err_stash import ( 13 | GithubAPI, 14 | merge, 15 | StashAPI, 16 | CheckError, 17 | create_plans, 18 | ensure_text_matches_unique_branch, 19 | make_pr_link, 20 | delete_branches, 21 | obtain_branches_to_delete, 22 | ) 23 | 24 | 25 | class DummyPullRequest: 26 | def __init__(self, can_merge): 27 | self._can_merge = can_merge 28 | self.merged_version = None 29 | 30 | def can_merge(self): 31 | return self._can_merge 32 | 33 | def merge(self, version): 34 | self.merged_version = version 35 | 36 | def decline(self, version): 37 | pass 38 | 39 | 40 | @pytest.fixture 41 | def mock_stash_api(mocker): 42 | mocker.patch.object(stashy, "connect", autospec=True) 43 | api = StashAPI("https://myserver.com/stash", username="fry", password="PASSWORD123") 44 | args, kwargs = stashy.connect.call_args 45 | assert args == ("https://myserver.com/stash",) 46 | assert kwargs == dict(username="fry", password="PASSWORD123") 47 | 48 | # noinspection PyDictCreation 49 | projects = { 50 | "PROJ-A": OrderedDict( 51 | [ 52 | ("repo1", dict(slug="repo1", project=dict(key="PROJ-A"))), 53 | ("repo2", dict(slug="repo2", project=dict(key="PROJ-A"))), 54 | ] 55 | ), 56 | "PROJ-B": OrderedDict( 57 | [("repo3", dict(slug="repo3", project=dict(key="PROJ-B")))] 58 | ), 59 | } 60 | 61 | projects["PROJ-A"]["repo1"]["branches"] = [ 62 | dict( 63 | id="refs/heads/fb-ASIM-81-network", 64 | displayId="fb-ASIM-81-network", 65 | latestCommit="2a0ae67e039099e300ec972e22c281af19f4ac9d", 66 | ), 67 | dict( 68 | id="refs/heads/fb-SSRL-1890-py3", 69 | displayId="fb-SSRL-1890-py3", 70 | latestCommit="f9a7c6df341325822e3ea264cfe39e5ef8c73aa4", 71 | ), 72 | ] 73 | projects["PROJ-A"]["repo2"]["branches"] = [ 74 | dict( 75 | id="refs/heads/fb-SSRL-1890-py3", 76 | displayId="fb-SSRL-1890-py3", 77 | latestCommit="94cd166631d14dab533858b9b47e9584a2ff3f65", 78 | ) 79 | ] 80 | projects["PROJ-B"]["repo3"]["branches"] = [ 81 | dict( 82 | id="refs/heads/fb-ASIM-81-network", 83 | displayId="fb-ASIM-81-network", 84 | latestCommit="a938dfdfbaa1f25ccbc39e16060f73c44e5ef0dd", 85 | ), 86 | dict( 87 | id="refs/heads/fb-SSRL-1890-py3", 88 | displayId="fb-SSRL-1890-py3", 89 | latestCommit="590f467a41ee833a33f8b6c44316bde00b9cda70", 90 | ), 91 | ] 92 | 93 | projects["PROJ-B"]["repo3"]["pull_requests"] = [ 94 | dict( 95 | id="10", 96 | fromRef=dict(id="refs/heads/fb-ASIM-81-network"), 97 | toRef=dict(id="refs/heads/master"), 98 | displayId="fb-ASIM-81-network", 99 | links=make_link("url.com/for/10"), 100 | version="10", 101 | ) 102 | ] 103 | projects["PROJ-B"]["repo3"]["pull_request"] = {"10": DummyPullRequest(True)} 104 | 105 | projects["PROJ-B"]["repo3"]["commits"] = { 106 | ("refs/heads/fb-ASIM-81-network", "refs/heads/master"): ["A", "B"] 107 | } 108 | 109 | def mock_fetch_repos(self, project): 110 | return projects[project].values() 111 | 112 | mocker.patch.object( 113 | StashAPI, "fetch_repos", autospec=True, side_effect=mock_fetch_repos 114 | ) 115 | 116 | def mock_fetch_branches(self, project, slug, filter_text): 117 | return [ 118 | x for x in projects[project][slug]["branches"] if filter_text in x["id"] 119 | ] 120 | 121 | mocker.patch.object( 122 | StashAPI, "fetch_branches", autospec=True, side_effect=mock_fetch_branches 123 | ) 124 | 125 | def mock_fetch_pull_requests(self, project, slug): 126 | return projects[project][slug].get("pull_requests", []) 127 | 128 | mocker.patch.object( 129 | StashAPI, 130 | "fetch_pull_requests", 131 | autospec=True, 132 | side_effect=mock_fetch_pull_requests, 133 | ) 134 | 135 | def mock_fetch_pull_request(self, project, slug, pr_id): 136 | return projects[project][slug].get("pull_request", {}).get(pr_id) 137 | 138 | mocker.patch.object( 139 | StashAPI, 140 | "fetch_pull_request", 141 | autospec=True, 142 | side_effect=mock_fetch_pull_request, 143 | ) 144 | 145 | def mock_fetch_repo_commits(self, project, slug, from_branch, to_branch): 146 | for (i_from_branch, i_to_branch), commits in ( 147 | projects[project][slug].get("commits", {}).items() 148 | ): 149 | if from_branch in i_from_branch and to_branch in i_to_branch: 150 | return commits 151 | response = mocker.MagicMock() 152 | msg = "fetch_repo_commits: {} {} {} {}".format( 153 | project, slug, from_branch, to_branch 154 | ) 155 | response.json.return_value = dict(errors=[dict(message=msg)]) 156 | raise stashy.errors.NotFoundException(response=response) 157 | 158 | mocker.patch.object( 159 | StashAPI, 160 | "fetch_repo_commits", 161 | autospec=True, 162 | side_effect=mock_fetch_repo_commits, 163 | ) 164 | 165 | def mock_delete_branch(self, project, slug, branch): 166 | branches = projects[project][slug]["branches"] 167 | for index, item in reversed(list(enumerate(branches))): 168 | if branch in item["id"]: 169 | del branches[index] 170 | 171 | mocker.patch.object( 172 | StashAPI, "delete_branch", autospec=True, side_effect=mock_delete_branch 173 | ) 174 | 175 | return projects 176 | 177 | 178 | def call_merge(branch_text, matching_lines, *, force=False, github_organizations=None): 179 | try: 180 | lines = list( 181 | merge( 182 | "https://myserver.com/stash", 183 | ["PROJ-A", "PROJ-B"], 184 | stash_username="fry", 185 | stash_password="PASSWORD123", 186 | github_username_or_token="", 187 | github_password="", 188 | github_organizations=list() 189 | if github_organizations is None 190 | else github_organizations, 191 | branch_text=branch_text, 192 | confirm=True, 193 | force=force, 194 | ) 195 | ) 196 | except CheckError as e: 197 | lines = list(e.lines) 198 | from _pytest.pytester import LineMatcher 199 | 200 | matcher = LineMatcher(sorted(lines)) 201 | matcher.re_match_lines(sorted(matching_lines)) 202 | 203 | 204 | def make_link(url): 205 | return dict(self=[dict(href=url)]) 206 | 207 | 208 | def test_duplicate_branches(mock_stash_api, github_api): 209 | mock_stash_api["PROJ-A"]["repo1"]["branches"] = [ 210 | dict( 211 | id="refs/heads/fb-ASIM-81-network", 212 | displayId="fb-ASIM-81-network", 213 | latestCommit="2a0ae67e039099e300ec972e22c281af19f4ac9d", 214 | ), 215 | dict( 216 | id="refs/heads/fb-ASIM-81-network-test", 217 | displayId="fb-ASIM-81-network-test", 218 | latestCommit="d4959b0578a5c06aaf3b2b1e195e0b57acf80c30", 219 | ), 220 | ] 221 | mock_stash_api["PROJ-A"]["repo2"]["branches"] = [ 222 | dict( 223 | id="refs/heads/fb-ASIM-81-network", 224 | displayId="fb-ASIM-81-network-test", 225 | latestCommit="97eff6cac35a03d8e8f0b830e6ac9fca5fcf6109", 226 | ) 227 | ] 228 | mock_stash_api["PROJ-B"]["repo3"]["branches"] = [ 229 | dict( 230 | id="refs/heads/fb-ASIM-81-network", 231 | displayId="fb-ASIM-81-network-test", 232 | latestCommit="a938dfdfbaa1f25ccbc39e16060f73c44e5ef0dd", 233 | ), 234 | dict( 235 | id="refs/heads/fb-SSRL-1890-py3", 236 | displayId="fb-SSRL-1890-py3", 237 | latestCommit="590f467a41ee833a33f8b6c44316bde00b9cda70", 238 | ), 239 | ] 240 | 241 | call_merge( 242 | "ASIM-81", 243 | [ 244 | r'More than one branch matches the text `"ASIM-81"`:', 245 | r"`repo1`: `fb-ASIM-81-network`, `fb-ASIM-81-network-test`", 246 | r"Use a more complete text.*", 247 | ], 248 | ) 249 | 250 | 251 | def test_multiples_prs_for_same_branch(mock_stash_api): 252 | mock_stash_api["PROJ-A"]["repo1"]["pull_requests"] = [ 253 | dict( 254 | id="10", 255 | fromRef=dict(id="refs/heads/fb-ASIM-81-network"), 256 | toRef=dict(id="refs/heads/master"), 257 | displayId="fb-ASIM-81-network", 258 | links=make_link("url.com/for/10"), 259 | ), 260 | dict( 261 | id="12", 262 | fromRef=dict(id="refs/heads/fb-ASIM-81-network"), 263 | toRef=dict(id="refs/heads/master"), 264 | displayId="fb-ASIM-81-network", 265 | links=make_link("url.com/for/12"), 266 | ), 267 | ] 268 | 269 | call_merge( 270 | "ASIM-81", 271 | [ 272 | r"Multiples PRs for branch `fb-ASIM-81-network` found:", 273 | r"`repo1`: \[PR#10\]\(url.com/for/10\), \[PR#12\]\(url.com/for/12\)", 274 | r"Sorry you will have to sort that mess yourself. :wink:", 275 | ], 276 | ) 277 | 278 | 279 | def test_prs_with_different_targets(mock_stash_api): 280 | mock_stash_api["PROJ-A"]["repo1"]["pull_requests"] = [ 281 | dict( 282 | id="10", 283 | fromRef=dict(id="refs/heads/fb-ASIM-81-network"), 284 | toRef=dict(id="refs/heads/features"), 285 | displayId="fb-ASIM-81-network", 286 | links=make_link("url.com/for/10"), 287 | ) 288 | ] 289 | mock_stash_api["PROJ-B"]["repo3"]["pull_requests"] = [ 290 | dict( 291 | id="17", 292 | fromRef=dict(id="refs/heads/fb-ASIM-81-network"), 293 | toRef=dict(id="refs/heads/master"), 294 | displayId="fb-ASIM-81-network", 295 | links=make_link("url.com/for/17"), 296 | ) 297 | ] 298 | 299 | call_merge( 300 | "ASIM-81", 301 | [ 302 | r"PRs in repositories for branch `fb-ASIM-81-network` have different targets:", 303 | r"`repo1`: \[PR#10\]\(url.com/for/10\) targets `refs/heads/features`", 304 | r"`repo3`: \[PR#17\]\(url.com/for/17\) targets `refs/heads/master`", 305 | r"Fix those PRs and try again.", 306 | r"Alternately you can pass `--force` to force the merge with different targets!", 307 | ], 308 | ) 309 | 310 | 311 | def test_branch_commits_without_pr(mock_stash_api): 312 | from_branch = "refs/heads/fb-ASIM-81-network" 313 | to_branch = "refs/heads/master" 314 | mock_stash_api["PROJ-B"]["repo3"]["commits"] = { 315 | (from_branch, to_branch): ["A", "B"] 316 | } 317 | mock_stash_api["PROJ-A"]["repo1"]["commits"] = {(from_branch, to_branch): ["C"]} 318 | pr_link = re.escape( 319 | "https://myserver.com/stash/projects/PROJ-A/repos/repo1/compare/commits?" 320 | "sourceBranch=fb-ASIM-81-network&" 321 | "targetBranch=refs%2Fheads%2Fmaster" 322 | ) 323 | call_merge( 324 | "ASIM-81", 325 | [ 326 | r"These repositories have commits in `fb-ASIM-81-network` but no PRs:", 327 | r"`repo1`: \*1 commit\* \(\[create PR\]\({pr_link}\)\)".format( 328 | pr_link=pr_link 329 | ), 330 | r"You need to create PRs for your changes before merging this branch.", 331 | ], 332 | ) 333 | 334 | 335 | def test_branch_missing(mock_stash_api): 336 | mock_stash_api["PROJ-A"]["repo1"]["branches"] = [] 337 | call_merge( 338 | "ASIM-81", 339 | [ 340 | r"Branch `fb-ASIM-81-network` merged into:", 341 | r":white_check_mark: `repo3` *2 commits* -> `master`", 342 | r"Branch deleted from repositories: `repo3`", 343 | ], 344 | ) 345 | 346 | 347 | def test_merge_conflicts(mock_stash_api): 348 | mock_stash_api["PROJ-B"]["repo3"]["pull_request"] = {"10": DummyPullRequest(False)} 349 | call_merge( 350 | "ASIM-81", 351 | [ 352 | r"The PRs below for branch `fb-ASIM-81-network` have problems such as conflicts, build requirements, etc:", 353 | r"`repo3`: \[PR#10\]\(url.com/for/10\)", 354 | r"Fix them and try again.", 355 | ], 356 | ) 357 | assert ( 358 | mock_stash_api["PROJ-B"]["repo3"]["pull_request"]["10"].merged_version is None 359 | ) 360 | 361 | 362 | def test_merge_success(mock_stash_api, github_api, mocker): 363 | mocker.patch.object(err_stash, "GithubAPI", return_value=github_api) 364 | 365 | pull_request = mock_stash_api["PROJ-B"]["repo3"]["pull_request"]["10"] 366 | call_merge( 367 | "ASIM-81", 368 | [ 369 | r"Branch `fb-ASIM-81-network` merged into:", 370 | r":white_check_mark: `repo3` *2 commits* -> `master`", 371 | r":white_check_mark: `conda-devenv` *3 commits* -> `master`", 372 | r"`repo1` - (no changes)", 373 | r"Branch deleted from repositories: `conda-devenv`, `repo1`, `repo3`", 374 | ], 375 | github_organizations=["esss"], 376 | ) 377 | assert pull_request.merged_version == "10" 378 | assert mock_stash_api["PROJ-A"]["repo1"]["branches"] == [ 379 | dict( 380 | id="refs/heads/fb-SSRL-1890-py3", 381 | displayId="fb-SSRL-1890-py3", 382 | latestCommit="f9a7c6df341325822e3ea264cfe39e5ef8c73aa4", 383 | ) 384 | ] 385 | assert mock_stash_api["PROJ-A"]["repo2"]["branches"] == [ 386 | dict( 387 | id="refs/heads/fb-SSRL-1890-py3", 388 | displayId="fb-SSRL-1890-py3", 389 | latestCommit="94cd166631d14dab533858b9b47e9584a2ff3f65", 390 | ) 391 | ] 392 | assert mock_stash_api["PROJ-B"]["repo3"]["branches"] == [ 393 | dict( 394 | id="refs/heads/fb-SSRL-1890-py3", 395 | displayId="fb-SSRL-1890-py3", 396 | latestCommit="590f467a41ee833a33f8b6c44316bde00b9cda70", 397 | ) 398 | ] 399 | 400 | 401 | def test_merge_success_github(mock_stash_api, github_api, mocker): 402 | mocker.patch.object(err_stash, "GithubAPI", return_value=github_api) 403 | call_merge( 404 | "fb-branch-only-in-github", 405 | [ 406 | r"Branch `fb-branch-only-in-github` merged into:", 407 | r":white_check_mark: `conda-devenv` *3 commits* -> `master`", 408 | r":white_check_mark: `deps` *3 commits* -> `master`", 409 | r"Branch deleted from repositories: `conda-devenv`, `deps`", 410 | ], 411 | github_organizations=["esss"], 412 | ) 413 | 414 | 415 | def test_merge_branch_nonexistent(mock_stash_api, github_api, mocker): 416 | mocker.patch.object(err_stash, "GithubAPI", return_value=github_api) 417 | call_merge( 418 | "fb-branch-nonexistent", 419 | [ 420 | r'Could not find any branch with text `"fb-branch-nonexistent"` in any repositories of Stash projects: `PROJ-A`, `PROJ-B` nor Github organizations: `esss`\.' 421 | ], 422 | github_organizations=["esss"], 423 | ) 424 | 425 | 426 | def test_merge_no_pull_request(mock_stash_api, github_api, mocker): 427 | mocker.patch.object(err_stash, "GithubAPI", return_value=github_api) 428 | call_merge("SSRL-1890", [r'No PRs are open with text `"SSRL-1890"`']) 429 | 430 | 431 | def test_prs_with_different_targets_force_merge(mock_stash_api): 432 | mock_stash_api["PROJ-A"]["repo1"]["commits"] = { 433 | ("refs/heads/fb-ASIM-81-network", "refs/heads/features"): ["C", "D"] 434 | } 435 | mock_stash_api["PROJ-A"]["repo1"]["pull_requests"] = [ 436 | dict( 437 | id="10", 438 | fromRef=dict(id="refs/heads/fb-ASIM-81-network"), 439 | toRef=dict(id="refs/heads/features"), 440 | displayId="fb-ASIM-81-network", 441 | links=make_link("url.com/for/10"), 442 | version="10", 443 | ) 444 | ] 445 | mock_stash_api["PROJ-A"]["repo1"]["pull_request"] = {"10": DummyPullRequest(True)} 446 | 447 | call_merge( 448 | "ASIM-81", 449 | [ 450 | r"Branch `fb-ASIM-81-network` merged into:", 451 | r":white_check_mark: `repo1` *2 commits* -> `features`", 452 | r":white_check_mark: `repo3` *2 commits* -> `master`", 453 | r"Branch deleted from repositories: `repo1`, `repo3`", 454 | ], 455 | force=True, 456 | ) 457 | 458 | 459 | def test_no_pull_requests(mock_stash_api): 460 | del mock_stash_api["PROJ-B"]["repo3"]["pull_requests"] 461 | call_merge("ASIM-81", [r'No PRs are open with text `"ASIM-81"`']) 462 | 463 | 464 | def test_no_pull_request_github(mock_stash_api, github_api, mocker): 465 | mock_stash_api["PROJ-A"]["repo1"]["pull_requests"] = [ 466 | dict( 467 | id="10", 468 | fromRef=dict(id="refs/heads/fb-SSRL-1890-py3"), 469 | toRef=dict(id="refs/heads/master"), 470 | displayId="fb-SSRL-1890-py3", 471 | links=make_link("url.com/for/10"), 472 | version="10", 473 | ) 474 | ] 475 | mock_stash_api["PROJ-B"]["repo3"]["pull_request"] = {"10": DummyPullRequest(True)} 476 | 477 | mocker.patch.object(err_stash, "GithubAPI", return_value=github_api) 478 | 479 | matching_lines = [ 480 | r"These repositories have commits in `fb-SSRL-1890-py3` but no PRs:", 481 | r"`deps`: *3 commits* ([create PR](https://github.com/esss/deps/compare/refs/heads/master...fb-SSRL-1890-py3))", 482 | r"You need to create PRs for your changes before merging this branch.", 483 | ] 484 | 485 | call_merge("fb-SSRL-1890-py3", matching_lines, github_organizations=["esss"]) 486 | 487 | 488 | def test_no_matching_branch(mock_stash_api): 489 | call_merge( 490 | "FOOBAR-81", 491 | [ 492 | r'Could not find any branch with text `"FOOBAR-81"` in any repositories of Stash projects: ' 493 | r"`PROJ-A`, `PROJ-B` nor Github organizations: ." 494 | ], 495 | ) 496 | 497 | 498 | pytest_plugins = ["errbot.backends.test"] 499 | extra_plugin_dir = "." 500 | 501 | 502 | def test_merge_default_branch(mock_stash_api): 503 | from_branch = "fb-SSRL-1890-py3" 504 | mock_stash_api["PROJ-A"]["repo1"]["pull_requests"] = [ 505 | dict( 506 | id="10", 507 | fromRef=dict(id="refs/heads/" + from_branch), 508 | toRef=dict(id="refs/heads/target_branch"), 509 | displayId=from_branch, 510 | links=make_link("url.com/for/10"), 511 | version="10", 512 | ) 513 | ] 514 | mock_stash_api["PROJ-A"]["repo1"]["pull_request"] = {"10": DummyPullRequest(True)} 515 | mock_stash_api["PROJ-A"]["repo1"]["commits"] = { 516 | ("refs/heads/" + from_branch, "refs/heads/target_branch"): ["A", "B"] 517 | } 518 | 519 | mock_stash_api["PROJ-B"]["repo3"]["branches"].append( 520 | dict(id="refs/heads/target_branch", displayId="target_branch") 521 | ) 522 | 523 | mock_stash_api["PROJ-B"]["repo3"]["commits"] = { 524 | ("refs/heads/" + from_branch, "refs/heads/master"): ["C", "D"] 525 | } 526 | 527 | call_merge( 528 | from_branch, 529 | [ 530 | r"Branch `fb-SSRL-1890-py3` merged into:", 531 | r":white_check_mark: `repo1` *2 commits* -> `target_branch`", 532 | r"`repo2` - (no changes)", 533 | r"`repo3` - (no changes)", 534 | r"Branch deleted from repositories: `repo1`, `repo2`, `repo3`", 535 | ], 536 | ) 537 | 538 | 539 | class TestBot: 540 | """Tests for the bot commands""" 541 | 542 | @pytest.fixture 543 | def testbot(self, testbot): 544 | from errbot.backends.test import TestPerson 545 | 546 | testbot.bot.sender = TestPerson("fry@localhost", nick="fry") 547 | return testbot 548 | 549 | @pytest.fixture(autouse=True) 550 | def stash_plugin(self, testbot): 551 | stash_plugin = testbot.bot.plugin_manager.get_plugin_obj_by_name("Stash") 552 | stash_plugin.config = { 553 | "STASH_URL": "https://my-server.com/stash", 554 | "STASH_PROJECTS": ["PROJ-A", "PROJ-B", "PROJ-FOO"], 555 | "GITHUB_ORGANIZATIONS": ["GIT-FOO"], 556 | } 557 | return stash_plugin 558 | 559 | @pytest.mark.parametrize( 560 | "stash_projects, github_organizations, expected_response", 561 | [ 562 | ( 563 | [], 564 | [], 565 | "STASH_PROJECTS not configured. Use !plugin config Stash to configure it.", 566 | ) 567 | ], 568 | ) 569 | def test_merge( 570 | self, 571 | testbot, 572 | stash_plugin, 573 | monkeypatch, 574 | stash_projects, 575 | github_organizations, 576 | expected_response, 577 | ): 578 | testbot.push_message("!merge ASIM-81") 579 | response = testbot.pop_message() 580 | assert "Stash API Token not configured" in response 581 | 582 | config = { 583 | "STASH_URL": "https://my-server.com/stash", 584 | "STASH_PROJECTS": stash_projects, 585 | "GITHUB_ORGANIZATIONS": github_organizations, 586 | } 587 | monkeypatch.setattr(stash_plugin, "config", config) 588 | 589 | testbot.push_message("!stash token secret-token") 590 | response = testbot.pop_message() 591 | assert response == "Token saved." 592 | 593 | testbot.push_message("!github token github-secret-token") 594 | response = testbot.pop_message() 595 | assert response == "Github token saved." 596 | 597 | testbot.push_message("!merge ASIM-81") 598 | response = testbot.pop_message() 599 | 600 | assert response == expected_response 601 | 602 | def test_token(self, testbot, stash_plugin, monkeypatch): 603 | monkeypatch.setattr(stash_plugin, "config", None) 604 | testbot.push_message("!stash token") 605 | response = testbot.pop_message() 606 | assert "Stash plugin not configured, contact an admin." in response 607 | 608 | monkeypatch.undo() 609 | testbot.push_message("!stash token") 610 | response = testbot.pop_message() 611 | assert "Stash API Token not configured" in response 612 | assert ( 613 | "https://my-server.com/stash/plugins/servlet/access-tokens/manage" 614 | in response 615 | ) 616 | 617 | testbot.push_message("!stash token secret-token") 618 | response = testbot.pop_message() 619 | assert response == "Token saved." 620 | 621 | testbot.push_message("!stash token") 622 | response = testbot.pop_message() 623 | assert response == "Your Stash API Token is: secret-token (user: fry)" 624 | 625 | testbot.push_message("!github token github-secret-token") 626 | response = testbot.pop_message() 627 | assert response == "Github token saved." 628 | 629 | testbot.push_message("!github token") 630 | response = testbot.pop_message() 631 | assert response == "Your Github Token is: github-secret-token (user: fry)" 632 | 633 | def test_version(self, testbot): 634 | testbot.push_message("!version") 635 | response = testbot.pop_message() 636 | assert "1.0.0" in response 637 | 638 | def test_delete_branch(self, testbot, stash_plugin, monkeypatch): 639 | config = { 640 | "STASH_URL": "https://my-server.com/stash", 641 | "STASH_PROJECTS": [], 642 | "GITHUB_ORGANIZATIONS": [], 643 | } 644 | monkeypatch.setattr(stash_plugin, "config", config) 645 | testbot.push_message("!stash token secret-token") 646 | testbot.pop_message() 647 | testbot.push_message("!github token github-secret-token") 648 | testbot.pop_message() 649 | testbot.push_message("!delete-branch branch-to-delete") 650 | assert testbot.pop_message() == "Working..." 651 | assert ( 652 | testbot.pop_message() 653 | == "STASH_PROJECTS not configured. Use !plugin config Stash to configure it." 654 | ) 655 | 656 | 657 | @pytest.fixture 658 | def github_api(mocker): 659 | mocker.patch.object(github, "Github", autospec=True) 660 | api = GithubAPI() 661 | organizations = {"esss": "esss"} 662 | repos = { 663 | "esss": { 664 | "conda-devenv": GitRepo("conda-devenv"), 665 | "deps": GitRepo("deps"), 666 | "jira2latex": GitRepo("jira2latex"), 667 | } 668 | } 669 | api.organizations = organizations 670 | api.repos = repos 671 | api.repos["esss"]["deps"].branches.append(GitBranch("fb-SSRL-1890-py3")) 672 | api.repos["esss"]["deps"].branches.append(GitBranch("fb-branch-only-in-github")) 673 | api.repos["esss"]["conda-devenv"].branches.append(GitBranch("fb-ASIM-81-network")) 674 | api.repos["esss"]["conda-devenv"].branches.append( 675 | GitBranch("fb-branch-only-in-github") 676 | ) 677 | return api 678 | 679 | 680 | @pytest.fixture 681 | def github_inner_mock(github_api): 682 | return github_api._github 683 | 684 | 685 | @pytest.fixture 686 | def github_get_repo(github_inner_mock): 687 | return github_inner_mock.get_organization.return_value.get_repo 688 | 689 | 690 | def test_github_fetch_repos(github_api): 691 | repos = github_api.fetch_repos("esss") 692 | assert {repo.name for repo in repos} == {"conda-devenv", "deps", "jira2latex"} 693 | 694 | 695 | @pytest.mark.parametrize( 696 | "branch_name, expected_branches", 697 | [("", ["master", "branch-1", "branch-2", "branch-3"]), ("non-existing", [])], 698 | ) 699 | def test_github_fetch_branches(github_api, branch_name, expected_branches): 700 | branches = github_api.fetch_branches("esss", "jira2latex", branch_name=branch_name) 701 | assert [branch.name for branch in branches] == expected_branches 702 | 703 | 704 | def test_github_delete_branch(github_api): 705 | github_api.delete_branch("esss", "jira2latex", "branch-1") 706 | 707 | 708 | def test_github_fetch_pull_requests(github_api): 709 | prs = github_api.fetch_pull_requests("esss", "jira2latex") 710 | assert [pr.number for pr in prs] == [0, 1, 2, 3, 4] 711 | 712 | 713 | def test_github_fetch_pull_request(github_api): 714 | pr = github_api.fetch_pull_request("esss", "jira2latex", 42) 715 | assert pr.number == 42 716 | 717 | 718 | def test_github_fetch_repo_commits(github_api): 719 | commits = github_api.fetch_repo_commits("esss", "jira2latex", "develop", "master") 720 | assert commits == [0, 1, 2] 721 | 722 | 723 | def test_github_create_plans(github_api, mock_stash_api): 724 | plans = create_plans(mock_stash_api, github_api, [], ["esss"], "branch-1") 725 | ensure_text_matches_unique_branch(plans, "branch-1") 726 | 727 | assert len(plans) == 3 728 | for plan in plans: 729 | # need to assert this way because the order is not a guaranteed 730 | assert plan.slug in ["conda-devenv", "deps", "jira2latex"] 731 | 732 | assert [plan.to_branch for plan in plans] == [ 733 | "refs/heads/master", 734 | "refs/heads/master", 735 | "refs/heads/master", 736 | ] 737 | assert [len(plan.pull_requests) for plan in plans] == [1, 1, 1] 738 | 739 | 740 | def test_make_pr_link(github_api): 741 | expected_pr_link = ( 742 | fr"https://github.com/esss/jira2latex/compare/to_branch...from_branch" 743 | ) 744 | assert ( 745 | make_pr_link(github_api.url, "esss", "jira2latex", "from_branch", "to_branch") 746 | == expected_pr_link 747 | ) 748 | 749 | expected_pr_link = fr"https://eden.esss.co/stash/projects/esss/repos/some_project/compare/commits?sourceBranch=dev_branch&targetBranch=master" 750 | assert ( 751 | make_pr_link( 752 | fr"https://eden.esss.co/stash", 753 | "esss", 754 | "some_project", 755 | "dev_branch", 756 | "master", 757 | ) 758 | == expected_pr_link 759 | ) 760 | 761 | 762 | @pytest.mark.parametrize( 763 | "branch_name, expected_message, success", 764 | [ 765 | ( 766 | "fb-ASIM-81-network", 767 | [ 768 | "Stash: repo1 (commit id *2a0ae67e039099e300ec972e22c281af19f4ac9d*) ", 769 | r"Stash: repo3 (commit id *a938dfdfbaa1f25ccbc39e16060f73c44e5ef0dd*) *has PR*", 770 | r"GitHub: conda-devenv (commit id *e685cbba9aab1683a4a504582b4e30af36cdfddb*) *has PR*", 771 | ], 772 | True, 773 | ), 774 | ( 775 | "fb-branch-only-in-github", 776 | [ 777 | r"GitHub: conda-devenv (commit id *e685cbba9aab1683a4a504582b4e30af36cdfddb*) *has PR*", 778 | r"GitHub: deps (commit id *e685cbba9aab1683a4a504582b4e30af36cdfddb*) *has PR*", 779 | ], 780 | True, 781 | ), 782 | ( 783 | "fb-SSRL-1890-py3", 784 | [ 785 | r"Stash: repo1 (commit id *f9a7c6df341325822e3ea264cfe39e5ef8c73aa4*) ", 786 | r"Stash: repo2 (commit id *94cd166631d14dab533858b9b47e9584a2ff3f65*) ", 787 | r"Stash: repo3 (commit id *590f467a41ee833a33f8b6c44316bde00b9cda70*) ", 788 | r"GitHub: deps (commit id *e685cbba9aab1683a4a504582b4e30af36cdfddb*) ", 789 | ], 790 | True, 791 | ), 792 | ( 793 | "fb-branch-nonexistent", 794 | [ 795 | r'Could not find any branch with text `"fb-branch-nonexistent"` in any repositories of Stash projects: `PROJ-A`, `PROJ-B` nor Github organizations: `esss`.' 796 | ], 797 | False, 798 | ), 799 | ], 800 | ) 801 | def test_obtain_branches_to_delete( 802 | mock_stash_api, github_api, branch_name, expected_message, success 803 | ): 804 | branches_to_delete = list() 805 | stash_api = StashAPI( 806 | "https://myserver.com/stash", username="fry", password="PASSWORD123" 807 | ) 808 | lines = list( 809 | obtain_branches_to_delete( 810 | stash_api, 811 | github_api, 812 | ["PROJ-A", "PROJ-B"], 813 | ["esss"], 814 | branch_name, 815 | branches_to_delete, 816 | ) 817 | ) 818 | 819 | from _pytest.pytester import LineMatcher 820 | 821 | matcher = LineMatcher(sorted(lines)) 822 | 823 | if success: 824 | matching_lines = [f"Found branch `{branch_name}` in these repositories:"] 825 | matching_lines.extend(expected_message) 826 | matching_lines.append(r"*To confirm, please repeat the command*") 827 | assert len(branches_to_delete) == len(expected_message) 828 | else: 829 | matching_lines = expected_message 830 | matcher.re_match_lines(sorted(matching_lines)) 831 | 832 | 833 | @pytest.mark.parametrize( 834 | "branch_name, expected_message", 835 | [ 836 | ( 837 | "fb-ASIM-81-network", 838 | [ 839 | "Branch from `Stash` project: `PROJ-A` - repository: `repo1` :nuclear-bomb:", 840 | "Branch from `Stash` project: `PROJ-B` - repository: `repo3` :nuclear-bomb:", 841 | "Branch from `GitHub` project: `esss` - repository: `conda-devenv` :nuclear-bomb:", 842 | ], 843 | ), 844 | ( 845 | "fb-SSRL-1890-py3", 846 | [ 847 | "Branch from `Stash` project: `PROJ-A` - repository: `repo1` :nuclear-bomb:", 848 | "Branch from `Stash` project: `PROJ-A` - repository: `repo2` :nuclear-bomb:", 849 | "Branch from `Stash` project: `PROJ-B` - repository: `repo3` :nuclear-bomb:", 850 | "Branch from `GitHub` project: `esss` - repository: `deps` :nuclear-bomb:", 851 | ], 852 | ), 853 | ( 854 | "fb-branch-only-in-github", 855 | [ 856 | "Branch from `GitHub` project: `esss` - repository: `conda-devenv` :nuclear-bomb:", 857 | "Branch from `GitHub` project: `esss` - repository: `deps` :nuclear-bomb:", 858 | ], 859 | ), 860 | ], 861 | ) 862 | def test_delete_branches(mock_stash_api, github_api, branch_name, expected_message): 863 | stash_api = StashAPI( 864 | "https://myserver.com/stash", username="fry", password="PASSWORD123" 865 | ) 866 | branches_to_delete = create_plans( 867 | stash_api, 868 | github_api, 869 | ["PROJ-A", "PROJ-B"], 870 | ["esss"], 871 | branch_name, 872 | exactly_branch_name=True, 873 | assure_has_prs=False, 874 | ) 875 | lines = list(delete_branches(stash_api, github_api, branches_to_delete)) 876 | 877 | from _pytest.pytester import LineMatcher 878 | 879 | matcher = LineMatcher(sorted(lines)) 880 | matching_lines = [f"Deleting Branches `{branch_name}`:"] 881 | matching_lines.extend(expected_message) 882 | matcher.re_match_lines(sorted(matching_lines)) 883 | 884 | 885 | def test_delete_branch_empty(): 886 | lines = list(delete_branches(None, None, [])) 887 | assert lines == ["No branches to delete."] 888 | 889 | 890 | class GitRepo: 891 | def __init__(self, name): 892 | self.owner = lambda: None 893 | self.owner.name = "esss" 894 | self.name = name 895 | self.branches = [ 896 | GitBranch("master"), 897 | GitBranch("branch-1"), 898 | GitBranch("branch-2"), 899 | GitBranch("branch-3"), 900 | ] 901 | 902 | def get_git_ref(self, ref): 903 | result = lambda: None 904 | result.ref = "heads/{name}".format(name=ref) 905 | result.delete = lambda: None 906 | return result 907 | 908 | def get_pull(self, pr_id): 909 | return GitPR(pr_id, "branch-1", "master") 910 | 911 | def get_pulls(self): 912 | return [ 913 | GitPR(0, "branch-1", "master"), 914 | GitPR(1, "branch-2", "branch-3"), 915 | GitPR(2, "branch-3", "master"), 916 | GitPR(3, "fb-ASIM-81-network", "master"), 917 | GitPR(4, "fb-branch-only-in-github", "master"), 918 | ] 919 | 920 | def compare(self, to_branch, from_branch): 921 | result = lambda: None 922 | result.commits = [0, 1, 2] 923 | return result 924 | 925 | def get_branches(self): 926 | return self.branches 927 | 928 | def get_branch(self, branch_name): 929 | for branch in self.branches: 930 | if branch_name == branch.name: 931 | return branch 932 | 933 | raise GithubException(404, "not found") 934 | 935 | 936 | class GitPR: 937 | def __init__(self, id, from_branch, to_branch): 938 | self.number = id 939 | self.head = lambda: None 940 | self.head.ref = from_branch 941 | self.mergeable = True 942 | 943 | self.base = lambda: None 944 | self.base.ref = to_branch 945 | 946 | def merge(self): 947 | return 948 | 949 | 950 | class GitBranch: 951 | def __init__(self, name): 952 | self.name = name 953 | self.items = dict() 954 | self.items["displayId"] = name 955 | default_sha = "e685cbba9aab1683a4a504582b4e30af36cdfddb" 956 | self.commit = attr.make_class("Commit", {"sha": attr.ib(default=default_sha)})() 957 | 958 | def __getitem__(self, value): 959 | return self.items.get(value, "") 960 | -------------------------------------------------------------------------------- /err_stash.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import pickle 4 | from collections import OrderedDict, defaultdict 5 | from concurrent.futures import ThreadPoolExecutor, as_completed 6 | from configparser import ConfigParser 7 | from pathlib import Path 8 | from typing import List, Iterator 9 | 10 | import stashy 11 | import stashy.errors 12 | import attr 13 | from errbot import BotPlugin, botcmd, arg_botcmd 14 | from github import Github, GithubException 15 | from github.Branch import Branch 16 | from github.PullRequest import PullRequest 17 | 18 | 19 | class StashAPI: 20 | """ 21 | Thin access to the stashy API. 22 | 23 | We have this thin layer in order to mock it during testing. 24 | """ 25 | 26 | def __init__(self, url, *, username, password): 27 | self._stash = stashy.connect(url, username=username, password=password) 28 | self._url = url 29 | 30 | @property 31 | def url(self): 32 | return self._url 33 | 34 | def fetch_repos(self, project): 35 | return self._stash.projects[project].repos.list() 36 | 37 | def fetch_branches(self, project, slug, filter_text): 38 | return ( 39 | self._stash.projects[project].repos[slug].branches(filterText=filter_text) 40 | ) 41 | 42 | def delete_branch(self, project, slug, branch): 43 | return self._stash.projects[project].repos[slug].delete_branch(branch) 44 | 45 | def fetch_pull_requests(self, project, slug): 46 | return self._stash.projects[project].repos[slug].pull_requests.all() 47 | 48 | def fetch_pull_request(self, project, slug, pr_id): 49 | return self._stash.projects[project].repos[slug].pull_requests[pr_id] 50 | 51 | def fetch_repo_commits(self, project, slug, until, since): 52 | return self._stash.projects[project].repos[slug].commits(until, since) 53 | 54 | 55 | class GithubAPI: 56 | """ 57 | Access to the pygithub API. 58 | """ 59 | 60 | def __init__(self, login_or_token=None, password=None, organizations=tuple()): 61 | self._github = Github(login_or_token=login_or_token, password=password) 62 | self.url = "https://github.com" 63 | # disable PyGithub logger 64 | logging.disable(logging.CRITICAL) 65 | 66 | # organizations cache 67 | self.organizations = { 68 | organization: self._github.get_organization(organization) 69 | for organization in organizations 70 | } 71 | 72 | self.repos = dict() 73 | # repositories cache 74 | for organization in self.organizations: 75 | repos = list(self.organizations[organization].get_repos()) 76 | self.repos[organization] = {repo.name: repo for repo in repos} 77 | 78 | def fetch_repos(self, organization: str): 79 | return list(self.repos[organization].values()) 80 | 81 | def fetch_branches( 82 | self, organization: str, repo_name: str, *, branch_name: str = "" 83 | ): 84 | """ 85 | Returns a list of branches based on organization and name of the repository. 86 | 87 | :param str organization: 88 | Name of the Github organization. 89 | 90 | :param str repo_name: 91 | Name of the repository. 92 | 93 | :param branch_name: 94 | If passed, searches for a specific branch, otherwise all branches of the repository are returned 95 | """ 96 | repo = self.repos[organization][repo_name] 97 | if branch_name == "": 98 | return list(repo.get_branches()) 99 | 100 | try: 101 | return [repo.get_branch(branch_name)] 102 | except GithubException as e: 103 | if e.status == 404: # branch doesn't exist on this repo 104 | # REMINDER: trying to match branch name like this: 105 | # return [branch for branch in list(repo.get_branches()) if branch_name in branch.name] 106 | # slows down the merge plans creation in ~5 to ~10 extra seconds. 107 | return [] 108 | else: 109 | raise 110 | 111 | def delete_branch(self, organization: str, repo_name: str, branch_name: str): 112 | """ 113 | Deletes one branch from one repository. 114 | """ 115 | repo = self.repos[organization][repo_name] 116 | try: 117 | git_ref = repo.get_git_ref( 118 | "heads/{branch_name}".format(branch_name=branch_name) 119 | ) 120 | git_ref.delete() 121 | except GithubException as e: 122 | raise CheckError( 123 | "Error deleting branch '{branch_name}' in repo {repo_name}".format( 124 | branch_name=branch_name, repo_name=repo_name 125 | ) 126 | ) from e 127 | 128 | def fetch_pull_requests(self, organization, repo_name): 129 | """ 130 | Returns a list with all open pull requests 131 | """ 132 | return list(self.repos[organization][repo_name].get_pulls()) 133 | 134 | def fetch_pull_request(self, organization, repo_name, pr_id): 135 | """ 136 | Returns a specific github.PullRequest.PullRequest 137 | 138 | :param str organization: 139 | Name of the Github organization 140 | 141 | :param str repo_name: 142 | Name of the Github repository 143 | 144 | :param int pr_id: 145 | Pull request ID (the same you see on the Github PR page). 146 | """ 147 | return self.repos[organization][repo_name].get_pull(pr_id) 148 | 149 | def fetch_repo_commits( 150 | self, organization, repo_name, from_branch: str, to_branch: str 151 | ): 152 | """ 153 | Returns a list of commits that are on from_branch, but not in to_branch, i.e., 154 | commits that are added by the PR. 155 | 156 | :param from_branch: 157 | Name of the branch that created the PR (Github calls this head branch). 158 | 159 | :param to_branch: 160 | Name of the target branch in the PR (Github calls this base branch). 161 | """ 162 | repo = self.repos[organization][repo_name] 163 | return repo.compare(to_branch, from_branch).commits 164 | 165 | 166 | @attr.s(frozen=True) 167 | class Branch: 168 | branch_id = attr.ib() 169 | display_id = attr.ib() 170 | latest_commit = attr.ib() 171 | 172 | 173 | @attr.s() 174 | class MergePlan: 175 | """ 176 | Contains information about branch and PRs that will be involved in a merge operation. 177 | """ 178 | 179 | project = attr.ib() 180 | slug = attr.ib() 181 | comes_from_github = attr.ib(default=None) 182 | branches = attr.ib(factory=list) 183 | pull_requests = attr.ib(factory=list) 184 | to_branch = attr.ib(default=None) 185 | 186 | @property 187 | def provider_name(self): 188 | return "GitHub" if self.comes_from_github else "Stash" 189 | 190 | 191 | def get_self_url(d): 192 | """Returns the URL of a Stash resource""" 193 | return d.html_url if isinstance(d, PullRequest) else d["links"]["self"][0]["href"] 194 | 195 | 196 | def commits_text(commits): 197 | """Returns text in the form 'X commits' or '1 commit'""" 198 | plural = "s" if len(commits) != 1 else "" 199 | return "{} commit{}".format(len(commits), plural) 200 | 201 | 202 | class CheckError(Exception): 203 | """Exception raised when one of the various checks done before a merge is done fails""" 204 | 205 | def __init__(self, lines): 206 | if isinstance(lines, str): 207 | lines = [lines] 208 | super().__init__("\n".join(lines)) 209 | self.lines = lines 210 | 211 | 212 | def create_plans( 213 | stash_api, 214 | github_api, 215 | stash_projects, 216 | github_organizations, 217 | branch_text, 218 | *, 219 | exactly_branch_name=False, 220 | assure_has_prs=True, 221 | ): 222 | """ 223 | Go over all the branches in all Stash and GitHub repositories searching for branches and PRs that match the given branch text. 224 | 225 | :rtype: List[MergePlan] 226 | """ 227 | # Plans for Stash repos: 228 | stash_repos = [] 229 | for project in stash_projects: 230 | stash_repos += stash_api.fetch_repos(project) 231 | 232 | plans = [] 233 | has_prs = False 234 | for repo in stash_repos: 235 | slug = repo["slug"] 236 | project = repo["project"]["key"] 237 | branches = list( 238 | Branch(b["id"], b["displayId"], b["latestCommit"]) 239 | for b in stash_api.fetch_branches(project, slug, branch_text) 240 | ) 241 | if exactly_branch_name: 242 | branches = [ 243 | branch for branch in branches if branch.display_id == branch_text 244 | ] 245 | if branches: 246 | plan = MergePlan(project, slug) 247 | plans.append(plan) 248 | plan.branches = branches 249 | branch_ids = [x.branch_id for x in plan.branches] 250 | prs = list(stash_api.fetch_pull_requests(project, slug)) 251 | for pr in prs: 252 | if pr["fromRef"]["id"] in branch_ids: 253 | has_prs = True 254 | plan.pull_requests.append(pr) 255 | if plan.pull_requests: 256 | plan.to_branch = plan.pull_requests[0]["toRef"]["id"] 257 | 258 | # Plans for Github repos: 259 | github_branch_text = branch_text 260 | if len(plans) > 0 and len(plans[0].branches) > 0: 261 | # if we already found the branch name on Stash, we can use its name here 262 | github_branch_text = plans[0].branches[0].display_id 263 | 264 | organization_to_repos = defaultdict(list) 265 | for organization in github_organizations: 266 | organization_to_repos[organization] += github_api.fetch_repos(organization) 267 | 268 | futures = dict() 269 | for organization in organization_to_repos.keys(): 270 | with ThreadPoolExecutor(max_workers=16) as executor: 271 | for repo in organization_to_repos[organization]: 272 | repo_name = repo.name 273 | f = executor.submit( 274 | github_api.fetch_branches, 275 | organization, 276 | repo_name, 277 | branch_name=github_branch_text, 278 | ) 279 | 280 | futures[f] = repo_name 281 | 282 | for f in as_completed(futures.keys()): 283 | repo_name = futures[f] 284 | branches = f.result() 285 | if not branches: 286 | continue 287 | 288 | plan = MergePlan(organization, repo_name, comes_from_github=True) 289 | plans.append(plan) 290 | # b.name is passed twice because github uses the same `id` and `display_id` 291 | plan.branches = [Branch(b.name, b.name, b.commit.sha) for b in branches] 292 | 293 | prs = github_api.fetch_pull_requests(organization, repo_name) 294 | 295 | for pr in prs: 296 | if pr.head.ref == github_branch_text: 297 | has_prs = True 298 | plan.pull_requests.append(pr) 299 | if plan.pull_requests: 300 | plan.to_branch = "refs/heads/{}".format( 301 | plan.pull_requests[0].base.ref 302 | ) 303 | 304 | if not plans: 305 | raise CheckError( 306 | 'Could not find any branch with text `"{}"` in any repositories of Stash projects: {} nor ' 307 | "Github organizations: {}.".format( 308 | branch_text, 309 | ", ".join("`{}`".format(x) for x in stash_projects), 310 | ", ".join("`{}`".format(x) for x in github_organizations), 311 | ) 312 | ) 313 | 314 | if assure_has_prs and not has_prs: 315 | raise CheckError('No PRs are open with text `"{}"`'.format(branch_text)) 316 | return plans 317 | 318 | 319 | def ensure_text_matches_unique_branch(plans, branch_text): 320 | """Ensure that the given branch text matches only a single branch""" 321 | # check if any of the plans have matched more than one branch 322 | error_lines = [] 323 | for plan in plans: 324 | if len(plan.branches) > 1: 325 | if not error_lines: 326 | error_lines.append( 327 | 'More than one branch matches the text `"{}"`:'.format(branch_text) 328 | ) 329 | names = ", ".join("`{}`".format(x.display_id) for x in plan.branches) 330 | error_lines.append("`{slug}`: {names}".format(slug=plan.slug, names=names)) 331 | 332 | if error_lines: 333 | error_lines.append("Use a more complete text or remove one of the branches.") 334 | raise CheckError(error_lines) 335 | 336 | branch = plans[0].branches[0] 337 | return branch.display_id 338 | 339 | 340 | def ensure_unique_pull_requests(plans, from_branch_display_id): 341 | """Ensure we have only one PR per repository for the given branch""" 342 | error_lines = [] 343 | for plan in plans: 344 | if len(plan.pull_requests) > 1: 345 | if not error_lines: 346 | error_lines.append( 347 | "Multiples PRs for branch `{}` found:".format( 348 | from_branch_display_id 349 | ) 350 | ) 351 | links = [ 352 | "[PR#{id}]({url})".format( 353 | id=x.number if isinstance(x, PullRequest) else x["id"], 354 | url=get_self_url(x), 355 | ) 356 | for x in plan.pull_requests 357 | ] 358 | error_lines.append( 359 | "`{slug}`: {links}".format(slug=plan.slug, links=", ".join(links)) 360 | ) 361 | if error_lines: 362 | error_lines.append("Sorry you will have to sort that mess yourself. :wink:") 363 | raise CheckError(error_lines) 364 | 365 | 366 | def ensure_pull_requests_target_same_branch(plans, from_branch_display_id): 367 | """Ensure that all PRs target the same branch""" 368 | # check that all PRs for the branch target the same "to" branch 369 | result = None 370 | multiple_target_branches = False 371 | for plan in plans: 372 | if plan.pull_requests: 373 | assert len(plan.pull_requests) == 1 374 | if result is None: 375 | result = plan.to_branch 376 | elif result != plan.to_branch: 377 | multiple_target_branches = True 378 | break 379 | 380 | if multiple_target_branches: 381 | error_lines = [ 382 | "PRs in repositories for branch `{}` have different targets:".format( 383 | from_branch_display_id 384 | ) 385 | ] 386 | for plan in plans: 387 | if plan.pull_requests: 388 | assert len(plan.pull_requests) == 1 389 | pr = plan.pull_requests[0] 390 | error_lines.append( 391 | "`{slug}`: [PR#{id}]({url}) targets `{to_ref}`".format( 392 | slug=plan.slug, 393 | id=pr.number if isinstance(pr, PullRequest) else pr["id"], 394 | url=get_self_url(pr), 395 | to_ref=pr.base.ref 396 | if isinstance(pr, PullRequest) 397 | else pr["toRef"]["id"], 398 | ) 399 | ) 400 | 401 | error_lines.append("Fix those PRs and try again. ") 402 | error_lines.append( 403 | "Alternately you can pass `--force` to force the merge with different targets!" 404 | ) 405 | raise CheckError(error_lines) 406 | 407 | 408 | def make_pr_link(url, project, slug, from_branch, to_branch): 409 | """Generates a URL that can be used to create a PR""" 410 | from urllib.parse import urlencode 411 | 412 | if "github.com" in url: 413 | result = "{url}/{organization}/{repo_name}/compare/{to_branch}...{from_branch}".format( 414 | url=url, 415 | organization=project, 416 | repo_name=slug, 417 | from_branch=from_branch, 418 | to_branch=to_branch, 419 | ) 420 | else: 421 | base_url = "{url}/projects/{project}/repos/{slug}/compare/commits?".format( 422 | url=url, project=project, slug=slug 423 | ) 424 | result = base_url + urlencode( 425 | OrderedDict([("sourceBranch", from_branch), ("targetBranch", to_branch)]) 426 | ) 427 | 428 | return result 429 | 430 | 431 | def get_commits_about_to_be_merged_by_pull_requests( 432 | stash_api, github_api, plans, from_branch 433 | ): 434 | """Returns a summary of the commits in each PR that will be merged""" 435 | error_lines = [] 436 | result = [] 437 | default_branch = next(plan.to_branch for plan in plans if plan.to_branch) 438 | 439 | def default_branch_exists(plan): 440 | branch_name = default_branch.replace("refs/heads/", "") 441 | 442 | if plan.comes_from_github: 443 | branches = list( 444 | github_api.fetch_branches( 445 | organization=plan.project, 446 | repo_name=plan.slug, 447 | branch_name=branch_name, 448 | ) 449 | ) 450 | else: 451 | branches = list( 452 | stash_api.fetch_branches(plan.project, plan.slug, branch_name) 453 | ) 454 | 455 | return len(branches) > 0 456 | 457 | for plan in plans: 458 | if plan.to_branch: 459 | to_branch = plan.to_branch 460 | elif default_branch_exists(plan): 461 | to_branch = default_branch 462 | else: 463 | to_branch = "refs/heads/master" 464 | 465 | if plan.comes_from_github: 466 | commits = list( 467 | github_api.fetch_repo_commits( 468 | organization=plan.project, 469 | repo_name=plan.slug, 470 | from_branch=from_branch, 471 | to_branch=to_branch, 472 | ) 473 | ) 474 | else: 475 | try: 476 | commits = list( 477 | stash_api.fetch_repo_commits( 478 | plan.project, plan.slug, from_branch, to_branch 479 | ) 480 | ) 481 | except stashy.errors.NotFoundException: 482 | commits = [] 483 | 484 | if commits and not plan.pull_requests: 485 | if not error_lines: 486 | error_lines.append( 487 | "These repositories have commits in `{}` but no PRs:".format( 488 | from_branch 489 | ) 490 | ) 491 | pr_link = make_pr_link( 492 | github_api.url if plan.comes_from_github else stash_api.url, 493 | plan.project, 494 | plan.slug, 495 | from_branch, 496 | to_branch, 497 | ) 498 | error_lines.append( 499 | "`{slug}`: *{commits_text}* ([create PR]({pr_link}))".format( 500 | slug=plan.slug, commits_text=commits_text(commits), pr_link=pr_link 501 | ) 502 | ) 503 | if commits: 504 | result.append((plan, commits)) 505 | 506 | if error_lines: 507 | error_lines.append( 508 | "You need to create PRs for your changes before merging this branch." 509 | ) 510 | raise CheckError(error_lines) 511 | 512 | return result 513 | 514 | 515 | def ensure_no_conflicts(stash_api, from_branch, plans): 516 | """Ensures that all PRs are not in a conflicting state""" 517 | error_lines = [] 518 | for plan in plans: 519 | pr_data = plan.pull_requests[0] 520 | pr_id = ( 521 | plan.pull_requests[0].number if plan.comes_from_github else pr_data["id"] 522 | ) 523 | 524 | pull_request = ( 525 | pr_data 526 | if plan.comes_from_github 527 | else stash_api.fetch_pull_request(plan.project, plan.slug, pr_id) 528 | ) 529 | 530 | is_mergeable = ( 531 | pull_request.mergeable 532 | if plan.comes_from_github 533 | else pull_request.can_merge() 534 | ) 535 | if not is_mergeable: 536 | if not error_lines: 537 | error_lines.append( 538 | "The PRs below for branch `{}` have problems such as conflicts, " 539 | "build requirements, etc:".format(from_branch) 540 | ) 541 | 542 | error_lines.append( 543 | "`{slug}`: [PR#{id}]({url})".format( 544 | slug=plan.slug, id=pr_id, url=get_self_url(pr_data) 545 | ) 546 | ) 547 | 548 | if error_lines: 549 | error_lines.append("Fix them and try again.") 550 | raise CheckError(error_lines) 551 | 552 | 553 | def ensure_has_pull_request(plans): 554 | message = """ 555 | No pull request open for this branch! 556 | """ 557 | if not any(plan for plan in plans if plan.to_branch): 558 | raise CheckError( 559 | message 560 | ) # Unreached code, already checked in `create_plans` method 561 | 562 | 563 | def merge( 564 | url, 565 | stash_projects, 566 | stash_username, 567 | stash_password, 568 | github_username_or_token, 569 | github_password, 570 | github_organizations, 571 | branch_text, 572 | confirm, 573 | force=False, 574 | ): 575 | """ 576 | Merges PRs in repositories which match a given branch name, performing various checks beforehand. 577 | 578 | :param str url: URL to stash server. 579 | :param list[str] stash_projects: List of Stash project keys to search branches 580 | :param str stash_username: username 581 | :param str stash_password: password or access token (write access). 582 | :param str github_username_or_token: username or token 583 | :param str github_password: password 584 | :param list github_organizations: List of organization names to search repositories 585 | :param str branch_text: complete or partial branch name to search for 586 | :param bool confirm: if True, perform the merge, otherwise just print what would happen. 587 | :param bool force: if True, won't check if branch target are the same 588 | :raise CheckError: if a check for merging-readiness fails. 589 | """ 590 | stash_api = StashAPI(url, username=stash_username, password=stash_password) 591 | github_api = GithubAPI( 592 | login_or_token=github_username_or_token, 593 | password=github_password, 594 | organizations=tuple(github_organizations), 595 | ) 596 | 597 | plans = create_plans( 598 | stash_api, github_api, stash_projects, github_organizations, branch_text 599 | ) 600 | from_branch = ensure_text_matches_unique_branch(plans, branch_text) 601 | ensure_unique_pull_requests(plans, from_branch) 602 | ensure_has_pull_request(plans) 603 | if not force: 604 | ensure_pull_requests_target_same_branch(plans, from_branch) 605 | plans_and_commits = get_commits_about_to_be_merged_by_pull_requests( 606 | stash_api, github_api, plans, from_branch 607 | ) 608 | ensure_no_conflicts( 609 | stash_api, from_branch, [plan for (plan, _) in plans_and_commits] 610 | ) 611 | 612 | yield "Branch `{}` merged into:".format(from_branch) 613 | shown = set() 614 | for plan, commits in plans_and_commits: 615 | pull_request = ( 616 | plan.pull_requests[0] 617 | if plan.comes_from_github 618 | else stash_api.fetch_pull_request( 619 | plan.project, plan.slug, plan.pull_requests[0]["id"] 620 | ) 621 | ) 622 | if confirm: 623 | if plan.comes_from_github: 624 | pull_request.merge() 625 | else: 626 | # https://confluence.atlassian.com/bitbucketserverkb/bitbucket-server-rest-api-for-merging-pull-request-fails-792309002.html 627 | pull_request.merge(version=plan.pull_requests[0]["version"]) 628 | yield ":white_check_mark: `{}` *{}* -> `{}`".format( 629 | plan.slug, commits_text(commits), plan.to_branch.replace("refs/heads/", "") 630 | ) 631 | shown.add(plan.slug) 632 | other_plans = (p for p in plans if p.slug not in shown) 633 | for plan in other_plans: 634 | yield "`{}` - (no changes)".format(plan.slug) 635 | 636 | for plan in plans: 637 | if confirm: 638 | if plan.comes_from_github: 639 | github_api.delete_branch( 640 | organization=plan.project, 641 | repo_name=plan.slug, 642 | branch_name=plan.branches[0].display_id, 643 | ) 644 | else: 645 | stash_api.delete_branch( 646 | plan.project, plan.slug, plan.branches[0].branch_id 647 | ) 648 | repo_list = ["`{}`".format(p.slug) for p in plans] 649 | yield "Branch deleted from repositories: {}".format(", ".join(sorted(repo_list))) 650 | if not confirm: # pragma: no cover 651 | yield "{x} dry-run {x}".format(x="-" * 30) 652 | 653 | 654 | def delete_branches( 655 | stash_api: StashAPI, github_api: GithubAPI, branches_to_delete: List[MergePlan] 656 | ) -> Iterator[str]: 657 | """ 658 | Responsible for deleting the received MergePlan's list (branches_to_delete). 659 | 660 | 661 | :param stash_api: Instanced StashAPI already logged 662 | :param github_api: Instanced GithubAPI already logged 663 | :param branches_to_delete: Populated MergePlan's list which will be deleted 664 | :return: Yield strings as messages to be showed by Bender 665 | """ 666 | if len(branches_to_delete) > 0: 667 | yield f"Deleting Branches `{branches_to_delete[0].branches[0].display_id}`:" 668 | for branch in branches_to_delete: 669 | if branch.comes_from_github: 670 | github_api.delete_branch( 671 | organization=branch.project, 672 | repo_name=branch.slug, 673 | branch_name=branch.branches[0].display_id, 674 | ) 675 | yield f"Branch from `GitHub` project: `{branch.project}` - repository: `{branch.slug}` :nuclear-bomb:" 676 | else: 677 | if len(branch.pull_requests) > 0: 678 | pr = stash_api.fetch_pull_request( 679 | branch.project, branch.slug, branch.pull_requests[0]["id"] 680 | ) 681 | pr.decline(branch.pull_requests[0]["version"]) 682 | 683 | stash_api.delete_branch( 684 | branch.project, branch.slug, branch.branches[0].display_id 685 | ) 686 | yield f"Branch from `Stash` project: `{branch.project}` - repository: `{branch.slug}` :nuclear-bomb:" 687 | else: 688 | yield "No branches to delete." 689 | 690 | 691 | def obtain_branches_to_delete( 692 | stash_api: StashAPI, 693 | github_api: GithubAPI, 694 | stash_projects: List["str"], 695 | github_organizations: List["str"], 696 | branch_name: str, 697 | branches_to_delete: List[MergePlan], 698 | ) -> Iterator[str]: 699 | """ 700 | Responsible for populate 'branches_to_delete' with the branches to be deleted afterwards, 701 | 702 | :param stash_api: Instanced StashAPI already logged 703 | :param github_api: Instanced GithubAPI already logged 704 | :param stash_projects: list of projects in Stash to be searched in 705 | :param github_organizations: list of projects in GitHub to be searched in 706 | :param branch_name: Full branch name to be deleted from all repositories that match the search 707 | :param branches_to_delete: Empty list that will receive the to delete MergePlan's 708 | :return: Yield strings as messages to be showed by Bender 709 | """ 710 | try: 711 | plans = create_plans( 712 | stash_api, 713 | github_api, 714 | stash_projects, 715 | github_organizations, 716 | branch_name, 717 | exactly_branch_name=True, 718 | assure_has_prs=False, 719 | ) 720 | except CheckError as e: 721 | yield "\n".join(e.lines) 722 | return 723 | 724 | yield "Found branch `{}` in these repositories:".format(branch_name) 725 | for plan in plans: 726 | branches_to_delete.append(plan) 727 | yield f"{plan.provider_name}: {plan.slug} (commit id *{plan.branches[0].latest_commit}*) {'*has PR*' if len(plan.pull_requests) > 0 else ''}" 728 | yield "*To confirm, please repeat the command*" 729 | 730 | 731 | class StashBot(BotPlugin): 732 | """Stash commands tailored to ESSS workflow""" 733 | 734 | def get_configuration_template(self): 735 | return { 736 | "STASH_URL": "https://eden.esss.co/stash", 737 | "STASH_PROJECTS": None, 738 | "GITHUB_ORGANIZATIONS": None, 739 | } 740 | 741 | def load_user_settings(self, user): 742 | key = "user:{}".format(user) 743 | settings = {"token": "", "github_token": "", "to-delete-branches": {}} 744 | loaded = self.get(key, settings) 745 | settings.update(loaded) 746 | self.log.debug("LOAD ({}) settings: {}".format(user, settings)) 747 | return settings 748 | 749 | def save_user_settings(self, user, settings): 750 | key = "user:{}".format(user) 751 | self[key] = settings 752 | self.log.debug("SAVE ({}) settings: {}".format(user, settings)) 753 | 754 | @botcmd 755 | def version(self, msg, args): 756 | """Get current version and CHANGELOG""" 757 | return Path(__file__).parent.joinpath("CHANGELOG.md").read_text() 758 | 759 | @botcmd(split_args_with=None) 760 | def stash_token(self, msg, args): 761 | """Set or get your Stash token""" 762 | user = msg.frm.nick 763 | settings = self.load_user_settings(user) 764 | if not self.config: 765 | return "Stash plugin not configured, contact an admin." 766 | if not args: 767 | if settings["token"]: 768 | return "Your Stash API Token is: `{}` (user: {})".format( 769 | settings["token"], user 770 | ) 771 | else: 772 | return NO_TOKEN_MSG.format(stash_url=self.config["STASH_URL"]) 773 | else: 774 | settings["token"] = args[0] 775 | self.save_user_settings(user, settings) 776 | return "Token saved." 777 | 778 | @botcmd(name="delete-branch", split_args_with=None) 779 | def delete_branch(self, msg, args): 780 | """Search the given branch in Stash and GitHub repositories, saving them and showing via Bender, 781 | and if confirmed by running the same command twice, will decline all pull request and delete 782 | the branches from all repositories""" 783 | user = msg.frm.nick 784 | yield "Working..." 785 | if len(args) > 1: 786 | yield f"Unknown arguments {args[1:]}, please pass only the branch name to be deleted." 787 | return 788 | branch_to_delete = args[0] 789 | settings = self.load_user_settings(user) 790 | 791 | if not settings["token"]: 792 | yield self.stash_token(msg, []) 793 | return 794 | 795 | if not settings["github_token"]: 796 | yield self.github_token(msg, []) 797 | return 798 | 799 | stash_projects = self.config.get("STASH_PROJECTS", None) 800 | if stash_projects is None or stash_projects == []: 801 | yield "`STASH_PROJECTS` not configured. Use `!plugin config Stash` to configure it." 802 | return 803 | 804 | stash_api = StashAPI( 805 | self.config["STASH_URL"], username=user, password=settings["token"] 806 | ) 807 | 808 | github_api = GithubAPI( 809 | login_or_token=settings["github_token"], 810 | password=None, 811 | organizations=tuple(self.config["GITHUB_ORGANIZATIONS"]), 812 | ) 813 | 814 | if branch_to_delete not in settings["to-delete-branches"]: 815 | branches: List[MergePlan] = list() 816 | lines = list( 817 | obtain_branches_to_delete( 818 | stash_api, 819 | github_api, 820 | self.config["STASH_PROJECTS"], 821 | self.config["GITHUB_ORGANIZATIONS"], 822 | branch_to_delete, 823 | branches, 824 | ) 825 | ) 826 | settings["to-delete-branches"][branch_to_delete] = branches 827 | else: 828 | lines = list( 829 | delete_branches( 830 | stash_api, 831 | github_api, 832 | settings["to-delete-branches"].pop(branch_to_delete), 833 | ) 834 | ) 835 | self.save_user_settings(user, settings) 836 | yield "\n".join(lines) 837 | 838 | @botcmd(split_args_with=None) 839 | def github_token(self, msg, args): 840 | """Set or get Github token""" 841 | user = msg.frm.nick 842 | settings = self.load_user_settings(user) 843 | if not self.config: 844 | return "Plugin not configured, contact an admin." 845 | if not args: 846 | if settings["github_token"]: 847 | return "Your Github Token is: `{}` (user: {})".format( 848 | settings["github_token"], user 849 | ) 850 | else: 851 | return NO_GITHUB_TOKEN_MSG 852 | else: 853 | settings["github_token"] = args[0] 854 | self.save_user_settings(user, settings) 855 | return "Github token saved." 856 | 857 | @arg_botcmd( 858 | "--force", action="store_true", help="If set, won't check target branch names" 859 | ) 860 | @arg_botcmd("branch_text", help="Branch name to merge") 861 | def merge(self, msg, branch_text, force=False): 862 | """Merges PRs related to a branch (which can be a partial match)""" 863 | user = msg.frm.nick 864 | settings = self.load_user_settings(user) 865 | if not settings["token"]: 866 | yield self.stash_token(msg, []) 867 | return 868 | 869 | if not settings["github_token"]: 870 | yield self.github_token(msg, []) 871 | return 872 | 873 | stash_projects = self.config.get("STASH_PROJECTS", None) 874 | if stash_projects is None or stash_projects == []: 875 | yield "`STASH_PROJECTS` not configured. Use `!plugin config Stash` to configure it." 876 | return 877 | 878 | yield "Working..." 879 | try: 880 | lines = list( 881 | merge( 882 | url=self.config["STASH_URL"], 883 | stash_projects=self.config["STASH_PROJECTS"], 884 | stash_username=user, 885 | stash_password=settings["token"], 886 | github_password=None, 887 | github_username_or_token=settings["github_token"], 888 | github_organizations=self.config["GITHUB_ORGANIZATIONS"], 889 | branch_text=branch_text, 890 | confirm=True, 891 | force=force, 892 | ) 893 | ) 894 | except CheckError as e: 895 | lines = e.lines 896 | yield "\n".join(lines) 897 | 898 | 899 | NO_TOKEN_MSG = """ 900 | *Stash API Token not configured*. 901 | Create a new token [here]({stash_url}/plugins/servlet/access-tokens/manage) with *write access* and then execute: 902 | `!stash token ` 903 | This only needs to be done once. 904 | """ 905 | 906 | NO_GITHUB_TOKEN_MSG = """ 907 | *Github API Token not configured*. 908 | Create a new token [here](https://github.com/settings/tokens/new) checking all boxes in `repo` and `user`, 909 | then execute: 910 | `!github token ` 911 | 912 | This only needs to be done once. 913 | """ 914 | 915 | 916 | def main(args): # pragma: no cover 917 | """Command-line implementation. 918 | 919 | For convenience one can define a "default.ini" file with user name and token: 920 | 921 | [err-stash] 922 | user = bruno 923 | password = secret-token 924 | github_username_or_token = username-or-token 925 | github_password = password-if-using-username 926 | """ 927 | p = Path(__file__).parent.joinpath("default.ini") 928 | if p.is_file(): 929 | config = ConfigParser() 930 | config.read(str(p)) 931 | 932 | default_user = config["err-stash"]["user"] 933 | default_password = config["err-stash"]["password"] 934 | default_github_username = config["err-stash"]["github_username_or_token"] 935 | default_github_password = config["err-stash"]["github_password"] 936 | else: 937 | default_user = None 938 | default_password = None 939 | default_github_username = None 940 | default_github_password = None 941 | 942 | parser = argparse.ArgumentParser(description="Merge multiples branches.") 943 | parser.add_argument("-u", "--username", default=default_user) 944 | parser.add_argument("-p", "--password", default=default_password) 945 | parser.add_argument("--github_username_or_token", default=default_github_username) 946 | parser.add_argument("--github_password", default=default_github_password) 947 | 948 | parser.add_argument("--confirm", default=False, action="store_true") 949 | parser.add_argument( 950 | "--force", 951 | default=False, 952 | action="store_true", 953 | help="Force the merge by ignoring different branches target", 954 | ) 955 | parser.add_argument("text", help="Branch text (possibly partial) to search for") 956 | parser.add_argument( 957 | "projects", 958 | help="list of Stash projects to search branches, separated by commas", 959 | ) 960 | parser.add_argument( 961 | "github_organizations", 962 | help="list of Github organizations to search branches, separated by commas", 963 | ) 964 | 965 | options = parser.parse_args(args) 966 | try: 967 | lines = list( 968 | merge( 969 | "https://eden.esss.co/stash", 970 | options.projects.split(","), 971 | stash_username=options.username, 972 | stash_password=options.password, 973 | github_username_or_token=options.github_username_or_token, 974 | github_password=options.github_password, 975 | github_organizations=options.github_organizations.split(","), 976 | branch_text=options.text, 977 | confirm=options.confirm, 978 | force=options.force, 979 | ) 980 | ) 981 | result = 0 982 | except CheckError as e: 983 | lines = e.lines 984 | result = 4 985 | print("\n".join(lines)) 986 | return result 987 | 988 | 989 | if __name__ == "__main__": # pragma: no cover 990 | import sys 991 | 992 | sys.exit(main(sys.argv[1:])) 993 | --------------------------------------------------------------------------------