[#36204] Add new GitHub plugin tab enabling users to copy git actions (#9027)

* Let GitHub integration show changes in a separate tab

* added new github integration icons to icon font
* add tab content:  working tab-header and copy-menu

* modernise github_integrations ruby code

* refactored some code to be more modern ruby (if wrote most of it 7
  years ago and couldn't look at some parts without squinting too much)
* make some intended-to-be-private module methods actually private
* fixed all rubocop errors in the /modules/github_integration
* re-organized tests a little
* gave our rubocop.yml some RSpec-related defaults -- happy to discuss
  these, but I think we can live with these as a good starting point

👆 all without actually (intentionally) changing the behaviour

* removed dead angular template code

* codeclimate found more things than rubocop :)

* removed create-pr-button since we decided against implementing that feature

* added missing translations

* properly cache the github related part of the wp api

* lower case pull requests in translations

* fix specs
pull/9151/head
Philipp Tessenow 4 years ago committed by GitHub
parent ebbe5ec584
commit a46de71009
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 15
      .rubocop.yml
  2. 2
      docker/ci/entrypoint.sh
  3. 6
      frontend/angular.json
  4. 2
      frontend/doc/TESTING.md
  5. 98
      frontend/npm-shrinkwrap.json
  6. 2
      frontend/package.json
  7. 2
      frontend/src/app/components/datetime/timezone.service.spec.ts
  8. 4
      frontend/src/app/components/op-context-menu/op-context-menu-handler.ts
  9. 2
      frontend/src/app/components/wp-edit-form/work-package-filter-values.spec.ts
  10. 2
      frontend/src/app/modules/bim/bcf/api/bcf-api.service.spec.ts
  11. 2
      frontend/src/app/modules/common/notifications/notifications.service.spec.ts
  12. 10
      frontend/src/app/modules/global_search/services/global-search.service.spec.ts
  13. 4
      frontend/src/app/modules/hal/resources/hal-resource.spec.ts
  14. 4
      frontend/src/app/modules/hal/resources/work-package-resource.spec.ts
  15. 8
      frontend/src/app/modules/work_packages/routing/wp-view-base/view-services/wp-view-hierarchy-indentation.service.spec.ts
  16. 410
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.svg
  17. BIN
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.ttf
  18. BIN
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.woff
  19. BIN
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.woff2
  20. 1036
      frontend/src/global_styles/fonts/_openproject_icon_definitions.scss
  21. 2
      frontend/src/global_styles/fonts/_openproject_icon_font.lsg
  22. 3
      modules/github_integration/config/locales/de.yml
  23. 3
      modules/github_integration/config/locales/en.yml
  24. 51
      modules/github_integration/config/locales/js-en.yml
  25. 120
      modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.component.ts
  26. 59
      modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.directive.ts
  27. 24
      modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.template.html
  28. 68
      modules/github_integration/frontend/module/git-actions-menu/styles/git-actions-menu.sass
  29. 97
      modules/github_integration/frontend/module/git-actions/git-actions.service.spec.ts
  30. 63
      modules/github_integration/frontend/module/git-actions/git-actions.service.ts
  31. 51
      modules/github_integration/frontend/module/github-tab/github-tab.component.ts
  32. 4
      modules/github_integration/frontend/module/github-tab/github-tab.template.html
  33. 82
      modules/github_integration/frontend/module/main.ts
  34. 43
      modules/github_integration/frontend/module/tab-header/styles/tab-header.sass
  35. 55
      modules/github_integration/frontend/module/tab-header/tab-header.component.ts
  36. 21
      modules/github_integration/frontend/module/tab-header/tab-header.template.html
  37. 50
      modules/github_integration/frontend/module/tab-prs/tab-prs.component.ts
  38. 3
      modules/github_integration/frontend/module/tab-prs/tab-prs.template.html
  39. 13
      modules/github_integration/lib/open_project/github_integration/engine.rb
  40. 8
      modules/github_integration/lib/open_project/github_integration/hook_handler.rb
  41. 314
      modules/github_integration/lib/open_project/github_integration/notification_handlers.rb
  42. 21
      modules/github_integration/lib/open_project/github_integration/patches/api/work_package_representer.rb
  43. 115
      modules/github_integration/spec/features/work_package_github_tab_spec.rb
  44. 139
      modules/github_integration/spec/lib/open_project/github_integration/hook_handler_example_webhooks_spec.rb
  45. 30
      modules/github_integration/spec/lib/open_project/github_integration/hook_handler_spec.rb
  46. 104
      modules/github_integration/spec/lib/open_project/github_integration/notification_handlers_spec.rb
  47. 68
      modules/github_integration/spec/support/pages/work_package_github_tab.rb
  48. 1
      vendor/openproject-icon-font/src/console-light.svg
  49. 1
      vendor/openproject-icon-font/src/merge-branch.svg

@ -7,6 +7,12 @@ AllCops:
Exclude: Exclude:
- db/schema.rb - db/schema.rb
Gemspec/RequiredRubyVersion:
Exclude:
- modules/**/*.gemspec
Layout/ConditionPosition: Layout/ConditionPosition:
Enabled: false Enabled: false
@ -282,5 +288,12 @@ Style/HashTransformKeys:
Style/HashTransformValues: Style/HashTransformValues:
Enabled: true Enabled: true
Rspec/MultipleMemoizedHelpers:
RSpec/MultipleMemoizedHelpers:
Max: 15
RSpec/MultipleExpectations:
Max: 15 Max: 15
RSpec/ExampleLength:
Max: 25

@ -51,7 +51,7 @@ if [ "$1" == "setup-tests" ]; then
done done
execute "time bundle install -j$JOBS" execute "time bundle install -j$JOBS"
execute "TEST_ENV_NUMBER=0 time bundle exec rake db:create db:migrate db:schema:dump webdrivers:chromedriver:update webdrivers:geckodriver:update" execute "TEST_ENV_NUMBER=0 time bundle exec rake db:create db:migrate db:schema:dump webdrivers:chromedriver:update webdrivers:geckodriver:update openproject:plugins:register_frontend"
execute "time bundle exec rake parallel:create parallel:load_schema" execute "time bundle exec rake parallel:create parallel:load_schema"
fi fi

@ -88,7 +88,7 @@
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"options": { "options": {
"browserTarget": "OpenProject:build", "browserTarget": "OpenProject:build",
"proxyConfig": "cli_to_rails_proxy.js", "proxyConfig": "cli_to_rails_proxy.js"
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -105,6 +105,7 @@
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular-devkit/build-angular:karma",
"options": { "options": {
"preserveSymlinks": true,
"main": "src/test.ts", "main": "src/test.ts",
"karmaConfig": "./karma.conf.js", "karmaConfig": "./karma.conf.js",
"tsConfig": "src/tsconfig.spec.json", "tsConfig": "src/tsconfig.spec.json",
@ -124,7 +125,8 @@
"lintFilePatterns": [ "lintFilePatterns": [
"src/**/*.ts", "src/**/*.ts",
"src/**/*.html" "src/**/*.html"
] ],
"preserveSymlinks": true
} }
} }
} }

@ -11,7 +11,7 @@ OpenProject is a hybrid application with most parts being Rails, along with some
The Angular frontend services and components can be tested with frontend specs. A good isolated example on how to set up an Angular TestBed to test components is `frontend/src/app/modules/a11y/accessible-by-keyboard.component.spec.ts` The Angular frontend services and components can be tested with frontend specs. A good isolated example on how to set up an Angular TestBed to test components is `frontend/src/app/modules/a11y/accessible-by-keyboard.component.spec.ts`

@ -56,6 +56,7 @@
"chart.js": "2.9.3", "chart.js": "2.9.3",
"chartjs-plugin-datalabels": "^0.6.0", "chartjs-plugin-datalabels": "^0.6.0",
"codemirror": "^5.48.4", "codemirror": "^5.48.4",
"copy-text-to-clipboard": "^3.0.0",
"core-js": "^3.2.1", "core-js": "^3.2.1",
"crossvent": "^1.5.4", "crossvent": "^1.5.4",
"dom-autoscroller": "^2.2.8", "dom-autoscroller": "^2.2.8",
@ -85,6 +86,7 @@
"rxjs": "^6.6.6", "rxjs": "^6.6.6",
"screenfull": "^4.2.1", "screenfull": "^4.2.1",
"tablesorter": "^2.31.3", "tablesorter": "^2.31.3",
"tickety-tick-formatter": "github:bitcrowd/tickety-tick-formatter",
"typedjson": "^1.5.1", "typedjson": "^1.5.1",
"typescript": "~4.1.2", "typescript": "~4.1.2",
"urijs": "^1.19.2", "urijs": "^1.19.2",
@ -6115,6 +6117,17 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/copy-text-to-clipboard": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz",
"integrity": "sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/copy-webpack-plugin": { "node_modules/copy-webpack-plugin": {
"version": "6.3.2", "version": "6.3.2",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-6.3.2.tgz", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-6.3.2.tgz",
@ -11926,6 +11939,14 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"engines": {
"node": ">=4"
}
},
"node_modules/mini-css-extract-plugin": { "node_modules/mini-css-extract-plugin": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.3.5.tgz", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.3.5.tgz",
@ -14178,6 +14199,17 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz",
"integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==",
"bin": {
"prettier": "bin-prettier.js"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/pretty-bytes": { "node_modules/pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@ -16220,6 +16252,14 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/speed-measure-webpack-plugin": { "node_modules/speed-measure-webpack-plugin": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/speed-measure-webpack-plugin/-/speed-measure-webpack-plugin-1.4.2.tgz", "resolved": "https://registry.npmjs.org/speed-measure-webpack-plugin/-/speed-measure-webpack-plugin-1.4.2.tgz",
@ -16516,6 +16556,17 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -17082,6 +17133,16 @@
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
}, },
"node_modules/tickety-tick-formatter": {
"version": "1.0.0",
"resolved": "git+ssh://git@github.com/bitcrowd/tickety-tick-formatter.git#aa1895189e08689d340bf05d9e2855695a4dbb18",
"license": "MIT",
"dependencies": {
"prettier": "^2.2.1",
"speakingurl": "^14.0.1",
"strip-indent": "^3.0.0"
}
},
"node_modules/ticky": { "node_modules/ticky": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ticky/-/ticky-1.0.1.tgz", "resolved": "https://registry.npmjs.org/ticky/-/ticky-1.0.1.tgz",
@ -24339,6 +24400,11 @@
"resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=" "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40="
}, },
"copy-text-to-clipboard": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz",
"integrity": "sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q=="
},
"copy-webpack-plugin": { "copy-webpack-plugin": {
"version": "6.3.2", "version": "6.3.2",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-6.3.2.tgz", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-6.3.2.tgz",
@ -29072,6 +29138,11 @@
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
}, },
"min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="
},
"mini-css-extract-plugin": { "mini-css-extract-plugin": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.3.5.tgz", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.3.5.tgz",
@ -30935,6 +31006,11 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true "dev": true
}, },
"prettier": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz",
"integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q=="
},
"pretty-bytes": { "pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@ -32648,6 +32724,11 @@
} }
} }
}, },
"speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="
},
"speed-measure-webpack-plugin": { "speed-measure-webpack-plugin": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/speed-measure-webpack-plugin/-/speed-measure-webpack-plugin-1.4.2.tgz", "resolved": "https://registry.npmjs.org/speed-measure-webpack-plugin/-/speed-measure-webpack-plugin-1.4.2.tgz",
@ -32891,6 +32972,14 @@
"resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
"integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8="
}, },
"strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"requires": {
"min-indent": "^1.0.0"
}
},
"strip-json-comments": { "strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -33338,6 +33427,15 @@
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
}, },
"tickety-tick-formatter": {
"version": "git+ssh://git@github.com/bitcrowd/tickety-tick-formatter.git#aa1895189e08689d340bf05d9e2855695a4dbb18",
"from": "tickety-tick-formatter@github:bitcrowd/tickety-tick-formatter",
"requires": {
"prettier": "^2.2.1",
"speakingurl": "^14.0.1",
"strip-indent": "^3.0.0"
}
},
"ticky": { "ticky": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ticky/-/ticky-1.0.1.tgz", "resolved": "https://registry.npmjs.org/ticky/-/ticky-1.0.1.tgz",

@ -77,6 +77,7 @@
"chart.js": "2.9.3", "chart.js": "2.9.3",
"chartjs-plugin-datalabels": "^0.6.0", "chartjs-plugin-datalabels": "^0.6.0",
"codemirror": "^5.48.4", "codemirror": "^5.48.4",
"copy-text-to-clipboard": "^3.0.0",
"core-js": "^3.2.1", "core-js": "^3.2.1",
"crossvent": "^1.5.4", "crossvent": "^1.5.4",
"dom-autoscroller": "^2.2.8", "dom-autoscroller": "^2.2.8",
@ -106,6 +107,7 @@
"rxjs": "^6.6.6", "rxjs": "^6.6.6",
"screenfull": "^4.2.1", "screenfull": "^4.2.1",
"tablesorter": "^2.31.3", "tablesorter": "^2.31.3",
"tickety-tick-formatter": "github:bitcrowd/tickety-tick-formatter",
"typedjson": "^1.5.1", "typedjson": "^1.5.1",
"typescript": "~4.1.2", "typescript": "~4.1.2",
"urijs": "^1.19.2", "urijs": "^1.19.2",

@ -59,7 +59,7 @@ describe('TimezoneService', function () {
] ]
}); });
timezoneService = TestBed.get(TimezoneService); timezoneService = TestBed.inject(TimezoneService);
}; };
describe('without time zone set', function () { describe('without time zone set', function () {

@ -18,11 +18,11 @@ export abstract class OpContextMenuHandler extends UntilDestroyedMixin {
* Called when the service closes this context menu * Called when the service closes this context menu
*/ */
public onClose() { public onClose() {
this.afterFocusOn.focus(); this.afterFocusOn.trigger('focus');
} }
public onOpen(menu:JQuery) { public onOpen(menu:JQuery) {
menu.find('.menu-item').first().focus(); menu.find('.menu-item').first().trigger('focus');
} }
/** /**

@ -97,7 +97,7 @@ describe('WorkPackageFilterValues', () => {
] ]
}).compileComponents(); }).compileComponents();
injector = TestBed.get(Injector); injector = TestBed.inject(Injector);
halResourceService = injector.get(HalResourceService); halResourceService = injector.get(HalResourceService);
resource = halResourceService.createHalResourceOfClass(WorkPackageResource, source, true); resource = halResourceService.createHalResourceOfClass(WorkPackageResource, source, true);

@ -43,7 +43,7 @@ describe('BcfApiService', function () {
}) })
.compileComponents() .compileComponents()
.then(() => { .then(() => {
service = TestBed.get(BcfApiService); service = TestBed.inject(BcfApiService);
}); });
})); }));

@ -49,7 +49,7 @@ describe('NotificationsService', function () {
}) })
.compileComponents() .compileComponents()
.then(() => { .then(() => {
notificationsService = TestBed.get(NotificationsService); notificationsService = TestBed.inject(NotificationsService);
}); });
})); }));

@ -53,11 +53,11 @@ describe('Global search service', function() {
GlobalSearchService, GlobalSearchService,
] ]
}) })
.compileComponents() .compileComponents()
.then(() => { .then(() => {
CurrentProject = TestBed.get(CurrentProjectService); CurrentProject = TestBed.inject(CurrentProjectService);
service = TestBed.get(GlobalSearchService); service = TestBed.inject(GlobalSearchService);
}); });
})); }));
describe('outside a project', () => { describe('outside a project', () => {

@ -61,8 +61,8 @@ describe('HalResource', () => {
}) })
.compileComponents() .compileComponents()
.then(() => { .then(() => {
halResourceService = TestBed.get(HalResourceService); halResourceService = TestBed.inject(HalResourceService);
injector = TestBed.get(Injector); injector = TestBed.inject(Injector);
}); });
})); }));

@ -91,8 +91,8 @@ describe('WorkPackage', () => {
}) })
.compileComponents() .compileComponents()
.then(() => { .then(() => {
halResourceService = TestBed.get(HalResourceService); halResourceService = TestBed.inject(HalResourceService);
injector = TestBed.get(Injector); injector = TestBed.inject(Injector);
notificationsService = injector.get(NotificationsService); notificationsService = injector.get(NotificationsService);
halResourceNotification = injector.get(HalResourceNotificationService); halResourceNotification = injector.get(HalResourceNotificationService);

@ -84,10 +84,10 @@ describe('WorkPackageViewIndentation service', function () {
}) })
.compileComponents() .compileComponents()
.then(() => { .then(() => {
service = TestBed.get(WorkPackageViewHierarchyIdentationService); service = TestBed.inject(WorkPackageViewHierarchyIdentationService);
querySpace = TestBed.get(IsolatedQuerySpace); querySpace = TestBed.inject(IsolatedQuerySpace);
hierarchyServiceStub = TestBed.get(WorkPackageViewHierarchiesService); hierarchyServiceStub = TestBed.inject(WorkPackageViewHierarchiesService);
states = TestBed.get(States); states = TestBed.inject(States);
}); });
})); }));

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 148 KiB

@ -57,6 +57,7 @@
<li><span class="icon icon-columns"></span>columns</li> <li><span class="icon icon-columns"></span>columns</li>
<li><span class="icon icon-compare2"></span>compare2</li> <li><span class="icon icon-compare2"></span>compare2</li>
<li><span class="icon icon-concept"></span>concept</li> <li><span class="icon icon-concept"></span>concept</li>
<li><span class="icon icon-console-light"></span>console-light</li>
<li><span class="icon icon-console"></span>console</li> <li><span class="icon icon-console"></span>console</li>
<li><span class="icon icon-contacts"></span>contacts</li> <li><span class="icon icon-contacts"></span>contacts</li>
<li><span class="icon icon-copy"></span>copy</li> <li><span class="icon icon-copy"></span>copy</li>
@ -138,6 +139,7 @@
<li><span class="icon icon-maintenance-support"></span>maintenance-support</li> <li><span class="icon icon-maintenance-support"></span>maintenance-support</li>
<li><span class="icon icon-meetings"></span>meetings</li> <li><span class="icon icon-meetings"></span>meetings</li>
<li><span class="icon icon-menu"></span>menu</li> <li><span class="icon icon-menu"></span>menu</li>
<li><span class="icon icon-merge-branch"></span>merge-branch</li>
<li><span class="icon icon-microphone"></span>microphone</li> <li><span class="icon icon-microphone"></span>microphone</li>
<li><span class="icon icon-milestone"></span>milestone</li> <li><span class="icon icon-milestone"></span>milestone</li>
<li><span class="icon icon-minus1"></span>minus1</li> <li><span class="icon icon-minus1"></span>minus1</li>

@ -27,6 +27,9 @@
#++ #++
de: de:
project_module_github: "GitHub"
permission_show_github_content: "GitHub Inhalte anzeigen"
github_integration: github_integration:
pull_request_opened_comment: > pull_request_opened_comment: >
**PR Geöffnet:** "Pull request %{pr_number} “%{pr_title}”":%{pr_url} in "%{repository}":%{repository_url} **PR Geöffnet:** "Pull request %{pr_number} “%{pr_title}”":%{pr_url} in "%{repository}":%{repository_url}

@ -27,6 +27,9 @@
#++ #++
en: en:
project_module_github: "GitHub"
permission_show_github_content: "Show GitHub content"
github_integration: github_integration:
pull_request_opened_comment: > pull_request_opened_comment: >
**PR Opened:** Pull request %{pr_number} [%{pr_title}](%{pr_url}) for [%{repository}](%{repository_url}) **PR Opened:** Pull request %{pr_number} [%{pr_title}](%{pr_url}) for [%{repository}](%{repository_url})

@ -0,0 +1,51 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
en:
js:
github_integration:
work_packages:
tab_name: "GitHub"
tab_header:
title: "Pull Requests"
copy_menu:
label: Git
description: Copy important git content to clipboard
git_actions:
branch: Branch
branch_help: Copy the default branch name.
message: Message
message_help: Copy the default commit message.
cmd: Command
cmd_help: Copy a shell command which creates a new branch and an empty commit using the default branch name and commit message.
title: Copy to Clipboard
copy_button_help: Copy to clipboard
copy_success: ✅ Copied!
copy_error: ❌ Copy failed!
tab_prs:
empty: There are no pull requests linked yet. Link an existing PR by using the code <code>OP#%{wp_id}</code> in the PR description or create a new PR.

@ -0,0 +1,120 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import copy from 'copy-text-to-clipboard';
import {Component, Inject, Input} from '@angular/core';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import { GitActionsService} from '../git-actions/git-actions.service';
import { OPContextMenuComponent } from 'core-app/components/op-context-menu/op-context-menu.component';
import { OpContextMenuLocalsMap, OpContextMenuLocalsToken } from 'core-app/components/op-context-menu/op-context-menu.types';
interface Tab {
label:string,
help:string,
selected:boolean,
lines:number,
textToCopy: ()=>string
}
@Component({
selector: 'git-actions-menu',
templateUrl: './git-actions-menu.template.html',
styleUrls: [
'./styles/git-actions-menu.sass'
]
})
export class GitActionsMenuComponent extends OPContextMenuComponent {
@Input() public workPackage:WorkPackageResource;
public text = {
title: this.I18n.t('js.github_integration.tab_header.git_actions.title'),
copyButtonHelpText: this.I18n.t('js.github_integration.tab_header.git_actions.copy_button_help'),
copyResult: {
success: this.I18n.t('js.github_integration.tab_header.git_actions.copy_success'),
error: this.I18n.t('js.github_integration.tab_header.git_actions.copy_error')
}
};
public lastCopyResult:string = this.text.copyResult.success;
public showCopyResult:boolean = false;
public tabs:Tab[] = [
{
label: this.I18n.t('js.github_integration.tab_header.git_actions.branch'),
help: this.I18n.t('js.github_integration.tab_header.git_actions.branch_help'),
selected: true,
lines: 1,
textToCopy: () => this.gitActions.branchName(this.workPackage)
},
{
label: this.I18n.t('js.github_integration.tab_header.git_actions.message'),
help: this.I18n.t('js.github_integration.tab_header.git_actions.message_help'),
selected: false,
lines: 6,
textToCopy: () => this.gitActions.commitMessage(this.workPackage)
},
{
label: this.I18n.t('js.github_integration.tab_header.git_actions.cmd'),
help: this.I18n.t('js.github_integration.tab_header.git_actions.cmd_help'),
selected: false,
lines: 6,
textToCopy: () => this.gitActions.gitCommand(this.workPackage)
},
];
constructor(@Inject(OpContextMenuLocalsToken)
public locals:OpContextMenuLocalsMap,
readonly I18n:I18nService,
readonly gitActions:GitActionsService) {
super(locals);
this.workPackage = this.locals.workPackage;
}
public selectedTab():Tab {
const selectedTabs = this.tabs.filter((tab)=>tab.selected);
return(selectedTabs[0] || this.tabs[0]);
}
public selectTab(tab:Tab) {
this.tabs.forEach(tab => tab.selected = false);
tab.selected = true;
}
public onCopyButtonClick() {
const success = copy(this.selectedTab().textToCopy())
if (success) {
this.lastCopyResult = this.text.copyResult.success;
} else {
this.lastCopyResult = this.text.copyResult.error;
}
this.showCopyResult = true;
window.setTimeout(() => { this.showCopyResult = false;}, 2000);
}
}

@ -0,0 +1,59 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import {OpContextMenuItem} from 'core-components/op-context-menu/op-context-menu.types';
import {OPContextMenuService} from 'core-components/op-context-menu/op-context-menu.service';
import {Directive, ElementRef, Input} from '@angular/core';
import {OpContextMenuTrigger} from 'core-components/op-context-menu/handlers/op-context-menu-trigger.directive';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {GitActionsMenuComponent} from './git-actions-menu.component';
@Directive({
selector: '[gitActionsCopyDropdown]'
})
export class GitActionsMenuDirective extends OpContextMenuTrigger {
@Input('gitActionsCopyDropdown-workPackage') public workPackage:WorkPackageResource;
constructor(readonly elementRef:ElementRef,
readonly opContextMenu:OPContextMenuService) {
super(elementRef, opContextMenu);
}
protected open(evt:JQuery.TriggeredEvent) {
this.opContextMenu.show(this, evt, GitActionsMenuComponent);
}
public get locals():{ showAnchorRight?:boolean, contextMenuId?:string, items:OpContextMenuItem[], workPackage:WorkPackageResource } {
return {
workPackage: this.workPackage,
contextMenuId: 'github-integration-git-actions-menu',
items: []
};
}
}

@ -0,0 +1,24 @@
<div class="git-actions-menu dropdown-relative dropdown -overflow-in-view dropdown-anchor-right">
<h3 class="title">
<op-icon icon-classes="button--icon icon-console-light"></op-icon>
{{text.title}}
</h3>
<ul class="tabrow">
<!-- The hrefs with empty URLs are necessary for IE10 to focus these links
properly. Thus, don't remove the hrefs or the empty URLs! -->
<li *ngFor="let tab of tabs" (click)="selectTab(tab)" [class.selected]="tab.selected">
<a href="" [textContent]="tab.label"></a>
</li>
</ul>
<div class="copy-wrapper">
<textarea class="copy-content" [textContent]="selectedTab().textToCopy()" [style.height.em]="selectedTab().lines" readonly="true"></textarea>
<button class="button copy-button"
type="button"
[attr.aria-label]="text.copyButtonHelpText"
(click)="onCopyButtonClick()">
<op-icon icon-classes="button--icon icon-copy"></op-icon>
</button>
<div class="copy-result-message" *ngIf="showCopyResult" [textContent]="lastCopyResult"></div>
</div>
<div class="help-text" [textContent]="selectedTab().help"></div>
</div>

@ -0,0 +1,68 @@
.git-actions-menu
background-color: var(--body-background)
border: var(--content-default-border-width) solid var(--content-default-border-color)
padding: 1rem
min-width: 25rem
box-shadow: .1em .1em .4em rgba(0,0,0,0.1)
.tabrow
margin-bottom: 0.5rem
.copy-wrapper
width: 100%
position: relative
margin-bottom: 1rem
.copy-content
width: calc(100% - 2.2em)
// the min-height should be the size of the copy-icon, which is the sum of:
// 2 * button padding (0.65em)
// font-size of the icon (0.9em)
// 1px where I don't know where it comes from
min-height: calc(2 * 0.65em + 0.9em + 1px)
border-radius: 2px 0 0 2px
padding: 0.65em
color: var(--gray-dark)
white-space: pre
resize: none
font-size: 1rem
display: inline-block
.copy-button
margin: 0
border: 1px solid #ccc
border-radius: 0 2px 2px 0
vertical-align: top
left: -1px
position: relative
&:hover
border-color: #999
.copy-result-message
background-color: var(--main-menu-bg-color)
display: inline-block
padding: 0.5em
border-radius: 5px
color: var(--main-menu-font-color)
position: absolute
right: 0
top: calc(2 * 0.65em + 0.9em + 1px + 9px)
box-shadow: 1px 1px 4px var(--gray-dark)
&:before
content: ""
border-bottom: 0.6em solid var(--main-menu-bg-color)
height: 0
width: 0
position: absolute
top: -9px
right: 10px
border-left: 0.3em solid transparent
border-right: 0.3em solid transparent
.help-text
color: var(--gray-dark)
margin-bottom: 1rem
display: inline-block
max-width: 20em

@ -0,0 +1,97 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
/*jshint expr: true*/
import { GitActionsService } from './git-actions.service';
import { WorkPackageResource } from 'core-app/modules/hal/resources/work-package-resource';
import { PathHelperService } from 'core-app/modules/common/path-helper/path-helper.service';
import { TestBed, waitForAsync } from '@angular/core/testing';
describe('GitActionsService', function() {
let service:GitActionsService;
const createWorkPackage = (overrides = {}) => {
const defaults = {
id: '42',
subject: 'Find the question',
description: {
raw: 'I recently found the answer is 42. We need to compute the correct question.'
},
type: { name: 'User Story' },
pathHelper: new PathHelperService()
};
const workPackage = { ...defaults, ...overrides };
return(workPackage as WorkPackageResource);
};
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: [
GitActionsService
]
}).compileComponents()
.then(() => {
service = TestBed.inject(GitActionsService);
});
}));
beforeEach(() => {
service = new GitActionsService();
});
it('it produces a branch name, commit message, and a git command', () => {
const wp = createWorkPackage();
expect(service.branchName(wp)).toEqual('user-story/42-find-the-question');
expect(service.commitMessage(wp)).toEqual(`[#42] Find the question
I recently found the answer is 42. We need to compute the correct
question.
http://localhost:9876/work_packages/42
`);
expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-find-the-question' && git commit --allow-empty -m '[#42] Find the question
I recently found the answer is 42. We need to compute the correct
question.
http://localhost:9876/work_packages/42
'`);
});
it('shell-escapes output for the git-command', () => {
const wp = createWorkPackage({description: { raw: "' && rm -rf / #"}});
expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-find-the-question' && git commit --allow-empty -m '[#42] Find the question
'\\'' && rm -rf / #
http://localhost:9876/work_packages/42
'`);
});
});

@ -0,0 +1,63 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import {Injectable} from '@angular/core';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import formatter from 'tickety-tick-formatter';
// probably not providable in root when we want to cache the formatter and set custom templates
@Injectable({
providedIn: 'root',
})
export class GitActionsService {
private formatter = formatter();
public branchName(workPackage:WorkPackageResource):string {
return(this.formatter.branch(this.formattingInput(workPackage)));
}
public commitMessage(workPackage:WorkPackageResource):string {
return(this.formatter.commit(this.formattingInput(workPackage)));
}
public gitCommand(workPackage:WorkPackageResource):string {
return(this.formatter.command(this.formattingInput(workPackage)));
}
private formattingInput(workPackage: WorkPackageResource) {
const type = workPackage.type.name || '';
const id = workPackage.id || '';
const title = workPackage.subject;
const url = window.location.origin + workPackage.pathHelper.workPackagePath(id);
const description = workPackage.description.raw || '';
return({
id, type, title, url, description
});
}
}

@ -0,0 +1,51 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import {Component, Input, OnInit} from '@angular/core';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import { TabComponent } from 'core-app/components/wp-tabs/components/wp-tab-wrapper/tab';
@Component({
selector: 'github-tab',
templateUrl: './github-tab.template.html'
})
export class GitHubTabComponent implements OnInit, TabComponent {
@Input() public workPackage:WorkPackageResource;
public pullRequests = [];
constructor(readonly PathHelper:PathHelperService,
readonly I18n:I18nService) {
}
ngOnInit() {
this.pullRequests = [];
}
}

@ -0,0 +1,4 @@
<tab-header [workPackage]="workPackage"></tab-header>
<tab-prs [workPackage]="workPackage"
[pullRequests]="pullRequests"
></tab-prs>

@ -0,0 +1,82 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
import {Injector, NgModule} from '@angular/core';
import {HookService} from 'core-app/modules/plugins/hook-service';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import { Tab } from 'core-app/components/wp-tabs/components/wp-tab-wrapper/tab';
import {OpenprojectCommonModule} from 'core-app/modules/common/openproject-common.module';
import {GitHubTabComponent} from './github-tab/github-tab.component';
import {TabHeaderComponent} from './tab-header/tab-header.component';
import {TabPrsComponent} from './tab-prs/tab-prs.component';
import {GitActionsMenuDirective} from './git-actions-menu/git-actions-menu.directive';
import {GitActionsMenuComponent} from './git-actions-menu/git-actions-menu.component';
function displayable(work_package: WorkPackageResource): boolean {
return(!!work_package.github);
}
export function initializeGithubIntegrationPlugin(injector:Injector) {
const hooks = injector.get<HookService>(HookService);
hooks.registerWorkPackageTab(
new Tab(
GitHubTabComponent,
I18n.t('js.github_integration.work_packages.tab_name'),
'github',
displayable
)
);
}
@NgModule({
imports: [
OpenprojectCommonModule
],
providers: [
],
declarations: [
GitHubTabComponent,
TabHeaderComponent,
TabPrsComponent,
GitActionsMenuDirective,
GitActionsMenuComponent,
],
exports: [
GitHubTabComponent,
TabHeaderComponent,
TabPrsComponent,
GitActionsMenuDirective,
GitActionsMenuComponent,
]
})
export class PluginModule {
constructor(injector:Injector) {
initializeGithubIntegrationPlugin(injector);
}
}

@ -0,0 +1,43 @@
/*-- copyright
* OpenProject is an open source project management software.
* Copyright (C) 2012-2021 the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See docs/COPYRIGHT.rdoc for more details.
*/
.github-pr-header
display: flex
flex-wrap: wrap-reverse
justify-content: flex-end
border-bottom: 1px solid #ddd
margin: 1.5rem 0 0.8rem 0
padding: 0 0 0.5rem 0
.title
flex: 1 1 auto
border-bottom: 0
margin: 0
padding: 0

@ -0,0 +1,55 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import {Component, Input} from '@angular/core';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
@Component({
selector: 'tab-header',
templateUrl: './tab-header.template.html',
styleUrls: [
'./styles/tab-header.sass'
]
})
export class TabHeaderComponent {
@Input() public workPackage:WorkPackageResource;
public text = {
title: this.I18n.t('js.github_integration.tab_header.title'),
createPrButtonLabel: this.I18n.t('js.github_integration.tab_header.create_pr.label'),
createPrButtonDescription: this.I18n.t('js.github_integration.tab_header.create_pr.description'),
gitMenuLabel: this.I18n.t('js.github_integration.tab_header.copy_menu.label'),
gitMenuDescription: this.I18n.t('js.github_integration.tab_header.copy_menu.description'),
};
constructor(readonly PathHelper:PathHelperService,
readonly I18n:I18nService) {
}
}

@ -0,0 +1,21 @@
<div class="github-pr-header">
<h3 class="title">
<op-icon icon-classes="button--icon icon-merge-branch"></op-icon>
{{text.title}}
</h3>
<ul class="toolbar-items hide-when-print">
<li class="toolbar-item">
<button class="button github-git-copy"
type="button"
[attr.aria-label]="text.gitMenuDescription"
gitActionsCopyDropdown
[gitActionsCopyDropdown-workPackage]="workPackage">
<op-icon icon-classes="button--icon icon-console-light"></op-icon>
<span class="button--text"
[textContent]="text.gitMenuLabel"
aria-hidden="true"></span>
<op-icon icon-classes="button--icon icon-small icon-pulldown hidden-for-mobile"></op-icon>
</button>
</li>
</ul>
</div>

@ -0,0 +1,50 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import {Component, Input} from '@angular/core';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
@Component({
selector: 'tab-prs',
templateUrl: './tab-prs.template.html'
})
export class TabPrsComponent {
@Input() public workPackage:WorkPackageResource;
@Input() public pullRequests:WorkPackageResource[];
constructor(readonly PathHelper:PathHelperService,
readonly I18n:I18nService) {
}
public getEmptyText() {
return this.I18n.t('js.github_integration.tab_prs.empty',{ wp_id: this.workPackage.id });
}
}

@ -0,0 +1,3 @@
<ng-container *ngIf="pullRequests.length === 0">
<p [innerHTML]="getEmptyText()"></p>
</ng-container>

@ -27,6 +27,8 @@
#++ #++
require 'open_project/plugins' require 'open_project/plugins'
require_relative './patches/api/work_package_representer'
require_relative './notification_handlers' require_relative './notification_handlers'
require_relative './hook_handler' require_relative './hook_handler'
@ -52,5 +54,16 @@ module OpenProject::GithubIntegration
::OpenProject::Notifications.subscribe('github.issue_comment', ::OpenProject::Notifications.subscribe('github.issue_comment',
&NotificationHandlers.method(:issue_comment)) &NotificationHandlers.method(:issue_comment))
end end
initializer 'github.permissions' do
OpenProject::AccessControl.map do |ac_map|
ac_map.project_module(:github, {}) do |pm_map|
pm_map.permission(:show_github_content, {}, {})
end
end
end
extend_api_response(:v3, :work_packages, :work_package,
&::OpenProject::GithubIntegration::Patches::API::WorkPackageRepresenter.extension)
end end
end end

@ -41,7 +41,7 @@ module OpenProject::GithubIntegration
Rails.logger.debug "Received github webhook #{event_type} (#{event_delivery})" Rails.logger.debug "Received github webhook #{event_type} (#{event_delivery})"
return 404 unless KNOWN_EVENTS.include?(event_type) && event_delivery return 404 unless KNOWN_EVENTS.include?(event_type) && event_delivery
return 403 unless user.present? return 403 if user.blank?
payload = params[:payload] payload = params[:payload]
.permit! .permit!
@ -50,13 +50,9 @@ module OpenProject::GithubIntegration
'github_event' => event_type, 'github_event' => event_type,
'github_delivery' => event_delivery) 'github_delivery' => event_delivery)
OpenProject::Notifications.send(event_name(event_type), payload) OpenProject::Notifications.send("github.#{event_type}", payload)
200 200
end end
private def event_name(github_event_name)
"github.#{github_event_name}"
end
end end
end end

@ -30,169 +30,175 @@ module OpenProject::GithubIntegration
## ##
# Handles github-related notifications. # Handles github-related notifications.
module NotificationHandlers module NotificationHandlers
## class << self
# Handles a pull_request webhook notification. ##
# The payload looks similar to this: # Handles a pull_request webhook notification.
# { open_project_user_id: <the id of the OpenProject user in whose name the webhook is processed>, # The payload looks similar to this:
# github_event: 'pull_request', # { open_project_user_id: <the id of the OpenProject user in whose name the webhook is processed>,
# github_delivery: <randomly generated ID idenfitying a single github notification>, # github_event: 'pull_request',
# Have a look at the github documentation about the next keys: # github_delivery: <randomly generated ID idenfitying a single github notification>,
# http://developer.github.com/v3/activity/events/types/#pullrequestevent # Have a look at the github documentation about the next keys:
# action: 'opened' | 'closed' | 'synchronize' | 'reopened', # http://developer.github.com/v3/activity/events/types/#pullrequestevent
# number: <pull request number>, # action: 'opened' | 'closed' | 'synchronize' | 'reopened',
# pull_request: <details of the pull request> # number: <pull request number>,
# We observed the following keys to appear. However they are not documented by github # pull_request: <details of the pull request>
# sender: <the github user who opened a pull request> (might not appear on closed, # We observed the following keys to appear. However they are not documented by github
# synchronized, or reopened - we haven't checked) # sender: <the github user who opened a pull request> (might not appear on closed,
# repository: <the repository in action> # synchronized, or reopened - we haven't checked)
# } # repository: <the repository in action>
def self.pull_request(payload) # }
# Don't add comments on new pushes to the pull request => ignore synchronize. def pull_request(payload)
# Don't add comments about assignments and labels either. # Don't add comments on new pushes to the pull request => ignore synchronize.
ignored_actions = %w[synchronize assigned unassigned labeled unlabeled] # Don't add comments about assignments and labels either.
return if ignored_actions.include? payload['action'] ignored_actions = %w[synchronize assigned unassigned labeled unlabeled]
return if ignored_actions.include? payload['action']
comment_on_referenced_work_packages payload['pull_request']['body'], payload
rescue StandardError => e comment_on_referenced_work_packages payload['pull_request']['body'], payload
Rails.logger.error "Failed to handle pull_request event: #{e} #{e.message}" rescue StandardError => e
raise e Rails.logger.error "Failed to handle pull_request event: #{e} #{e.message}"
end raise e
end
##
# Handles an issue_comment webhook notification.
# The payload looks similar to this:
# { open_project_user_id: <the id of the OpenProject user in whose name the webhook is processed>,
# github_event: 'issue_comment',
# github_delivery: <randomly generated ID idenfitying a single github notification>,
# Have a look at the github documentation about the next keys:
# http://developer.github.com/v3/activity/events/types/#pullrequestevent
# action: 'created',
# issue: <details of the pull request/github issue>
# comment: <details of the created comment>
# We observed the following keys to appear. However they are not documented by github
# sender: <the github user who opened a pull request> (might not appear on closed,
# synchronized, or reopened - we habven't checked)
# repository: <the repository in action>
# }
def self.issue_comment(payload)
# if the comment is not associated with a PR, ignore it
return unless payload['issue']['pull_request']['html_url']
comment_on_referenced_work_packages payload['comment']['body'], payload
rescue StandardError => e
Rails.logger.error "Failed to handle issue_comment event: #{e} #{e.message}"
raise e
end
## ##
# Parses the text for links to WorkPackages and adds a comment # Handles an issue_comment webhook notification.
# to those WorkPackages depending on the payload. # The payload looks similar to this:
def self.comment_on_referenced_work_packages(text, payload) # { open_project_user_id: <the id of the OpenProject user in whose name the webhook is processed>,
user = User.find_by_id(payload['open_project_user_id']) # github_event: 'issue_comment',
wp_ids = extract_work_package_ids(text) # github_delivery: <randomly generated ID idenfitying a single github notification>,
wps = find_visible_work_packages(wp_ids, user) # Have a look at the github documentation about the next keys:
# http://developer.github.com/v3/activity/events/types/#pullrequestevent
# We may get events for pull_request type that we don't support # action: 'created',
# such as review_requested. # issue: <details of the pull request/github issue>
notes = notes_for_payload(payload) # comment: <details of the created comment>
return if notes.nil? # We observed the following keys to appear. However they are not documented by github
# sender: <the github user who opened a pull request> (might not appear on closed,
attributes = { journal_notes: notes } # synchronized, or reopened - we haven't checked)
wps.each do |wp| # repository: <the repository in action>
::WorkPackages::UpdateService # }
.new(user: user, model: wp) def issue_comment(payload)
.call(attributes.merge(send_notifications: false).symbolize_keys) # if the comment is not associated with a PR, ignore it
return unless payload['issue']['pull_request']['html_url']
comment_on_referenced_work_packages(payload['comment']['body'], payload)
rescue StandardError => e
Rails.logger.error "Failed to handle issue_comment event: #{e} #{e.message}"
raise e
end end
end
## ##
# Parses the given source string and returns a list of work_package ids # Parses the given source string and returns a list of work_package ids
# which it finds. # which it finds.
# WorkPackages are identified by their URL. # WorkPackages are identified by their URL.
# Params: # Params:
# source: string # source: string
# Returns: # Returns:
# Array<int> # Array<int>
def self.extract_work_package_ids(source) def extract_work_package_ids(source)
# matches the following things (given that `Setting.host_name` equals 'www.openproject.org') # matches the following things (given that `Setting.host_name` equals 'www.openproject.org')
# - http://www.openproject.org/wp/1234 # - http://www.openproject.org/wp/1234
# - https://www.openproject.org/wp/1234 # - https://www.openproject.org/wp/1234
# - http://www.openproject.org/work_packages/1234 # - http://www.openproject.org/work_packages/1234
# - https://www.openproject.org/subdirectory/work_packages/1234 # - https://www.openproject.org/subdirectory/work_packages/1234
# Or with the following prefix: OP# # Or with the following prefix: OP#
# e.g.,: This is a reference to OP#1234 # e.g.,: This is a reference to OP#1234
host_name = Regexp.escape(Setting.host_name) host_name = Regexp.escape(Setting.host_name)
wp_regex = /OP#(\d+)|http(?:s?):\/\/#{host_name}\/(?:\S+?\/)*(?:work_packages|wp)\/([0-9]+)/ wp_regex = /OP#(\d+)|http(?:s?):\/\/#{host_name}\/(?:\S+?\/)*(?:work_packages|wp)\/([0-9]+)/
source.scan(wp_regex) source.scan(wp_regex)
.map { |first, second| (first || second).to_i } .map { |first, second| (first || second).to_i }
.select { |el| el > 0 } .select { |el| el > 0 }
.uniq .uniq
end end
## ##
# Given a list of work package ids this methods returns all work packages that match those ids # Given a list of work package ids this methods returns all work packages that match those ids
# and are visible by the given user. # and are visible by the given user.
# Params: # Params:
# - Array<int>: An list of WorkPackage ids # - Array<int>: An list of WorkPackage ids
# - User: The user who may (or may not) see those WorkPackages # - User: The user who may (or may not) see those WorkPackages
# Returns: # Returns:
# - Array<WorkPackage> # - Array<WorkPackage>
def self.find_visible_work_packages(ids, user) def find_visible_work_packages(ids, user)
ids.collect do |id| WorkPackage.includes(:project)
WorkPackage.includes(:project).find_by_id(id) .where(id: ids)
end.select do |wp| .select { |wp| user.allowed_to?(:add_work_package_notes, wp.project) }
wp.present? && user.allowed_to?(:add_work_package_notes, wp.project)
end end
end
## private
# Find a matching translation for the action specified in the payload.
def self.notes_for_payload(payload) ##
case payload['github_event'] # Parses the text for links to WorkPackages and adds a comment
when 'pull_request' # to those WorkPackages depending on the payload.
notes_for_pull_request_payload(payload) def comment_on_referenced_work_packages(text, payload)
when 'issue_comment' user = User.find_by(id: payload['open_project_user_id'])
notes_for_issue_comment_payload(payload) wp_ids = extract_work_package_ids(text)
wps = find_visible_work_packages(wp_ids, user)
# We may get events for pull_request type that we don't support
# such as review_requested.
notes = notes_for_payload(payload)
return if notes.nil?
attributes = { journal_notes: notes }
wps.each do |wp|
::WorkPackages::UpdateService
.new(user: user, model: wp)
.call(attributes.merge(send_notifications: false).symbolize_keys)
end
end end
end
def self.notes_for_pull_request_payload(payload) ##
key = { # Find a matching translation for the action specified in the payload.
'opened' => 'opened', def notes_for_payload(payload)
'reopened' => 'opened', case payload['github_event']
'closed' => 'closed', when 'pull_request'
'edited' => 'referenced', notes_for_pull_request_payload(payload)
'referenced' => 'referenced' when 'issue_comment'
}[payload['action']] notes_for_issue_comment_payload(payload)
end
# a closed pull request which has been merged end
# deserves a different label :)
key = 'merged' if key == 'closed' && payload['pull_request']['merged']
return nil unless key
I18n.t("github_integration.pull_request_#{key}_comment",
pr_number: payload['number'],
pr_title: payload['pull_request']['title'],
pr_url: payload['pull_request']['html_url'],
repository: payload['pull_request']['base']['repo']['full_name'],
repository_url: payload['pull_request']['base']['repo']['html_url'],
github_user: payload['sender']['login'],
github_user_url: payload['sender']['html_url'])
end
def self.notes_for_issue_comment_payload(payload) # rubocop:disable Metrics/AbcSize
return nil unless payload['action'] == 'created' def notes_for_pull_request_payload(payload)
key = {
I18n.t("github_integration.pull_request_referenced_comment", 'opened' => 'opened',
pr_number: payload['issue']['number'], 'reopened' => 'opened',
pr_title: payload['issue']['title'], 'closed' => 'closed',
pr_url: payload['comment']['html_url'], 'edited' => 'referenced',
repository: payload['repository']['full_name'], 'referenced' => 'referenced'
repository_url: payload['repository']['html_url'], }[payload['action']]
github_user: payload['comment']['user']['login'],
github_user_url: payload['comment']['user']['html_url']) # a closed pull request which has been merged
# deserves a different label :)
key = 'merged' if key == 'closed' && payload['pull_request']['merged']
return nil unless key
I18n.t("github_integration.pull_request_#{key}_comment",
pr_number: payload['number'],
pr_title: payload['pull_request']['title'],
pr_url: payload['pull_request']['html_url'],
repository: payload['pull_request']['base']['repo']['full_name'],
repository_url: payload['pull_request']['base']['repo']['html_url'],
github_user: payload['sender']['login'],
github_user_url: payload['sender']['html_url'])
end
# rubocop:enable Metrics/AbcSize
# rubocop:disable Metrics/AbcSize
def notes_for_issue_comment_payload(payload)
return nil unless payload['action'] == 'created'
I18n.t("github_integration.pull_request_referenced_comment",
pr_number: payload['issue']['number'],
pr_title: payload['issue']['title'],
pr_url: payload['comment']['html_url'],
repository: payload['repository']['full_name'],
repository_url: payload['repository']['html_url'],
github_user: payload['comment']['user']['login'],
github_user_url: payload['comment']['user']['html_url'])
end
# rubocop:enable Metrics/AbcSize
end end
end end
end end

@ -0,0 +1,21 @@
module OpenProject::GithubIntegration
module Patches
module API
module WorkPackageRepresenter
module_function
def extension
->(*) do
link :github,
cache_if: -> { current_user.allowed_to?(:show_github_content, represented.project) } do
{
href: "#{work_package_path(id: represented.id)}/tabs/github",
title: "github"
}
end
end
end
end
end
end
end

@ -0,0 +1,115 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require_relative '../support/pages/work_package_github_tab'
describe 'Open the GitHub tab', type: :feature, js: true do
let(:user) do
FactoryBot.create(:user,
member_in_project: project,
member_through_role: role)
end
let(:role) do
FactoryBot.create(:role,
permissions: %i(view_work_packages
add_work_package_notes
show_github_content))
end
let(:project) { FactoryBot.create :project }
let(:work_package) { FactoryBot.create(:work_package, project: project) }
let(:github_tab) { Pages::GitHubTab.new(work_package.id) }
shared_examples_for "a github tab" do
before do
login_as(user)
work_package
end
# compares the clipboard content by drafting a new comment, pressing ctrl+v and
# comparing the pasted content against the provided text
def expect_clipboard_content(text)
work_package_page.switch_to_tab(tab: 'activity')
work_package_page.trigger_edit_comment
work_package_page.update_comment(' ') # ensure the comment editor is fully loaded
github_tab.paste_clipboard_content
expect(work_package_page.add_comment_container).to have_content(text)
work_package_page.switch_to_tab(tab: 'github')
end
it 'show the github tab when the user is allowed to see it' do
work_package_page.visit!
work_package_page.switch_to_tab(tab: 'github')
expect(page).to have_content('There are no pull requests')
expect(page).to have_content("Link an existing PR by using the code OP##{work_package.id}")
github_tab.git_actions_menu_button.click
github_tab.git_actions_copy_button.click
expect(page).to have_text('Copied!')
expect_clipboard_content("#{work_package.type.name.downcase}/#{work_package.id}-workpackage-no-#{work_package.id}")
end
describe 'when the user does not have the permissions to see the github tab' do
let(:role) do
FactoryBot.create(:role,
permissions: %i(view_work_packages
add_work_package_notes))
end
it 'does not show the github tab' do
work_package_page.visit!
github_tab.expect_tab_not_present
end
end
describe 'when the github integration is not enabled for the project' do
let(:project) { FactoryBot.create(:project, disable_modules: 'github') }
it 'does not show the github tab' do
work_package_page.visit!
github_tab.expect_tab_not_present
end
end
end
describe 'work package full view' do
let(:work_package_page) { Pages::FullWorkPackage.new(work_package) }
it_behaves_like 'a github tab'
end
describe 'work package split view' do
let(:work_package_page) { Pages::SplitWorkPackage.new(work_package) }
it_behaves_like 'a github tab'
end
end

@ -26,45 +26,53 @@
# See docs/COPYRIGHT.rdoc for more details. # See docs/COPYRIGHT.rdoc for more details.
#++ #++
require File.expand_path('../spec_helper', __dir__) # rubocop:disable RSpec/ExampleLength
require File.expand_path('../../../spec_helper', __dir__)
describe OpenProject::GithubIntegration do describe OpenProject::GithubIntegration::HookHandler do
before do before do
allow(Setting).to receive(:host_name).and_return('example.net') allow(Setting).to receive(:host_name).and_return('example.net')
end end
describe 'with sane set-up' do let(:user) { FactoryBot.create(:user) }
let(:user) { FactoryBot.create(:user) } let(:role) do
let(:role) do FactoryBot.create(:role,
FactoryBot.create(:role, permissions: %i[view_work_packages add_work_package_notes])
permissions: %i[view_work_packages add_work_package_notes]) end
end let(:statuses) { (1..5).map { |_i| FactoryBot.create(:status) } }
let(:statuses) { (1..5).map { |_i| FactoryBot.create(:status) } } let(:priority) { FactoryBot.create :priority, is_default: true }
let(:priority) { FactoryBot.create :priority, is_default: true } let(:status) { statuses[0] }
let(:status) { statuses[0] } let(:project) do
let(:project) do FactoryBot.create(:project).tap do |p|
FactoryBot.create(:project).tap do |p| p.add_member(user, role).save
p.add_member(user, role).save
end
end
let(:project_without_permission) { FactoryBot.create(:project) }
let(:wp1) do
FactoryBot.create :work_package, project: project
end
let(:wp2) do
FactoryBot.create :work_package, project: project
end
let(:wp3) do
FactoryBot.create :work_package,
project: project_without_permission
end end
let(:wp4) do end
FactoryBot.create :work_package, let(:project_without_permission) { FactoryBot.create(:project) }
project: project_without_permission let(:wp1) do
FactoryBot.create :work_package, project: project
end
let(:wp2) do
FactoryBot.create :work_package, project: project
end
let(:wp3) do
FactoryBot.create :work_package,
project: project_without_permission
end
let(:wp4) do
FactoryBot.create :work_package,
project: project_without_permission
end
let(:wps) { [wp1, wp2, wp3, wp4] }
context 'when receiving a pull request webhook' do
let(:environment) do
{
'HTTP_X_GITHUB_EVENT' => 'pull_request',
'HTTP_X_GITHUB_DELIVERY' => 'test delivery'
}
end end
let(:wps) { [wp1, wp2, wp3, wp4] }
it "should handle the pull_request creation payload" do it "handles the pull_request creation payload" do
params = ActionController::Parameters.new( params = ActionController::Parameters.new(
payload: { payload: {
'action' => 'opened', 'action' => 'opened',
@ -91,15 +99,10 @@ describe OpenProject::GithubIntegration do
} }
) )
environment = {
'HTTP_X_GITHUB_EVENT' => 'pull_request',
'HTTP_X_GITHUB_DELIVERY' => 'test delivery'
}
journal_count = wps.map { |wp| wp.journals.count } journal_count = wps.map { |wp| wp.journals.count }
OpenProject::GithubIntegration::HookHandler.new.process('github', OpenStruct.new(env: environment), params, user) described_class.new.process('github', OpenStruct.new(env: environment), params, user)
[wp1, wp2, wp3, wp4].map { |x| x.reload } [wp1, wp2, wp3, wp4].map(&:reload)
expect(wp1.journals.count).to equal(journal_count[0] + 1) expect(wp1.journals.count).to equal(journal_count[0] + 1)
expect(wp2.journals.count).to equal(journal_count[1] + 1) expect(wp2.journals.count).to equal(journal_count[1] + 1)
@ -109,7 +112,7 @@ describe OpenProject::GithubIntegration do
expect(wp1.journals.last.notes).to include('PR Opened') expect(wp1.journals.last.notes).to include('PR Opened')
end end
it "should handle the pull_request close payload" do it "handles the pull_request close payload" do
params = ActionController::Parameters.new( params = ActionController::Parameters.new(
payload: { payload: {
'action' => 'closed', 'action' => 'closed',
@ -136,15 +139,10 @@ describe OpenProject::GithubIntegration do
} }
) )
environment = {
'HTTP_X_GITHUB_EVENT' => 'pull_request',
'HTTP_X_GITHUB_DELIVERY' => 'test delivery'
}
journal_count = wps.map { |wp| wp.journals.count } journal_count = wps.map { |wp| wp.journals.count }
OpenProject::GithubIntegration::HookHandler.new.process('github', OpenStruct.new(env: environment), params, user) described_class.new.process('github', OpenStruct.new(env: environment), params, user)
[wp1, wp2, wp3, wp4].map { |x| x.reload } [wp1, wp2, wp3, wp4].map(&:reload)
expect(wp1.journals.count).to eq(journal_count[0] + 1) expect(wp1.journals.count).to eq(journal_count[0] + 1)
expect(wp2.journals.count).to eq(journal_count[1] + 1) expect(wp2.journals.count).to eq(journal_count[1] + 1)
@ -154,7 +152,7 @@ describe OpenProject::GithubIntegration do
expect(wp1.journals.last.notes).to include('PR Closed') expect(wp1.journals.last.notes).to include('PR Closed')
end end
it "should handle the pull_request merged payload" do it "handles the pull_request merged payload" do
params = ActionController::Parameters.new( params = ActionController::Parameters.new(
payload: { payload: {
'action' => 'closed', 'action' => 'closed',
@ -182,15 +180,10 @@ describe OpenProject::GithubIntegration do
} }
) )
environment = {
'HTTP_X_GITHUB_EVENT' => 'pull_request',
'HTTP_X_GITHUB_DELIVERY' => 'test delivery'
}
journal_count = wps.map { |wp| wp.journals.count } journal_count = wps.map { |wp| wp.journals.count }
OpenProject::GithubIntegration::HookHandler.new.process('github', OpenStruct.new(env: environment), params, user) described_class.new.process('github', OpenStruct.new(env: environment), params, user)
[wp1, wp2, wp3, wp4].map { |x| x.reload } [wp1, wp2, wp3, wp4].map(&:reload)
expect(wp1.journals.count).to equal(journal_count[0] + 1) expect(wp1.journals.count).to equal(journal_count[0] + 1)
expect(wp2.journals.count).to equal(journal_count[1] + 1) expect(wp2.journals.count).to equal(journal_count[1] + 1)
@ -199,8 +192,17 @@ describe OpenProject::GithubIntegration do
expect(wp1.journals.last.notes).to include('PR Merged') expect(wp1.journals.last.notes).to include('PR Merged')
end end
end
it "should handle the pull_request comment creation payload" do context 'when receiving an issue_comment webhook' do
let(:environment) do
{
'HTTP_X_GITHUB_EVENT' => 'issue_comment',
'HTTP_X_GITHUB_DELIVERY' => 'test delivery'
}
end
it "handles the pull_request comment creation payload" do
params = ActionController::Parameters.new( params = ActionController::Parameters.new(
payload: { payload: {
'action' => 'created', 'action' => 'created',
@ -228,35 +230,13 @@ describe OpenProject::GithubIntegration do
'full_name' => 'full/name', 'full_name' => 'full/name',
'html_url' => 'http://pull.request' 'html_url' => 'http://pull.request'
} }
},
'comment' => {
'body' => "Fixes http://example.net/wp/#{wp1.id} and " +
"https://example.net/work_packages/#{wp2.id} and " +
"http://example.net/subdir/wp/#{wp3.id} and " +
"https://example.net/subdir/work_packages/#{wp4.id}.",
'html_url' => 'http://comment.url',
'user' => {
'login' => 'github_login',
'html_url' => 'http://user.name'
}
},
'sender' => {
},
'repository' => {
'full_name' => 'full/name',
'html_url' => 'http://pull.request'
} }
) )
environment = {
'HTTP_X_GITHUB_EVENT' => 'issue_comment',
'HTTP_X_GITHUB_DELIVERY' => 'test delivery'
}
journal_count = wps.map { |wp| wp.journals.count } journal_count = wps.map { |wp| wp.journals.count }
OpenProject::GithubIntegration::HookHandler.new.process('github', OpenStruct.new(env: environment), params, user) described_class.new.process('github', OpenStruct.new(env: environment), params, user)
[wp1, wp2, wp3, wp4].map { |x| x.reload } [wp1, wp2, wp3, wp4].map(&:reload)
expect(wp1.journals.count).to equal(journal_count[0] + 1) expect(wp1.journals.count).to equal(journal_count[0] + 1)
expect(wp2.journals.count).to equal(journal_count[1] + 1) expect(wp2.journals.count).to equal(journal_count[1] + 1)
@ -267,3 +247,4 @@ describe OpenProject::GithubIntegration do
end end
end end
end end
# rubocop:enable RSpec/ExampleLength

@ -26,11 +26,11 @@
# See docs/COPYRIGHT.rdoc for more details. # See docs/COPYRIGHT.rdoc for more details.
#++ #++
require File.expand_path('../spec_helper', __dir__) require File.expand_path('../../../spec_helper', __dir__)
describe OpenProject::GithubIntegration::HookHandler do describe OpenProject::GithubIntegration::HookHandler do
describe '#process' do describe '#process' do
let(:handler) { OpenProject::GithubIntegration::HookHandler.new } let(:handler) { described_class.new }
let(:hook) { 'fake hook' } let(:hook) { 'fake hook' }
let(:params) { ActionController::Parameters.new({ payload: { 'fake' => 'value' } }) } let(:params) { ActionController::Parameters.new({ payload: { 'fake' => 'value' } }) }
let(:environment) do let(:environment) do
@ -39,7 +39,7 @@ describe OpenProject::GithubIntegration::HookHandler do
end end
let(:request) { OpenStruct.new(env: environment) } let(:request) { OpenStruct.new(env: environment) }
let(:user) do let(:user) do
user = double(User) user = instance_double(User)
allow(user).to receive(:id).and_return(12) allow(user).to receive(:id).and_return(12)
user user
end end
@ -50,7 +50,7 @@ describe OpenProject::GithubIntegration::HookHandler do
'HTTP_X_GITHUB_DELIVERY' => 'veryuniqueid2' } 'HTTP_X_GITHUB_DELIVERY' => 'veryuniqueid2' }
end end
it 'should return 404' do it 'returns 404' do
result = handler.process(hook, request, params, user) result = handler.process(hook, request, params, user)
expect(result).to eq(404) expect(result).to eq(404)
end end
@ -59,28 +59,32 @@ describe OpenProject::GithubIntegration::HookHandler do
context 'with a supported event and without user' do context 'with a supported event and without user' do
let(:user) { nil } let(:user) { nil }
it 'should return 403' do it 'returns 403' do
result = handler.process(hook, request, params, user) result = handler.process(hook, request, params, user)
expect(result).to eq(403) expect(result).to eq(403)
end end
end end
context 'with a supported event and a user' do context 'with a supported event and a user' do
let(:expected_params) do
{
'fake' => 'value',
'open_project_user_id' => 12,
'github_event' => 'pull_request',
'github_delivery' => 'veryuniqueid'
}
end
before do before do
allow(OpenProject::Notifications).to receive(:send) allow(OpenProject::Notifications).to receive(:send)
end end
it 'should send a notification with the correct contents' do it 'sends a notification with the correct contents' do
expect(OpenProject::Notifications).to receive(:send).with("github.pull_request", {
'fake' => 'value',
'open_project_user_id' => 12,
'github_event' => 'pull_request',
'github_delivery' => 'veryuniqueid'
})
handler.process(hook, request, params, user) handler.process(hook, request, params, user)
expect(OpenProject::Notifications).to have_received(:send).with("github.pull_request", expected_params)
end end
it 'should return 200' do it 'returns 200' do
result = handler.process(hook, request, params, user) result = handler.process(hook, request, params, user)
expect(result).to eq(200) expect(result).to eq(200)
end end

@ -26,56 +26,56 @@
# See docs/COPYRIGHT.rdoc for more details. # See docs/COPYRIGHT.rdoc for more details.
#++ #++
require File.expand_path('../spec_helper', __dir__) require File.expand_path('../../../spec_helper', __dir__)
describe OpenProject::GithubIntegration do describe OpenProject::GithubIntegration::NotificationHandlers do
before do before do
allow(Setting).to receive(:host_name).and_return('example.net') allow(Setting).to receive(:host_name).and_return('example.net')
end end
describe '.extract_work_package_ids' do describe '.extract_work_package_ids' do
it 'should return an empty array for an empty source' do it 'returns an empty array for an empty source' do
result = OpenProject::GithubIntegration::NotificationHandlers.send( result = described_class.send(
:extract_work_package_ids, '' :extract_work_package_ids, ''
) )
expect(result).to eql([]) expect(result).to eql([])
end end
it 'should find a plain work package url' do it 'finds a work package by code' do
source = "Blabla\nOP#1234\n" source = "Blabla\nOP#1234\n"
result = OpenProject::GithubIntegration::NotificationHandlers.send( result = described_class.send(
:extract_work_package_ids, source :extract_work_package_ids, source
) )
expect(result).to eql([1234]) expect(result).to eql([1234])
end end
it 'should find a plain work package url' do it 'finds a plain work package url' do
source = 'Blabla\nhttps://example.net/work_packages/234\n' source = 'Blabla\nhttps://example.net/work_packages/234\n'
result = OpenProject::GithubIntegration::NotificationHandlers.send( result = described_class.send(
:extract_work_package_ids, source :extract_work_package_ids, source
) )
expect(result).to eql([234]) expect(result).to eql([234])
end end
it 'should find a work package url in markdown link syntax' do it 'finds a work package url in markdown link syntax' do
source = 'Blabla\n[WP 234](https://example.net/work_packages/234)\n' source = 'Blabla\n[WP 234](https://example.net/work_packages/234)\n'
result = OpenProject::GithubIntegration::NotificationHandlers.send( result = described_class.send(
:extract_work_package_ids, source :extract_work_package_ids, source
) )
expect(result).to eql([234]) expect(result).to eql([234])
end end
it 'should find multiple work package urls' do it 'finds multiple work package urls' do
source = "I reference https://example.net/work_packages/434\n and Blabla\n[WP 234](https://example.net/wp/234)\n" source = "I reference https://example.net/work_packages/434\n and Blabla\n[WP 234](https://example.net/wp/234)\n"
result = OpenProject::GithubIntegration::NotificationHandlers.send( result = described_class.send(
:extract_work_package_ids, source :extract_work_package_ids, source
) )
expect(result).to eql([434, 234]) expect(result).to eql([434, 234])
end end
it 'should find multiple occurences of a work package only once' do it 'finds multiple occurences of a work package only once' do
source = "I reference https://example.net/work_packages/434\n and Blabla\n[WP 234](https://example.net/work_packages/434)\n" source = "I reference https://example.net/work_packages/434\n and Blabla\n[WP 234](https://example.net/work_packages/434)\n"
result = OpenProject::GithubIntegration::NotificationHandlers.send( result = described_class.send(
:extract_work_package_ids, source :extract_work_package_ids, source
) )
expect(result).to eql([434]) expect(result).to eql([434])
@ -83,49 +83,41 @@ describe OpenProject::GithubIntegration do
end end
describe '.find_visible_work_packages' do describe '.find_visible_work_packages' do
let(:user) do let(:user) { instance_double(User) }
user = double('A User') let(:visible_wp) { instance_double(WorkPackage, project: :project_with_permissions) }
expect(user).to receive(:allowed_to?) { |permission, project| let(:invisible_wp) { instance_double(WorkPackage, project: :project_without_permissions) }
expect(permission).to equal(:add_work_package_notes)
project == :project_with_permissions
}.at_least(:once)
user
end
let(:visible_wp) do
wp = double('Visible Work Package')
allow(wp).to receive(:project).and_return(:project_with_permissions)
wp
end
let(:invisible_wp) do
wp = double('Invisible Work Package')
allow(wp).to receive(:project).and_return(:project_without_permissions)
wp
end
before do
allow(WorkPackage).to receive(:includes).and_return(WorkPackage)
allow(WorkPackage).to receive(:find_by_id) { |id| wps[id] }
end
shared_examples_for 'GithubIntegration.find_visible_work_packages' do shared_examples_for 'GithubIntegration.find_visible_work_packages' do
subject do subject(:find_visible_work_packages) do
OpenProject::GithubIntegration::NotificationHandlers.send( described_class.send(
:find_visible_work_packages, ids, user :find_visible_work_packages, ids, user
) )
end end
it { expect(subject).to eql(expected) }
before do
allow(WorkPackage).to receive(:includes).and_return(WorkPackage)
allow(WorkPackage).to receive(:where).with(id: ids).and_return(work_packages)
allow(user).to receive(:allowed_to?) { |permission, project|
(permission == :add_work_package_notes) && (project == :project_with_permissions)
}
end
it 'processes the notification' do
expect(find_visible_work_packages).to eql(expected)
expect(user).to have_received(:allowed_to?).exactly(ids.length).times
end
end end
describe 'should find an existing work package' do describe 'should find an existing work package' do
let(:wps) { [visible_wp] } let(:work_packages) { [visible_wp] }
let(:ids) { [0] } let(:ids) { [0] }
let(:expected) { wps } let(:expected) { work_packages }
it_behaves_like 'GithubIntegration.find_visible_work_packages' it_behaves_like 'GithubIntegration.find_visible_work_packages'
end end
describe 'should not find a non-existing work package' do describe 'should not find a non-existing work package' do
let(:wps) { [invisible_wp] } let(:work_packages) { [invisible_wp] }
let(:ids) { [0] } let(:ids) { [0] }
let(:expected) { [] } let(:expected) { [] }
@ -133,15 +125,15 @@ describe OpenProject::GithubIntegration do
end end
describe 'should find multiple existing work packages' do describe 'should find multiple existing work packages' do
let(:wps) { [visible_wp, visible_wp] } let(:work_packages) { [visible_wp, visible_wp] }
let(:ids) { [0, 1] } let(:ids) { [0, 1] }
let(:expected) { wps } let(:expected) { work_packages }
it_behaves_like 'GithubIntegration.find_visible_work_packages' it_behaves_like 'GithubIntegration.find_visible_work_packages'
end end
describe 'should not find work package which the user shall not see' do describe 'should not find work package which the user shall not see' do
let(:wps) { [visible_wp, invisible_wp, visible_wp, invisible_wp] } let(:work_packages) { [visible_wp, invisible_wp, visible_wp, invisible_wp] }
let(:ids) { [0, 1, 2, 3] } let(:ids) { [0, 1, 2, 3] }
let(:expected) { [visible_wp, visible_wp] } let(:expected) { [visible_wp, visible_wp] }
@ -150,20 +142,19 @@ describe OpenProject::GithubIntegration do
end end
describe '.issue_comment' do describe '.issue_comment' do
context 'for a non-pull request issue' do context 'when an issue request is not a pull request' do
let(:payload) do let(:payload) do
{ 'action' => 'created', { 'action' => 'created',
'issue' => { 'pull_request' => { 'html_url' => nil } } } 'issue' => { 'pull_request' => { 'html_url' => nil } } }
end end
before do before do
expect(OpenProject::GithubIntegration::NotificationHandlers).not_to receive( allow(WorkPackages::UpdateService).to receive(:new)
:comment_on_referenced_work_packages
)
end end
it 'should do nothing' do it 'does not add comments to work packages' do
OpenProject::GithubIntegration::NotificationHandlers.issue_comment(payload) described_class.issue_comment(payload)
expect(WorkPackages::UpdateService).not_to have_received(:new)
end end
end end
end end
@ -173,13 +164,12 @@ describe OpenProject::GithubIntegration do
let(:payload) { { 'action' => 'synchronize' } } let(:payload) { { 'action' => 'synchronize' } }
before do before do
expect(OpenProject::GithubIntegration::NotificationHandlers).not_to receive( allow(WorkPackages::UpdateService).to receive(:new)
:comment_on_referenced_work_packages
)
end end
it 'should do nothing' do it 'does not add comments to work packages' do
OpenProject::GithubIntegration::NotificationHandlers.pull_request(payload) described_class.pull_request(payload)
expect(WorkPackages::UpdateService).not_to have_received(:new)
end end
end end
end end

@ -0,0 +1,68 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'rbconfig'
require 'support/pages/page'
module Pages
class GitHubTab < Page
attr_reader :work_package_id
def initialize(work_package_id)
super()
@work_package_id = work_package_id
end
def path
"/work_packages/#{work_package_id}/tabs/github"
end
def git_actions_menu_button
find('.github-git-copy:not([disabled])', text: 'Git')
end
def git_actions_copy_button
find('.git-actions-menu .copy-button:not([disabled])')
end
def paste_clipboard_content
meta_key = osx? ? :command : :control
page.send_keys(meta_key, 'v')
end
def expect_tab_not_present
expect(page).not_to have_selector('.tabrow li', text: 'GITHUB')
end
private
def osx?
RbConfig::CONFIG['host_os'] =~ /darwin/
end
end
end

@ -0,0 +1 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="m445.80273 56.996094c-126.74933.0028-253.49876-.0069-380.248042.01172-22.530291 2.718871-38.158694 18.671975-37.839844 39.888672.0036 105.392914-.0085 210.786044.01292 316.178824 2.664007 22.57046 18.647075 38.25228 39.889029 37.9286 126.450357-.003 252.900857.007 379.351117-.0116 22.59362-2.50826 38.3561-18.69231 38.03601-39.88836-.00066-105.39111.009-210.78238-.0141-316.173373-2.61333-22.267761-18.20963-37.929138-39.18708-37.934441zm-379.230464 34.457031c126.657754.0028 253.315734-.0069 379.973354.01171 5.24232 1.269893 3.90388 6.06231 4.0013 10.022665-.003 103.68601.007 207.37225-.0117 311.05812-1.27108 5.24202-6.06174 3.90387-10.02266 4.0013-124.77911-.003-249.55844.007-374.337418-.0116-5.217882-1.25983-3.903003-5.9801-4.003205-9.92278.0027-103.71935-.0067-207.43891.0116-311.158122.0091-1.980636 1.608777-3.826098 3.619104-3.951152.255527-.03488.512198-.0437.769688-.0501zm32.035156 56.187495 79.541018 103.78126-78.10547 101.61914 26.81836 21.63476 95.53125-123.2207-96.9336-125.4082zm135.416018 230.19532h162.60547v-34.45703h-162.60547z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m235.875 101.32227 100.72778 79.0332-.44141-60.86328c23.13659.73788 37.00455 10.98752 39.00504 33.16882s4.71836 204.54797 4.71836 204.54797c-29.37044 10.15753-47.70629 27.50612-47.5757 60.67021s30.59599 60.71692 64.08456 60.87053c33.48858.15361 61.1051-29.64185 61.76857-58.98025.66348-29.3384-12.79095-51.46209-43.91415-61.70502 0 0-1.19223-201.97801-7.85241-231.43227-6.66018-29.45427-40.12229-43.090743-70.54286-43.899489l-.44922-60.515894zm-174.779828-1.29862c.150864 29.57707 17.36017 56.06187 47.234908 64.43143l-1.29492 191.86133c-29.296165 10.39833-46.487158 26.82563-46.289888 63.30668.197271 36.48105 36.713369 60.86945 65.601168 60.62955 28.8878-.23989 59.57587-28.61509 59.96256-59.90126.38668-31.28618-12.46396-52.64567-44.19572-63.2381.4006-64.24283.33418-128.48745.68945-192.73047 22.52072-7.20135 43.67994-30.84782 43.94056-62.54208.26062-31.694257-29.03958-60.771589-62.93381-61.190419-33.894227-.41883-62.865172 29.79627-62.714308 59.373339zm65.217328-24.965056c15.03882.470687 26.31439 13.676635 26.26445 27.945606-.0499 14.26897-9.99282 29.31808-27.21172 29.27119s-29.371506-13.38125-29.414546-29.12376c-.04304-15.742514 15.322996-28.563723 30.361816-28.093036zm25.48579 344.368276c-.51677 14.9946-14.01128 26.70158-27.78729 26.63085-13.77601-.0707-29.011453-11.12196-28.98562-27.07515.02583-15.9532 11.76572-30.13687 28.90423-30.18299 17.13851-.0461 28.38545 15.63269 27.86868 30.62729zm273.86966-.75504c-.19737 16.6479-13.36729 28.58635-28.97657 28.33208-15.60929-.25427-29.27678-13.76284-29.00027-29.53405s13.64733-27.63791 29.87373-27.64289c16.2264-.005 28.30048 12.19696 28.10311 28.84486z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Loading…
Cancel
Save