├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.sbt ├── project ├── build.properties └── plugins.sbt └── src └── main ├── resources └── update │ ├── gitbucket-notifications_1.0.xml │ ├── gitbucket-notifications_1.1.xml │ └── gitbucket-notifications_1.3.xml ├── scala ├── Plugin.scala └── gitbucket │ └── notifications │ ├── controller │ └── NotificationsController.scala │ ├── model │ ├── IssueNotification.scala │ ├── NotificationsAccount.scala │ ├── Profile.scala │ └── Watch.scala │ ├── service │ ├── NotificationsHook.scala │ └── NotificationsService.scala │ └── view │ └── helpers.scala └── twirl └── gitbucket └── notifications ├── issue.scala.html ├── settings.scala.html └── watch.scala.html /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @takezoe @xuwei-k 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | java: [11] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Cache 14 | uses: actions/cache@v4 15 | env: 16 | cache-name: cache-sbt-libs 17 | with: 18 | path: | 19 | ~/.ivy2/cache 20 | ~/.sbt 21 | ~/.coursier 22 | key: build-${{ env.cache-name }}-${{ hashFiles('build.sbt') }} 23 | - name: Set up JDK 24 | uses: actions/setup-java@v4 25 | with: 26 | distribution: temurin 27 | java-version: ${{ matrix.java }} 28 | - uses: sbt/setup-sbt@v1 29 | - name: Run tests 30 | run: | 31 | git clone https://github.com/gitbucket/gitbucket.git 32 | cd gitbucket 33 | sbt publishLocal 34 | cd ../ 35 | sbt test 36 | - name: Assembly 37 | run: sbt assembly 38 | - name: Upload artifacts 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: gitbucket-notifications-plugin-java${{ matrix.java }}-${{ github.sha }} 42 | path: ./target/scala-2.13/*.jar 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | # sbt specific 5 | dist/* 6 | target/ 7 | lib_managed/ 8 | src_managed/ 9 | project/boot/ 10 | project/plugins/project/ 11 | .bsp/ 12 | 13 | # Scala-IDE specific 14 | .scala_dependencies 15 | .classpath 16 | .project 17 | .cache 18 | .settings 19 | 20 | # IntelliJ specific 21 | .idea/ 22 | .idea_modules/ 23 | 24 | # Ensime 25 | .ensime 26 | .ensime_cache/ 27 | 28 | # Metals 29 | .bloop/ 30 | .metals/ 31 | .vscode/ 32 | **/metals.sbt 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitbucket-notifications-plugin [![build](https://github.com/gitbucket/gitbucket-notifications-plugin/workflows/build/badge.svg?branch=master)](https://github.com/gitbucket/gitbucket-notifications-plugin/actions?query=workflow%3Abuild+branch%3Amaster) 2 | 3 | This plug-in provides notifications feature on GitBucket. 4 | 5 | | Plugin version | GitBucket version | 6 | |:---------------|:------------------| 7 | | 1.11.x | 4.37.x | 8 | | 1.10.x | 4.35.x | 9 | | 1.9.x | 4.34.x | 10 | | 1.8.x | 4.32.x - 4.33.x | 11 | | 1.7.x | 4.30.x - 4.31.x | 12 | | 1.6.x | 4.26.x - 4.29.x | 13 | | 1.5.x | 4.23.x - 4.25.x | 14 | | 1.4.x | 4.19.x - 4.22.x | 15 | | 1.2.x, 1.3.x | 4.17.x - 4.18.x | 16 | | 1.1.x | 4.16.x | 17 | | 1.0.x | 4.15.x | 18 | 19 | ## Features 20 | 21 | The current version of plug-in provides features such as: 22 | 23 | - Pre-included notifications (see below) 24 | - Watching repositories 25 | - Subscribing to issues 26 | 27 | ### Pre-included notifications 28 | 29 | GitBucket can send email notifications to users if this feature is enabled by an administrator. 30 | 31 | You'll automatically receive these notifications when: 32 | 33 | - Opened issues (new issues, new pull requests) 34 | - When a record is inserted into the ```ISSUE``` table 35 | - Comments 36 | - Among the records in the ```ISSUE_COMMENT``` table, them to be counted as a comment (i.e. the record ```ACTION``` column value is "comment" or "close_comment" or "reopen_comment") are inserted 37 | - Updated state (close, reopen, merge) 38 | - When the ```CLOSED``` column value is updated 39 | 40 | Notified users are as follows: 41 | 42 | - individual repository's owner 43 | - group members of group repository 44 | - collaborators 45 | - participants 46 | 47 | However, the person performing the operation is excluded from the notification. 48 | 49 | ### Watching repositories 50 | 51 | When you watch a repository, you get notifications. 52 | You can unwatch a repository to receive notifications when participating. 53 | If you won't receive any notifications, you select Ignoring. 54 | 55 | ### Subscribing to issues 56 | 57 | You can subscribe or unsubscribe to individual issues. 58 | 59 | ## Build from source 60 | 61 | Run `sbt assembly` and copy generated `/target/scala-2.13/gitbucket-notifications-plugin-x.x.x.jar` to `~/.gitbucket/plugins/` (If the directory does not exist, create it by hand before copying the jar), or just run `sbt install`. 62 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "gitbucket-notifications-plugin" 2 | organization := "io.github.gitbucket" 3 | version := "1.11.0" 4 | scalaVersion := "2.13.16" 5 | gitbucketVersion := "4.42.1" 6 | scalacOptions := Seq("-deprecation", "-language:postfixOps", "-feature") 7 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.1 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("io.github.gitbucket" % "sbt-gitbucket-plugin" % "1.6.0") -------------------------------------------------------------------------------- /src/main/resources/update/gitbucket-notifications_1.0.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/main/resources/update/gitbucket-notifications_1.1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/main/resources/update/gitbucket-notifications_1.3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/scala/Plugin.scala: -------------------------------------------------------------------------------- 1 | import gitbucket.core.controller.Context 2 | import gitbucket.core.model.Issue 3 | import gitbucket.core.plugin.Link 4 | import gitbucket.core.service.RepositoryService.RepositoryInfo 5 | import gitbucket.core.util.Implicits.request2Session 6 | import gitbucket.notifications._ 7 | import gitbucket.notifications.model.Watch 8 | import io.github.gitbucket.solidbase.migration.LiquibaseMigration 9 | import io.github.gitbucket.solidbase.model.Version 10 | 11 | class Plugin extends gitbucket.core.plugin.Plugin { 12 | 13 | override val pluginId = "notifications" 14 | 15 | override val pluginName = "Notifications Plugin" 16 | 17 | override val description = "Provides Notifications feature on GitBucket." 18 | 19 | override val versions = List( 20 | new Version("1.0.0", 21 | new LiquibaseMigration("update/gitbucket-notifications_1.0.xml") 22 | ), 23 | new Version("1.1.0", 24 | new LiquibaseMigration("update/gitbucket-notifications_1.1.xml") 25 | ), 26 | new Version("1.2.0"), 27 | new Version("1.3.0", 28 | new LiquibaseMigration("update/gitbucket-notifications_1.3.xml") 29 | ), 30 | new Version("1.4.0"), 31 | new Version("1.5.0"), 32 | new Version("1.5.1"), 33 | new Version("1.6.0"), 34 | new Version("1.7.0"), 35 | new Version("1.7.1"), 36 | new Version("1.8.0"), 37 | new Version("1.9.0"), 38 | new Version("1.10.0"), 39 | new Version("1.11.0") 40 | ) 41 | 42 | override val controllers = Seq( 43 | "/*" -> new controller.NotificationsController() 44 | ) 45 | 46 | override val accountHooks = Seq(new service.AccountHook) 47 | override val repositoryHooks = Seq(new service.RepositoryHook) 48 | override val issueHooks = Seq(new service.IssueHook) 49 | override val pullRequestHooks = Seq(new service.PullRequestHook) 50 | 51 | override val repositoryHeaders = Seq( 52 | (repository: RepositoryInfo, context: Context) => { 53 | context.loginAccount.map { loginAccount => 54 | implicit val session = request2Session(context.request) 55 | 56 | val owner = repository.owner 57 | val name = repository.name 58 | val userName = loginAccount.userName 59 | 60 | html.watch(view.helpers.getWatch(owner, name, userName).map(_.notification) getOrElse { 61 | if (view.helpers.autoSubscribeUsersForRepository(owner, name) contains userName) Watch.Watching else Watch.NotWatching 62 | }, repository)(context) 63 | } 64 | } 65 | ) 66 | 67 | override val issueSidebars = Seq( 68 | (issue: Issue, repository: RepositoryInfo, context: Context) => 69 | context.loginAccount map { account => 70 | implicit val session = request2Session(context.request) 71 | 72 | html.issue( 73 | view.helpers.getNotificationUsers(issue).contains(account.userName), 74 | issue, 75 | repository)(context) 76 | } 77 | ) 78 | 79 | override val accountSettingMenus = Seq( 80 | (context: Context) => 81 | context.loginAccount map { account => 82 | Link( 83 | id = "notifications", 84 | label = "Notifications", 85 | path = s"${account.userName}/_notifications" 86 | ) 87 | } 88 | ) 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/main/scala/gitbucket/notifications/controller/NotificationsController.scala: -------------------------------------------------------------------------------- 1 | package gitbucket.notifications.controller 2 | 3 | import gitbucket.core.controller.ControllerBase 4 | import gitbucket.core.service._ 5 | import gitbucket.core.util.Implicits._ 6 | import gitbucket.core.util.{OneselfAuthenticator, ReadableUsersAuthenticator} 7 | import gitbucket.core.util.SyntaxSugars._ 8 | import gitbucket.notifications.model.Watch 9 | import gitbucket.notifications.service.NotificationsService 10 | import org.scalatra.Ok 11 | 12 | class NotificationsController extends NotificationsControllerBase 13 | with NotificationsService with RepositoryService with AccountService 14 | with IssuesService with LabelsService with PrioritiesService with MilestonesService 15 | with ReadableUsersAuthenticator with OneselfAuthenticator 16 | 17 | trait NotificationsControllerBase extends ControllerBase { 18 | self: NotificationsService with RepositoryService with AccountService with IssuesService 19 | with ReadableUsersAuthenticator with OneselfAuthenticator => 20 | 21 | ajaxPost("/:owner/:repository/watch")(readableUsersOnly { repository => 22 | params.get("notification").flatMap(Watch.Notification.valueOf).map { notification => 23 | updateWatch(repository.owner, repository.name, context.loginAccount.get.userName, notification) 24 | Ok() 25 | } getOrElse NotFound() 26 | }) 27 | 28 | ajaxPost("/:owner/:repository/issues/:id/notification")(readableUsersOnly { repository => 29 | defining(repository.owner, repository.name) { case (owner, name) => 30 | getIssue(owner, name, params("id")).flatMap { issue => 31 | params.getAs[Boolean]("subscribed").map { subscribed => 32 | updateIssueNotification(owner, name, issue.issueId, context.loginAccount.get.userName, subscribed) 33 | Ok() 34 | } 35 | } getOrElse NotFound() 36 | } 37 | }) 38 | 39 | get("/:userName/_notifications")(oneselfOnly { 40 | val userName = params("userName") 41 | getAccountByUserName(userName).map { account => 42 | gitbucket.notifications.html.settings(account, isDisableEmailNotification(account), flash.get("info")) 43 | } getOrElse NotFound() 44 | }) 45 | 46 | post("/:userName/_notifications")(oneselfOnly { 47 | val userName = params("userName") 48 | val disable = params.getAs[Boolean]("disable").getOrElse(false) 49 | updateEmailNotification(userName, disable) 50 | flash.update("info", "Notification setting has been updated.") 51 | redirect(s"/${userName}/_notifications") 52 | }) 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/main/scala/gitbucket/notifications/model/IssueNotification.scala: -------------------------------------------------------------------------------- 1 | package gitbucket.notifications.model 2 | 3 | trait IssueNotificationComponent { self: gitbucket.core.model.Profile => 4 | import profile.api._ 5 | 6 | lazy val IssueNotifications = TableQuery[IssueNotifications] 7 | 8 | class IssueNotifications(tag: Tag) extends Table[IssueNotification](tag, "ISSUE_NOTIFICATION") { 9 | val userName = column[String]("USER_NAME") 10 | val repositoryName = column[String]("REPOSITORY_NAME") 11 | val issueId = column[Int]("ISSUE_ID") 12 | val notificationUserName = column[String]("NOTIFICATION_USER_NAME") 13 | val subscribed = column[Boolean]("SUBSCRIBED") 14 | def * = (userName, repositoryName, issueId, notificationUserName, subscribed).<>(IssueNotification.tupled, IssueNotification.unapply) 15 | } 16 | } 17 | 18 | case class IssueNotification( 19 | userName: String, 20 | repositoryName: String, 21 | issueId: Int, 22 | notificationUserName: String, 23 | subscribed: Boolean 24 | ) 25 | -------------------------------------------------------------------------------- /src/main/scala/gitbucket/notifications/model/NotificationsAccount.scala: -------------------------------------------------------------------------------- 1 | package gitbucket.notifications.model 2 | 3 | trait NotificationsAccountComponent { self: gitbucket.core.model.Profile => 4 | import profile.api._ 5 | 6 | lazy val NotificationsAccounts = TableQuery[NotificationsAccounts] 7 | 8 | class NotificationsAccounts(tag: Tag) extends Table[NotificationsAccount](tag, "NOTIFICATIONS_ACCOUNT") { 9 | val userName = column[String]("USER_NAME") 10 | val disableEmail = column[Boolean]("DISABLE_EMAIL") 11 | def * = (userName, disableEmail).<>(NotificationsAccount.tupled, NotificationsAccount.unapply) 12 | } 13 | } 14 | 15 | case class NotificationsAccount( 16 | userName: String, 17 | disableEmail: Boolean 18 | ) 19 | -------------------------------------------------------------------------------- /src/main/scala/gitbucket/notifications/model/Profile.scala: -------------------------------------------------------------------------------- 1 | package gitbucket.notifications.model 2 | 3 | import gitbucket.core.model._ 4 | 5 | object Profile extends CoreProfile 6 | with IssueNotificationComponent 7 | with WatchComponent 8 | with NotificationsAccountComponent 9 | -------------------------------------------------------------------------------- /src/main/scala/gitbucket/notifications/model/Watch.scala: -------------------------------------------------------------------------------- 1 | package gitbucket.notifications.model 2 | 3 | trait WatchComponent { self: gitbucket.core.model.Profile => 4 | import profile.api._ 5 | 6 | implicit val watchNotificationType = MappedColumnType.base[Watch.Notification, String](_.id, Watch.Notification.valueOf(_).get) 7 | 8 | lazy val Watches = TableQuery[Watches] 9 | 10 | class Watches(tag: Tag) extends Table[Watch](tag, "WATCH") { 11 | val userName = column[String]("USER_NAME") 12 | val repositoryName = column[String]("REPOSITORY_NAME") 13 | val notificationUserName = column[String]("NOTIFICATION_USER_NAME") 14 | val notification = column[Watch.Notification]("NOTIFICATION") 15 | def * = (userName, repositoryName, notificationUserName, notification).<>((Watch.apply _).tupled, Watch.unapply) 16 | } 17 | } 18 | 19 | case class Watch( 20 | userName: String, 21 | repositoryName: String, 22 | notificationUserName: String, 23 | notification: Watch.Notification 24 | ) 25 | 26 | object Watch { 27 | abstract sealed class Notification(val id: String, val name: String, val description: String) 28 | case object Watching extends Notification("watching", "Watching", "Notify all conversations.") 29 | case object NotWatching extends Notification("not_watching", "Not watching", "Notify when participating.") 30 | case object Ignoring extends Notification("ignoring", "Ignoring", "Never notify.") 31 | 32 | object Notification { 33 | val values: Seq[Notification] = Seq(Watching, NotWatching, Ignoring) 34 | def valueOf(id: String): Option[Notification] = values.find(_.id == id) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/scala/gitbucket/notifications/service/NotificationsHook.scala: -------------------------------------------------------------------------------- 1 | package gitbucket.notifications.service 2 | 3 | import gitbucket.core.controller.Context 4 | import gitbucket.core.model.{Account, Issue} 5 | import gitbucket.core.service._ 6 | import RepositoryService.RepositoryInfo 7 | import gitbucket.core 8 | import gitbucket.core.model 9 | import gitbucket.core.util.{LDAPUtil, Mailer} 10 | import gitbucket.core.view.Markdown 11 | import gitbucket.notifications.model.Profile._ 12 | import org.slf4j.LoggerFactory 13 | import profile.blockingApi._ 14 | 15 | import scala.concurrent.Future 16 | import scala.concurrent.ExecutionContext.Implicits.global 17 | import scala.util.{Failure, Success} 18 | 19 | 20 | class AccountHook extends gitbucket.core.plugin.AccountHook { 21 | 22 | override def deleted(userName: String)(implicit session: Session): Unit = { 23 | IssueNotifications.filter(_.notificationUserName === userName.bind).delete 24 | Watches.filter(_.notificationUserName === userName.bind).delete 25 | } 26 | 27 | } 28 | 29 | class RepositoryHook extends gitbucket.core.plugin.RepositoryHook { 30 | 31 | override def deleted(owner: String, repository: String)(implicit session: Session): Unit = { 32 | IssueNotifications.filter(t => t.userName === owner.bind && t.repositoryName === repository.bind).delete 33 | Watches.filter(t => t.userName === owner.bind && t.repositoryName === repository.bind).delete 34 | } 35 | 36 | override def renamed(owner: String, repository: String, newRepository: String)(implicit session: Session): Unit = { 37 | rename(owner, repository, owner, newRepository) 38 | } 39 | 40 | override def transferred(owner: String, newOwner: String, repository: String)(implicit session: Session): Unit = { 41 | rename(owner, repository, newOwner, repository) 42 | } 43 | 44 | // TODO select - insert 45 | private def rename(owner: String, repository: String, newOwner: String, newRepository: String)(implicit session: Session) = { 46 | val n = IssueNotifications.filter(t => t.userName === owner.bind && t.repositoryName === repository.bind).list 47 | val w = Watches.filter(t => t.userName === owner.bind && t.repositoryName === repository.bind).list 48 | 49 | deleted(owner, repository) 50 | 51 | IssueNotifications.insertAll(n.map(_.copy(userName = newOwner, repositoryName = newRepository)) :_*) 52 | Watches.insertAll(w.map(_.copy(userName = newOwner, repositoryName = newRepository)) :_*) 53 | } 54 | 55 | } 56 | 57 | class IssueHook extends gitbucket.core.plugin.IssueHook 58 | with NotificationsService 59 | with RepositoryService 60 | with AccountService 61 | with IssuesService 62 | with LabelsService 63 | with PrioritiesService 64 | with MilestonesService 65 | with SystemSettingsService { 66 | 67 | private val logger = LoggerFactory.getLogger(classOf[IssueHook]) 68 | 69 | override def created(issue: Issue, r: RepositoryInfo)(implicit session: Session, context: Context): Unit = { 70 | val markdown = 71 | s"""|${issue.content getOrElse ""} 72 | | 73 | |---- 74 | |[View it on GitBucket](${s"${context.baseUrl}/${r.owner}/${r.name}/issues/${issue.issueId}"}) 75 | |""".stripMargin 76 | 77 | sendAsync(issue, r, subject(issue, r), markdown) 78 | } 79 | 80 | override def addedComment(commentId: Int, content: String, issue: Issue, r: RepositoryInfo) 81 | (implicit session: Session, context: Context): Unit = { 82 | val markdown = 83 | s"""|${content} 84 | | 85 | |---- 86 | |[View it on GitBucket](${s"${context.baseUrl}/${r.owner}/${r.name}/issues/${issue.issueId}#comment-$commentId"}) 87 | |""".stripMargin 88 | 89 | sendAsync(issue, r, subject(issue, r), markdown) 90 | } 91 | 92 | override def closed(issue: Issue, r: RepositoryInfo)(implicit session: Session, context: Context): Unit = { 93 | val markdown = 94 | s"""|close #[${issue.issueId}](${s"${context.baseUrl}/${r.owner}/${r.name}/issues/${issue.issueId}"}) 95 | |""".stripMargin 96 | 97 | sendAsync(issue, r, subject(issue, r), markdown) 98 | } 99 | 100 | override def reopened(issue: Issue, r: RepositoryInfo)(implicit session: Session, context: Context): Unit = { 101 | val markdown = 102 | s"""|reopen #[${issue.issueId}](${s"${context.baseUrl}/${r.owner}/${r.name}/issues/${issue.issueId}"}) 103 | |""".stripMargin 104 | 105 | sendAsync(issue, r, subject(issue, r), markdown) 106 | } 107 | 108 | override def assigned(issue: Issue, r: RepositoryInfo, assigner: Option[String], assigned: Option[String], oldAssigned: Option[String])(implicit session: model.Profile.profile.api.Session, context: Context): Unit = { 109 | val assignerMessage = assigner.flatMap(getAccountByUserName(_)).map(a => s"${a.fullName}(@${a.userName})").getOrElse("unknown user") 110 | val assignedMessage = assigned.flatMap(getAccountByUserName(_)).map(a => s"${a.fullName}(@${a.userName})").getOrElse("not assigned") 111 | val oldAssignedMessage = oldAssigned.flatMap(getAccountByUserName(_, true)).map(a => s"${a.fullName}(@${a.userName})").getOrElse("not assigned") 112 | val markdown = 113 | s"""assigned from ${oldAssignedMessage} to ${assignedMessage} by ${assignerMessage} 114 | |""".stripMargin 115 | sendAsync(issue, r, subject(issue, r), markdown) 116 | } 117 | 118 | override def closedByCommitComment(issue: Issue, r: RepositoryInfo, commitMessage: String, pusher: Account)(implicit session: core.model.Profile.profile.api.Session): Unit = { 119 | val settings = loadSystemSettings() 120 | val message = s"""|close #[${issue.issueId}](${s"${settings.baseUrl}/${r.owner}/${r.name}/issues/${issue.issueId}"}) 121 | | 122 | |${commitMessage}""".stripMargin 123 | println(message) 124 | sendAsyncTextOnly(issue, r, subject(issue, r), message, pusher, settings) 125 | } 126 | 127 | protected def subject(issue: Issue, r: RepositoryInfo): String = { 128 | s"[${r.owner}/${r.name}] ${issue.title} (#${issue.issueId})" 129 | } 130 | 131 | protected def toHtml(markdown: String, r: RepositoryInfo)(implicit context: Context): String = 132 | Markdown.toHtml( 133 | markdown = markdown, 134 | repository = r, 135 | branch = r.repository.defaultBranch, 136 | enableWikiLink = false, 137 | enableRefsLink = true, 138 | enableAnchor = false, 139 | enableLineBreaks = false 140 | ) 141 | 142 | protected def sendAsyncTextOnly(issue: Issue, repository: RepositoryInfo, subject: String, message: String, senderAccount: Account, settings: SystemSettingsService.SystemSettings)(implicit session:Session): Unit = { 143 | val recipients = getRecipients(issue, senderAccount) 144 | val mailer = new Mailer(settings) 145 | val f = Future { 146 | recipients.foreach { address => 147 | mailer.send(address, subject, message, None, Some(senderAccount)) 148 | } 149 | "Notifications Successful." 150 | } 151 | f.onComplete { 152 | case Success(s) => logger.debug(s) 153 | case Failure(t) => logger.error("Notifications Failed.", t) 154 | } 155 | } 156 | 157 | protected def sendAsync(issue: Issue, repository: RepositoryInfo, subject: String, markdown: String) 158 | (implicit session: Session, context: Context): Unit = { 159 | val recipients = getRecipients(issue, context.loginAccount.get) 160 | val mailer = new Mailer(context.settings) 161 | val html = toHtml(markdown, repository) 162 | val f = Future { 163 | recipients.foreach { address => 164 | mailer.send(address, subject, markdown, Some(html), context.loginAccount) 165 | } 166 | "Notifications Successful." 167 | } 168 | f.onComplete { 169 | case Success(s) => logger.debug(s) 170 | case Failure(t) => logger.error("Notifications Failed.", t) 171 | } 172 | } 173 | 174 | protected def getRecipients(issue: Issue, loginAccount: Account)(implicit session: Session): Seq[String] = { 175 | getNotificationUsers(issue) 176 | .withFilter ( _ != loginAccount.userName ) // the operation in person is excluded 177 | .flatMap ( 178 | getAccountByUserName(_) 179 | .filterNot (_.isGroupAccount) 180 | .filterNot (LDAPUtil.isDummyMailAddress) 181 | .filterNot (isDisableEmailNotification) 182 | .map (account => 183 | account.mailAddress :: getAccountExtraMailAddresses(account.userName) 184 | ) 185 | ) 186 | .flatten 187 | .distinct 188 | } 189 | 190 | } 191 | 192 | class PullRequestHook extends IssueHook with gitbucket.core.plugin.PullRequestHook { 193 | 194 | override def created(issue: Issue, r: RepositoryInfo)(implicit session: Session, context: Context): Unit = { 195 | val markdown = 196 | s"""|${issue.content getOrElse ""} 197 | | 198 | |---- 199 | |View, comment on, or merge it at: 200 | |${context.baseUrl}/${r.owner}/${r.name}/pull/${issue.issueId} 201 | |""".stripMargin 202 | 203 | sendAsync(issue, r, subject(issue, r), markdown) 204 | } 205 | 206 | override def addedComment(commentId: Int, content: String, issue: Issue, r: RepositoryInfo) 207 | (implicit session: Session, context: Context): Unit = { 208 | val markdown = 209 | s"""|$content 210 | | 211 | |---- 212 | |[View it on GitBucket](${s"${context.baseUrl}/${r.owner}/${r.name}/pull/${issue.issueId}#comment-$commentId"}) 213 | |""".stripMargin 214 | 215 | sendAsync(issue, r, subject(issue, r), markdown) 216 | } 217 | 218 | override def merged(issue: Issue, r: RepositoryInfo)(implicit session: Session, context: Context): Unit = { 219 | val markdown = 220 | s"""|merge #[${issue.issueId}](${s"${context.baseUrl}/${r.owner}/${r.name}/pull/${issue.issueId}"}) 221 | |""".stripMargin 222 | 223 | sendAsync(issue, r, subject(issue, r), markdown) 224 | } 225 | 226 | } 227 | -------------------------------------------------------------------------------- /src/main/scala/gitbucket/notifications/service/NotificationsService.scala: -------------------------------------------------------------------------------- 1 | package gitbucket.notifications.service 2 | 3 | import gitbucket.core.model.{Account, Issue} 4 | import gitbucket.core.service.{AccountService, IssuesService, RepositoryService} 5 | import gitbucket.notifications.model._, Profile._ 6 | import profile.blockingApi._ 7 | 8 | trait NotificationsService { 9 | self: RepositoryService with AccountService with IssuesService => 10 | 11 | def getWatch(owner: String, repository: String, userName: String)(implicit s: Session): Option[Watch] = { 12 | Watches 13 | .filter(t => t.userName === owner.bind && t.repositoryName === repository.bind && t.notificationUserName === userName.bind) 14 | .firstOption 15 | } 16 | 17 | def updateWatch(owner: String, repository: String, userName: String, notification: Watch.Notification)(implicit s: Session): Unit = { 18 | Watches 19 | .filter(t => t.userName === owner.bind && t.repositoryName === repository.bind && t.notificationUserName === userName.bind) 20 | .delete 21 | Watches insert Watch( 22 | userName = owner, 23 | repositoryName = repository, 24 | notificationUserName = userName, 25 | notification = notification 26 | ) 27 | } 28 | 29 | def updateIssueNotification(owner: String, repository: String, issueId: Int, userName: String, subscribed: Boolean)(implicit s: Session): Unit = { 30 | IssueNotifications 31 | .filter { t => 32 | t.userName === owner.bind && t.repositoryName === repository.bind && 33 | t.issueId === issueId.bind && t.notificationUserName === userName.bind 34 | } 35 | .delete 36 | IssueNotifications insert IssueNotification( 37 | userName = owner, 38 | repositoryName = repository, 39 | issueId = issueId, 40 | notificationUserName = userName, 41 | subscribed = subscribed 42 | ) 43 | } 44 | 45 | def isDisableEmailNotification(account: Account)(implicit s: Session): Boolean = { 46 | NotificationsAccounts.filter(_.userName === account.userName.bind).firstOption.exists(_.disableEmail) 47 | } 48 | 49 | def updateEmailNotification(userName: String, disable: Boolean)(implicit s: Session): Unit = { 50 | NotificationsAccounts.filter(_.userName === userName.bind).delete 51 | if (disable) NotificationsAccounts insert NotificationsAccount(userName = userName, disableEmail = true) 52 | } 53 | 54 | def autoSubscribeUsersForRepository(owner: String, repository: String)(implicit s: Session): List[String] = { 55 | // individual repository's owner 56 | owner :: 57 | // group members of group repository 58 | getGroupMembers(owner).map(_.userName) ::: 59 | // collaborators 60 | getCollaboratorUserNames(owner, repository) 61 | } 62 | 63 | def getNotificationUsers(issue: Issue)(implicit s: Session): List[String] = { 64 | val watches = Watches.filter(t => 65 | t.userName === issue.userName.bind && t.repositoryName === issue.repositoryName.bind 66 | ).list 67 | val notifications = IssueNotifications.filter(t => 68 | t.userName === issue.userName.bind && t.repositoryName === issue.repositoryName.bind && t.issueId === issue.issueId.bind 69 | ).list 70 | 71 | ( 72 | Seq( 73 | // auto-subscribe users for repository 74 | autoSubscribeUsersForRepository(issue.userName, issue.repositoryName) ::: 75 | // watching users 76 | watches.withFilter(_.notification == Watch.Watching).map(_.notificationUserName), 77 | // participants 78 | issue.openedUserName :: 79 | getComments(issue.userName, issue.repositoryName, issue.issueId).map(_.commentedUserName), 80 | // subscribers 81 | notifications.withFilter(_.subscribed).map(_.notificationUserName) 82 | ) zip Seq( 83 | // not watching users 84 | watches.withFilter(_.notification == Watch.NotWatching).map(_.notificationUserName), 85 | // ignoring users 86 | watches.withFilter(_.notification == Watch.Ignoring).map(_.notificationUserName), 87 | // unsubscribers 88 | notifications.withFilter(!_.subscribed).map(_.notificationUserName) 89 | ) 90 | ).foldLeft[List[String]](Nil){ case (res, (add, remove)) => 91 | (add ++ res).distinct diff remove 92 | } 93 | 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /src/main/scala/gitbucket/notifications/view/helpers.scala: -------------------------------------------------------------------------------- 1 | package gitbucket.notifications.view 2 | 3 | import gitbucket.core.service._ 4 | import gitbucket.notifications.service.NotificationsService 5 | 6 | 7 | object helpers extends NotificationsService with RepositoryService with AccountService 8 | with IssuesService with LabelsService with PrioritiesService with MilestonesService 9 | -------------------------------------------------------------------------------- /src/main/twirl/gitbucket/notifications/issue.scala.html: -------------------------------------------------------------------------------- 1 | @(subscribed: Boolean, 2 | issue: gitbucket.core.model.Issue, 3 | repository: gitbucket.core.service.RepositoryService.RepositoryInfo)(implicit context: gitbucket.core.controller.Context) 4 | @import gitbucket.core.view.helpers 5 | 6 |
7 |
8 | Notifications 9 |
10 | @if(subscribed){ 11 | 12 | } else { 13 | 14 | } 15 |
16 |
17 | 18 | @if(subscribed){ 19 | You’re receiving notifications. 20 | } else { 21 | You’re not receiving notifications. 22 | } 23 | 24 | 44 | -------------------------------------------------------------------------------- /src/main/twirl/gitbucket/notifications/settings.scala.html: -------------------------------------------------------------------------------- 1 | @(account: gitbucket.core.model.Account, 2 | disableEmail: Boolean, info: Option[Any])(implicit context: gitbucket.core.controller.Context) 3 | @import gitbucket.core.view.helpers 4 | @gitbucket.core.html.main("Notifications"){ 5 | @gitbucket.core.account.html.menu("notifications", account.userName, account.isGroupAccount){ 6 | @gitbucket.core.helper.html.information(info) 7 |
8 |
9 |
Email notification preferences
10 |
11 |
12 | 17 |
18 |
19 |
20 |
21 | 22 |
23 |
24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/twirl/gitbucket/notifications/watch.scala.html: -------------------------------------------------------------------------------- 1 | @(notification: gitbucket.notifications.model.Watch.Notification, 2 | repository: gitbucket.core.service.RepositoryService.RepositoryInfo)(implicit context: gitbucket.core.controller.Context) 3 | @import gitbucket.core.view.helpers 4 | @gitbucket.core.helper.html.dropdown(value = notification.name, right = true){ 5 | @gitbucket.notifications.model.Watch.Notification.values.map { n => 6 |
  • 7 | 8 | @gitbucket.core.helper.html.checkicon(notification.id == n.id) 9 | @n.name 10 |
    @n.description
    11 |
    12 |
  • 13 | } 14 | } 15 | 35 | --------------------------------------------------------------------------------