├── .coveragerc ├── .github └── workflows │ └── tox.yml ├── .gitignore ├── .readthedocs.yaml ├── .tekton ├── build-pipeline.yaml ├── sync2jira-pull-request.yaml ├── sync2jira-push.yaml ├── sync2jira-sync-page-pull-request.yaml └── sync2jira-sync-page-push.yaml ├── CODEOWNERS ├── Dockerfile ├── Dockerfile.sync-page ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── docs-requirements.txt └── source │ ├── adding-new-repo-guide.rst │ ├── conf.py │ ├── config-file.rst │ ├── downstream_issue.rst │ ├── downstream_pr.rst │ ├── index.rst │ ├── intermediary.rst │ ├── mailer.rst │ ├── main.rst │ ├── quickstart.rst │ ├── sync_page.rst │ ├── upstream_issue.rst │ └── upstream_pr.rst ├── fedmsg.d └── sync2jira.py ├── renovate.json ├── requirements.txt ├── setup.py ├── sync-page ├── __init__.py ├── assets │ ├── font.css │ └── redhat-favicon.ico ├── event-handler.py └── templates │ ├── sync-page-failure.jinja │ ├── sync-page-github.jinja │ └── sync-page-success.jinja ├── sync2jira ├── __init__.py ├── downstream_issue.py ├── downstream_pr.py ├── failure_template.jinja ├── intermediary.py ├── mailer.py ├── main.py ├── upstream_issue.py └── upstream_pr.py ├── test-requirements.txt ├── tests ├── __init__.py ├── integration_tests │ ├── integration_test.py │ ├── jira_values.py │ └── runtime_config.py ├── test_downstream_issue.py ├── test_downstream_pr.py ├── test_intermediary.py ├── test_main.py ├── test_upstream_issue.py └── test_upstream_pr.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = sync2jira/ 4 | 5 | [report] 6 | omit = 7 | sync2jira/mailer.py 8 | continuous-deployment/continuous_deployment.py 9 | 10 | fail_under = 70 -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Run Tox 3 | 4 | # yamllint disable rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | - legacy 10 | tags: 11 | - v* 12 | pull_request: 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Run Tox tests 22 | id: test 23 | uses: fedora-python/tox-github-action@main 24 | with: 25 | tox_env: py39,py313,lint,isort,black 26 | dnf_install: >- 27 | --repo fedora --repo updates 28 | krb5-devel 29 | 30 | - name: Coveralls 31 | uses: AndreMiras/coveralls-python-action@develop 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | *.pem 3 | !mock-cert.pem 4 | .env 5 | package-lock.json 6 | coverage 7 | .DS_Store 8 | .idea/ 9 | */node_modules/ 10 | /dist 11 | sync2jira.egg-info/ 12 | *venv/ 13 | build/ 14 | *__pycache__* 15 | xunit-tests.xml 16 | .coverage 17 | .tox/* 18 | htmlcov-py36/* 19 | fedmsg.d/sync2jira.py 20 | /.tox 21 | *.pyc 22 | __pycache__ 23 | .vscode/settings.json 24 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.13" 12 | # You can also specify other tool versions: 13 | # nodejs: "20" 14 | # rust: "1.70" 15 | # golang: "1.20" 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/source/conf.py 20 | # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs 21 | # builder: "dirhtml" 22 | # Fail on all warnings to avoid broken references 23 | # fail_on_warning: true 24 | 25 | # Optionally build your docs in additional formats such as PDF and ePub 26 | # formats: 27 | # - pdf 28 | # - epub 29 | 30 | # Optional but recommended, declare the Python requirements required 31 | # to build your documentation 32 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 33 | python: 34 | install: 35 | - requirements: docs/docs-requirements.txt 36 | -------------------------------------------------------------------------------- /.tekton/build-pipeline.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: tekton.dev/v1 2 | kind: Pipeline 3 | metadata: 4 | name: build-pipeline 5 | spec: 6 | description: | 7 | This pipeline is ideal for building container images from a Containerfile while maintaining trust after pipeline customization. 8 | 9 | _Uses `buildah` to create a container image leveraging [trusted artifacts](https://konflux-ci.dev/architecture/ADR/0036-trusted-artifacts.html). It also optionally creates a source image and runs some build-time tests. Information is shared between tasks using OCI artifacts instead of PVCs. EC will pass the [`trusted_task.trusted`](https://enterprisecontract.dev/docs/ec-policies/release_policy.html#trusted_task__trusted) policy as long as all data used to build the artifact is generated from trusted tasks. 10 | This pipeline is pushed as a Tekton bundle to [quay.io](https://quay.io/repository/konflux-ci/tekton-catalog/pipeline-docker-build-oci-ta?tab=tags)_ 11 | finally: 12 | - name: show-sbom 13 | params: 14 | - name: IMAGE_URL 15 | value: $(tasks.build-image-index.results.IMAGE_URL) 16 | taskRef: 17 | params: 18 | - name: name 19 | value: show-sbom 20 | - name: bundle 21 | value: quay.io/konflux-ci/tekton-catalog/task-show-sbom:0.1@sha256:002f7c8c1d2f9e09904035da414aba1188ae091df0ea9532cd997be05e73d594 22 | - name: kind 23 | value: task 24 | resolver: bundles 25 | params: 26 | - description: Source Repository URL 27 | name: git-url 28 | type: string 29 | - default: "" 30 | description: Revision of the Source Repository 31 | name: revision 32 | type: string 33 | - description: Fully Qualified Output Image 34 | name: output-image 35 | type: string 36 | - default: . 37 | description: Path to the source code of an application's component from where 38 | to build image. 39 | name: path-context 40 | type: string 41 | - default: Dockerfile 42 | description: Path to the Dockerfile inside the context specified by parameter 43 | path-context 44 | name: dockerfile 45 | type: string 46 | - default: "false" 47 | description: Force rebuild image 48 | name: rebuild 49 | type: string 50 | - default: "false" 51 | description: Skip checks against built image 52 | name: skip-checks 53 | type: string 54 | - default: "false" 55 | description: Execute the build with network isolation 56 | name: hermetic 57 | type: string 58 | - default: "" 59 | description: Build dependencies to be prefetched by Cachi2 60 | name: prefetch-input 61 | type: string 62 | - default: "" 63 | description: Image tag expiration time, time values could be something like 64 | 1h, 2d, 3w for hours, days, and weeks, respectively. 65 | name: image-expires-after 66 | - default: "false" 67 | description: Build a source image. 68 | name: build-source-image 69 | type: string 70 | - default: "false" 71 | description: Add built image into an OCI image index 72 | name: build-image-index 73 | type: string 74 | - default: [] 75 | description: Array of --build-arg values ("arg=value" strings) for buildah 76 | name: build-args 77 | type: array 78 | - default: "" 79 | description: Path to a file with build arguments for buildah, see https://www.mankier.com/1/buildah-build#--build-arg-file 80 | name: build-args-file 81 | type: string 82 | results: 83 | - description: "" 84 | name: IMAGE_URL 85 | value: $(tasks.build-image-index.results.IMAGE_URL) 86 | - description: "" 87 | name: IMAGE_DIGEST 88 | value: $(tasks.build-image-index.results.IMAGE_DIGEST) 89 | - description: "" 90 | name: CHAINS-GIT_URL 91 | value: $(tasks.clone-repository.results.url) 92 | - description: "" 93 | name: CHAINS-GIT_COMMIT 94 | value: $(tasks.clone-repository.results.commit) 95 | tasks: 96 | - name: init 97 | params: 98 | - name: image-url 99 | value: $(params.output-image) 100 | - name: rebuild 101 | value: $(params.rebuild) 102 | - name: skip-checks 103 | value: $(params.skip-checks) 104 | taskRef: 105 | params: 106 | - name: name 107 | value: init 108 | - name: bundle 109 | value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:7a24924417260b7094541caaedd2853dc8da08d4bb0968f710a400d3e8062063 110 | - name: kind 111 | value: task 112 | resolver: bundles 113 | - name: clone-repository 114 | params: 115 | - name: url 116 | value: $(params.git-url) 117 | - name: revision 118 | value: $(params.revision) 119 | - name: ociStorage 120 | value: $(params.output-image).git 121 | - name: ociArtifactExpiresAfter 122 | value: $(params.image-expires-after) 123 | runAfter: 124 | - init 125 | taskRef: 126 | params: 127 | - name: name 128 | value: git-clone-oci-ta 129 | - name: bundle 130 | value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.1@sha256:8ecf57d5a6697ce709bee65b62781efe79a10b0c2b95e05576442b67fbd61744 131 | - name: kind 132 | value: task 133 | resolver: bundles 134 | when: 135 | - input: $(tasks.init.results.build) 136 | operator: in 137 | values: 138 | - "true" 139 | workspaces: 140 | - name: basic-auth 141 | workspace: git-auth 142 | - name: prefetch-dependencies 143 | params: 144 | - name: input 145 | value: $(params.prefetch-input) 146 | - name: SOURCE_ARTIFACT 147 | value: $(tasks.clone-repository.results.SOURCE_ARTIFACT) 148 | - name: ociStorage 149 | value: $(params.output-image).prefetch 150 | - name: ociArtifactExpiresAfter 151 | value: $(params.image-expires-after) 152 | runAfter: 153 | - clone-repository 154 | taskRef: 155 | params: 156 | - name: name 157 | value: prefetch-dependencies-oci-ta 158 | - name: bundle 159 | value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.2@sha256:d48c621ae828a3cbca162e12ec166210d2d77a7ba23b0e5d60c4a1b94491adeb 160 | - name: kind 161 | value: task 162 | resolver: bundles 163 | workspaces: 164 | - name: git-basic-auth 165 | workspace: git-auth 166 | - name: netrc 167 | workspace: netrc 168 | - name: build-container 169 | params: 170 | - name: IMAGE 171 | value: $(params.output-image) 172 | - name: DOCKERFILE 173 | value: $(params.dockerfile) 174 | - name: CONTEXT 175 | value: $(params.path-context) 176 | - name: HERMETIC 177 | value: $(params.hermetic) 178 | - name: PREFETCH_INPUT 179 | value: $(params.prefetch-input) 180 | - name: IMAGE_EXPIRES_AFTER 181 | value: $(params.image-expires-after) 182 | - name: COMMIT_SHA 183 | value: $(tasks.clone-repository.results.commit) 184 | - name: BUILD_ARGS 185 | value: 186 | - $(params.build-args[*]) 187 | - name: BUILD_ARGS_FILE 188 | value: $(params.build-args-file) 189 | - name: SOURCE_ARTIFACT 190 | value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) 191 | - name: CACHI2_ARTIFACT 192 | value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) 193 | runAfter: 194 | - prefetch-dependencies 195 | taskRef: 196 | params: 197 | - name: name 198 | value: buildah-oci-ta 199 | - name: bundle 200 | value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.4@sha256:6ac9d16f598c14a4b56e662eccda0a438e94aa8f87dd27a3ea0ff1abc6e00c66 201 | - name: kind 202 | value: task 203 | resolver: bundles 204 | when: 205 | - input: $(tasks.init.results.build) 206 | operator: in 207 | values: 208 | - "true" 209 | - name: build-image-index 210 | params: 211 | - name: IMAGE 212 | value: $(params.output-image) 213 | - name: COMMIT_SHA 214 | value: $(tasks.clone-repository.results.commit) 215 | - name: IMAGE_EXPIRES_AFTER 216 | value: $(params.image-expires-after) 217 | - name: ALWAYS_BUILD_INDEX 218 | value: $(params.build-image-index) 219 | - name: IMAGES 220 | value: 221 | - $(tasks.build-container.results.IMAGE_URL)@$(tasks.build-container.results.IMAGE_DIGEST) 222 | runAfter: 223 | - build-container 224 | taskRef: 225 | params: 226 | - name: name 227 | value: build-image-index 228 | - name: bundle 229 | value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.1@sha256:462ecbf94ec44a8b770d6ef8838955f91f57ee79795e5c18bdc0fcb0df593742 230 | - name: kind 231 | value: task 232 | resolver: bundles 233 | when: 234 | - input: $(tasks.init.results.build) 235 | operator: in 236 | values: 237 | - "true" 238 | - name: build-source-image 239 | params: 240 | - name: BINARY_IMAGE 241 | value: $(params.output-image) 242 | - name: SOURCE_ARTIFACT 243 | value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) 244 | - name: CACHI2_ARTIFACT 245 | value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) 246 | runAfter: 247 | - build-image-index 248 | taskRef: 249 | params: 250 | - name: name 251 | value: source-build-oci-ta 252 | - name: bundle 253 | value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.2@sha256:56fa2cbfc04bad4765b7fe1fa8022587f4042d4e8533bb5f65311d46b43226ee 254 | - name: kind 255 | value: task 256 | resolver: bundles 257 | when: 258 | - input: $(tasks.init.results.build) 259 | operator: in 260 | values: 261 | - "true" 262 | - input: $(params.build-source-image) 263 | operator: in 264 | values: 265 | - "true" 266 | - name: deprecated-base-image-check 267 | params: 268 | - name: IMAGE_URL 269 | value: $(tasks.build-image-index.results.IMAGE_URL) 270 | - name: IMAGE_DIGEST 271 | value: $(tasks.build-image-index.results.IMAGE_DIGEST) 272 | runAfter: 273 | - build-image-index 274 | taskRef: 275 | params: 276 | - name: name 277 | value: deprecated-image-check 278 | - name: bundle 279 | value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:ecd33669676b3a193ff4c2c6223cb912cc1b0cf5cc36e080eaec7718500272cf 280 | - name: kind 281 | value: task 282 | resolver: bundles 283 | when: 284 | - input: $(params.skip-checks) 285 | operator: in 286 | values: 287 | - "false" 288 | - name: clair-scan 289 | params: 290 | - name: image-digest 291 | value: $(tasks.build-image-index.results.IMAGE_DIGEST) 292 | - name: image-url 293 | value: $(tasks.build-image-index.results.IMAGE_URL) 294 | runAfter: 295 | - build-image-index 296 | taskRef: 297 | params: 298 | - name: name 299 | value: clair-scan 300 | - name: bundle 301 | value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.2@sha256:878ae247ffc58d95a9ac68e4d658ef91ef039363e03e65a386bc0ead02d9d7d8 302 | - name: kind 303 | value: task 304 | resolver: bundles 305 | when: 306 | - input: $(params.skip-checks) 307 | operator: in 308 | values: 309 | - "false" 310 | - name: ecosystem-cert-preflight-checks 311 | params: 312 | - name: image-url 313 | value: $(tasks.build-image-index.results.IMAGE_URL) 314 | runAfter: 315 | - build-image-index 316 | taskRef: 317 | params: 318 | - name: name 319 | value: ecosystem-cert-preflight-checks 320 | - name: bundle 321 | value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:1157c6ac9805af8b8874e4b8d68d2403d99e1c007f63623566b5d848b27c1826 322 | - name: kind 323 | value: task 324 | resolver: bundles 325 | when: 326 | - input: $(params.skip-checks) 327 | operator: in 328 | values: 329 | - "false" 330 | - name: sast-snyk-check 331 | params: 332 | - name: image-digest 333 | value: $(tasks.build-image-index.results.IMAGE_DIGEST) 334 | - name: image-url 335 | value: $(tasks.build-image-index.results.IMAGE_URL) 336 | - name: SOURCE_ARTIFACT 337 | value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) 338 | - name: CACHI2_ARTIFACT 339 | value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) 340 | runAfter: 341 | - build-image-index 342 | taskRef: 343 | params: 344 | - name: name 345 | value: sast-snyk-check-oci-ta 346 | - name: bundle 347 | value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.4@sha256:89aead32dc21404e4e0913be9668bdd2eea795db3e4caa762fb619044e479cb8 348 | - name: kind 349 | value: task 350 | resolver: bundles 351 | when: 352 | - input: $(params.skip-checks) 353 | operator: in 354 | values: 355 | - "false" 356 | - name: clamav-scan 357 | params: 358 | - name: image-digest 359 | value: $(tasks.build-image-index.results.IMAGE_DIGEST) 360 | - name: image-url 361 | value: $(tasks.build-image-index.results.IMAGE_URL) 362 | runAfter: 363 | - build-image-index 364 | taskRef: 365 | params: 366 | - name: name 367 | value: clamav-scan 368 | - name: bundle 369 | value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.2@sha256:24182598bf5161c4007988a7236e240f361c77a0a9b6973a6712dbae50d296bc 370 | - name: kind 371 | value: task 372 | resolver: bundles 373 | when: 374 | - input: $(params.skip-checks) 375 | operator: in 376 | values: 377 | - "false" 378 | - name: sast-shell-check 379 | params: 380 | - name: image-digest 381 | value: $(tasks.build-image-index.results.IMAGE_DIGEST) 382 | - name: image-url 383 | value: $(tasks.build-image-index.results.IMAGE_URL) 384 | - name: SOURCE_ARTIFACT 385 | value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) 386 | - name: CACHI2_ARTIFACT 387 | value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) 388 | runAfter: 389 | - build-image-index 390 | taskRef: 391 | params: 392 | - name: name 393 | value: sast-shell-check-oci-ta 394 | - name: bundle 395 | value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:57b3262138eb06186ae7375f84ca53788bba2a66cfd03d39cb82c78df050aba5 396 | - name: kind 397 | value: task 398 | resolver: bundles 399 | when: 400 | - input: $(params.skip-checks) 401 | operator: in 402 | values: 403 | - "false" 404 | - name: sast-unicode-check 405 | params: 406 | - name: image-url 407 | value: $(tasks.build-image-index.results.IMAGE_URL) 408 | - name: SOURCE_ARTIFACT 409 | value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) 410 | - name: CACHI2_ARTIFACT 411 | value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) 412 | runAfter: 413 | - build-image-index 414 | taskRef: 415 | params: 416 | - name: name 417 | value: sast-unicode-check-oci-ta 418 | - name: bundle 419 | value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.2@sha256:df185dbe4e2852668f9c46f938dd752e90ea9c79696363378435a6499596c319 420 | - name: kind 421 | value: task 422 | resolver: bundles 423 | when: 424 | - input: $(params.skip-checks) 425 | operator: in 426 | values: 427 | - "false" 428 | - name: apply-tags 429 | params: 430 | - name: IMAGE 431 | value: $(tasks.build-image-index.results.IMAGE_URL) 432 | runAfter: 433 | - build-image-index 434 | taskRef: 435 | params: 436 | - name: name 437 | value: apply-tags 438 | - name: bundle 439 | value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.1@sha256:3f89ba89cacf8547261b5ce064acce81bfe470c8ace127794d0e90aebc8c347d 440 | - name: kind 441 | value: task 442 | resolver: bundles 443 | - name: push-dockerfile 444 | params: 445 | - name: IMAGE 446 | value: $(tasks.build-image-index.results.IMAGE_URL) 447 | - name: IMAGE_DIGEST 448 | value: $(tasks.build-image-index.results.IMAGE_DIGEST) 449 | - name: DOCKERFILE 450 | value: $(params.dockerfile) 451 | - name: CONTEXT 452 | value: $(params.path-context) 453 | - name: SOURCE_ARTIFACT 454 | value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) 455 | runAfter: 456 | - build-image-index 457 | taskRef: 458 | params: 459 | - name: name 460 | value: push-dockerfile-oci-ta 461 | - name: bundle 462 | value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.1@sha256:d0ee13ab3d9564f7ee806a8ceaced934db493a3a40e11ff6db3a912b8bbace95 463 | - name: kind 464 | value: task 465 | resolver: bundles 466 | - name: rpms-signature-scan 467 | params: 468 | - name: image-url 469 | value: $(tasks.build-image-index.results.IMAGE_URL) 470 | - name: image-digest 471 | value: $(tasks.build-image-index.results.IMAGE_DIGEST) 472 | runAfter: 473 | - build-image-index 474 | taskRef: 475 | params: 476 | - name: name 477 | value: rpms-signature-scan 478 | - name: bundle 479 | value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2@sha256:297c2d8928aa3b114fcb1ba5d9da8b10226b68fed30706e78a6a5089c6cd30e3 480 | - name: kind 481 | value: task 482 | resolver: bundles 483 | when: 484 | - input: $(params.skip-checks) 485 | operator: in 486 | values: 487 | - "false" 488 | workspaces: 489 | - name: git-auth 490 | optional: true 491 | - name: netrc 492 | optional: true 493 | -------------------------------------------------------------------------------- /.tekton/sync2jira-pull-request.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: tekton.dev/v1 2 | kind: PipelineRun 3 | metadata: 4 | annotations: 5 | build.appstudio.openshift.io/repo: https://github.com/release-engineering/Sync2Jira?rev={{revision}} 6 | build.appstudio.redhat.com/commit_sha: '{{revision}}' 7 | build.appstudio.redhat.com/pull_request_number: '{{pull_request_number}}' 8 | build.appstudio.redhat.com/target_branch: '{{target_branch}}' 9 | pipelinesascode.tekton.dev/max-keep-runs: "3" 10 | pipelinesascode.tekton.dev/on-cel-expression: event == "pull_request" && target_branch 11 | == "main" 12 | creationTimestamp: null 13 | labels: 14 | appstudio.openshift.io/application: sync2jira 15 | appstudio.openshift.io/component: sync2jira 16 | pipelines.appstudio.openshift.io/type: build 17 | name: sync2jira-on-pull-request 18 | namespace: sync2jira-tenant 19 | spec: 20 | params: 21 | - name: git-url 22 | value: '{{source_url}}' 23 | - name: revision 24 | value: '{{revision}}' 25 | - name: output-image 26 | value: quay.io/redhat-user-workloads/sync2jira-tenant/sync2jira:on-pr-{{revision}} 27 | - name: image-expires-after 28 | value: 5d 29 | - name: dockerfile 30 | value: Dockerfile 31 | pipelineRef: 32 | name: build-pipeline 33 | taskRunTemplate: 34 | serviceAccountName: build-pipeline-sync2jira 35 | workspaces: 36 | - name: git-auth 37 | secret: 38 | secretName: '{{ git_auth_secret }}' 39 | status: {} 40 | -------------------------------------------------------------------------------- /.tekton/sync2jira-push.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: tekton.dev/v1 2 | kind: PipelineRun 3 | metadata: 4 | annotations: 5 | build.appstudio.openshift.io/repo: https://github.com/release-engineering/Sync2Jira?rev={{revision}} 6 | build.appstudio.redhat.com/commit_sha: '{{revision}}' 7 | build.appstudio.redhat.com/target_branch: '{{target_branch}}' 8 | pipelinesascode.tekton.dev/max-keep-runs: "3" 9 | pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch 10 | == "main" 11 | creationTimestamp: null 12 | labels: 13 | appstudio.openshift.io/application: sync2jira 14 | appstudio.openshift.io/component: sync2jira 15 | pipelines.appstudio.openshift.io/type: build 16 | name: sync2jira-on-push 17 | namespace: sync2jira-tenant 18 | spec: 19 | params: 20 | - name: git-url 21 | value: '{{source_url}}' 22 | - name: revision 23 | value: '{{revision}}' 24 | - name: output-image 25 | value: quay.io/redhat-user-workloads/sync2jira-tenant/sync2jira:{{revision}} 26 | - name: dockerfile 27 | value: Dockerfile 28 | pipelineRef: 29 | name: build-pipeline 30 | taskRunTemplate: 31 | serviceAccountName: build-pipeline-sync2jira 32 | workspaces: 33 | - name: git-auth 34 | secret: 35 | secretName: '{{ git_auth_secret }}' 36 | status: {} 37 | -------------------------------------------------------------------------------- /.tekton/sync2jira-sync-page-pull-request.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: tekton.dev/v1 2 | kind: PipelineRun 3 | metadata: 4 | annotations: 5 | build.appstudio.openshift.io/repo: https://github.com/release-engineering/Sync2Jira?rev={{revision}} 6 | build.appstudio.redhat.com/commit_sha: '{{revision}}' 7 | build.appstudio.redhat.com/pull_request_number: '{{pull_request_number}}' 8 | build.appstudio.redhat.com/target_branch: '{{target_branch}}' 9 | pipelinesascode.tekton.dev/max-keep-runs: "3" 10 | pipelinesascode.tekton.dev/on-cel-expression: event == "pull_request" && target_branch 11 | == "main" 12 | creationTimestamp: null 13 | labels: 14 | appstudio.openshift.io/application: sync2jira 15 | appstudio.openshift.io/component: sync2jira-sync-page 16 | pipelines.appstudio.openshift.io/type: build 17 | name: sync2jira-sync-page-on-pull-request 18 | namespace: sync2jira-tenant 19 | spec: 20 | params: 21 | - name: git-url 22 | value: '{{source_url}}' 23 | - name: revision 24 | value: '{{revision}}' 25 | - name: output-image 26 | value: quay.io/redhat-user-workloads/sync2jira-tenant/sync2jira-sync-page:on-pr-{{revision}} 27 | - name: image-expires-after 28 | value: 5d 29 | - name: dockerfile 30 | value: Dockerfile.sync-page 31 | pipelineRef: 32 | name: build-pipeline 33 | taskRunTemplate: {} 34 | workspaces: 35 | - name: git-auth 36 | secret: 37 | secretName: '{{ git_auth_secret }}' 38 | status: {} 39 | -------------------------------------------------------------------------------- /.tekton/sync2jira-sync-page-push.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: tekton.dev/v1 2 | kind: PipelineRun 3 | metadata: 4 | annotations: 5 | build.appstudio.openshift.io/repo: https://github.com/release-engineering/Sync2Jira?rev={{revision}} 6 | build.appstudio.redhat.com/commit_sha: '{{revision}}' 7 | build.appstudio.redhat.com/target_branch: '{{target_branch}}' 8 | pipelinesascode.tekton.dev/max-keep-runs: "3" 9 | pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch 10 | == "main" 11 | creationTimestamp: null 12 | labels: 13 | appstudio.openshift.io/application: sync2jira 14 | appstudio.openshift.io/component: sync2jira-sync-page 15 | pipelines.appstudio.openshift.io/type: build 16 | name: sync2jira-sync-page-on-push 17 | namespace: sync2jira-tenant 18 | spec: 19 | params: 20 | - name: git-url 21 | value: '{{source_url}}' 22 | - name: revision 23 | value: '{{revision}}' 24 | - name: output-image 25 | value: quay.io/redhat-user-workloads/sync2jira-tenant/sync2jira-sync-page:{{revision}} 26 | - name: dockerfile 27 | value: Dockerfile.sync-page 28 | pipelineRef: 29 | name: build-pipeline 30 | taskRunTemplate: {} 31 | workspaces: 32 | - name: git-auth 33 | secret: 34 | secretName: '{{ git_auth_secret }}' 35 | status: {} 36 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These users have approval access to all resources (wildcard) 2 | * @ralphbean @Zyzyx 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry.access.redhat.com/ubi9/ubi:9.6-1747219013 2 | 3 | ARG SYNC2JIRA_GIT_REPO=https://github.com/release-engineering/Sync2Jira.git 4 | ARG SYNC2JIRA_GIT_REF=master 5 | ARG SYNC2JIRA_VERSION= 6 | 7 | LABEL \ 8 | name="sync2jira" \ 9 | org.opencontainers.image.name="sync2jira" \ 10 | description="sync2jira application" \ 11 | org.opencontainers.image.description="sync2jira application" \ 12 | io.k8s.description="sync2jira application" \ 13 | vendor="Red Hat, Inc." \ 14 | org.opencontainers.image.vendor="Red Hat, Inc." \ 15 | license="GPLv2+" \ 16 | org.opencontainers.image.license="GPLv2+" \ 17 | url="$SYNC2JIRA_GIT_REPO" \ 18 | org.opencontainers.image.url="$SYNC2JIRA_GIT_REPO" \ 19 | release="$SYNC2JIRA_GIT_REF" \ 20 | com.redhat.component="null" \ 21 | build-date="" \ 22 | distribution-scope="public" 23 | 24 | # Installing sync2jira dependencies 25 | RUN rpm -ivh https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm 26 | RUN dnf -y install \ 27 | git \ 28 | python3-pip \ 29 | krb5-devel \ 30 | python-devel \ 31 | fedora-messaging \ 32 | gcc \ 33 | && dnf -y clean all 34 | 35 | ENV SYNC2JIRA_VERSION=$SYNC2JIRA_VERSION 36 | 37 | USER root 38 | 39 | # Copy in license file 40 | RUN mkdir /licenses 41 | COPY LICENSE /licenses/LICENSE 42 | 43 | # Create Sync2Jira folder 44 | RUN mkdir -p /usr/local/src/sync2jira 45 | 46 | # Copy over our repo 47 | COPY . /usr/local/src/sync2jira 48 | 49 | # Install deps 50 | RUN pip install -r /usr/local/src/sync2jira/requirements.txt 51 | 52 | # Install Sync2Jira 53 | RUN pip3 install --no-deps -v /usr/local/src/sync2jira 54 | 55 | USER 1001 56 | 57 | CMD ["/usr/local/bin/sync2jira"] 58 | -------------------------------------------------------------------------------- /Dockerfile.sync-page: -------------------------------------------------------------------------------- 1 | FROM registry.access.redhat.com/ubi9/ubi:9.6-1747219013 2 | 3 | ARG SYNC2JIRA_GIT_REPO=https://github.com/release-engineering/Sync2Jira.git 4 | ARG SYNC2JIRA_GIT_REF=master 5 | ARG SYNC2JIRA_VERSION= 6 | 7 | LABEL \ 8 | name="sync2jira-sync-page" \ 9 | org.opencontainers.image.name="sync2jira-sync-page" \ 10 | description="sync2jira application" \ 11 | org.opencontainers.image.description="sync2jira application" \ 12 | io.k8s.description="sync2jira application" \ 13 | vendor="Red Hat, Inc." \ 14 | org.opencontainers.image.vendor="Red Hat, Inc." \ 15 | license="GPLv2+" \ 16 | org.opencontainers.image.license="GPLv2+" \ 17 | url="$SYNC2JIRA_GIT_REPO" \ 18 | org.opencontainers.image.url="$SYNC2JIRA_GIT_REPO" \ 19 | release="$SYNC2JIRA_GIT_REF" \ 20 | com.redhat.component="null" \ 21 | build-date="" \ 22 | distribution-scope="public" 23 | 24 | # Installing sync2jira dependencies 25 | RUN rpm -ivh https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm 26 | RUN dnf -y install \ 27 | git \ 28 | python3-pip \ 29 | krb5-devel \ 30 | python-devel \ 31 | fedora-messaging \ 32 | gcc \ 33 | && dnf -y clean all 34 | 35 | USER root 36 | 37 | # Copy in license file 38 | RUN mkdir /licenses 39 | COPY LICENSE /licenses/LICENSE 40 | 41 | # Create Sync2Jira folder 42 | RUN mkdir -p /usr/local/src/sync2jira 43 | 44 | # Copy over our repo 45 | COPY . /usr/local/src/sync2jira 46 | 47 | # Install deps 48 | RUN pip install -r /usr/local/src/sync2jira/requirements.txt 49 | 50 | # Install Sync2Jira 51 | RUN pip3 install --no-deps -v /usr/local/src/sync2jira 52 | 53 | USER 1001 54 | 55 | CMD ["python3", "/usr/local/src/sync2jira/sync-page/event-handler.py"] 56 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include fedmsg.d/sync2jira.py 4 | include requirements.txt 5 | include test-requirements.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sync2Jira 2 | 3 | [![Documentation Status](https://readthedocs.org/projects/sync2jira/badge/?version=master)](https://sync2jira.readthedocs.io/en/master/?badge=master) 4 | [![Docker Repository on Quay](https://quay.io/repository/redhat-aqe/sync2jira/status "Docker Repository on Quay")](https://quay.io/repository/redhat-aqe/sync2jira) 5 | [![Build Status](https://travis-ci.org/release-engineering/Sync2Jira.svg?branch=master)](https://travis-ci.org/release-engineering/Sync2Jira) 6 | [![Coverage Status](https://coveralls.io/repos/github/release-engineering/Sync2Jira/badge.svg?branch=master)](https://coveralls.io/github/release-engineering/Sync2Jira?branch=master) 7 | ![Python 3.7](https://img.shields.io/badge/python-3.7-blue.svg) 8 | 9 | ## What is Sync2Jira? 10 | 11 | This is a process that listens to activity on upstream repos on 12 | GitHub via fedmsg, and syncs new issues there to a Jira instance elsewhere. 13 | 14 | ## Documentation 15 | 16 | Documentation is hosted on readthedocs.io and can be found [here](https://sync2jira.readthedocs.io/en/latest/) 17 | 18 | ## Configuration 19 | 20 | We have set up a quick-start [here](https://sync2jira.readthedocs.io/en/master/quickstart.html) 21 | 22 | Configuration is in folder `fedmsg.d`. 23 | 24 | You can maintain a mapping there that allows you to match one upstream repo 25 | (say, 'pungi') to a downstream project/component pair in Jira (say, 26 | 'COMPOSE', and 'Pungi'). 27 | 28 | On startup: 29 | 30 | - in `fedmsg.d/sync2jira.py`, if the `testing` option is set to `True`, then the script will perform a "dry run" and not actually add any new issues to Jira. 31 | - if the `INITIALIZE` environment variable is set to `1`, the script will sync all issues to Jira. Use caution as this may be very expensive and difficult to undo. 32 | 33 | Please look at our documentation [here](https://sync2jira.readthedocs.io/en/master/config-file.html) for a full list of what can be synced and how to set it up. 34 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/docs-requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_rtd_theme -------------------------------------------------------------------------------- /docs/source/adding-new-repo-guide.rst: -------------------------------------------------------------------------------- 1 | Adding New Repos Guide 2 | ======================= 3 | 4 | Have you ever wanted to add new upstream repos? Well now you can! 5 | 6 | 1. First ensure that your upstream repo is on the Fed Message Bus 7 | 2. Now add two new functions to `sync2jira/upstream_pr.py` and `sync2jira/upstream_issue.py` 8 | * :code:`def hande_REPO-NAME_message(msg, config)` 9 | * This function will take in a fedmessage message (`msg`) and the config dict 10 | * This function will return a :code:`sync2jira.Intermediary.Issue` object 11 | * This function will be used when listening to the message bus 12 | * :code:`def REPO-NAME_issues(upstream, config)` 13 | * This function will take in an upstream repo name and the config dict 14 | * This function will return a generator of :code:`sync2jira.Intermediary.Issue` objects that contain all upstream Issues 15 | * This function will be used to initialize and sync upstream/downstream issues 16 | 3. Now modify the `sync2jira/main.py` functions: 17 | * :code:`def initialize_pr(config, ...)` and :code:`def initialize_issues(config, ...)` 18 | * Add another section (like GitHub) to utilize the :code:`REPO-NAME_issues` function you just made. 19 | * :code:`def listen(config)` 20 | * Add another section to the if statement under GitHub 21 | * :code:`elif 'REPO-NAME' in suffix:` 22 | * Now utilize the :code:`handle_REPO-NAME_message` function you just made 23 | 4. If all goes smoothly, your new repo should work with Sync2Jira! 24 | 25 | .. note:: If you want to submit a Pull Request, ensure that you add appropriate Unit Tests, Tox is passing, and you have appropriate documentation! 26 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | 18 | sys.path.insert(0, os.path.abspath("..")) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "Sync2Jira" 24 | copyright = "2019, Ralph Bean" 25 | author = "Ralph Bean" 26 | 27 | # The short X.Y version 28 | version = "2.0" 29 | # The full version, including alpha/beta/rc tags 30 | release = "2.0" 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | "sphinx.ext.autodoc", 44 | "sphinx.ext.doctest", 45 | "sphinx.ext.intersphinx", 46 | "sphinx.ext.todo", 47 | "sphinx.ext.coverage", 48 | "sphinx.ext.mathjax", 49 | "sphinx.ext.ifconfig", 50 | "sphinx.ext.viewcode", 51 | "sphinx.ext.githubpages", 52 | ] 53 | 54 | # Add any paths that contain templates here, relative to this directory. 55 | templates_path = ["ntemplates"] 56 | 57 | # The suffix(es) of source filenames. 58 | # You can specify multiple suffix as a list of string: 59 | # 60 | # source_suffix = ['.rst', '.md'] 61 | source_suffix = ".rst" 62 | 63 | # The master toctree document. 64 | master_doc = "index" 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = None 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | # This pattern also affects html_static_path and html_extra_path. 76 | exclude_patterns = [] 77 | 78 | # The name of the Pygments (syntax highlighting) style to use. 79 | pygments_style = None 80 | 81 | 82 | # -- Options for HTML output ------------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = "sphinx_rtd_theme" 88 | 89 | # Theme options are theme-specific and customize the look and feel of a theme 90 | # further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | # html_theme_options = {} 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | html_static_path = ["nstatic"] 99 | 100 | # Custom sidebar templates, must be a dictionary that maps document names 101 | # to template names. 102 | # 103 | # The default sidebars (for documents that don't match any pattern) are 104 | # defined by theme itself. Builtin themes are using these templates by 105 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 106 | # 'searchbox.html']``. 107 | # 108 | html_sidebars = {"**": ["globaltoc.html", "searchbox.html"]} 109 | 110 | 111 | # -- Options for HTMLHelp output --------------------------------------------- 112 | 113 | # Output file base name for HTML help builder. 114 | htmlhelp_basename = "Sync2Jiradoc" 115 | 116 | 117 | # -- Options for LaTeX output ------------------------------------------------ 118 | 119 | latex_elements = { 120 | # The paper size ('letterpaper' or 'a4paper'). 121 | # 122 | # 'papersize': 'letterpaper', 123 | # 124 | # The font size ('10pt', '11pt' or '12pt'). 125 | # 126 | # 'pointsize': '10pt', 127 | # 128 | # Additional stuff for the LaTeX preamble. 129 | # 130 | # 'preamble': '', 131 | # 132 | # Latex figure (float) alignment 133 | # 134 | # 'figure_align': 'htbp', 135 | } 136 | 137 | # Grouping the document tree into LaTeX files. List of tuples 138 | # (source start file, target name, title, 139 | # author, documentclass [howto, manual, or own class]). 140 | latex_documents = [ 141 | (master_doc, "Sync2Jira.tex", "Sync2Jira Documentation", "Ralph Bean", "manual"), 142 | ] 143 | 144 | 145 | # -- Options for manual page output ------------------------------------------ 146 | 147 | # One entry per manual page. List of tuples 148 | # (source start file, name, description, authors, manual section). 149 | man_pages = [(master_doc, "sync2jira", "Sync2Jira Documentation", [author], 1)] 150 | 151 | 152 | # -- Options for Texinfo output ---------------------------------------------- 153 | 154 | # Grouping the document tree into Texinfo files. List of tuples 155 | # (source start file, target name, title, author, 156 | # dir menu entry, description, category) 157 | texinfo_documents = [ 158 | ( 159 | master_doc, 160 | "Sync2Jira", 161 | "Sync2Jira Documentation", 162 | author, 163 | "Sync2Jira", 164 | "One line description of project.", 165 | "Miscellaneous", 166 | ), 167 | ] 168 | 169 | 170 | # -- Options for Epub output ------------------------------------------------- 171 | 172 | # Bibliographic Dublin Core info. 173 | epub_title = project 174 | 175 | # The unique identifier of the text. This can be a ISBN number 176 | # or the project homepage. 177 | # 178 | # epub_identifier = '' 179 | 180 | # A unique identification for the text. 181 | # 182 | # epub_uid = '' 183 | 184 | # A list of files that should not be packed into the epub file. 185 | epub_exclude_files = ["search.html"] 186 | 187 | 188 | # -- Extension configuration ------------------------------------------------- 189 | -------------------------------------------------------------------------------- /docs/source/config-file.rst: -------------------------------------------------------------------------------- 1 | Config File 2 | =========== 3 | The config file is made up of multiple parts 4 | 5 | .. code-block:: python 6 | 7 | 'admins': ['demo_jira_username'] 8 | 9 | * Admins can be users who manage Sync2Jira. They will be cc'd in any emails regarding duplicate issues found. 10 | 11 | .. code-block:: python 12 | 13 | 'mailing-list': 'demo_email@demo.com' 14 | 15 | * Mailing list is used to alert users when there is a failure. A failure email with the traceback will be sent to the email address. 16 | 17 | .. code-block:: python 18 | 19 | 'debug': False 20 | 21 | * Enable or disable debugging output. This will emit on fedmsg messages and can be very noisy; not for production use. 22 | 23 | .. code-block:: python 24 | 25 | 'testing': True 26 | 27 | * Testing is a flag that will determine if any changes are actually made downstream (on JIRA tickets). 28 | Set to false if you are developing and don't want any changes to take effect. 29 | 30 | .. code-block:: python 31 | 32 | 'develop': False 33 | 34 | * If the develop flag is set to :code:`False` then Sync2Jira will perform a sentinel query after 35 | getting a JIRA client and failure email will be sent anytime the service fails. 36 | 37 | .. code-block:: python 38 | 39 | 'github_token': 'YOUR_TOKEN', 40 | 41 | * This is where you can enter your GitHub API token. 42 | 43 | .. code-block:: python 44 | 45 | 'default_jira_instance': 'example' 46 | 47 | * This is the default JIRA instance to be used if none is provided in the project. 48 | 49 | .. code-block:: python 50 | 51 | 'jira': { 52 | 'example': { 53 | 'options': { 54 | 'server': 'https://some_jira_server_somewhere.com', 55 | 'verify': True, 56 | }, 57 | 'token_auth': 'YOUR_API_TOKEN', 58 | }, 59 | }, 60 | 61 | * Here you can configure multiple JIRA instances if you have projects with differing downstream JIRA instances. 62 | Ensure to name them appropriately, in name of the JIRA instance above is `example`. 63 | 64 | .. code-block:: python 65 | 66 | "default_jira_fields": { 67 | "storypoints": "customfield_12310243" 68 | }, 69 | 70 | * The keys in the :code:`default_jira_fields` map are used as the values of the github_project_fields 71 | list (see below). The keys indicate how the fields in GitHub Project items correspond to 72 | fields in Jira issues so that these values can be synced properly. Currently, we support 73 | only :code:`storypoints` and :code:`priority`. By default, the value for the priority is sourced 74 | from the upstream :code:`priority` field, but providing a :code:`priority` key for 75 | :code:`default_jira_fields` will override that. Unfortunately, there is no default available 76 | for sourcing story points, so, the :code:`storypoints` key must be provided if projects wish 77 | to sync story point values, and, for our corporate Jira instance, the value should be specified 78 | as :code:`customfield_12310243`. 79 | 80 | .. code-block:: python 81 | 82 | 'map': { 83 | 'github': { 84 | 'GITHUB_USERNAME/Demo_project': {'project': 'FACTORY', 'component': 'gitbz', 85 | 'issue_updates': [...], 'pr_updates': [...], 'mapping': [...], 'labels': [...], 86 | 'owner': 'jira_username'}, 87 | }, 88 | }, 89 | 90 | * You can add the following to your project configuration: 91 | 92 | * :code:`'project'` 93 | * Downstream project to sync with 94 | * :code:`'component'` 95 | * Downstream component to sync with 96 | * :code:`sync` 97 | * This array contains information on what to sync from upstream repos (i.e. 'issue' and/or 'pullrequest') 98 | * :code:`'owner'` 99 | * Optional (Recommended): Alerts the owner of an issue if there are duplicate issues present 100 | * :code:`'qa-contact'` 101 | * Optional: Automatically add a QA contact field when issues are created 102 | * :code:`'epic-link'` 103 | * Optional: Pass the downstream key to automatically create an epic-link when issues are created 104 | * :code:`'labels': ['tag1'..]` 105 | * Optional: Field to have custom set labels on all downstream issues created. 106 | * :code:`'type'` 107 | * Optional: Set the issue type that will be created. The default is Bug. 108 | * :code:`'issue_types': {'bug': 'Bug', 'enhancement': 'Story'}` 109 | * Optional: Set the issue type based on GitHub labels. If none match, fall back to what :code:`type` is set to. 110 | * :code:`'EXD-Service': {'guild': 'SOME_GUILD', 'value': 'SOME_VALUE'}` 111 | * Sync custom EXD-Service field 112 | 113 | .. note:: 114 | 115 | :pullrequest: After enabling PR syncing, just type "JIRA: XXXX-1234" in the comment or description of the PR to sync with a JIRA issue. After this, updates such as when it has been merged will automatically be added to the JIRA ticket. 116 | 117 | * You can add your projects here. The :code:`'project'` field is associated 118 | with downstream JIRA projects, and :code:`'component'` with downstream 119 | components. You can add the following to the :code:`issue_updates` array: 120 | 121 | * :code:`'comments'` 122 | * Sync comments and comment edits 123 | * :code:`{'tags': {'overwrite': True/False}}` 124 | * Sync tags, do/don't overwrite downstream tags 125 | * :code:`{'fixVersion': {'overwrite': True/False}}` 126 | * Sync fixVersion (downstream milestone), do/don't overwrite downstream fixVersion 127 | * :code:`{'assignee': {'overwrite': True/False}}` 128 | * Sync assignee (for Github only the first assignee will sync) do/don't overwrite downstream assignee 129 | * :code:`'description'` 130 | * Sync description 131 | * :code:`'title'` 132 | * Sync title 133 | * :code:`{'transition': True/'CUSTOM_TRANSITION'}` 134 | * Sync status (open/closed), Sync only status/Attempt to transition JIRA ticket to CUSTOM_TRANSITION on upstream closure 135 | * :code:`{'on_close': {'apply_labels': ['label', ...]}}` 136 | * When the upstream issue is closed, apply additional labels on the corresponding Jira ticket. 137 | * :code:`github_markdown` 138 | * If description syncing is turned on, this flag will convert Github markdown to Jira syntax. This uses the pypandoc module. 139 | * :code:`upstream_id` 140 | * If selected this will add a comment to all newly created JIRA issue in the format 'UPSTREAM_PROJECT-#1' where the number indicates the issue ID. This allows users to search for the issue on JIRA via the issue number. 141 | * :code:`url` 142 | * This flag will add the upstream url to the bottom of the JIRA ticket 143 | * :code:`github_project_number` 144 | * Specify the GitHub project number. If specified, story points and priority will be selected from this project, and other projects linked to the issues will be ignored. 145 | * :code:`github_project_fields` 146 | * Sync GitHub projects fields. e.g, storypoints, priority 147 | 148 | .. note:: 149 | 150 | :Overwrite: Setting this to :code:`True` will ensure that Upstream (GitHub) values will overwrite downstream ones (i.e. if its empty upstream it'll be empty downstream) 151 | :CUSTOM_TRANSITION: Setting this value will get Sync2Jira to automatically transition downstream tickets once their upstream counterparts get closed. Set this to whatever 'closed' means downstream. 152 | 153 | * You can add your projects here. The 'project' field is associated with downstream JIRA projects, and 'component' with downstream components 154 | You can add the following to the :code:`pr_updates` array: 155 | 156 | * :code:`{'merge_transition': 'CUSTOM_TRANSITION'}` 157 | * Sync when upstream PR gets merged. Attempts to transition JIRA ticket to CUSTOM_TRANSITION on upstream merge 158 | * :code:`{'link_transition': 'CUSTOM_TRANSITION'}` 159 | * Sync when upstream PR gets linked. Attempts to transition JIRA ticket to CUSTOM_TRANSITION on upstream link 160 | 161 | * You can add the following to the mapping array. This array will map an upstream field to the downstream counterpart with XXX replaced. 162 | 163 | * :code:`{'fixVersion': 'Test XXX'}` 164 | * Maps upstream milestone (suppose it's called 'milestone') to downstream fixVersion with a mapping (for our example it would be 'Test milestone') 165 | 166 | * It is strongly encouraged for teams to use the :code:`owner` field. If configured, owners will be alerted if Sync2Jira finds duplicate downstream issues. 167 | Further the owner will be used as a default in case the program is unable to find a valid assignee. 168 | 169 | .. code-block:: python 170 | 171 | 'github_project_fields': { 172 | 'storypoints': { 173 | 'gh_field': 'Estimate' 174 | }, 175 | 'priority': { 176 | 'gh_field': 'Priority', 177 | 'options': { 178 | 'P0': 'Blocker', 179 | 'P1': 'Critical', 180 | 'P2': 'Major', 181 | 'P3': 'Minor', 182 | 'P4': 'Optional', 183 | 'P5': 'Trivial' 184 | } 185 | } 186 | } 187 | 188 | * Specify the GitHub project field and its mapping to Jira. The currently supported fields are :code:`storypoints` 189 | and :code:`priority`. 190 | 191 | * The :code:`gh_field` points to the GitHub field name. 192 | 193 | * The :code:`options` key is used to specify a mapping between GitHub field values as Jira field values. 194 | This is particularly useful for cases like priority which uses keywords for values. 195 | 196 | .. code-block:: python 197 | 198 | 'filters': { 199 | 'github': { 200 | # Only sync multi-type tickets from bodhi. 201 | 'fedora-infra/bodhi': {'status': 'open', 'milestone': 4, }, 202 | }, 203 | } 204 | 205 | * You can also add filters per-project. The following can be added to the filter dict: 206 | 207 | * :code:`status` 208 | * Open/Closed 209 | * :code:`tags` 210 | * List of tags to look for 211 | * :code:`milestone` 212 | * Upstream milestone status 213 | -------------------------------------------------------------------------------- /docs/source/downstream_issue.rst: -------------------------------------------------------------------------------- 1 | Downstream Issue 2 | ================ 3 | 4 | .. automodule:: sync2jira.downstream_issue 5 | :members: 6 | :private-members: 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/source/downstream_pr.rst: -------------------------------------------------------------------------------- 1 | Downstream PR 2 | ============= 3 | 4 | .. automodule:: sync2jira.downstream_pr 5 | :members: 6 | :private-members: 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Sync2Jira documentation 2 | ===================================== 3 | 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | :caption: Setup Guide 8 | 9 | quickstart 10 | config-file 11 | continuous_deployment 12 | sync_page 13 | 14 | 15 | .. toctree:: 16 | :maxdepth: 1 17 | :caption: Adding New Repos 18 | 19 | adding-new-repo-guide 20 | 21 | 22 | .. toctree:: 23 | :maxdepth: 5 24 | :caption: Code Documentation 25 | 26 | main 27 | upstream_pr 28 | upstream_issue 29 | downstream_pr 30 | downstream_issue 31 | intermediary 32 | mailer -------------------------------------------------------------------------------- /docs/source/intermediary.rst: -------------------------------------------------------------------------------- 1 | Intermediary 2 | ============ 3 | 4 | Sync2Jira converts upstream issues/PRs into custom Issue/PR objects. 5 | 6 | .. automodule:: sync2jira.intermediary 7 | :members: 8 | :private-members: 9 | -------------------------------------------------------------------------------- /docs/source/mailer.rst: -------------------------------------------------------------------------------- 1 | Mailer 2 | ======= 3 | 4 | .. automodule:: sync2jira.mailer 5 | :members: 6 | :private-members: 7 | 8 | -------------------------------------------------------------------------------- /docs/source/main.rst: -------------------------------------------------------------------------------- 1 | Main 2 | ==== 3 | 4 | .. automodule:: sync2jira.main 5 | :members: 6 | :private-members: 7 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quick Start 2 | ============ 3 | 4 | Want to quickly get started working with Sync2Jira? Follow these steps: 5 | 6 | 1. **First open up** :code:`fedmsg.d/sync2jira.py` 7 | 8 | 2. Enter your GitHub token which you can get `here `_ 9 | .. code-block:: python 10 | 11 | 'github_token': 'YOUR_TOKEN', 12 | 13 | 3. Enter relevant JIRA information 14 | .. code-block:: python 15 | 16 | 'default_jira_instance': 'example', 17 | # This should be the username of the account corresponding with `token_auth` below. 18 | 'jira_username': 'your-bot-account', 19 | 'jira': { 20 | 'example': { 21 | 'options': { 22 | 'server': 'https://some_jira_server_somewhere.com', 23 | 'verify': True, 24 | }, 25 | 'token_auth': 'YOUR_TOKEN', 26 | }, 27 | }, 28 | 29 | .. note:: You might have to set verify to False 30 | 31 | 4. Add your upstream repos to the `map` section 32 | .. code-block:: python 33 | 34 | 'map': { 35 | 'github': { 36 | 'GITHUB_USERNAME/Demo_project': {'project': 'FACTORY', 'component': 'gitbz', 37 | 'issue_updates': [...], 'sync': [..]}, 38 | }, 39 | }, 40 | 41 | .. note:: You can learn more about what can go into the issue_updates list `here `_ 42 | 43 | 5. Finally you can tweak the config files optional settings to your liking 44 | .. code-block:: python 45 | 46 | # Admins to be cc'd in duplicate emails 47 | 'admins': ['demo_jira_username'], 48 | # Scrape sources at startup 49 | 'initialize': True, 50 | # Don't actually make changes to JIRA... 51 | 'testing': True, 52 | 53 | 'filters': { 54 | 'github': { 55 | # Only sync multi-type tickets from bodhi. 56 | 'fedora-infra/bodhi': {'state': 'open', 'milestone': 4, }, 57 | }, 58 | } 59 | 6. Now that you're done with the config file you can install sync2jira and run 60 | .. code-block:: shell 61 | 62 | python setup.py install 63 | >> .... 64 | >> Finished processing dependencies for sync2jira==1.7 65 | sync2jira 66 | .. note:: You might have to add `config['validate_signatures'] = False`. 67 | You can find out more under the `main `_. 68 | -------------------------------------------------------------------------------- /docs/source/sync_page.rst: -------------------------------------------------------------------------------- 1 | Sync Page 2 | ====================== 3 | We noticed that sometimes tickets would be lost and not sync. This would require taking down the entire service in order to re-sync that one ticket. To fix this we created a flask micro-service that provides a UI for users to sync individual repos. 4 | 5 | The following environmental variables will have to be set: 6 | 7 | Related to OpenShift: 8 | 1. :code:`CA_URL` :: CA URL used for sync2jira 9 | 2. :code:`DEFAULT_SERVER` :: Default server to use for mailing 10 | 3. :code:`DEFAULT_FROM` :: Default from to use for mailing 11 | 4. :code:`USER` :: JIRA username 12 | 5. :code:`INITIALIZE` :: True/False Initialize our repos on startup (Should be set to "0") 13 | 6. :code:`IMAGE_URL` :: Image URL:TAG to pull from 14 | 7. :code:`JIRA_PNT_PASS` :: PNT password in base64 15 | 8. :code:`JIRA_OMEGAPRIME_PASS` :: Omegaprime password in base64 16 | 9. :code:`GITHUB_TOKEN` :: GitHub token in base64 17 | 10. :code:`PAAS_DOMAIN` :: Domain to use for the service 18 | 19 | You can also use the OpenShift template to quickly deploy this service (it can be found in the repo under :code:`openshift/sync2jira-sync-page-template.yaml`) 20 | 21 | Once deployed, you can go to the url :code:`sync2jira-page-sync.PAAS_DOMAIN` to select and sync individual repos! -------------------------------------------------------------------------------- /docs/source/upstream_issue.rst: -------------------------------------------------------------------------------- 1 | Upstream Issue 2 | ============== 3 | 4 | .. automodule:: sync2jira.upstream_issue 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/source/upstream_pr.rst: -------------------------------------------------------------------------------- 1 | Upstream PR 2 | ============ 3 | 4 | .. automodule:: sync2jira.upstream_pr 5 | :members: 6 | -------------------------------------------------------------------------------- /fedmsg.d/sync2jira.py: -------------------------------------------------------------------------------- 1 | # This file is part of sync2jira. 2 | # Copyright (C) 2016 Red Hat, Inc. 3 | # 4 | # sync2jira is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2.1 of the License, or (at your option) any later version. 8 | # 9 | # sync2jira is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with sync2jira; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110.15.0 USA 17 | # 18 | # Authors: Ralph Bean 19 | 20 | config = { 21 | 'sync2jira': { 22 | # Admins to be cc'd in duplicate emails 23 | 'admins': [{'admin_username': 'admin_email@demo.com'}], 24 | 25 | # Mailing list email to send failure-email notices too 26 | 'mailing-list': 'some_email@demo.com', 27 | 28 | # Enable debug logging 29 | 'debug': False, 30 | 31 | # Listen on the message bus 32 | 'listen': True, 33 | 34 | # Don't actually make changes to JIRA... 35 | 'testing': True, 36 | 37 | # Set to True when developing to disable sentinel query 38 | 'develop': False, 39 | 40 | # Your Github token 41 | 'github_token': 'YOUR_GITHUB_API_TOKEN', 42 | 43 | 'legacy_matching': False, 44 | 45 | 'default_jira_instance': 'example', 46 | 'jira_username': 'your-bot-account', 47 | 'jira': { 48 | 'example': { 49 | 'options': { 50 | 'server': 'https://some_jira_server_somewhere.com', 51 | 'verify': True, 52 | }, 53 | 'token_auth': 'YOUR_JIRA_ACCESS_TOKEN', 54 | }, 55 | }, 56 | 'default_jira_fields': { 57 | 'storypoints': 'customfield_12310243', 58 | }, 59 | 'map': { 60 | 'github': { 61 | 'GITHUB_USERNAME/Demo_project': {'project': 'FACTORY', 'component': 'gitbz', 62 | 'issue_updates': [ 63 | 'comments', 64 | 'upstream_id', 65 | 'title', 66 | 'description', 67 | 'github_markdown', 68 | 'upstream_id', 69 | 'url', 70 | {'transition': 'Closed'}, 71 | {'assignee': {'overwrite': False}}, 72 | 'github_project_fields'], 73 | 'github_project_number': '1', 74 | 'github_project_fields': {'storypoints': {'gh_field': 'Estimate'}, 75 | 'priority': {'gh_field': 'Priority', 'options': 76 | {'P0': 'Blocker', 'P1': 'Critical', 'P2': 'Major', 77 | 'P3': 'Minor', 'P4': 'Optional', 'P5': 'Trivial'}}}, 78 | 'sync': ['pullrequest', 'issue']}, 79 | }, 80 | }, 81 | 'filters': { 82 | 'github': { 83 | # Only sync multi-type tickets from bodhi. 84 | 'fedora-infra/bodhi': {'status': 'open', 'milestone': 4, }, 85 | }, 86 | } 87 | }, 88 | } 89 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "automerge": true, 3 | "updateNotScheduled": false, 4 | "schedule": [ 5 | "after 5pm on sunday" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jira 2 | requests 3 | requests_kerberos 4 | fedmsg 5 | fedora-messaging 6 | PyGithub 7 | pypandoc_binary 8 | urllib3 9 | jinja2 10 | flask 11 | webhook-to-fedora-messaging-messages 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # This file is part of sync2jira. 2 | # Copyright (C) 2016 Red Hat, Inc. 3 | # 4 | # sync2jira is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2.1 of the License, or (at your option) any later version. 8 | # 9 | # sync2jira is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with sync2jira; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110.15.0 USA 17 | # 18 | # Authors: Ralph Bean 19 | import os 20 | 21 | from setuptools import setup 22 | 23 | with open("requirements.txt", "rb") as f: 24 | install_requires = f.read().decode("utf-8").split("\n") 25 | if not os.getenv("READTHEDOCS"): 26 | install_requires.append("requests-kerberos") 27 | 28 | with open("test-requirements.txt", "rb") as f: 29 | test_requires = f.read().decode("utf-8").split("\n") 30 | 31 | setup( 32 | name="sync2jira", 33 | version=2.0, 34 | description="Sync GitHub issues to jira, via fedmsg", 35 | author="Ralph Bean", 36 | author_email="rbean@redhat.com", 37 | url="https://github.com/release-engineering/Sync2Jira", 38 | license="LGPLv2+", 39 | classifiers=[ 40 | "Development Status :: 5 - Production/Stable", 41 | "License :: OSI Approved :: GNU Lesser General " 42 | "Public License v2 or later (LGPLv2+)", 43 | "Intended Audience :: Developers", 44 | "Intended Audience :: System Administrators", 45 | "Programming Language :: Python :: 3", 46 | ], 47 | install_requires=install_requires, 48 | tests_require=test_requires, 49 | packages=[ 50 | "sync2jira", 51 | ], 52 | include_package_data=True, 53 | zip_safe=False, 54 | entry_points={ 55 | "console_scripts": [ 56 | "sync2jira=sync2jira.main:main", 57 | ], 58 | }, 59 | ) 60 | -------------------------------------------------------------------------------- /sync-page/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/release-engineering/Sync2Jira/88c81cba55b1a68dc5e691d0db6e7cb97ddaabc0/sync-page/__init__.py -------------------------------------------------------------------------------- /sync-page/assets/font.css: -------------------------------------------------------------------------------- 1 | 2 | @font-face { 3 | font-family: "overpass"; 4 | font-style: normal; 5 | font-weight: 200; 6 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-thin.eot"); 7 | /* IE9 Compat Modes */ 8 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-thin.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-thin.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-thin.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-thin.ttf") format("truetype"); 9 | /* Safari, Android, iOS */ 10 | } 11 | 12 | @font-face { 13 | font-family: "overpass"; 14 | font-style: italic; 15 | font-weight: 200; 16 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-thin-italic.eot"); 17 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-thin-italic.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-thin-italic.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-thin-italic.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-thin-italic.ttf") format("truetype"); 18 | } 19 | 20 | @font-face { 21 | font-family: "overpass"; 22 | font-style: normal; 23 | font-weight: 300; 24 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extralight.eot"); 25 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extralight.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extralight.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extralight.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extralight.ttf") format("truetype"); 26 | } 27 | 28 | @font-face { 29 | font-family: "overpass"; 30 | font-style: italic; 31 | font-weight: 300; 32 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extralight-italic.eot"); 33 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extralight-italic.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extralight-italic.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extralight-italic.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extralight-italic.ttf") format("truetype"); 34 | } 35 | 36 | @font-face { 37 | font-family: "overpass"; 38 | font-style: normal; 39 | font-weight: 400; 40 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-light.eot"); 41 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-light.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-light.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-light.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-light.ttf") format("truetype"); 42 | } 43 | 44 | @font-face { 45 | font-family: "overpass"; 46 | font-style: italic; 47 | font-weight: 400; 48 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-light-italic.eot"); 49 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-light-italic.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-light-italic.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-light-italic.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-light-italic.ttf") format("truetype"); 50 | } 51 | 52 | @font-face { 53 | font-family: "overpass"; 54 | font-style: normal; 55 | font-weight: 500; 56 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-regular.eot"); 57 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-regular.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-regular.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-regular.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-regular.ttf") format("truetype"); 58 | } 59 | 60 | @font-face { 61 | font-family: "overpass"; 62 | font-style: italic; 63 | font-weight: 500; 64 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-italic.eot"); 65 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-italic.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-italic.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-italic.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-italic.ttf") format("truetype"); 66 | } 67 | 68 | @font-face { 69 | font-family: "overpass"; 70 | font-style: normal; 71 | font-weight: 600; 72 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-semibold.eot"); 73 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-semibold.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-semibold.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-semibold.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-semibold.ttf") format("truetype"); 74 | } 75 | 76 | @font-face { 77 | font-family: "overpass"; 78 | font-style: italic; 79 | font-weight: 600; 80 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-semibold-italic.eot"); 81 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-semibold-italic.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-semibold-italic.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-semibold-italic.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-semibold-italic.ttf") format("truetype"); 82 | } 83 | 84 | @font-face { 85 | font-family: "overpass"; 86 | font-style: normal; 87 | font-weight: 700; 88 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-bold.eot"); 89 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-bold.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-bold.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-bold.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-bold.ttf") format("truetype"); 90 | } 91 | 92 | @font-face { 93 | font-family: "overpass"; 94 | font-style: italic; 95 | font-weight: 700; 96 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-bold-italic.eot"); 97 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-bold-italic.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-bold-italic.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-bold-italic.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-bold-italic.ttf") format("truetype"); 98 | } 99 | 100 | @font-face { 101 | font-family: "overpass"; 102 | font-style: normal; 103 | font-weight: 800; 104 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extrabold.eot"); 105 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extrabold.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extrabold.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extrabold.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extrabold.ttf") format("truetype"); 106 | } 107 | 108 | @font-face { 109 | font-family: "overpass"; 110 | font-style: italic; 111 | font-weight: 800; 112 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extrabold-italic.eot"); 113 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extrabold-italic.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extrabold-italic.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extrabold-italic.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-extrabold-italic.ttf") format("truetype"); 114 | } 115 | 116 | @font-face { 117 | font-family: "overpass"; 118 | font-style: normal; 119 | font-weight: 900; 120 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-heavy.eot"); 121 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-heavy.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-heavy.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-heavy.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-heavy.ttf") format("truetype"); 122 | } 123 | 124 | @font-face { 125 | font-family: "overpass"; 126 | font-style: italic; 127 | font-weight: 900; 128 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-heavy-italic.eot"); 129 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-heavy-italic.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-heavy-italic.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-heavy-italic.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-webfont/overpass-heavy-italic.ttf") format("truetype"); 130 | } 131 | 132 | @font-face { 133 | font-family: "overpass-mono"; 134 | font-style: normal; 135 | font-weight: 300; 136 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-light.eot"); 137 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-light.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-light.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-light.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-light.ttf") format("truetype"); 138 | } 139 | 140 | @font-face { 141 | font-family: "overpass-mono"; 142 | font-style: normal; 143 | font-weight: 400; 144 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-regular.eot"); 145 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-regular.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-regular.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-regular.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-regular.ttf") format("truetype"); 146 | } 147 | 148 | @font-face { 149 | font-family: "overpass-mono"; 150 | font-style: normal; 151 | font-weight: 500; 152 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-semibold.eot"); 153 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-semibold.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-semibold.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-semibold.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-semibold.ttf") format("truetype"); 154 | } 155 | 156 | @font-face { 157 | font-family: "overpass-mono"; 158 | font-style: normal; 159 | font-weight: 600; 160 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-bold.eot"); 161 | src: url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-bold.eot?#iefix") format("embedded-opentype"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-bold.woff2") format("woff2"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-bold.woff") format("woff"), url("https://unpkg.com/@patternfly/patternfly@2.40.13/assets/fonts/overpass-mono-webfont/overpass-mono-bold.ttf") format("truetype"); 162 | } 163 | -------------------------------------------------------------------------------- /sync-page/assets/redhat-favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/release-engineering/Sync2Jira/88c81cba55b1a68dc5e691d0db6e7cb97ddaabc0/sync-page/assets/redhat-favicon.ico -------------------------------------------------------------------------------- /sync-page/event-handler.py: -------------------------------------------------------------------------------- 1 | # Build-In Modules 2 | import logging 3 | import os 4 | 5 | # 3rd Party Modules 6 | from flask import Flask, redirect, render_template, request 7 | 8 | # Local Modules 9 | from sync2jira.main import initialize_issues, initialize_pr, load_config 10 | 11 | # Global Variables 12 | app = Flask(__name__, static_url_path="/assets", static_folder="assets") 13 | BASE_URL = os.environ["BASE_URL"] 14 | REDIRECT_URL = os.environ["REDIRECT_URL"] 15 | config = load_config() 16 | 17 | # Set up our logging 18 | FORMAT = "[%(asctime)s] %(levelname)s: %(message)s" 19 | logging.basicConfig(format=FORMAT, level=logging.INFO) 20 | logging.basicConfig(format=FORMAT, level=logging.DEBUG) 21 | logging.basicConfig(format=FORMAT, level=logging.WARNING) 22 | log = logging.getLogger("sync2jira-sync-page") 23 | 24 | 25 | @app.route("/handle-event", methods=["POST"]) 26 | def handle_event(): 27 | """ 28 | Handler for when a user wants to sync a repo 29 | """ 30 | response = request.form 31 | synced_repos = [] 32 | for repo_name, switch in response.items(): 33 | if switch == "on": 34 | # Sync repo_name 35 | log.info(f"Starting sync for repo: {repo_name}") 36 | initialize_issues(config, repo_name=repo_name) 37 | initialize_pr(config, repo_name=repo_name) 38 | synced_repos.append(repo_name) 39 | if synced_repos: 40 | return render_template( 41 | "sync-page-success.jinja", 42 | synced_repos=synced_repos, 43 | url=f"https://{REDIRECT_URL}", 44 | ) 45 | else: 46 | return render_template("sync-page-failure.jinja", url=f"https://{REDIRECT_URL}") 47 | 48 | 49 | @app.route("/", methods=["GET"]) 50 | def index(): 51 | """ 52 | Return relevant redirect 53 | """ 54 | return redirect("/github") 55 | 56 | 57 | @app.route("/github", methods=["GET"]) 58 | def github(): 59 | """ 60 | Github Sync Page 61 | """ 62 | # Build and return our updated HTML page 63 | return render_template( 64 | "sync-page-github.jinja", 65 | github=config["sync2jira"]["map"]["github"], 66 | url=f"https://{REDIRECT_URL}", 67 | ) 68 | 69 | 70 | if __name__ == "__main__": 71 | app.run(host=BASE_URL) 72 | -------------------------------------------------------------------------------- /sync-page/templates/sync-page-failure.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | Sync2Jira - Sync Page 16 | 21 | 22 | 23 |
24 | 29 |
30 |
31 | 42 |
43 |
44 |
45 |
46 |
47 |

Failed to started syncing

48 |

Make sure you selected at least one repo!

49 |
50 |
51 |
52 |
53 |
54 |
55 | 56 |
57 |
58 |
59 |
60 |
61 |
62 | 63 | 64 | -------------------------------------------------------------------------------- /sync-page/templates/sync-page-github.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | Sync2Jira - Sync Page 16 | 17 | 18 |
19 | 24 |
25 |
26 | 37 |
38 |
39 |
40 |
41 |
42 |

GitHub Sync

43 |

Select the repos you would like to be re-syned!

44 |
45 |
46 |
47 |
48 | {% for repo_name, info in github.items() %} 49 |
50 |
51 |
52 | 53 | 54 |
55 |
56 |
57 | {% endfor %} 58 |
59 |
60 |
61 | 62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | 70 | 71 | -------------------------------------------------------------------------------- /sync-page/templates/sync-page-success.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | Sync2Jira - Sync Page 16 | 21 | 22 | 23 |
24 | 29 |
30 |
31 | 42 |
43 |
44 |
45 |
46 |
47 |

Successfully started syncing the following repos:

48 |
    49 | {% for repo_name in synced_repos %} 50 |
  • {{ repo_name }}
  • 51 | {% endfor %} 52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | 60 |
61 |
62 |
63 |
64 |
65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /sync2jira/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of sync2jira. 2 | # Copyright (C) 2016 Red Hat, Inc. 3 | # 4 | # sync2jira is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2.1 of the License, or (at your option) any later version. 8 | # 9 | # sync2jira is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with sync2jira; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110.15.0 USA 17 | # 18 | # Authors: Ralph Bean 19 | __version__ = "1.7" 20 | -------------------------------------------------------------------------------- /sync2jira/downstream_pr.py: -------------------------------------------------------------------------------- 1 | # This file is part of sync2jira. 2 | # Copyright (C) 2016 Red Hat, Inc. 3 | # 4 | # sync2jira is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2.1 of the License, or (at your option) any later version. 8 | # 9 | # sync2jira is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with sync2jira; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110.15.0 USA 17 | # 18 | # Authors: Ralph Bean 19 | # Built-In Modules 20 | import logging 21 | 22 | # 3rd Party Modules 23 | from jira import JIRAError 24 | from jira.client import Issue as JIRAIssue 25 | from jira.client import ResultList 26 | 27 | # Local Modules 28 | import sync2jira.downstream_issue as d_issue 29 | from sync2jira.intermediary import Issue, matcher 30 | 31 | log = logging.getLogger("sync2jira") 32 | 33 | 34 | def format_comment(pr, pr_suffix, client): 35 | """ 36 | Formats comment to link PR. 37 | :param sync2jira.intermediary.PR pr: Upstream issue we're pulling data from 38 | :param String pr_suffix: Suffix to indicate what state we're transitioning too 39 | :param jira.client.JIRA client: JIRA Client 40 | :return: Formatted comment 41 | :rtype: String 42 | """ 43 | # Find the pr.reporters JIRA username 44 | ret = client.search_users(pr.reporter) 45 | # Loop through ret till we find a match 46 | for user in ret: 47 | if user.displayName == pr.reporter: 48 | reporter = f"[~{user.key}]" 49 | break 50 | else: 51 | reporter = pr.reporter 52 | 53 | if "closed" in pr_suffix: 54 | comment = f"Merge request [{pr.title}| {pr.url}] was closed." 55 | elif "reopened" in pr_suffix: 56 | comment = f"Merge request [{pr.title}| {pr.url}] was reopened." 57 | elif "merged" in pr_suffix: 58 | comment = f"Merge request [{pr.title}| {pr.url}] was merged!" 59 | else: 60 | comment = ( 61 | f"{reporter} mentioned this issue in " 62 | f"merge request [{pr.title}| {pr.url}]." 63 | ) 64 | return comment 65 | 66 | 67 | def issue_link_exists(client, existing: JIRAIssue, pr): 68 | """ 69 | Checks if we've already linked this PR 70 | 71 | :param jira.client.JIRA client: JIRA Client 72 | :param jira.resources.Issue existing: Existing JIRA issue that was found 73 | :param sync2jira.intermediary.PR pr: Upstream issue we're pulling data from 74 | :returns: True/False if the issue exists/does not exist 75 | """ 76 | # Query for our issue 77 | for issue_link in client.remote_links(existing): 78 | if issue_link.object.url == pr.url: 79 | # Issue has already been linked 80 | return True 81 | return False 82 | 83 | 84 | def comment_exists(client, existing: JIRAIssue, new_comment): 85 | """ 86 | Checks if new_comment exists in existing 87 | :param jira.client.JIRA client: JIRA Client 88 | :param jira.resources.Issue existing: Existing JIRA issue that was found 89 | :param String new_comment: Formatted comment we're looking for 90 | :returns: Nothing 91 | """ 92 | # Grab and loop over comments 93 | comments = client.comments(existing) 94 | for comment in comments: 95 | if new_comment == comment.body: 96 | # If the comment was 97 | return True 98 | return False 99 | 100 | 101 | def update_jira_issue(existing, pr, client): 102 | """ 103 | Updates an existing JIRA issue (i.e. tags, assignee, comments etc.). 104 | 105 | :param jira.resources.Issue existing: Existing JIRA issue that was found 106 | :param sync2jira.intermediary.PR pr: Upstream issue we're pulling data from 107 | :param jira.client.JIRA client: JIRA Client 108 | :returns: Nothing 109 | """ 110 | # Get our updates array 111 | updates = pr.downstream.get("pr_updates", {}) 112 | 113 | # Format and add comment to indicate PR has been linked 114 | new_comment = format_comment(pr, pr.suffix, client) 115 | # See if the issue_link and comment exists 116 | exists = issue_link_exists(client, existing, pr) 117 | comment_exist = comment_exists(client, existing, new_comment) 118 | # Check if the comment is already there 119 | if not exists: 120 | if not comment_exist: 121 | log.info(f"Added comment for PR {pr.title} on JIRA {pr.jira_key}") 122 | client.add_comment(existing, new_comment) 123 | # Attach remote link 124 | remote_link = dict(url=pr.url, title=f"[PR] {pr.title}") 125 | d_issue.attach_link(client, existing, remote_link) 126 | 127 | # Only synchronize link_transition for listings that op-in 128 | if any("merge_transition" in item for item in updates) and "merged" in pr.suffix: 129 | log.info("Looking for new merged_transition") 130 | update_transition(client, existing, pr, "merge_transition") 131 | 132 | # Only synchronize merge_transition for listings that op-in 133 | # and a link comment has been created 134 | if ( 135 | any("link_transition" in item for item in updates) 136 | and "mentioned" in new_comment 137 | and not exists 138 | ): 139 | log.info("Looking for new link_transition") 140 | update_transition(client, existing, pr, "link_transition") 141 | 142 | 143 | def update_transition(client, existing, pr, transition_type): 144 | """ 145 | Helper function to update the transition of a downstream JIRA issue. 146 | 147 | :param jira.client.JIRA client: JIRA client 148 | :param jira.resource.Issue existing: Existing JIRA issue 149 | :param sync2jira.intermediary.PR pr: Upstream issue 150 | :param string transition_type: Transition type (link vs merged) 151 | :returns: Nothing 152 | """ 153 | # Get our closed status 154 | closed_status = next( 155 | filter(lambda d: transition_type in d, pr.downstream.get("pr_updates", {})) 156 | )[transition_type] 157 | 158 | # Update the state 159 | d_issue.change_status(client, existing, closed_status, pr) 160 | 161 | log.info(f"Updated {transition_type} for issue {pr.title}") 162 | 163 | 164 | def sync_with_jira(pr, config): 165 | """ 166 | Attempts to sync an upstream PR with JIRA (i.e. by finding 167 | an existing issue). 168 | 169 | :param sync2jira.intermediary.PR/Issue pr: PR or Issue object 170 | :param Dict config: Config dict 171 | :returns: Nothing 172 | """ 173 | log.info("[PR] Considering upstream %s, %s", pr.url, pr.title) 174 | 175 | # Return if testing 176 | if config["sync2jira"]["testing"]: 177 | log.info("Testing flag is true. Skipping actual update.") 178 | return None 179 | 180 | if not pr.match: 181 | log.info(f"[PR] No match found for {pr.title}") 182 | return None 183 | 184 | # Create a client connection for this issue 185 | client = d_issue.get_jira_client(pr, config) 186 | 187 | retry = False 188 | while True: 189 | try: 190 | update_jira(client, config, pr) 191 | break 192 | except JIRAError: 193 | # We got an error from Jira; if this was a re-try attempt, let the 194 | # exception propagate (and crash the run). 195 | if retry: 196 | log.info("[PR] Jira retry failed; aborting") 197 | raise 198 | 199 | # The error is probably because our access has expired; refresh it 200 | # and try again. 201 | log.info("[PR] Jira request failed; refreshing the Jira client") 202 | client = d_issue.get_jira_client(pr, config) 203 | 204 | # Retry the update 205 | retry = True 206 | 207 | 208 | def update_jira(client, config, pr): 209 | # Check the status of the JIRA client 210 | if not config["sync2jira"]["develop"] and not d_issue.check_jira_status(client): 211 | log.warning("The JIRA server looks like its down. Shutting down...") 212 | raise RuntimeError("Jira server status check failed; aborting...") 213 | 214 | # Find our JIRA issue if one exists 215 | if isinstance(pr, Issue): 216 | pr.jira_key = matcher(pr.content, pr.comments) 217 | 218 | query = f"Key = {pr.jira_key}" 219 | try: 220 | response: ResultList[JIRAIssue] = client.search_issues(query) 221 | # Throw error and return if nothing could be found 222 | if len(response) == 0 or len(response) > 1: 223 | log.warning(f"No JIRA issue could be found for {pr.title}") 224 | return 225 | except JIRAError: 226 | # If no issue exists, it will throw a JIRA error 227 | log.warning(f"No JIRA issue exists for PR: {pr.title}. Query: {query}") 228 | return 229 | 230 | # Existing JIRA issue is the only one in the query 231 | existing = response[0] 232 | 233 | # Else start syncing relevant information 234 | log.info(f"Syncing PR {pr.title}") 235 | update_jira_issue(existing, pr, client) 236 | log.info(f"Done syncing PR {pr.title}") 237 | -------------------------------------------------------------------------------- /sync2jira/failure_template.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Looks like Sync2Jira has failed!

7 |

Here is the full traceback:

8 | {{ traceback }} 9 | 10 | -------------------------------------------------------------------------------- /sync2jira/intermediary.py: -------------------------------------------------------------------------------- 1 | # This file is part of sync2jira. 2 | # Copyright (C) 2016 Red Hat, Inc. 3 | # 4 | # sync2jira is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2.1 of the License, or (at your option) any later version. 8 | # 9 | # sync2jira is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with sync2jira; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110.15.0 USA 17 | # 18 | # Authors: Ralph Bean 19 | import re 20 | from typing import Optional 21 | 22 | 23 | class Issue(object): 24 | """Issue Intermediary object""" 25 | 26 | def __init__( 27 | self, 28 | source, 29 | title, 30 | url, 31 | upstream, 32 | comments, 33 | config, 34 | tags, 35 | fixVersion, 36 | priority, 37 | content, 38 | reporter, 39 | assignee, 40 | status, 41 | id_, 42 | storypoints, 43 | upstream_id, 44 | issue_type, 45 | downstream=None, 46 | ): 47 | self.source = source 48 | self._title = title[:254] 49 | self.url = url 50 | self.upstream = upstream 51 | self.comments = comments 52 | self.tags = tags 53 | self.fixVersion = fixVersion 54 | self.priority = priority 55 | self.storypoints = storypoints 56 | 57 | # First trim the size of the content 58 | self.content = trim_string(content) 59 | 60 | # JIRA treats utf-8 characters in ways we don't totally understand, so scrub content down to 61 | # simple ascii characters right from the start. 62 | self.content = self.content.encode("ascii", errors="replace").decode("ascii") 63 | 64 | # We also apply this content in regexs to pattern match, so remove any escape characters 65 | self.content = self.content.replace("\\", "") 66 | 67 | self.reporter = reporter 68 | self.assignee = assignee 69 | self.status = status 70 | self.id = str(id_) 71 | self.upstream_id = upstream_id 72 | self.issue_type = issue_type 73 | if not downstream: 74 | self.downstream = config["sync2jira"]["map"][self.source][upstream] 75 | else: 76 | self.downstream = downstream 77 | 78 | @property 79 | def title(self): 80 | _title = "[%s] %s" % (self.upstream, self._title) 81 | return _title[:254].strip() 82 | 83 | @property 84 | def upstream_title(self): 85 | return self._title 86 | 87 | @classmethod 88 | def from_github(cls, upstream, issue, config): 89 | """Helper function to create an intermediary Issue object.""" 90 | upstream_source = "github" 91 | comments = reformat_github_comments(issue) 92 | 93 | # Reformat the state field 94 | if issue["state"]: 95 | if issue["state"] == "open": 96 | issue["state"] = "Open" 97 | elif issue["state"] == "closed": 98 | issue["state"] = "Closed" 99 | 100 | # Get the issue type if any 101 | issue_type = issue.get("type") 102 | if isinstance(issue_type, dict): 103 | issue_type = issue_type.get("name") 104 | 105 | # Perform any mapping 106 | mapping = config["sync2jira"]["map"][upstream_source][upstream].get( 107 | "mapping", [] 108 | ) 109 | 110 | # Check for fixVersion 111 | if any("fixVersion" in item for item in mapping): 112 | map_fixVersion(mapping, issue) 113 | 114 | return cls( 115 | source=upstream_source, 116 | title=issue["title"], 117 | url=issue["html_url"], 118 | upstream=upstream, 119 | config=config, 120 | comments=comments, 121 | tags=issue["labels"], 122 | fixVersion=[issue["milestone"]], 123 | priority=issue.get("priority"), 124 | content=issue["body"] or "", 125 | reporter=issue["user"], 126 | assignee=issue["assignees"], 127 | status=issue["state"], 128 | id_=issue["id"], 129 | storypoints=issue.get("storypoints"), 130 | upstream_id=issue["number"], 131 | issue_type=issue_type, 132 | ) 133 | 134 | def __repr__(self): 135 | return f"" 136 | 137 | 138 | class PR(object): 139 | """PR intermediary object""" 140 | 141 | def __init__( 142 | self, 143 | source, 144 | jira_key, 145 | title, 146 | url, 147 | upstream, 148 | config, 149 | comments, 150 | priority, 151 | content, 152 | reporter, 153 | assignee, 154 | status, 155 | id_, 156 | suffix, 157 | match, 158 | downstream=None, 159 | ): 160 | self.source = source 161 | self.jira_key = jira_key 162 | self._title = title[:254] 163 | self.url = url 164 | self.upstream = upstream 165 | self.comments = comments 166 | # self.tags = tags 167 | # self.fixVersion = fixVersion 168 | self.priority = priority 169 | 170 | # JIRA treats utf-8 characters in ways we don't totally understand, so scrub content down to 171 | # simple ascii characters right from the start. 172 | if content: 173 | # First trim the size of the content 174 | self.content = trim_string(content) 175 | 176 | self.content = self.content.encode("ascii", errors="replace").decode( 177 | "ascii" 178 | ) 179 | 180 | # We also apply this content in regexs to pattern match, so remove any escape characters 181 | self.content = self.content.replace("\\", "") 182 | else: 183 | self.content = None 184 | 185 | self.reporter = reporter 186 | self.assignee = assignee 187 | self.status = status 188 | self.id = str(id_) 189 | self.suffix = suffix 190 | self.match = match 191 | # self.upstream_id = upstream_id 192 | 193 | if not downstream: 194 | self.downstream = config["sync2jira"]["map"][self.source][upstream] 195 | else: 196 | self.downstream = downstream 197 | return 198 | 199 | @property 200 | def title(self): 201 | return "[%s] %s" % (self.upstream, self._title) 202 | 203 | @classmethod 204 | def from_github(cls, upstream, pr, suffix, config): 205 | """Helper function to create an intermediary PR object.""" 206 | # Set our upstream source 207 | upstream_source = "github" 208 | 209 | # Format our comments 210 | comments = reformat_github_comments(pr) 211 | 212 | # Build our URL 213 | url = pr["html_url"] 214 | 215 | # Match to a JIRA 216 | match = matcher(pr.get("body"), comments) 217 | 218 | # Figure out what state we're transitioning too 219 | if "reopened" in suffix: 220 | suffix = "reopened" 221 | elif "closed" in suffix: 222 | # Check if we're merging or closing 223 | if pr["merged"]: 224 | suffix = "merged" 225 | else: 226 | suffix = "closed" 227 | 228 | # Return our PR object 229 | return cls( 230 | source=upstream_source, 231 | jira_key=match, 232 | title=pr["title"], 233 | url=url, 234 | upstream=upstream, 235 | config=config, 236 | comments=comments, 237 | # tags=issue['labels'], 238 | # fixVersion=[issue['milestone']], 239 | priority=None, 240 | content=pr.get("body"), 241 | reporter=pr["user"]["fullname"], 242 | assignee=pr["assignee"], 243 | # GitHub PRs do not have status 244 | status=None, 245 | id_=pr["number"], 246 | # upstream_id=issue['number'], 247 | suffix=suffix, 248 | match=match, 249 | ) 250 | 251 | 252 | def reformat_github_comments(issue): 253 | return [ 254 | { 255 | "author": comment["author"], 256 | "name": comment["name"], 257 | "body": trim_string(comment["body"]), 258 | "id": comment["id"], 259 | "date_created": comment["date_created"], 260 | "changed": None, 261 | } 262 | for comment in issue["comments"] 263 | ] 264 | 265 | 266 | def map_fixVersion(mapping, issue): 267 | """ 268 | Helper function to perform any fixVersion mapping. 269 | 270 | :param Dict mapping: Mapping dict we are given 271 | :param Dict issue: Upstream issue object 272 | """ 273 | # Get our fixVersion mapping 274 | fixVersion_map = next(filter(lambda d: "fixVersion" in d, mapping))["fixVersion"] 275 | 276 | # Now update the fixVersion 277 | if issue["milestone"]: 278 | issue["milestone"] = fixVersion_map.replace("XXX", issue["milestone"]) 279 | 280 | 281 | JIRA_REFERENCE = re.compile(r"\bJIRA:\s*([A-Z]+-\d+)\b") 282 | 283 | 284 | def matcher(content: Optional[str], comments: list[dict[str, str]]) -> str: 285 | """ 286 | Helper function to match to a JIRA 287 | 288 | Extract the Jira ticket reference from the first instance of the magic 289 | cookie (e.g., "JIRA: FACTORY-1234") found when searching 290 | through the comments in reverse order. If no reference is found in the 291 | comments, then look in the PR description. This ordering allows later 292 | comments to override earlier ones as well as any reference in the 293 | description. 294 | 295 | :param String content: PR description 296 | :param List comments: Comments 297 | :return: JIRA match or None 298 | :rtype: Bool 299 | """ 300 | 301 | def find_it(input_str: str) -> Optional[str]: 302 | if not input_str: 303 | return None 304 | match = JIRA_REFERENCE.search(input_str) 305 | return match.group(1) if match else None 306 | 307 | for comment in reversed(comments): 308 | match_str = find_it(comment["body"]) 309 | if match_str: 310 | break 311 | else: 312 | match_str = find_it(content) 313 | return match_str 314 | 315 | 316 | def trim_string(content): 317 | """ 318 | Helper function to trim a string to ensure it is not over 50,000 char 319 | Ref: https://github.com/release-engineering/Sync2Jira/issues/123 320 | 321 | :param String content: Comment content 322 | :rtype: String 323 | """ 324 | if len(content) > 50000: 325 | return content[:50000] 326 | else: 327 | return content 328 | -------------------------------------------------------------------------------- /sync2jira/mailer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | This script is used to send emails 4 | """ 5 | 6 | from email.mime.multipart import MIMEMultipart 7 | from email.mime.text import MIMEText 8 | import os 9 | import smtplib 10 | 11 | DEFAULT_FROM = os.environ.get("DEFAULT_FROM") 12 | DEFAULT_SERVER = os.environ.get("DEFAULT_SERVER") 13 | 14 | 15 | def send_mail(recipients, subject, text, cc): 16 | """ 17 | Sends email to recipients. 18 | 19 | :param List recipients: recipients of email 20 | :param String subject: subject of the email 21 | :param String text: HTML text 22 | :param String cc: cc of the email 23 | :param String text: text of the email 24 | :returns: Nothing 25 | """ 26 | _cfg = {} 27 | _cfg.setdefault("server", DEFAULT_SERVER) 28 | _cfg.setdefault("from", DEFAULT_FROM) 29 | sender = _cfg["from"] 30 | msg = MIMEMultipart("related") 31 | msg["Subject"] = subject 32 | msg["From"] = sender 33 | msg["To"] = ", ".join(recipients) 34 | if cc: 35 | msg["Cc"] = ", ".join(cc) 36 | server = smtplib.SMTP(_cfg["server"]) 37 | part = MIMEText(text, "html", "utf-8") 38 | msg.attach(part) 39 | server.sendmail(sender, recipients, msg.as_string()) 40 | server.quit() 41 | -------------------------------------------------------------------------------- /sync2jira/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # This file is part of sync2jira. 3 | # Copyright (C) 2016 Red Hat, Inc. 4 | # 5 | # sync2jira is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU Lesser General Public 7 | # License as published by the Free Software Foundation; either 8 | # version 2.1 of the License, or (at your option) any later version. 9 | # 10 | # sync2jira is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # Lesser General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Lesser General Public 16 | # License along with sync2jira; if not, write to the Free Software 17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110.15.0 USA 18 | # 19 | # Authors: Ralph Bean 20 | """Sync GitHub issues to a jira instance, via fedora-messaging.""" 21 | 22 | # Build-In Modules 23 | import logging 24 | import os 25 | from time import sleep 26 | import traceback 27 | import warnings 28 | 29 | # 3rd Party Modules 30 | import fedmsg.config 31 | import fedora_messaging.api 32 | from github import GithubException 33 | import jinja2 34 | from jira import JIRAError 35 | 36 | # Local Modules 37 | import sync2jira.downstream_issue as d_issue 38 | import sync2jira.downstream_pr as d_pr 39 | from sync2jira.intermediary import matcher 40 | from sync2jira.mailer import send_mail 41 | import sync2jira.upstream_issue as u_issue 42 | import sync2jira.upstream_pr as u_pr 43 | 44 | # Set up our logging 45 | FORMAT = "[%(asctime)s] %(levelname)s: %(message)s" 46 | logging.basicConfig(format=FORMAT, level=logging.INFO) 47 | logging.basicConfig(format=FORMAT, level=logging.DEBUG) 48 | logging.basicConfig(format=FORMAT, level=logging.WARNING) 49 | log = logging.getLogger("sync2jira") 50 | 51 | # Only allow fedmsg logs that are critical 52 | fedmsg_log = logging.getLogger("fedmsg.crypto.utils") 53 | fedmsg_log.setLevel(50) 54 | 55 | remote_link_title = "Upstream issue" 56 | failure_email_subject = "Sync2Jira Has Failed!" 57 | 58 | # Issue related handlers 59 | issue_handlers = { 60 | # GitHub 61 | # New webhook-2fm topics 62 | "github.issues": u_issue.handle_github_message, 63 | "github.issue_comment": u_issue.handle_github_message, 64 | # Old github2fedmsg topics 65 | "github.issue.opened": u_issue.handle_github_message, 66 | "github.issue.reopened": u_issue.handle_github_message, 67 | "github.issue.labeled": u_issue.handle_github_message, 68 | "github.issue.assigned": u_issue.handle_github_message, 69 | "github.issue.unassigned": u_issue.handle_github_message, 70 | "github.issue.closed": u_issue.handle_github_message, 71 | "github.issue.comment": u_issue.handle_github_message, 72 | "github.issue.unlabeled": u_issue.handle_github_message, 73 | "github.issue.milestoned": u_issue.handle_github_message, 74 | "github.issue.demilestoned": u_issue.handle_github_message, 75 | "github.issue.edited": u_issue.handle_github_message, 76 | } 77 | 78 | # PR related handlers 79 | pr_handlers = { 80 | # GitHub 81 | # New webhook-2fm topics 82 | "github.pull_request": u_pr.handle_github_message, 83 | "github.issue_comment": u_pr.handle_github_message, 84 | # Old github2fedmsg topics 85 | "github.pull_request.opened": u_pr.handle_github_message, 86 | "github.pull_request.edited": u_pr.handle_github_message, 87 | "github.issue.comment": u_pr.handle_github_message, 88 | "github.pull_request.reopened": u_pr.handle_github_message, 89 | "github.pull_request.closed": u_pr.handle_github_message, 90 | } 91 | INITIALIZE = os.getenv("INITIALIZE", "0") 92 | 93 | 94 | def load_config(loader=fedmsg.config.load_config): 95 | """ 96 | Generates and validates the config file \ 97 | that will be used by fedmsg and JIRA client. 98 | 99 | :param Function loader: Function to set up runtime config 100 | :returns: The config dict to be used later in the program 101 | :rtype: Dict 102 | """ 103 | config = loader() 104 | 105 | # Force some vars that we like 106 | config["mute"] = True 107 | 108 | # debug mode 109 | if config.get("sync2jira", {}).get("debug", False): 110 | handler = logging.FileHandler("sync2jira_main.log") 111 | log.addHandler(handler) 112 | log.setLevel(logging.DEBUG) 113 | 114 | # Validate it 115 | if "sync2jira" not in config: 116 | raise ValueError("No sync2jira section found in fedmsg.d/ config") 117 | 118 | if "map" not in config["sync2jira"]: 119 | raise ValueError("No sync2jira.map section found in fedmsg.d/ config") 120 | 121 | possible = {"github"} 122 | specified = set(config["sync2jira"]["map"].keys()) 123 | if not specified.issubset(possible): 124 | message = "Specified handlers: %s, must be a subset of %s." 125 | raise ValueError( 126 | message 127 | % ( 128 | ", ".join(f'"{item}"' for item in specified), 129 | ", ".join(f'"{item}"' for item in possible), 130 | ) 131 | ) 132 | 133 | if "jira" not in config["sync2jira"]: 134 | raise ValueError("No sync2jira.jira section found in fedmsg.d/ config") 135 | 136 | # Provide some default values 137 | defaults = { 138 | "listen": True, 139 | } 140 | for key, value in defaults.items(): 141 | config["sync2jira"][key] = config["sync2jira"].get(key, value) 142 | 143 | return config 144 | 145 | 146 | def callback(msg): 147 | topic = msg.topic 148 | idx = msg.id 149 | suffix = ".".join(topic.split(".")[3:]) 150 | 151 | if suffix not in issue_handlers and suffix not in pr_handlers: 152 | log.info("No handler for %r %r %r", suffix, topic, idx) 153 | return 154 | 155 | config = load_config() 156 | body = msg.body.get("body") or msg.body 157 | try: 158 | handle_msg(body, suffix, config) 159 | except GithubException as e: 160 | log.error("Unexpected GitHub error: %s", e) 161 | except JIRAError as e: 162 | log.error("Unexpected Jira error: %s", e) 163 | except Exception as e: 164 | log.exception("Unexpected error.", exc_info=e) 165 | 166 | 167 | def listen(config): 168 | """ 169 | Listens to activity on upstream repos on GitHub 170 | via fedmsg, and syncs new issues there to the JIRA instance 171 | defined in 'fedmsg.d/sync2jira.py' 172 | 173 | :param Dict config: Config dict 174 | :returns: Nothing 175 | """ 176 | if not config["sync2jira"].get("listen"): 177 | log.info("`listen` is disabled. Exiting.") 178 | return 179 | 180 | # Next, we need a queue to consume messages from. We can define 181 | # the queue and binding configurations in these dictionaries: 182 | queue = os.getenv("FEDORA_MESSAGING_QUEUE", "8b16c196-7ee3-4e33-92b9-e69d80fce333") 183 | queues = { 184 | queue: { 185 | "durable": True, # Persist the queue on broker restart 186 | "auto_delete": False, # Delete the queue when the client terminates 187 | "exclusive": False, # Allow multiple simultaneous consumers 188 | "arguments": {}, 189 | }, 190 | } 191 | bindings = { 192 | "exchange": "amq.topic", # The AMQP exchange to bind our queue to 193 | "queue": queue, 194 | "routing_keys": [ # The topics that should be delivered to the queue 195 | # New style 196 | "org.fedoraproject.prod.github.issues", 197 | "org.fedoraproject.prod.github.issue_comment", 198 | "org.fedoraproject.prod.github.pull_request", 199 | # Old style 200 | "org.fedoraproject.prod.github.issue.#", 201 | "org.fedoraproject.prod.github.pull_request.#", 202 | ], 203 | } 204 | 205 | log.info("Waiting for a relevant fedmsg message to arrive...") 206 | fedora_messaging.api.consume(callback, bindings=bindings, queues=queues) 207 | 208 | 209 | def initialize_issues(config, testing=False, repo_name=None): 210 | """ 211 | Initial initialization needed to sync any upstream 212 | repo with JIRA. Goes through all issues and 213 | checks if they're already on JIRA / Need to be 214 | created. 215 | 216 | :param Dict config: Config dict for JIRA 217 | :param Bool testing: Flag to indicate if we are testing. Default false 218 | :param String repo_name: Optional individual repo name. If defined we will only sync the provided repo 219 | :returns: Nothing 220 | """ 221 | log.info("Running initialization to sync all issues from upstream to jira") 222 | log.info("Testing flag is %r", config["sync2jira"]["testing"]) 223 | mapping = config["sync2jira"]["map"] 224 | for upstream in mapping.get("github", {}).keys(): 225 | if "issue" not in mapping.get("github", {}).get(upstream, {}).get("sync", []): 226 | continue 227 | if repo_name is not None and upstream != repo_name: 228 | continue 229 | # Try and except for GitHub API limit 230 | try: 231 | for issue in u_issue.github_issues(upstream, config): 232 | try: 233 | d_issue.sync_with_jira(issue, config) 234 | except Exception: 235 | log.error(" Failed on %r", issue) 236 | raise 237 | except Exception as e: 238 | if "API rate limit exceeded" in e.__str__(): 239 | # If we've hit our API limit, sleep for 1 hour, and call our 240 | # function again. 241 | log.info("Hit Github API limit. Sleeping for 1 hour...") 242 | sleep(3600) 243 | if not testing: 244 | initialize_issues(config) 245 | return 246 | else: 247 | if not config["sync2jira"]["develop"]: 248 | # Only send the failure email if we are not developing 249 | report_failure(config) 250 | raise 251 | log.info("Done with GitHub issue initialization.") 252 | 253 | 254 | def initialize_pr(config, testing=False, repo_name=None): 255 | """ 256 | Initial initialization needed to sync any upstream 257 | repo with JIRA. Goes through all PRs and 258 | checks if they're already on JIRA / Need to be 259 | created. 260 | 261 | :param Dict config: Config dict for JIRA 262 | :param Bool testing: Flag to indicate if we are testing. Default false 263 | :param String repo_name: Optional individual repo name. If defined we will only sync the provided repo 264 | :returns: Nothing 265 | """ 266 | log.info("Running initialization to sync all PRs from upstream to jira") 267 | log.info("Testing flag is %r", config["sync2jira"]["testing"]) 268 | mapping = config["sync2jira"]["map"] 269 | for upstream in mapping.get("github", {}).keys(): 270 | if "pullrequest" not in mapping.get("github", {}).get(upstream, {}).get( 271 | "sync", [] 272 | ): 273 | continue 274 | if repo_name is not None and upstream != repo_name: 275 | continue 276 | # Try and except for GitHub API limit 277 | try: 278 | for pr in u_pr.github_prs(upstream, config): 279 | try: 280 | if pr: 281 | d_pr.sync_with_jira(pr, config) 282 | except Exception: 283 | log.error(" Failed on %r", pr) 284 | raise 285 | except Exception as e: 286 | if "API rate limit exceeded" in e.__str__(): 287 | # If we've hit our API limit, sleep for 1 hour, and call our 288 | # function again. 289 | log.info("Hit Github API limit. Sleeping for 1 hour...") 290 | sleep(3600) 291 | if not testing: 292 | initialize_pr(config) 293 | return 294 | else: 295 | if not config["sync2jira"]["develop"]: 296 | # Only send the failure email if we are not developing 297 | report_failure(config) 298 | raise 299 | log.info("Done with GitHub PR initialization.") 300 | 301 | 302 | def handle_msg(body, suffix, config): 303 | """ 304 | Function to handle incoming message 305 | :param Dict body: Incoming message body 306 | :param String suffix: Incoming suffix 307 | :param Dict config: Config dict 308 | """ 309 | if handler := issue_handlers.get(suffix): 310 | # GitHub '.issue*' is used for both PR and Issue; check if this update 311 | # is actually for a PR 312 | if "pull_request" in body["issue"]: 313 | if body["action"] == "deleted": 314 | # I think this gets triggered when someone deletes a comment 315 | # from a PR. Since we don't capture PR comments (only Issue 316 | # comments), we don't need to react if one is deleted. 317 | log.debug("Not handling PR 'action' == 'deleted'") 318 | return 319 | # Handle this PR update as though it were an Issue, if that's 320 | # acceptable to the configuration. 321 | if not (pr := handler(body, config, is_pr=True)): 322 | log.info("Not handling PR issue update -- not configured") 323 | return 324 | # PRs require additional handling (Issues do not have suffix, and 325 | # reporter needs to be reformatted). 326 | pr.suffix = suffix 327 | pr.reporter = pr.reporter.get("fullname") 328 | setattr(pr, "match", matcher(pr.content, pr.comments)) 329 | d_pr.sync_with_jira(pr, config) 330 | else: 331 | if issue := handler(body, config): 332 | d_issue.sync_with_jira(issue, config) 333 | else: 334 | log.info("Not handling Issue update -- not configured") 335 | elif handler := pr_handlers.get(suffix): 336 | if pr := handler(body, config, suffix): 337 | d_pr.sync_with_jira(pr, config) 338 | else: 339 | log.info("Not handling PR update -- not configured") 340 | 341 | 342 | def main(runtime_test=False, runtime_config=None): 343 | """ 344 | Main function to check for initial sync 345 | and listen for fedmsgs. 346 | 347 | :param Bool runtime_test: Flag to indicate if we are performing a runtime test. Default false 348 | :param Dict runtime_config: Config file to be used if it is a runtime test. runtime_test must be true 349 | :return: Nothing 350 | """ 351 | # Load config and disable warnings 352 | config = runtime_config if runtime_test and runtime_config else load_config() 353 | 354 | logging.basicConfig(level=logging.INFO) 355 | warnings.simplefilter("ignore") 356 | config["validate_signatures"] = False 357 | 358 | try: 359 | if str(INITIALIZE) == "1": 360 | log.info("Initialization True") 361 | # Initialize issues 362 | log.info("Initializing Issues...") 363 | initialize_issues(config) 364 | log.info("Initializing PRs...") 365 | initialize_pr(config) 366 | if runtime_test: 367 | return 368 | try: 369 | listen(config) 370 | except KeyboardInterrupt: 371 | pass 372 | except: # noqa: E722 373 | if not config["sync2jira"]["develop"]: 374 | # Only send the failure email if we are not developing 375 | report_failure(config) 376 | raise 377 | 378 | 379 | def report_failure(config): 380 | """ 381 | Helper function to alert admins in case of failure. 382 | 383 | :param Dict config: Config dict for JIRA 384 | """ 385 | # Email our admins with the traceback 386 | template_loader = jinja2.FileSystemLoader( 387 | searchpath="usr/local/src/sync2jira/sync2jira/" 388 | ) 389 | template_env = jinja2.Environment(loader=template_loader, autoescape=True) 390 | template = template_env.get_template("failure_template.jinja") 391 | html_text = template.render(traceback=traceback.format_exc()) 392 | 393 | # Send mail 394 | send_mail( 395 | recipients=[config["sync2jira"]["mailing-list"]], 396 | cc=None, 397 | subject=failure_email_subject, 398 | text=html_text, 399 | ) 400 | 401 | 402 | if __name__ == "__main__": 403 | main() 404 | -------------------------------------------------------------------------------- /sync2jira/upstream_issue.py: -------------------------------------------------------------------------------- 1 | # This file is part of sync2jira. 2 | # Copyright (C) 2016 Red Hat, Inc. 3 | # 4 | # sync2jira is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2.1 of the License, or (at your option) any later version. 8 | # 9 | # sync2jira is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with sync2jira; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110.15.0 USA 17 | # 18 | # Authors: Ralph Bean 19 | 20 | from copy import deepcopy 21 | import logging 22 | from urllib.parse import urlencode 23 | 24 | from github import Github, UnknownObjectException 25 | import requests 26 | 27 | import sync2jira.intermediary as i 28 | 29 | log = logging.getLogger("sync2jira") 30 | graphqlurl = "https://api.github.com/graphql" 31 | 32 | ghquery = """ 33 | query MyQuery( 34 | $orgname: String!, $reponame: String!, $issuenumber: Int! 35 | ) { 36 | repository(owner: $orgname, name: $reponame) { 37 | issue(number: $issuenumber) { 38 | title 39 | body 40 | projectItems(first: 3) { 41 | nodes { 42 | project { 43 | title 44 | number 45 | url 46 | } 47 | fieldValues(first: 100) { 48 | nodes { 49 | ... on ProjectV2ItemFieldSingleSelectValue { 50 | name 51 | fieldName: field { 52 | ... on ProjectV2FieldCommon { 53 | name 54 | } 55 | } 56 | } 57 | ... on ProjectV2ItemFieldTextValue { 58 | text 59 | fieldName: field { 60 | ... on ProjectV2FieldCommon { 61 | name 62 | } 63 | } 64 | } 65 | ... on ProjectV2ItemFieldNumberValue { 66 | number 67 | fieldName: field { 68 | ... on ProjectV2FieldCommon { 69 | name 70 | } 71 | } 72 | } 73 | ... on ProjectV2ItemFieldDateValue { 74 | date 75 | fieldName: field { 76 | ... on ProjectV2FieldCommon { 77 | name 78 | } 79 | } 80 | } 81 | ... on ProjectV2ItemFieldUserValue { 82 | users(first: 10) { 83 | nodes { 84 | login 85 | } 86 | } 87 | fieldName: field { 88 | ... on ProjectV2FieldCommon { 89 | name 90 | } 91 | } 92 | } 93 | ... on ProjectV2ItemFieldIterationValue { 94 | title 95 | duration 96 | startDate 97 | fieldName: field { 98 | ... on ProjectV2FieldCommon { 99 | name 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | } 110 | """ 111 | 112 | 113 | def handle_github_message(body, config, is_pr=False): 114 | """ 115 | Handle GitHub message from FedMsg. 116 | 117 | :param Dict body: FedMsg Message body 118 | :param Dict config: Config File 119 | :param Bool is_pr: msg refers to a pull request 120 | :returns: Issue object 121 | :rtype: sync2jira.intermediary.Issue 122 | """ 123 | owner = body["repository"]["owner"]["login"] 124 | repo = body["repository"]["name"] 125 | upstream = "{owner}/{repo}".format(owner=owner, repo=repo) 126 | 127 | mapped_repos = config["sync2jira"]["map"]["github"] 128 | if upstream not in mapped_repos: 129 | log.debug("%r not in Github map: %r", upstream, mapped_repos.keys()) 130 | return None 131 | key = "pullrequest" if is_pr else "issue" 132 | if key not in mapped_repos[upstream].get("sync", []): 133 | log.debug( 134 | "%r not in Github sync map: %r", 135 | key, 136 | mapped_repos[upstream].get("sync", []), 137 | ) 138 | return None 139 | 140 | _filter = config["sync2jira"].get("filters", {}).get("github", {}).get(upstream, {}) 141 | 142 | issue = body["issue"] 143 | for key, expected in _filter.items(): 144 | if key == "labels": 145 | # special handling for label: we look for it in the list of msg labels 146 | actual = {label["name"] for label in issue["labels"]} 147 | if actual.isdisjoint(expected): 148 | log.debug("Labels %s not found on issue: %s", expected, upstream) 149 | return None 150 | elif key == "milestone": 151 | # special handling for milestone: use the number 152 | milestone = issue.get(key) or {} # Key might exist with value `None` 153 | actual = milestone.get("number") 154 | if expected != actual: 155 | log.debug("Milestone %s not set on issue: %s", expected, upstream) 156 | return None 157 | else: 158 | # direct comparison 159 | actual = issue.get(key) 160 | if actual != expected: 161 | log.debug( 162 | "Actual %r %r != expected %r on issue %s", 163 | key, 164 | actual, 165 | expected, 166 | upstream, 167 | ) 168 | return None 169 | 170 | if is_pr and not issue.get("closed_at"): 171 | log.debug( 172 | "%r is a pull request. Ignoring.", issue.get("html_url", "") 173 | ) 174 | return None 175 | 176 | token = config["sync2jira"].get("github_token") 177 | headers = {"Authorization": "token " + token} if token else {} 178 | github_client = Github(token, retry=5) 179 | reformat_github_issue(issue, upstream, github_client) 180 | add_project_values(issue, upstream, headers, config) 181 | return i.Issue.from_github(upstream, issue, config) 182 | 183 | 184 | def github_issues(upstream, config): 185 | """ 186 | Returns a generator for all GitHub issues in upstream repo. 187 | 188 | :param String upstream: Upstream Repo 189 | :param Dict config: Config Dict 190 | :returns: a generator for GitHub Issue objects 191 | :rtype: Generator[sync2jira.intermediary.Issue] 192 | """ 193 | token = config["sync2jira"].get("github_token") 194 | headers = {"Authorization": "token " + token} if token else {} 195 | github_client = Github(token, retry=5) 196 | for issue in generate_github_items("issues", upstream, config): 197 | if "pull_request" in issue or "/pull/" in issue.get("html_url", ""): 198 | # We don't want to copy these around 199 | orgname, reponame = upstream.rsplit("/", 1) 200 | log.debug( 201 | "Issue %s/%s#%s is a pull request; skipping", 202 | orgname, 203 | reponame, 204 | issue["number"], 205 | ) 206 | continue 207 | reformat_github_issue(issue, upstream, github_client) 208 | add_project_values(issue, upstream, headers, config) 209 | yield i.Issue.from_github(upstream, issue, config) 210 | 211 | 212 | def add_project_values(issue, upstream, headers, config): 213 | """Add values to an issue from its corresponding card in a GitHub Project 214 | 215 | :param dict issue: Issue 216 | :param str upstream: Upstream repo name 217 | :param dict headers: HTTP Request headers, including access token, if any 218 | :param dict config: Config 219 | """ 220 | upstream_config = config["sync2jira"]["map"]["github"][upstream] 221 | project_number = upstream_config.get("github_project_number") 222 | issue_updates = upstream_config.get("issue_updates", {}) 223 | if "github_project_fields" not in issue_updates: 224 | return 225 | issue["storypoints"] = None 226 | issue["priority"] = None 227 | issuenumber = issue["number"] 228 | github_project_fields = upstream_config["github_project_fields"] 229 | orgname, reponame = upstream.rsplit("/", 1) 230 | variables = {"orgname": orgname, "reponame": reponame, "issuenumber": issuenumber} 231 | response = requests.post( 232 | graphqlurl, headers=headers, json={"query": ghquery, "variables": variables} 233 | ) 234 | if response.status_code != 200: 235 | log.info( 236 | "HTTP error while fetching issue %s/%s#%s: %s", 237 | orgname, 238 | reponame, 239 | issuenumber, 240 | response.text, 241 | ) 242 | return 243 | data = response.json() 244 | gh_issue = data.get("data", {}).get("repository", {}).get("issue") 245 | if not gh_issue: 246 | log.info( 247 | "GitHub error while fetching issue %s/%s#%s: %s", 248 | orgname, 249 | reponame, 250 | issuenumber, 251 | response.text, 252 | ) 253 | return 254 | project_node = _get_current_project_node( 255 | upstream, project_number, issuenumber, gh_issue 256 | ) 257 | if not project_node: 258 | return 259 | item_nodes = project_node.get("fieldValues", {}).get("nodes", {}) 260 | for item in item_nodes: 261 | gh_field_name = item.get("fieldName", {}).get("name") 262 | if not gh_field_name: 263 | continue 264 | prio_field = github_project_fields.get("priority", {}).get("gh_field") 265 | if gh_field_name == prio_field: 266 | issue["priority"] = item.get("name") 267 | continue 268 | sp_field = github_project_fields.get("storypoints", {}).get("gh_field") 269 | if gh_field_name == sp_field: 270 | try: 271 | issue["storypoints"] = int(item["number"]) 272 | except (ValueError, KeyError) as err: 273 | log.info( 274 | "Error while processing storypoints for issue %s/%s#%s: %s", 275 | orgname, 276 | reponame, 277 | issuenumber, 278 | err, 279 | ) 280 | continue 281 | 282 | 283 | def reformat_github_issue(issue, upstream, github_client): 284 | """Tweak Issue data format to better match Pagure""" 285 | 286 | # Update comments: 287 | # If there are no comments just make an empty array 288 | if not issue["comments"]: 289 | issue["comments"] = [] 290 | else: 291 | # We have multiple comments and need to make api call to get them 292 | try: 293 | repo = github_client.get_repo(upstream) 294 | except UnknownObjectException: 295 | logging.warning( 296 | "GitHub repo %r not found (has it been deleted or made private?)", 297 | upstream, 298 | ) 299 | raise 300 | github_issue = repo.get_issue(number=issue["number"]) 301 | issue["comments"] = reformat_github_comments(github_issue.get_comments()) 302 | 303 | # Update the rest of the parts 304 | reformat_github_common(issue, github_client) 305 | 306 | 307 | def reformat_github_comments(comments): 308 | """Helper function which encapsulates reformatting comments""" 309 | return [ 310 | { 311 | "author": comment.user.name or comment.user.login, 312 | "name": comment.user.login, 313 | "body": comment.body, 314 | "id": comment.id, 315 | "date_created": comment.created_at, 316 | "changed": None, 317 | } 318 | for comment in comments 319 | ] 320 | 321 | 322 | def reformat_github_common(item, github_client): 323 | """Helper function which tweaks the data format of the parts of Issues and 324 | PRs which are common so that they better match Pagure 325 | """ 326 | # Update reporter: 327 | # Search for the user 328 | reporter = github_client.get_user(item["user"]["login"]) 329 | # Update the reporter field in the message (to match Pagure format) 330 | if reporter.name and reporter.name != "None": 331 | item["user"]["fullname"] = reporter.name 332 | else: 333 | item["user"]["fullname"] = item["user"]["login"] 334 | 335 | # Update assignee(s): 336 | assignees = [] 337 | for person in item.get("assignees", []): 338 | assignee = github_client.get_user(person["login"]) 339 | if assignee.name and assignee.name != "None": 340 | assignees.append({"fullname": assignee.name}) 341 | # Update the assignee field in the message (to match Pagure format) 342 | item["assignees"] = assignees 343 | 344 | # Update the label field in the message (to match Pagure format) 345 | if item["labels"]: 346 | # Loop through all the labels on GitHub and add them 347 | # to the new label list and then reassign the message 348 | new_label = [] 349 | for label in item["labels"]: 350 | new_label.append(label["name"]) 351 | item["labels"] = new_label 352 | 353 | # Update the milestone field in the message (to match Pagure format) 354 | if item.get("milestone"): 355 | item["milestone"] = item["milestone"]["title"] 356 | 357 | 358 | def generate_github_items(api_method, upstream, config): 359 | """ 360 | Returns a generator which yields all GitHub issues in upstream repo. 361 | 362 | :param String api_method: API method name 363 | :param String upstream: Upstream Repo 364 | :param Dict config: Config Dict 365 | :returns: a generator for GitHub Issue/PR objects 366 | :rtype: Generator[Any, Any, None] 367 | """ 368 | token = config["sync2jira"].get("github_token") 369 | if not token: 370 | headers = {} 371 | log.warning("No github_token found. We will be rate-limited...") 372 | else: 373 | headers = {"Authorization": "token " + token} 374 | 375 | params = config["sync2jira"].get("filters", {}).get("github", {}).get(upstream, {}) 376 | 377 | url = "https://api.github.com/repos/" + upstream + "/" + api_method 378 | if params: 379 | labels = params.get("labels") 380 | if isinstance(labels, list): 381 | # We have to flatten the labels list to a comma-separated string, 382 | # so make a copy to avoid mutating the config object 383 | url_filter = deepcopy(params) 384 | url_filter["labels"] = ",".join(labels) 385 | else: 386 | url_filter = params # Use the existing filter, unmodified 387 | url += "?" + urlencode(url_filter) 388 | 389 | return get_all_github_data(url, headers) 390 | 391 | 392 | def _get_current_project_node(upstream, project_number, issue_number, gh_issue): 393 | project_items = gh_issue["projectItems"]["nodes"] 394 | # If there are no project items, there is nothing to return. 395 | if len(project_items) == 0: 396 | log.debug( 397 | "Issue %s#%s is not associated with any project", upstream, issue_number 398 | ) 399 | return None 400 | 401 | if not project_number: 402 | # We don't have a configured project. If there is exactly one project 403 | # item, we'll assume it's the right one and return it. 404 | if len(project_items) == 1: 405 | return project_items[0] 406 | 407 | # There are multiple projects associated with this issue; since we 408 | # don't have a configured project, we don't know which one to return, 409 | # so return none. 410 | prj = (f"{x['project']['url']}: {x['project']['title']}" for x in project_items) 411 | log.debug( 412 | "Project number is not configured, and the issue %s#%s" 413 | " is associated with more than one project: %s", 414 | upstream, 415 | issue_number, 416 | ", ".join(prj), 417 | ) 418 | return None 419 | 420 | # Return the associated project which matches the configured project if we 421 | # can find it. 422 | for item in project_items: 423 | if item["project"]["number"] == project_number: 424 | return item 425 | 426 | log.debug( 427 | "Issue %s#%s is associated with multiple projects, " 428 | "but none match the configured project.", 429 | upstream, 430 | issue_number, 431 | ) 432 | return None 433 | 434 | 435 | def get_all_github_data(url, headers): 436 | """A generator which returns each response from a paginated GitHub API call""" 437 | link = {"next": url} 438 | while "next" in link: 439 | response = api_call_get(link["next"], headers=headers) 440 | for issue in response.json(): 441 | comments = api_call_get(issue["comments_url"], headers=headers) 442 | issue["comments"] = comments.json() 443 | yield issue 444 | link = _github_link_field_to_dict(response.headers.get("link")) 445 | 446 | 447 | def _github_link_field_to_dict(field): 448 | """Utility for ripping apart GitHub's Link header field.""" 449 | 450 | if not field: 451 | return {} 452 | return dict( 453 | (part.split("; ")[1][5:-1], part.split("; ")[0][1:-1]) 454 | for part in field.split(", ") 455 | ) 456 | 457 | 458 | def api_call_get(url, **kwargs): 459 | """Helper function to encapsulate a REST API GET call""" 460 | response = requests.get(url, **kwargs) 461 | if not bool(response): 462 | # noinspection PyBroadException 463 | try: 464 | reason = response.json() 465 | except Exception: 466 | reason = response.text 467 | raise IOError("response: %r %r %r" % (response, reason, response.request.url)) 468 | return response 469 | -------------------------------------------------------------------------------- /sync2jira/upstream_pr.py: -------------------------------------------------------------------------------- 1 | # This file is part of sync2jira. 2 | # Copyright (C) 2016 Red Hat, Inc. 3 | # 4 | # sync2jira is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU Lesser General Public 6 | # License as published by the Free Software Foundation; either 7 | # version 2.1 of the License, or (at your option) any later version. 8 | # 9 | # sync2jira is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | # Lesser General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Lesser General Public 15 | # License along with sync2jira; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110.15.0 USA 17 | # 18 | # Authors: Ralph Bean 19 | 20 | import logging 21 | 22 | from github import Github, UnknownObjectException 23 | 24 | import sync2jira.intermediary as i 25 | import sync2jira.upstream_issue as u_issue 26 | 27 | log = logging.getLogger("sync2jira") 28 | 29 | 30 | def handle_github_message(body, config, suffix): 31 | """ 32 | Handle GitHub message from FedMsg. 33 | 34 | :param Dict body: FedMsg Message body 35 | :param Dict config: Config File 36 | :param String suffix: FedMsg suffix 37 | :returns: Issue object 38 | :rtype: sync2jira.intermediary.PR 39 | """ 40 | owner = body["repository"]["owner"]["login"] 41 | repo = body["repository"]["name"] 42 | upstream = "{owner}/{repo}".format(owner=owner, repo=repo) 43 | 44 | mapped_repos = config["sync2jira"]["map"]["github"] 45 | if upstream not in mapped_repos: 46 | log.debug("%r not in Github map: %r", upstream, mapped_repos.keys()) 47 | return None 48 | elif "pullrequest" not in mapped_repos[upstream].get("sync", []): 49 | log.debug("%r not in Github PR map: %r", upstream, mapped_repos.keys()) 50 | return None 51 | 52 | pr = body["pull_request"] 53 | github_client = Github(config["sync2jira"]["github_token"]) 54 | reformat_github_pr(pr, upstream, github_client) 55 | return i.PR.from_github(upstream, pr, suffix, config) 56 | 57 | 58 | def github_prs(upstream, config): 59 | """ 60 | Returns a generator for all GitHub PRs in upstream repo. 61 | 62 | :param String upstream: Upstream Repo 63 | :param Dict config: Config Dict 64 | :returns: a generator for GitHub PR objects 65 | :rtype: Generator[sync2jira.intermediary.PR] 66 | """ 67 | github_client = Github(config["sync2jira"]["github_token"]) 68 | for pr in u_issue.generate_github_items("pulls", upstream, config): 69 | reformat_github_pr(pr, upstream, github_client) 70 | yield i.PR.from_github(upstream, pr, "open", config) 71 | 72 | 73 | def reformat_github_pr(pr, upstream, github_client): 74 | """Tweak PR data format to better match Pagure""" 75 | 76 | # Update comments: 77 | # If there are no comments just make an empty array 78 | if not pr["comments"]: 79 | pr["comments"] = [] 80 | else: 81 | # We have multiple comments and need to make api call to get them 82 | try: 83 | repo = github_client.get_repo(upstream) 84 | except UnknownObjectException: 85 | logging.warning( 86 | "GitHub repo %r not found (has it been deleted or made private?)", 87 | upstream, 88 | ) 89 | raise 90 | github_pr = repo.get_pull(number=pr["number"]) 91 | pr["comments"] = u_issue.reformat_github_comments( 92 | github_pr.get_issue_comments() 93 | ) 94 | 95 | u_issue.reformat_github_common(pr, github_client) 96 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | pytest 3 | pytest-cov 4 | python-coveralls 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/release-engineering/Sync2Jira/88c81cba55b1a68dc5e691d0db6e7cb97ddaabc0/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration_tests/integration_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a helper program to listen for UMB trigger. Test and then deploy Sync2Jira 3 | """ 4 | 5 | # Built-In Modules 6 | import logging 7 | import os 8 | 9 | # 3rd Party Modules 10 | import jira.client 11 | 12 | # Local Modules 13 | from jira_values import GITHUB 14 | from runtime_config import runtime_config 15 | 16 | from sync2jira.main import main as m 17 | 18 | # Global Variables 19 | URL = os.environ["JIRA_STAGE_URL"] 20 | TOKEN = os.environ["JIRA_TOKEN"] 21 | log = logging.getLogger(__name__) 22 | hdlr = logging.FileHandler("integration_test.log") 23 | formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") 24 | hdlr.setFormatter(formatter) 25 | log.addHandler(hdlr) 26 | log.setLevel(logging.DEBUG) 27 | 28 | 29 | def main(): 30 | """ 31 | Main message to listen and react to messages. 32 | """ 33 | log.info("[OS-BUILD] Running sync2jira.main...") 34 | 35 | # Make our JIRA client 36 | client = get_jira_client() 37 | 38 | # First init with what we have 39 | m(runtime_test=True, runtime_config=runtime_config) 40 | 41 | # Now we need to make sure that Sync2Jira didn't update anything, 42 | failed = False 43 | 44 | # Compare to our old values 45 | log.info("[OS-BUILD] Comparing values with GitHub...") 46 | try: 47 | compare_data(client, GITHUB) 48 | except Exception as e: 49 | failed = True 50 | log.info( 51 | f"[OS-BUILD] When comparing GitHub something went wrong.\nException {e}" 52 | ) 53 | 54 | if failed: 55 | log.info("[OS-BUILD] Tests have failed :(") 56 | raise Exception() 57 | else: 58 | log.info("[OS-BUILD] Tests have passed :)") 59 | 60 | 61 | def compare_data(client, data): 62 | """ 63 | Helper function to loop over values and compare to ensure they are the same 64 | :param jira.client.JIRA client: JIRA client 65 | :param Dict data: Data used to compare against 66 | :return: True/False if we 67 | """ 68 | # First get our existing JIRA issue 69 | jira_ticket = data["JIRA"] 70 | existing = client.search_issues(f"Key = {jira_ticket}") 71 | 72 | # Throw an error if too many issues were found 73 | if len(existing) > 1: 74 | raise Exception(f"Too many issues were found with ticket {jira_ticket}") 75 | 76 | existing = existing[0] 77 | log.info("TEST - " + existing.fields.summary) 78 | # Check Tags 79 | if data["tags"] != existing.fields.labels: 80 | raise Exception( 81 | f"Error when comparing tags for {jira_ticket}\n" 82 | f"Expected: {data['tags']}\n" 83 | f"Actual: {existing.fields.labels}" 84 | ) 85 | 86 | # Check FixVersion 87 | formatted_fixVersion = format_fixVersion(existing.fields.fixVersions) 88 | 89 | if data["fixVersions"] != formatted_fixVersion: 90 | raise Exception( 91 | f"Error when comparing fixVersions for {jira_ticket}\n" 92 | f"Expected: {data['fixVersions']}\n" 93 | f"Actual: {formatted_fixVersion}" 94 | ) 95 | 96 | # Check Assignee 97 | if not existing.fields.assignee: 98 | raise Exception( 99 | f"Error when comparing assignee for {jira_ticket}\n" 100 | f"Expected: {data['assignee']}\n" 101 | f"Actual: {existing.fields.assignee}" 102 | ) 103 | 104 | elif data["assignee"] != existing.fields.assignee.name: 105 | raise Exception( 106 | f"Error when comparing assignee for {jira_ticket}\n" 107 | f"Expected: {data['assignee']}\n" 108 | f"Actual: {existing.fields.assignee.name}" 109 | ) 110 | 111 | # Check Title 112 | if data["title"] != existing.fields.summary: 113 | raise Exception( 114 | f"Error when comparing title for {jira_ticket}\n" 115 | f"Expected: {data['title']}\n" 116 | f"Actual: {existing.fields.summary}" 117 | ) 118 | 119 | # Check Descriptions 120 | if data["description"].replace("\n", "").replace(" ", "").replace( 121 | "\r", "" 122 | ) != existing.fields.description.replace("\n", "").replace(" ", "").replace( 123 | "\r", "" 124 | ): 125 | raise Exception( 126 | f"Error when comparing descriptions for {jira_ticket}\n" 127 | f"Expected: {data['description']}\n" 128 | f"Actual: {existing.fields.description}" 129 | ) 130 | 131 | 132 | def format_fixVersion(existing): 133 | """ 134 | Helper function to format fixVersions 135 | :param jira.version existing: Existing fixVersions 136 | :return: Formatted fixVersions 137 | :rtype: List 138 | """ 139 | new_list = [] 140 | for version in existing: 141 | new_list.append(version.name) 142 | return new_list 143 | 144 | 145 | def get_jira_client(): 146 | """ 147 | Helper function to get JIRA client 148 | :return: JIRA Client 149 | :rtype: jira.client.JIRA 150 | """ 151 | return jira.client.JIRA( 152 | **{ 153 | "options": { 154 | "server": URL, 155 | "verify": False, 156 | }, 157 | "token_auth": TOKEN, 158 | } 159 | ) 160 | 161 | 162 | if __name__ == "__main__": 163 | # Call our main method after parsing out message 164 | main() 165 | -------------------------------------------------------------------------------- /tests/integration_tests/jira_values.py: -------------------------------------------------------------------------------- 1 | GITHUB = { 2 | "JIRA": "FACTORY-6186", 3 | "title": "[sidpremkumar/Demo_repo] Test Issue DO NOT TOUCH", 4 | "description": "[555670302] Upstream Reporter: Sid Premkumar \n Upstream issue status: Open\nUpstream description: {quote}Some Description{quote} \nUpstream URL: https://github.com/sidpremkumar/Demo_repo/issues/30", 5 | "fixVersions": ["FY19 Q1"], 6 | "assignee": "sid", 7 | "tags": ["bug"], 8 | } 9 | -------------------------------------------------------------------------------- /tests/integration_tests/runtime_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | runtime_config = { 4 | "sync2jira": { 5 | "jira": { 6 | "pnt-jira": { 7 | "options": { 8 | "server": os.environ["JIRA_STAGE_URL"], 9 | "verify": True, 10 | }, 11 | "token_auth": os.environ["JIRA_TOKEN"], 12 | }, 13 | }, 14 | "github_token": os.environ["SYNC2JIRA_GITHUB_TOKEN"], 15 | "admins": [{"spremkum", "spremkum@redhat.com"}, {"rbean", "rbean@redhat.com"}], 16 | "initialize": True, 17 | "testing": False, 18 | "develop": True, 19 | # We don't need legacy mode anymore. Not for a long time. Let's 20 | # remove it soon. 21 | "legacy_matching": False, 22 | # Set the default jira to be pnt-jira 23 | "default_jira_instance": "pnt-jira", 24 | "filters": { 25 | "github": {}, 26 | }, 27 | "map": { 28 | "github": { 29 | "sidpremkumar/Demo_repo": { 30 | "project": "FACTORY", 31 | "component": "gitbz", 32 | "issue_updates": [ 33 | {"transition": True}, 34 | "description", 35 | "title", 36 | {"tags": {"overwrite": True}}, 37 | {"fixVersion": {"overwrite": True}}, 38 | {"assignee": {"overwrite": True}}, 39 | "url", 40 | ], 41 | "sync": ["issue"], 42 | } 43 | }, 44 | }, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/test_downstream_pr.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import unittest.mock as mock 3 | from unittest.mock import MagicMock 4 | 5 | import sync2jira.downstream_pr as d 6 | 7 | PATH = "sync2jira.downstream_pr." 8 | 9 | 10 | class TestDownstreamPR(unittest.TestCase): 11 | """ 12 | This class tests the downstream_pr.py file under sync2jira 13 | """ 14 | 15 | def setUp(self): 16 | """ 17 | Setting up the testing environment 18 | """ 19 | self.mock_pr = MagicMock() 20 | self.mock_pr.jira_key = "JIRA-1234" 21 | self.mock_pr.suffix = "mock_suffix" 22 | self.mock_pr.title = "mock_title" 23 | self.mock_pr.url = "mock_url" 24 | self.mock_pr.reporter = "mock_reporter" 25 | self.mock_pr.downstream = { 26 | "pr_updates": [ 27 | {"merge_transition": "CUSTOM_TRANSITION1"}, 28 | {"link_transition": "CUSTOM_TRANSITION2"}, 29 | ] 30 | } 31 | 32 | self.mock_config = { 33 | "sync2jira": { 34 | "default_jira_instance": "another_jira_instance", 35 | "jira_username": "mock_user", 36 | "jira": { 37 | "mock_jira_instance": {"mock_jira": "mock_jira"}, 38 | "another_jira_instance": { 39 | "token_auth": "mock_token", 40 | "options": {"server": "mock_server"}, 41 | }, 42 | }, 43 | "testing": False, 44 | "legacy_matching": False, 45 | "admins": [{"mock_admin": "mock_email"}], 46 | "develop": False, 47 | }, 48 | } 49 | 50 | self.mock_client = MagicMock() 51 | mock_user = MagicMock() 52 | mock_user.displayName = "mock_reporter" 53 | mock_user.key = "mock_key" 54 | self.mock_client.search_users.return_value = [mock_user] 55 | self.mock_client.search_issues.return_value = ["mock_existing"] 56 | 57 | self.mock_existing = MagicMock() 58 | 59 | @mock.patch(PATH + "update_jira_issue") 60 | @mock.patch(PATH + "d_issue") 61 | @mock.patch(PATH + "update_transition") 62 | def test_sync_with_jira_link( 63 | self, mock_update_transition, mock_d_issue, mock_update_jira_issue 64 | ): 65 | """ 66 | This function tests 'sync_with_jira' 67 | """ 68 | # Set up return values 69 | mock_d_issue.get_jira_client.return_value = self.mock_client 70 | 71 | # Call the function 72 | d.sync_with_jira(self.mock_pr, self.mock_config) 73 | 74 | # Assert everything was called correctly 75 | mock_update_jira_issue.assert_called_with( 76 | "mock_existing", self.mock_pr, self.mock_client 77 | ) 78 | self.mock_client.search_issues.assert_called_with("Key = JIRA-1234") 79 | mock_d_issue.get_jira_client.assert_called_with(self.mock_pr, self.mock_config) 80 | mock_update_transition.mock.asset_called_with( 81 | self.mock_client, "mock_existing", self.mock_pr, "link_transition" 82 | ) 83 | 84 | @mock.patch(PATH + "update_jira_issue") 85 | @mock.patch(PATH + "d_issue") 86 | @mock.patch(PATH + "update_transition") 87 | def test_sync_with_jira_merged( 88 | self, mock_update_transition, mock_d_issue, mock_update_jira_issue 89 | ): 90 | """ 91 | This function tests 'sync_with_jira' 92 | """ 93 | # Set up return values 94 | mock_client = MagicMock() 95 | mock_client.search_issues.return_value = ["mock_existing"] 96 | mock_d_issue.get_jira_client.return_value = mock_client 97 | self.mock_pr.suffix = "merged" 98 | 99 | # Call the function 100 | d.sync_with_jira(self.mock_pr, self.mock_config) 101 | 102 | # Assert everything was called correctly 103 | mock_update_jira_issue.assert_called_with( 104 | "mock_existing", self.mock_pr, mock_client 105 | ) 106 | mock_client.search_issues.assert_called_with("Key = JIRA-1234") 107 | mock_d_issue.get_jira_client.assert_called_with(self.mock_pr, self.mock_config) 108 | mock_update_transition.mock.asset_called_with( 109 | mock_client, "mock_existing", self.mock_pr, "merged_transition" 110 | ) 111 | 112 | @mock.patch(PATH + "update_jira_issue") 113 | @mock.patch(PATH + "d_issue") 114 | def test_sync_with_jira_no_issues_found(self, mock_d_issue, mock_update_jira_issue): 115 | """ 116 | This function tests 'sync_with_jira' where no issues are found 117 | """ 118 | # Set up return values 119 | self.mock_client.search_issues.return_value = [] 120 | mock_d_issue.get_jira_client.return_value = self.mock_client 121 | 122 | # Call the function 123 | d.sync_with_jira(self.mock_pr, self.mock_config) 124 | 125 | # Assert everything was called correctly 126 | mock_update_jira_issue.assert_not_called() 127 | self.mock_client.search_issues.assert_called_with("Key = JIRA-1234") 128 | mock_d_issue.get_jira_client.assert_called_with(self.mock_pr, self.mock_config) 129 | 130 | @mock.patch(PATH + "update_jira_issue") 131 | @mock.patch(PATH + "d_issue") 132 | def test_sync_with_jira_testing(self, mock_d_issue, mock_update_jira_issue): 133 | """ 134 | This function tests 'sync_with_jira' where no issues are found 135 | """ 136 | # Set up return values 137 | mock_client = MagicMock() 138 | mock_client.search_issues.return_value = [] 139 | self.mock_config["sync2jira"]["testing"] = True 140 | mock_d_issue.get_jira_client.return_value = mock_client 141 | 142 | # Call the function 143 | d.sync_with_jira(self.mock_pr, self.mock_config) 144 | 145 | # Assert everything was called correctly 146 | mock_update_jira_issue.assert_not_called() 147 | mock_client.search_issues.assert_not_called() 148 | mock_d_issue.get_jira_client.assert_not_called() 149 | 150 | @mock.patch(PATH + "comment_exists") 151 | @mock.patch(PATH + "format_comment") 152 | @mock.patch(PATH + "d_issue.attach_link") 153 | @mock.patch(PATH + "issue_link_exists") 154 | def test_update_jira_issue_link( 155 | self, 156 | mock_issue_link_exists, 157 | mock_attach_link, 158 | mock_format_comment, 159 | mock_comment_exists, 160 | ): 161 | """ 162 | This function tests 'update_jira_issue' 163 | """ 164 | # Set up return values 165 | mock_format_comment.return_value = "mock_formatted_comment" 166 | mock_comment_exists.return_value = False 167 | mock_issue_link_exists.return_value = False 168 | 169 | # Call the function 170 | d.update_jira_issue("mock_existing", self.mock_pr, self.mock_client) 171 | 172 | # Assert everything was called correctly 173 | self.mock_client.add_comment.assert_called_with( 174 | "mock_existing", "mock_formatted_comment" 175 | ) 176 | mock_format_comment.assert_called_with( 177 | self.mock_pr, self.mock_pr.suffix, self.mock_client 178 | ) 179 | mock_comment_exists.assert_called_with( 180 | self.mock_client, "mock_existing", "mock_formatted_comment" 181 | ) 182 | mock_attach_link.assert_called_with( 183 | self.mock_client, 184 | "mock_existing", 185 | {"url": "mock_url", "title": "[PR] mock_title"}, 186 | ) 187 | 188 | def test_issue_link_exists_false(self): 189 | """ 190 | This function tests 'issue_link_exists' where it does not exist 191 | """ 192 | # Set up return values 193 | mock_issue_link = MagicMock() 194 | mock_issue_link.object.url = "bad_url" 195 | self.mock_client.remote_links.return_value = [mock_issue_link] 196 | 197 | # Call the function 198 | ret = d.issue_link_exists(self.mock_client, self.mock_existing, self.mock_pr) 199 | 200 | # Assert everything was called correctly 201 | self.mock_client.remote_links.assert_called_with(self.mock_existing) 202 | self.assertEqual(ret, False) 203 | 204 | def test_issue_link_exists_true(self): 205 | """ 206 | This function tests 'issue_link_exists' where it does exist 207 | """ 208 | # Set up return values 209 | mock_issue_link = MagicMock() 210 | mock_issue_link.object.url = self.mock_pr.url 211 | self.mock_client.remote_links.return_value = [mock_issue_link] 212 | 213 | # Call the function 214 | ret = d.issue_link_exists(self.mock_client, self.mock_existing, self.mock_pr) 215 | 216 | # Assert everything was called correctly 217 | self.mock_client.remote_links.assert_called_with(self.mock_existing) 218 | self.assertEqual(ret, True) 219 | 220 | @mock.patch(PATH + "format_comment") 221 | @mock.patch(PATH + "comment_exists") 222 | @mock.patch(PATH + "d_issue.attach_link") 223 | @mock.patch(PATH + "issue_link_exists") 224 | def test_update_jira_issue_exists( 225 | self, 226 | mock_issue_link_exists, 227 | mock_attach_link, 228 | mock_comment_exists, 229 | mock_format_comment, 230 | ): 231 | """ 232 | This function tests 'update_jira_issue' where the comment already exists 233 | """ 234 | # Set up return values 235 | mock_format_comment.return_value = "mock_formatted_comment" 236 | mock_comment_exists.return_value = True 237 | mock_issue_link_exists.return_value = True 238 | 239 | # Call the function 240 | d.update_jira_issue("mock_existing", self.mock_pr, self.mock_client) 241 | 242 | # Assert everything was called correctly 243 | self.mock_client.add_comment.assert_not_called() 244 | mock_format_comment.assert_called_with( 245 | self.mock_pr, self.mock_pr.suffix, self.mock_client 246 | ) 247 | mock_comment_exists.assert_called_with( 248 | self.mock_client, "mock_existing", "mock_formatted_comment" 249 | ) 250 | mock_attach_link.assert_not_called() 251 | mock_issue_link_exists.assert_called_with( 252 | self.mock_client, "mock_existing", self.mock_pr 253 | ) 254 | 255 | def test_comment_exists_false(self): 256 | """ 257 | This function tests 'comment_exists' where the comment does not exists 258 | """ 259 | # Set up return values 260 | mock_comment = MagicMock() 261 | mock_comment.body = "not_mock_new_comment" 262 | self.mock_client.comments.return_value = [mock_comment] 263 | 264 | # Call the function 265 | response = d.comment_exists( 266 | self.mock_client, "mock_existing", "mock_new_comment" 267 | ) 268 | 269 | # Assert Everything was called correctly 270 | self.mock_client.comments.assert_called_with("mock_existing") 271 | self.assertEqual(response, False) 272 | 273 | def test_comment_exists_true(self): 274 | """ 275 | This function tests 'comment_exists' where the comment exists 276 | """ 277 | # Set up return values 278 | mock_comment = MagicMock() 279 | mock_comment.body = "mock_new_comment" 280 | self.mock_client.comments.return_value = [mock_comment] 281 | 282 | # Call the function 283 | response = d.comment_exists( 284 | self.mock_client, "mock_existing", "mock_new_comment" 285 | ) 286 | 287 | # Assert Everything was called correctly 288 | self.mock_client.comments.assert_called_with("mock_existing") 289 | self.assertEqual(response, True) 290 | 291 | def test_format_comment_closed(self): 292 | """ 293 | This function tests 'format_comment' where the PR is closed 294 | """ 295 | # Call the function 296 | response = d.format_comment(self.mock_pr, "closed", self.mock_client) 297 | 298 | # Assert Everything was called correctly 299 | self.assertEqual(response, "Merge request [mock_title| mock_url] was closed.") 300 | 301 | def test_format_comment_reopened(self): 302 | """ 303 | This function tests 'format_comment' where the PR is reopened 304 | """ 305 | # Call the function 306 | response = d.format_comment(self.mock_pr, "reopened", self.mock_client) 307 | 308 | # Assert Everything was called correctly 309 | self.assertEqual(response, "Merge request [mock_title| mock_url] was reopened.") 310 | 311 | def test_format_comment_merged(self): 312 | """ 313 | This function tests 'format_comment' where the PR is merged 314 | """ 315 | # Call the function 316 | response = d.format_comment(self.mock_pr, "merged", self.mock_client) 317 | 318 | # Assert Everything was called correctly 319 | self.assertEqual(response, "Merge request [mock_title| mock_url] was merged!") 320 | 321 | def test_format_comment_open(self): 322 | """ 323 | This function tests 'format_comment' where the PR is open 324 | """ 325 | # Call the function 326 | response = d.format_comment(self.mock_pr, "open", self.mock_client) 327 | 328 | # Assert Everything was called correctly 329 | self.assertEqual( 330 | response, 331 | "[~mock_key] mentioned this issue in merge request [mock_title| mock_url].", 332 | ) 333 | 334 | def test_format_comment_open_no_user_found(self): 335 | """ 336 | This function tests 'format_comment' where the PR is open and search_users returns nothing 337 | """ 338 | # Set up return values 339 | self.mock_client.search_users.return_value = [] 340 | 341 | # Call the function 342 | response = d.format_comment(self.mock_pr, "open", self.mock_client) 343 | 344 | # Assert Everything was called correctly 345 | self.assertEqual( 346 | response, 347 | "mock_reporter mentioned this issue in merge request [mock_title| mock_url].", 348 | ) 349 | 350 | @mock.patch(PATH + "d_issue") 351 | def test_update_transition(self, mock_d_issue): 352 | """ 353 | This function tests 'update_transition' 354 | """ 355 | # Set up return values 356 | mock_client = MagicMock() 357 | 358 | # Call the function 359 | d.update_transition( 360 | mock_client, self.mock_existing, self.mock_pr, "merge_transition" 361 | ) 362 | 363 | # Assert everything was called correctly 364 | mock_d_issue.change_status.assert_called_with( 365 | mock_client, self.mock_existing, "CUSTOM_TRANSITION1", self.mock_pr 366 | ) 367 | -------------------------------------------------------------------------------- /tests/test_intermediary.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import unittest.mock as mock 3 | 4 | import sync2jira.intermediary as i 5 | 6 | PATH = "sync2jira.intermediary." 7 | 8 | 9 | class TestIntermediary(unittest.TestCase): 10 | """ 11 | This class tests the downstream_issue.py file under sync2jira 12 | """ 13 | 14 | def setUp(self): 15 | self.mock_config = { 16 | "sync2jira": { 17 | "map": {"github": {"github": {"mock_downstream": "mock_key"}}} 18 | } 19 | } 20 | 21 | self.mock_github_issue = { 22 | "comments": [ 23 | { 24 | "author": "mock_author", 25 | "name": "mock_name", 26 | "body": "mock_body", 27 | "id": "mock_id", 28 | "date_created": "mock_date", 29 | } 30 | ], 31 | "title": "mock_title", 32 | "html_url": "mock_url", 33 | "id": 1234, 34 | "labels": "mock_tags", 35 | "milestone": "mock_milestone", 36 | "priority": "mock_priority", 37 | "body": "mock_content", 38 | "user": "mock_reporter", 39 | "assignees": "mock_assignee", 40 | "state": "open", 41 | "date_created": "mock_date", 42 | "number": "1", 43 | "storypoints": "mock_storypoints", 44 | "type": None, 45 | } 46 | 47 | self.mock_github_pr = { 48 | "comments": [ 49 | { 50 | "author": "mock_author", 51 | "name": "mock_name", 52 | "body": "mock_body", 53 | "id": "mock_id", 54 | "date_created": "mock_date", 55 | } 56 | ], 57 | "title": "mock_title", 58 | "html_url": "mock_url", 59 | "id": 1234, 60 | "labels": "mock_tags", 61 | "milestone": "mock_milestone", 62 | "priority": "mock_priority", 63 | "body": "mock_content", 64 | "user": {"fullname": "mock_reporter"}, 65 | "assignee": "mock_assignee", 66 | "state": "open", 67 | "date_created": "mock_date", 68 | "number": 1234, 69 | } 70 | 71 | def checkResponseFields(self, response): 72 | self.assertEqual(response.source, "github") 73 | self.assertEqual(response.title, "[github] mock_title") 74 | self.assertEqual(response.url, "mock_url") 75 | self.assertEqual(response.upstream, "github") 76 | self.assertEqual( 77 | response.comments, 78 | [ 79 | { 80 | "body": "mock_body", 81 | "name": "mock_name", 82 | "author": "mock_author", 83 | "changed": None, 84 | "date_created": "mock_date", 85 | "id": "mock_id", 86 | } 87 | ], 88 | ) 89 | self.assertEqual(response.content, "mock_content") 90 | self.assertEqual(response.reporter, "mock_reporter") 91 | self.assertEqual(response.assignee, "mock_assignee") 92 | self.assertEqual(response.id, "1234") 93 | 94 | def test_from_github_open(self): 95 | """ 96 | This tests the 'from_github' function under the Issue class where the state is open 97 | """ 98 | # Call the function 99 | response = i.Issue.from_github( 100 | upstream="github", issue=self.mock_github_issue, config=self.mock_config 101 | ) 102 | 103 | # Assert that we made the calls correctly 104 | self.checkResponseFields(response) 105 | 106 | self.assertEqual(response.fixVersion, ["mock_milestone"]) 107 | self.assertEqual(response.priority, "mock_priority") 108 | self.assertEqual(response.status, "Open") 109 | self.assertEqual(response.downstream, {"mock_downstream": "mock_key"}) 110 | self.assertEqual(response.storypoints, "mock_storypoints") 111 | self.assertEqual(response.issue_type, None) 112 | 113 | def test_from_github_open_without_priority(self): 114 | """ 115 | This tests the 'from_github' function under the Issue class 116 | where the state is open but the priority is not initialized. 117 | """ 118 | mock_github_issue = { 119 | "comments": [ 120 | { 121 | "author": "mock_author", 122 | "name": "mock_name", 123 | "body": "mock_body", 124 | "id": "mock_id", 125 | "date_created": "mock_date", 126 | } 127 | ], 128 | "title": "mock_title", 129 | "html_url": "mock_url", 130 | "id": 1234, 131 | "labels": "mock_tags", 132 | "milestone": "mock_milestone", 133 | "body": "mock_content", 134 | "user": "mock_reporter", 135 | "assignees": "mock_assignee", 136 | "state": "open", 137 | "date_created": "mock_date", 138 | "number": "1", 139 | "storypoints": "mock_storypoints", 140 | } 141 | 142 | # Call the function 143 | response = i.Issue.from_github( 144 | upstream="github", issue=mock_github_issue, config=self.mock_config 145 | ) 146 | 147 | # Assert that we made the calls correctly 148 | self.checkResponseFields(response) 149 | 150 | self.assertEqual(response.priority, None) 151 | self.assertEqual(response.status, "Open") 152 | 153 | def test_from_github_closed(self): 154 | """ 155 | This tests the 'from_github' function under the Issue class where the state is closed 156 | """ 157 | # Set up return values 158 | self.mock_github_issue["state"] = "closed" 159 | 160 | # Call the function 161 | response = i.Issue.from_github( 162 | upstream="github", issue=self.mock_github_issue, config=self.mock_config 163 | ) 164 | 165 | # Assert that we made the calls correctly 166 | self.checkResponseFields(response) 167 | 168 | self.assertEqual(response.tags, "mock_tags") 169 | self.assertEqual(response.fixVersion, ["mock_milestone"]) 170 | self.assertEqual(response.priority, "mock_priority") 171 | self.assertEqual(response.status, "Closed") 172 | self.assertEqual(response.downstream, {"mock_downstream": "mock_key"}) 173 | self.assertEqual(response.storypoints, "mock_storypoints") 174 | 175 | def test_from_github_with_type(self): 176 | """ 177 | This tests the 'from_github' function under the Issue class with 178 | various values in the 'type' field 179 | """ 180 | for issue_type, expected in ( 181 | (None, None), 182 | ({}, None), 183 | ({"name": None}, None), 184 | ({"name": "issue_type_name"}, "issue_type_name"), 185 | ({"fred": 1}, None), 186 | ): 187 | # Set up return values 188 | self.mock_github_issue["type"] = issue_type 189 | 190 | # Call the function 191 | response = i.Issue.from_github( 192 | upstream="github", issue=self.mock_github_issue, config=self.mock_config 193 | ) 194 | 195 | # Assert that we made the calls correctly 196 | self.checkResponseFields(response) 197 | self.assertEqual(expected, response.issue_type) 198 | 199 | def test_mapping_github(self): 200 | """ 201 | This tests the mapping feature from GitHub 202 | """ 203 | # Set up return values 204 | self.mock_config["sync2jira"]["map"]["github"]["github"] = { 205 | "mock_downstream": "mock_key", 206 | "mapping": [{"fixVersion": "Test XXX"}], 207 | } 208 | self.mock_github_issue["state"] = "closed" 209 | 210 | # Call the function 211 | response = i.Issue.from_github( 212 | upstream="github", issue=self.mock_github_issue, config=self.mock_config 213 | ) 214 | 215 | # Assert that we made the calls correctly 216 | self.checkResponseFields(response) 217 | 218 | self.assertEqual(response.tags, "mock_tags") 219 | self.assertEqual(response.fixVersion, ["Test mock_milestone"]) 220 | self.assertEqual(response.priority, "mock_priority") 221 | self.assertEqual(response.status, "Closed") 222 | self.assertEqual( 223 | response.downstream, 224 | {"mock_downstream": "mock_key", "mapping": [{"fixVersion": "Test XXX"}]}, 225 | ) 226 | self.assertEqual(response.storypoints, "mock_storypoints") 227 | 228 | @mock.patch(PATH + "matcher") 229 | def test_from_github_pr_reopen(self, mock_matcher): 230 | """ 231 | This tests the message from GitHub for a PR 232 | """ 233 | # Set up return values 234 | mock_matcher.return_value = "JIRA-1234" 235 | 236 | # Call the function 237 | response = i.PR.from_github( 238 | upstream="github", 239 | pr=self.mock_github_pr, 240 | suffix="reopened", 241 | config=self.mock_config, 242 | ) 243 | 244 | # Assert that we made the calls correctly 245 | self.checkResponseFields(response) 246 | 247 | self.assertEqual(response.suffix, "reopened") 248 | self.assertEqual(response.status, None) 249 | self.assertEqual(response.downstream, {"mock_downstream": "mock_key"}) 250 | self.assertEqual(response.jira_key, "JIRA-1234") 251 | self.mock_github_pr["comments"][0]["changed"] = None 252 | mock_matcher.assert_called_with( 253 | self.mock_github_pr["body"], self.mock_github_pr["comments"] 254 | ) 255 | 256 | def test_matcher(self): 257 | """This tests the matcher function""" 258 | # Found in content, no comments 259 | expected = "XYZ-5678" 260 | content = f"Relates to JIRA: {expected}" 261 | comments = [] 262 | actual = i.matcher(content, comments) 263 | self.assertEqual(expected, actual) 264 | 265 | # Found in comment, no content 266 | expected = "XYZ-5678" 267 | content = None 268 | comments = [{"body": f"Relates to JIRA: {expected}"}] 269 | actual = i.matcher(content, comments) 270 | self.assertEqual(expected, actual) 271 | 272 | # Found in content, not spanning comments 273 | expected = "XYZ-5678" 274 | content = f"Relates to JIRA: {expected}" 275 | comments = [ 276 | {"body": "ABC-1234"}, 277 | {"body": "JIRA:"}, 278 | {"body": "to"}, 279 | {"body": "Relates"}, 280 | ] 281 | actual = i.matcher(content, comments) 282 | self.assertEqual(expected, actual) 283 | 284 | # Found in comment, not contents 285 | expected = "XYZ-5678" 286 | content = "Nothing here" 287 | comments = [ 288 | {"body": "Relates"}, 289 | {"body": f"Relates to JIRA: {expected}"}, 290 | {"body": "stuff"}, 291 | ] 292 | actual = i.matcher(content, comments) 293 | self.assertEqual(expected, actual) 294 | 295 | # Overridden in comment 296 | expected = "XYZ-5678" 297 | content = "Relates to JIRA: ABC-1234" 298 | comments = [ 299 | {"body": "Relates"}, 300 | {"body": f"Relates to JIRA: {expected}"}, 301 | {"body": "stuff"}, 302 | ] 303 | actual = i.matcher(content, comments) 304 | self.assertEqual(expected, actual) 305 | 306 | # Overridden twice in comments 307 | expected = "XYZ-5678" 308 | content = "Relates to JIRA: ABC-1234" 309 | comments = [ 310 | {"body": "Relates to JIRA: ABC-1235"}, 311 | {"body": f"Relates to JIRA: {expected}"}, 312 | {"body": "stuff"}, 313 | ] 314 | actual = i.matcher(content, comments) 315 | self.assertEqual(expected, actual) 316 | 317 | # Funky spacing 318 | expected = "XYZ-5678" 319 | content = f"Relates to JIRA: {expected}" 320 | comments = [] 321 | actual = i.matcher(content, comments) 322 | self.assertEqual(expected, actual) 323 | 324 | # Funkier spacing 325 | expected = "XYZ-5678" 326 | content = f"Relates to JIRA:{expected}" 327 | comments = [] 328 | actual = i.matcher(content, comments) 329 | self.assertEqual(expected, actual) 330 | 331 | # Negative case 332 | content = "No JIRAs here..." 333 | comments = [{"body": "... nor here"}] 334 | expected = None 335 | actual = i.matcher(content, comments) 336 | self.assertEqual(expected, actual) 337 | 338 | # TODO: Add new tests from PR 339 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import unittest.mock as mock 3 | from unittest.mock import MagicMock 4 | 5 | import sync2jira.main as m 6 | 7 | PATH = "sync2jira.main." 8 | 9 | 10 | class MockMessage(object): 11 | def __init__(self, msg_id, body, topic): 12 | self.id = msg_id 13 | self.body = body 14 | self.topic = topic 15 | 16 | 17 | class TestMain(unittest.TestCase): 18 | """ 19 | This class tests the main.py file under sync2jira 20 | """ 21 | 22 | def setUp(self): 23 | """ 24 | Set up the testing environment 25 | """ 26 | # Mock Config dict 27 | self.mock_config = { 28 | "sync2jira": { 29 | "jira": {"mock_jira_instance": {"mock_jira": "mock_jira"}}, 30 | "testing": {}, 31 | "legacy_matching": False, 32 | "map": {"github": {"key_github": {"sync": ["issue", "pullrequest"]}}}, 33 | "initialize": True, 34 | "listen": True, 35 | "develop": False, 36 | }, 37 | } 38 | 39 | # Mock Fedmsg Message 40 | self.mock_message_body = {"issue": "mock_issue"} 41 | self.old_style_mock_message = MockMessage( 42 | msg_id="mock_id", 43 | body=self.mock_message_body, 44 | topic=None, 45 | ) 46 | self.new_style_mock_message = MockMessage( 47 | msg_id="mock_id", 48 | body={"body": self.mock_message_body}, 49 | topic=None, 50 | ) 51 | 52 | def _check_for_exception(self, loader, target, exc=ValueError): 53 | try: 54 | m.load_config(loader) 55 | assert False, "Exception expected." 56 | except exc as e: 57 | self.assertIn(target, repr(e)) 58 | 59 | def test_config_validate_empty(self): 60 | loader = lambda: {} 61 | self._check_for_exception(loader, "No sync2jira section") 62 | 63 | def test_config_validate_missing_map(self): 64 | loader = lambda: {"sync2jira": {}} 65 | self._check_for_exception(loader, "No sync2jira.map section") 66 | 67 | def test_config_validate_misspelled_mappings(self): 68 | loader = lambda: {"sync2jira": {"map": {"githob": {}}}, "jira": {}} 69 | self._check_for_exception(loader, 'Specified handlers: "githob", must') 70 | 71 | def test_config_validate_missing_jira(self): 72 | loader = lambda: {"sync2jira": {"map": {"github": {}}}} 73 | self._check_for_exception(loader, "No sync2jira.jira section") 74 | 75 | def test_config_validate_all_good(self): 76 | loader = lambda: {"sync2jira": {"map": {"github": {}}, "jira": {}}} 77 | m.load_config(loader) # Should succeed without an exception. 78 | 79 | @mock.patch(PATH + "report_failure") 80 | @mock.patch(PATH + "INITIALIZE", 1) 81 | @mock.patch(PATH + "initialize_issues") 82 | @mock.patch(PATH + "initialize_pr") 83 | @mock.patch(PATH + "load_config") 84 | @mock.patch(PATH + "listen") 85 | def test_main_initialize( 86 | self, 87 | mock_listen, 88 | mock_load_config, 89 | mock_initialize_pr, 90 | mock_initialize_issues, 91 | mock_report_failure, 92 | ): 93 | """ 94 | This tests the 'main' function 95 | """ 96 | # Set up return values 97 | mock_load_config.return_value = self.mock_config 98 | 99 | # Call the function 100 | m.main() 101 | 102 | # Assert everything was called correctly 103 | mock_load_config.assert_called_once() 104 | mock_listen.assert_called_with(self.mock_config) 105 | mock_listen.assert_called_with(self.mock_config) 106 | mock_initialize_issues.assert_called_with(self.mock_config) 107 | mock_initialize_pr.assert_called_with(self.mock_config) 108 | mock_report_failure.assert_not_called() 109 | 110 | @mock.patch(PATH + "report_failure") 111 | @mock.patch(PATH + "INITIALIZE", 0) 112 | @mock.patch(PATH + "initialize_issues") 113 | @mock.patch(PATH + "initialize_pr") 114 | @mock.patch(PATH + "load_config") 115 | @mock.patch(PATH + "listen") 116 | def test_main_no_initialize( 117 | self, 118 | mock_listen, 119 | mock_load_config, 120 | mock_initialize_pr, 121 | mock_initialize_issues, 122 | mock_report_failure, 123 | ): 124 | """ 125 | This tests the 'main' function 126 | """ 127 | # Set up return values 128 | mock_load_config.return_value = self.mock_config 129 | 130 | # Call the function 131 | m.main() 132 | 133 | # Assert everything was called correctly 134 | mock_load_config.assert_called_once() 135 | mock_listen.assert_called_with(self.mock_config) 136 | mock_listen.assert_called_with(self.mock_config) 137 | mock_initialize_issues.assert_not_called() 138 | mock_initialize_pr.assert_not_called() 139 | mock_report_failure.assert_not_called() 140 | 141 | @mock.patch(PATH + "u_issue") 142 | @mock.patch(PATH + "d_issue") 143 | def test_initialize(self, mock_d, mock_u): 144 | """ 145 | This tests 'initialize' function where everything goes smoothly! 146 | """ 147 | # Set up return values 148 | mock_u.github_issues.return_value = ["mock_issue_github"] 149 | 150 | # Call the function 151 | m.initialize_issues(self.mock_config) 152 | 153 | # Assert everything was called correctly 154 | mock_u.github_issues.assert_called_with("key_github", self.mock_config) 155 | mock_d.sync_with_jira.assert_any_call("mock_issue_github", self.mock_config) 156 | 157 | @mock.patch(PATH + "u_issue") 158 | @mock.patch(PATH + "d_issue") 159 | def test_initialize_repo_name_github(self, mock_d, mock_u): 160 | """ 161 | This tests 'initialize' function where we want to sync an individual repo for GitHub 162 | """ 163 | # Set up return values 164 | mock_u.github_issues.return_value = ["mock_issue_github"] 165 | 166 | # Call the function 167 | m.initialize_issues(self.mock_config, repo_name="key_github") 168 | 169 | # Assert everything was called correctly 170 | mock_u.github_issues.assert_called_with("key_github", self.mock_config) 171 | mock_d.sync_with_jira.assert_called_with("mock_issue_github", self.mock_config) 172 | 173 | @mock.patch(PATH + "u_issue") 174 | @mock.patch(PATH + "d_issue") 175 | def test_initialize_errors(self, mock_d, mock_u): 176 | """ 177 | This tests 'initialize' function where syncing with JIRA throws an exception 178 | """ 179 | # Set up return values 180 | mock_u.github_issues.return_value = ["mock_issue_github"] 181 | mock_d.sync_with_jira.side_effect = Exception() 182 | 183 | # Call the function 184 | with self.assertRaises(Exception): 185 | m.initialize_issues(self.mock_config) 186 | 187 | # Assert everything was called correctly 188 | mock_u.github_issues.assert_called_with("key_github", self.mock_config) 189 | mock_d.sync_with_jira.assert_any_call("mock_issue_github", self.mock_config) 190 | 191 | @mock.patch(PATH + "u_issue") 192 | @mock.patch(PATH + "d_issue") 193 | @mock.patch(PATH + "sleep") 194 | @mock.patch(PATH + "report_failure") 195 | def test_initialize_api_limit( 196 | self, mock_report_failure, mock_sleep, mock_d, mock_u 197 | ): 198 | """ 199 | This tests 'initialize' where we get an GitHub API limit error. 200 | """ 201 | # Set up return values 202 | mock_error = MagicMock(side_effect=Exception("API rate limit exceeded")) 203 | mock_u.github_issues.side_effect = mock_error 204 | 205 | # Call the function 206 | m.initialize_issues(self.mock_config, testing=True) 207 | 208 | # Assert everything was called correctly 209 | mock_u.github_issues.assert_called_with("key_github", self.mock_config) 210 | mock_d.sync_with_jira.assert_not_called() 211 | mock_sleep.assert_called_with(3600) 212 | mock_report_failure.assert_not_called() 213 | 214 | @mock.patch(PATH + "u_issue") 215 | @mock.patch(PATH + "d_issue") 216 | @mock.patch(PATH + "sleep") 217 | @mock.patch(PATH + "report_failure") 218 | def test_initialize_github_error( 219 | self, mock_report_failure, mock_sleep, mock_d, mock_u 220 | ): 221 | """ 222 | This tests 'initialize' where we get a GitHub API (not limit) error. 223 | """ 224 | # Set up return values 225 | mock_error = MagicMock(side_effect=Exception("Random Error")) 226 | mock_u.github_issues.side_effect = mock_error 227 | 228 | # Call the function 229 | with self.assertRaises(Exception): 230 | m.initialize_issues(self.mock_config, testing=True) 231 | 232 | # Assert everything was called correctly 233 | mock_u.github_issues.assert_called_with("key_github", self.mock_config) 234 | mock_d.sync_with_jira.assert_not_called() 235 | mock_sleep.assert_not_called() 236 | mock_report_failure.assert_called_with(self.mock_config) 237 | 238 | @mock.patch(PATH + "handle_msg") 239 | @mock.patch(PATH + "load_config") 240 | def test_listen_no_handlers(self, mock_load_config, mock_handle_msg): 241 | """ 242 | Test 'listen' function where suffix is not in handlers 243 | """ 244 | # Set up return values 245 | mock_load_config.return_value = self.mock_config 246 | 247 | # Call the function 248 | self.old_style_mock_message.topic = "d.d.d.github.issue.no_handlers_match_this" 249 | m.callback(self.old_style_mock_message) 250 | 251 | # Assert everything was called correctly 252 | mock_handle_msg.assert_not_called() 253 | 254 | @mock.patch.dict( 255 | PATH + "issue_handlers", {"github.issue.comment": lambda msg, c: "dummy_issue"} 256 | ) 257 | @mock.patch(PATH + "handle_msg") 258 | @mock.patch(PATH + "load_config") 259 | def test_listen(self, mock_load_config, mock_handle_msg): 260 | """ 261 | Test 'listen' function where everything goes smoothly 262 | """ 263 | # Set up return values 264 | mock_load_config.return_value = self.mock_config 265 | 266 | # Call the function once with the old style 267 | self.old_style_mock_message.topic = "d.d.d.github.issue.comment" 268 | m.callback(self.old_style_mock_message) 269 | 270 | # ... and again with the new style 271 | self.new_style_mock_message.topic = "d.d.d.github.issue.comment" 272 | m.callback(self.new_style_mock_message) 273 | 274 | # Assert everything was called correctly 275 | # It should be called twice, once for the old style message and once for the new. 276 | mock_handle_msg.assert_has_calls( 277 | [ 278 | mock.call( 279 | self.mock_message_body, "github.issue.comment", self.mock_config 280 | ), 281 | mock.call( 282 | self.mock_message_body, "github.issue.comment", self.mock_config 283 | ), 284 | ] 285 | ) 286 | 287 | @mock.patch(PATH + "send_mail") 288 | @mock.patch(PATH + "jinja2") 289 | def test_report_failure(self, mock_jinja2, mock_send_mail): 290 | """ 291 | Tests 'report_failure' function 292 | """ 293 | # Set up return values 294 | mock_template_loader = MagicMock() 295 | mock_template_env = MagicMock() 296 | mock_template = MagicMock() 297 | mock_template.render.return_value = "mock_html" 298 | mock_template_env.get_template.return_value = mock_template 299 | mock_jinja2.FileSystemLoader.return_value = mock_template_loader 300 | mock_jinja2.Environment.return_value = mock_template_env 301 | 302 | # Call the function 303 | m.report_failure({"sync2jira": {"mailing-list": "mock_email"}}) 304 | 305 | # Assert everything was called correctly 306 | mock_send_mail.assert_called_with( 307 | cc=None, 308 | recipients=["mock_email"], 309 | subject="Sync2Jira Has Failed!", 310 | text="mock_html", 311 | ) 312 | 313 | @mock.patch(PATH + "u_issue") 314 | @mock.patch(PATH + "d_issue") 315 | def test_handle_msg_no_handlers(self, mock_d, mock_u): 316 | """ 317 | Tests 'handle_msg' function where there are no handlers 318 | """ 319 | # Call the function 320 | m.handle_msg( 321 | body=self.mock_message_body, suffix="no_handler", config=self.mock_config 322 | ) 323 | 324 | # Assert everything was called correctly 325 | mock_d.sync_with_jira.assert_not_called() 326 | mock_u.handle_github_message.assert_not_called() 327 | 328 | @mock.patch.dict( 329 | PATH + "issue_handlers", {"github.issue.comment": lambda msg, c: None} 330 | ) 331 | @mock.patch(PATH + "u_issue") 332 | @mock.patch(PATH + "d_issue") 333 | def test_handle_msg_no_issue(self, mock_d, mock_u): 334 | """ 335 | Tests 'handle_msg' function where there is no issue 336 | """ 337 | # Call the function 338 | m.handle_msg( 339 | body=self.mock_message_body, 340 | suffix="github.issue.comment", 341 | config=self.mock_config, 342 | ) 343 | 344 | # Assert everything was called correctly 345 | mock_d.sync_with_jira.assert_not_called() 346 | mock_u.handle_github_message.assert_not_called() 347 | 348 | @mock.patch.dict( 349 | PATH + "issue_handlers", {"github.issue.comment": lambda msg, c: "dummy_issue"} 350 | ) 351 | @mock.patch(PATH + "u_issue") 352 | @mock.patch(PATH + "d_issue") 353 | def test_handle_msg(self, mock_d, mock_u): 354 | """ 355 | Tests 'handle_msg' function 356 | """ 357 | # Set up return values 358 | mock_u.handle_github_message.return_value = "dummy_issue" 359 | 360 | # Call the function 361 | m.handle_msg( 362 | body=self.mock_message_body, 363 | suffix="github.issue.comment", 364 | config=self.mock_config, 365 | ) 366 | 367 | # Assert everything was called correctly 368 | mock_d.sync_with_jira.assert_called_with("dummy_issue", self.mock_config) 369 | -------------------------------------------------------------------------------- /tests/test_upstream_pr.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | import unittest 3 | import unittest.mock as mock 4 | from unittest.mock import MagicMock 5 | 6 | import sync2jira.upstream_pr as u 7 | 8 | PATH = "sync2jira.upstream_pr." 9 | 10 | 11 | class TestUpstreamPR(unittest.TestCase): 12 | """ 13 | This class tests the upstream_pr.py file under sync2jira 14 | """ 15 | 16 | def setUp(self): 17 | self.mock_config = { 18 | "sync2jira": { 19 | "map": { 20 | "github": { 21 | "org/repo": {"sync": ["pullrequest"]}, 22 | }, 23 | }, 24 | "jira": { 25 | # Nothing, really.. 26 | }, 27 | "filters": { 28 | "github": { 29 | "org/repo": {"filter1": "filter1", "labels": ["custom_tag"]} 30 | }, 31 | }, 32 | "github_token": "mock_token", 33 | }, 34 | } 35 | 36 | # Mock GitHub Comment 37 | self.mock_github_comment = MagicMock() 38 | self.mock_github_comment.user.name = "mock_username" 39 | self.mock_github_comment.body = "mock_body" 40 | self.mock_github_comment.id = "mock_id" 41 | self.mock_github_comment.created_at = "mock_created_at" 42 | 43 | # Mock GitHub Message 44 | self.mock_github_message_body = { 45 | "repository": {"owner": {"login": "org"}, "name": "repo"}, 46 | "pull_request": { 47 | "filter1": "filter1", 48 | "labels": [{"name": "custom_tag"}], 49 | "comments": ["some_comments!"], 50 | "number": "mock_number", 51 | "user": {"login": "mock_login"}, 52 | "assignees": [{"login": "mock_login"}], 53 | "milestone": {"title": "mock_milestone"}, 54 | }, 55 | } 56 | 57 | # Mock GitHub issue 58 | self.mock_github_pr = MagicMock() 59 | self.mock_github_pr.get_issue_comments.return_value = [self.mock_github_comment] 60 | 61 | # Mock GitHub Issue Raw 62 | self.mock_github_issue_raw = { 63 | "comments": ["some comment"], 64 | "number": "1234", 65 | "user": {"login": "mock_login"}, 66 | "assignees": [{"login": "mock_assignee_login"}], 67 | "labels": [{"name": "some_label"}], 68 | "milestone": {"title": "mock_milestone"}, 69 | } 70 | 71 | # Mock GitHub Reporter 72 | self.mock_github_person = MagicMock() 73 | self.mock_github_person.name = "mock_name" 74 | 75 | # Mock GitHub Repo 76 | self.mock_github_repo = MagicMock() 77 | self.mock_github_repo.get_pull.return_value = self.mock_github_pr 78 | self.mock_github_repo.get_issue.return_value = self.mock_github_pr 79 | 80 | # Mock GitHub Client 81 | self.mock_github_client = MagicMock() 82 | self.mock_github_client.get_repo.return_value = self.mock_github_repo 83 | self.mock_github_client.get_user.return_value = self.mock_github_person 84 | 85 | @mock.patch(PATH + "Github") 86 | @mock.patch("sync2jira.intermediary.PR.from_github") 87 | def test_handle_github_message(self, mock_pr_from_github, mock_github): 88 | """ 89 | This function tests 'handle_github_message' 90 | """ 91 | # Set up return values 92 | mock_pr_from_github.return_value = "Successful Call!" 93 | mock_github.return_value = self.mock_github_client 94 | 95 | # Call function 96 | response = u.handle_github_message( 97 | body=self.mock_github_message_body, 98 | config=self.mock_config, 99 | suffix="mock_suffix", 100 | ) 101 | 102 | # Assert that calls were made correctly 103 | mock_pr_from_github.assert_called_with( 104 | "org/repo", 105 | { 106 | "filter1": "filter1", 107 | "labels": ["custom_tag"], 108 | "comments": [ 109 | { 110 | "author": "mock_username", 111 | "name": unittest.mock.ANY, 112 | "body": "mock_body", 113 | "id": "mock_id", 114 | "date_created": "mock_created_at", 115 | "changed": None, 116 | } 117 | ], 118 | "number": "mock_number", 119 | "user": {"login": "mock_login", "fullname": "mock_name"}, 120 | "assignees": [{"fullname": "mock_name"}], 121 | "milestone": "mock_milestone", 122 | }, 123 | "mock_suffix", 124 | self.mock_config, 125 | ) 126 | mock_github.assert_called_with("mock_token") 127 | self.assertEqual("Successful Call!", response) 128 | self.mock_github_client.get_repo.assert_called_with("org/repo") 129 | self.mock_github_repo.get_pull.assert_called_with(number="mock_number") 130 | self.mock_github_pr.get_issue_comments.assert_any_call() 131 | self.mock_github_client.get_user.assert_called_with("mock_login") 132 | 133 | @mock.patch(PATH + "Github") 134 | @mock.patch("sync2jira.intermediary.Issue.from_github") 135 | def test_handle_github_message_not_in_mapped( 136 | self, mock_issue_from_github, mock_github 137 | ): 138 | """ 139 | This function tests 'handle_github_message' where upstream is not in mapped repos 140 | """ 141 | # Set up return values 142 | self.mock_github_message_body["repository"]["owner"]["login"] = "bad_owner" 143 | 144 | # Call the function 145 | response = u.handle_github_message( 146 | body=self.mock_github_message_body, 147 | config=self.mock_config, 148 | suffix="mock_suffix", 149 | ) 150 | 151 | # Assert that all calls were made correctly 152 | mock_issue_from_github.assert_not_called() 153 | mock_github.assert_not_called() 154 | self.assertEqual(None, response) 155 | 156 | @mock.patch("sync2jira.intermediary.PR.from_github") 157 | @mock.patch(PATH + "Github") 158 | @mock.patch(PATH + "u_issue.get_all_github_data") 159 | def test_github_issues( 160 | self, mock_get_all_github_data, mock_github, mock_pr_from_github 161 | ): 162 | """ 163 | This function tests 'github_issues' function 164 | """ 165 | # Set up return values 166 | mock_github.return_value = self.mock_github_client 167 | mock_get_all_github_data.return_value = [self.mock_github_issue_raw] 168 | mock_pr_from_github.return_value = "Successful Call!" 169 | 170 | # Call the function 171 | response = list(u.github_prs(upstream="org/repo", config=self.mock_config)) 172 | 173 | # Assert that calls were made correctly 174 | mock_get_all_github_data.assert_called_with( 175 | "https://api.github.com/repos/org/repo/pulls?filter1=filter1&labels=custom_tag", 176 | {"Authorization": "token mock_token"}, 177 | ) 178 | self.mock_github_client.get_user.assert_any_call("mock_login") 179 | self.mock_github_client.get_user.assert_any_call("mock_assignee_login") 180 | mock_pr_from_github.assert_called_with( 181 | "org/repo", 182 | { 183 | "comments": [ 184 | { 185 | "author": "mock_username", 186 | "name": unittest.mock.ANY, 187 | "body": "mock_body", 188 | "id": "mock_id", 189 | "date_created": "mock_created_at", 190 | "changed": None, 191 | } 192 | ], 193 | "number": "1234", 194 | "user": {"login": "mock_login", "fullname": "mock_name"}, 195 | "assignees": [{"fullname": "mock_name"}], 196 | "labels": ["some_label"], 197 | "milestone": "mock_milestone", 198 | }, 199 | "open", 200 | self.mock_config, 201 | ) 202 | self.mock_github_client.get_repo.assert_called_with("org/repo") 203 | self.mock_github_repo.get_pull.assert_called_with(number="1234") 204 | self.mock_github_pr.get_issue_comments.assert_any_call() 205 | self.assertEqual(response[0], "Successful Call!") 206 | 207 | @mock.patch("sync2jira.intermediary.PR.from_github") 208 | @mock.patch(PATH + "Github") 209 | @mock.patch(PATH + "u_issue.get_all_github_data") 210 | def test_filter_multiple_labels( 211 | self, mock_get_all_github_data, mock_github, mock_issue_from_github 212 | ): 213 | """ 214 | This function tests 'github_issues' function with a filter including multiple labels 215 | """ 216 | # Set up return values 217 | self.mock_config["sync2jira"]["filters"]["github"]["org/repo"]["labels"].extend( 218 | ["another_tag", "and_another"] 219 | ) 220 | mock_github.return_value = self.mock_github_client 221 | mock_issue_from_github.return_value = "Successful Call!" 222 | # We mutate the issue object so we need to pass a copy here 223 | mock_get_all_github_data.return_value = [deepcopy(self.mock_github_issue_raw)] 224 | 225 | # Call the function 226 | list(u.github_prs(upstream="org/repo", config=self.mock_config)) 227 | 228 | # Assert that the labels filter is correct 229 | self.assertIn( 230 | "labels=custom_tag%2Canother_tag%2Cand_another", 231 | mock_get_all_github_data.call_args[0][0], 232 | ) 233 | # Assert the config value was not mutated 234 | self.assertEqual( 235 | self.mock_config["sync2jira"]["filters"]["github"]["org/repo"]["labels"], 236 | ["custom_tag", "another_tag", "and_another"], 237 | ) 238 | 239 | # Restore the return value to the original object 240 | mock_get_all_github_data.return_value = [deepcopy(self.mock_github_issue_raw)] 241 | 242 | # Call the function again to ensure consistency for subsequent calls 243 | list(u.github_prs(upstream="org/repo", config=self.mock_config)) 244 | 245 | # Assert that the labels filter is correct 246 | self.assertIn( 247 | "labels=custom_tag%2Canother_tag%2Cand_another", 248 | mock_get_all_github_data.call_args[0][0], 249 | ) 250 | # Assert the config value was not mutated 251 | self.assertEqual( 252 | self.mock_config["sync2jira"]["filters"]["github"]["org/repo"]["labels"], 253 | ["custom_tag", "another_tag", "and_another"], 254 | ) 255 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39,py313,lint,isort,black 3 | 4 | [testenv] 5 | setenv = 6 | DEFAULT_FROM = mock_email@mock.com 7 | DEFAULT_SERVER = mock_server 8 | INITIALIZE=1 9 | basepython = 10 | py313: python3.13 11 | py39: python3.9 12 | deps = 13 | -r{toxinidir}/requirements.txt 14 | -r{toxinidir}/test-requirements.txt 15 | sitepackages = True 16 | allowlist_externals = /usr/bin/flake8,/usr/bin/black 17 | commands = 18 | coverage run -m pytest {posargs} --ignore=tests/integration_tests 19 | # Add the following line locally to get an HTML report --cov-report html:htmlcov-py313 20 | 21 | [testenv:lint] 22 | skip_install = true 23 | basepython = python3.13 24 | deps = 25 | flake8 26 | commands = 27 | flake8 sync2jira --max-line-length=140 28 | 29 | [isort] 30 | profile = black 31 | known_first_party = ["sync2jira"] 32 | force_sort_within_sections = true 33 | order_by_type = false 34 | 35 | [testenv:isort] 36 | skip_install = true 37 | deps = isort 38 | commands = isort --check --diff {posargs:.} 39 | 40 | [testenv:isort-format] 41 | skip_install = true 42 | deps = isort 43 | commands = isort {posargs:.} 44 | 45 | [testenv:black] 46 | skip_install = true 47 | deps = black 48 | commands = black --check --diff {posargs:.} 49 | 50 | [testenv:black-format] 51 | skip_install = true 52 | deps = black 53 | commands = black {posargs:.} 54 | --------------------------------------------------------------------------------