├── .editorconfig ├── .github └── workflows │ └── scala.yml ├── .gitignore ├── .scalafmt.conf ├── .travis.yml ├── LICENSE ├── README.md ├── build.sbt ├── doc ├── dragging.png ├── keys.png ├── labels.png └── screenshot.png ├── project ├── build.properties └── plugins.sbt └── src └── main ├── resources └── plugins │ └── labelkanban │ └── assets │ ├── plugin-labelkanban.css │ ├── plugin-labelkanban.js │ ├── setup-issue.js │ └── vue.min.js ├── scala ├── Plugin.scala └── io │ └── github │ └── gitbucket │ └── labelkanban │ ├── api │ ├── ApiAssigneeKanban.scala │ ├── ApiDatasetKanban.scala │ ├── ApiIssueKanban.scala │ ├── ApiLabelKanban.scala │ ├── ApiLaneKanban.scala │ ├── ApiMilestoneKanban.scala │ └── ApiPriorityKanban.scala │ ├── controller │ └── LabelKanbanController.scala │ └── service │ └── KanbanHelpers.scala └── twirl └── labelkanban └── gitbucket ├── core.scala.html ├── newissue.scala.html ├── profile.scala.html ├── repository.scala.html └── summary.scala.html /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [*.java] 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.github/workflows/scala.yml: -------------------------------------------------------------------------------- 1 | name: Scala CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up JDK 11 20 | uses: actions/setup-java@v3 21 | with: 22 | java-version: '11' 23 | distribution: 'temurin' 24 | cache: 'sbt' 25 | - name: Run tests 26 | run: sbt test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | vue.js 4 | 5 | # sbt specific 6 | dist/* 7 | target/ 8 | lib_managed/ 9 | src_managed/ 10 | project/boot/ 11 | project/plugins/project/ 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/ -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "2.3.2" 2 | project.git = true 3 | 4 | maxColumn = 120 5 | docstrings = JavaDoc 6 | 7 | align.tokens = ["%", "%%", {code = "=>", owner = "Case"}] 8 | align.openParenCallSite = false 9 | align.openParenDefnSite = false 10 | continuationIndent.callSite = 2 11 | continuationIndent.defnSite = 2 12 | danglingParentheses = true 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: scala 3 | scala: 4 | - 2.13.1 5 | jdk: 6 | - openjdk8 7 | script: 8 | - sbt test 9 | -------------------------------------------------------------------------------- /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-label-kanban-plugin 2 | [![Scala CI](https://github.com/kasancode/gitbucket-label-kanban-plugin/actions/workflows/scala.yml/badge.svg)](https://github.com/kasancode/gitbucket-label-kanban-plugin/actions/workflows/scala.yml) 3 | 4 | A [GitBucket](https://github.com/gitbucket/gitbucket) plugin for Kanban-style issue management. 5 | The lanes are labels prefixed with "@", milestones, priorities and assignees. 6 | 7 | ![Screenshot](./doc/screenshot.png) 8 | 9 | 10 | ## Installation 11 | 12 | Download jar file from [the release page](https://github.com/kasancode/gitbucket-label-kanban-plugin/releases) and put it into `GITBUCKET_HOME/plugins`. 13 | 14 | ## Version 15 | 16 | Plugin version|GitBucket version 17 | :---|:--- 18 | 3.8.0 -|4.38.x- 19 | 3.7.0 |4.35.x - 4.37.x 20 | 3.6.0 |4.34.x 21 | 3.3.0 - 3.5.0|4.32.x - 4.33.x 22 | 3.0.x|4.26.x - 4.31.x 23 | 2.0.x|4.26.x - 4.31.x 24 | 1.0.x|4.26.x - 4.29.x 25 | 26 | ## Build from source 27 | 28 | `$ sbt assembly` 29 | 30 | ## Usage 31 | 32 | 33 | 1. Click "Add lane" button to add lanes (prefixed labels). 34 | ![labelList](./doc/labels.png) 35 | 36 | 1. Select the lane type. 37 | ![lanes](./doc/keys.png) 38 | 39 | 1. Drag an Issue to another lane. 40 | ![dragging](./doc/dragging.png) 41 | 42 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | 2 | name := "gitbucket-label-kanban-plugin" 3 | organization := "io.github.gitbucket" 4 | version := "3.8.0" 5 | scalaVersion := "2.13.8" 6 | gitbucketVersion := "4.38.0" 7 | 8 | lazy val root = (project in file(".")) 9 | .enablePlugins(SbtTwirl) 10 | -------------------------------------------------------------------------------- /doc/dragging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kasancode/gitbucket-label-kanban-plugin/dc0713d8acf15d044ae5f5c59c13b5f9a0bdf519/doc/dragging.png -------------------------------------------------------------------------------- /doc/keys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kasancode/gitbucket-label-kanban-plugin/dc0713d8acf15d044ae5f5c59c13b5f9a0bdf519/doc/keys.png -------------------------------------------------------------------------------- /doc/labels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kasancode/gitbucket-label-kanban-plugin/dc0713d8acf15d044ae5f5c59c13b5f9a0bdf519/doc/labels.png -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kasancode/gitbucket-label-kanban-plugin/dc0713d8acf15d044ae5f5c59c13b5f9a0bdf519/doc/screenshot.png -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.4.6 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("io.github.gitbucket" % "sbt-gitbucket-plugin" % "1.5.0") 2 | addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.4.2") -------------------------------------------------------------------------------- /src/main/resources/plugins/labelkanban/assets/plugin-labelkanban.css: -------------------------------------------------------------------------------- 1 | [v-cloak]{ 2 | display: none; 3 | } 4 | 5 | .kanban-container{ 6 | margin-top:10px; 7 | } 8 | 9 | .kanban-row{ 10 | display: flex; 11 | flex-direction: row; 12 | margin-left: 10; 13 | margin-right: 10; 14 | } 15 | 16 | .kanban-column{ 17 | flex: 1; 18 | min-width: 100px; 19 | margin: 2px; 20 | border: solid 1px #f5f5f5; 21 | } 22 | 23 | .kanban-header > div{ 24 | padding: 10px; 25 | overflow: hidden; 26 | text-overflow: ellipsis; 27 | } 28 | 29 | .kanban-header{ 30 | border: 0; 31 | font-size: 110%; 32 | background-color: #f5f5f5; 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | } 36 | 37 | .kanban-row-header{ 38 | flex: 0.5 !important; 39 | max-width: 300px; 40 | } 41 | 42 | .kanban-header:hover .kanban-header-button{ 43 | visibility: visible; 44 | } 45 | .kanban-header-button{ 46 | visibility: hidden; 47 | margin: 0; 48 | padding: 0; 49 | cursor: pointer; 50 | float: right; 51 | } 52 | 53 | .kanban-header-button button{ 54 | margin: 0; 55 | border: 0; 56 | padding: 0 2px; 57 | color: #000; 58 | font-weight: bold; 59 | text-shadow: 0 1px 0 #fff; 60 | filter: alpha(opacity=20); 61 | background: transparent; 62 | opacity: 0.5; 63 | box-sizing: border-box; 64 | } 65 | 66 | .kanban-header-button button:hover{ 67 | opacity: 0.9; 68 | } 69 | 70 | .kanban-column-body{ 71 | padding: 10px; 72 | } 73 | .kanban-column:hover .kanban-issue-dotted{ 74 | visibility: visible; 75 | } 76 | 77 | .compact .kanban-column-body{ 78 | padding: 5px; 79 | } 80 | 81 | .kanban-column-header a{ 82 | color: #333; 83 | } 84 | 85 | .kanban-movable{ 86 | cursor: move; 87 | } 88 | .kanban-issue{ 89 | max-width: calc(100% - 20px); 90 | margin-left: 10px; 91 | margin-right: 10px; 92 | } 93 | 94 | .kanban-issue-dotted{ 95 | background-color: white; 96 | border: 1px dashed #ddd; 97 | border-radius: 3px; 98 | margin-bottom: 5px; 99 | padding: 3px; 100 | visibility: hidden; 101 | } 102 | 103 | .compact .kanban-issue{ 104 | max-width: calc(100% - 10px); 105 | margin: 5px; 106 | } 107 | 108 | @media screen and (max-width:767px) { 109 | .kanban-issue{ 110 | float: none; 111 | } 112 | } 113 | 114 | @media screen and (min-width:768px){ 115 | .kanban-issue{ 116 | float: left; 117 | width: 300px; 118 | } 119 | } 120 | 121 | .kanban-issue-header, 122 | .kanban-issue-footer{ 123 | font-size: 100%; 124 | overflow: hidden; 125 | text-overflow: ellipsis; 126 | } 127 | .kanban-issue-footer{ 128 | border-top: 0; 129 | background-color: white; 130 | } 131 | 132 | .kanban-icon{ 133 | vertical-align: middle; 134 | font-size:12px; 135 | } 136 | 137 | .kanban-close{ 138 | border-radius: 3px; 139 | padding: 2px 5px 2px 5px; 140 | cursor: pointer; 141 | } 142 | 143 | .compact .kanban-issue-header{ 144 | font-size: 95%; 145 | padding: 5px; 146 | } 147 | 148 | .kanban-avatar{ 149 | width: 14px; 150 | height: 14px; 151 | margin-left: 3px; 152 | margin-right: 3px; 153 | vertical-align: middle; 154 | display: inline-block; 155 | } 156 | 157 | .kanban-issue-header a{ 158 | color:#333333; 159 | } 160 | 161 | .kanban-issue-header .text-muted{ 162 | font-size: 90%; 163 | padding-left: 3px; 164 | } 165 | 166 | .compact .issue-body{ 167 | font-size: 90%; 168 | padding: 5px; 169 | } 170 | 171 | 172 | .compact .kanban-issue-footer{ 173 | font-size: 95%; 174 | padding: 5px; 175 | } 176 | 177 | .kanban-expand-enter-active, 178 | .kanban-expand-leave-active { 179 | transition: all 0.2s; 180 | overflow: hidden; 181 | } 182 | 183 | .kanban-expand-enter, 184 | .kanban-expand-leave-to { 185 | transform: translateY(-10px); 186 | position: relative; 187 | height: 0 !important; 188 | opacity: 0; 189 | padding-top: 0 !important; 190 | padding-bottom: 0 !important; 191 | } 192 | 193 | .kanban-expand-enter div, 194 | .kanban-expand-leave-to div{ 195 | height: 0 !important; 196 | padding-top: 0 !important; 197 | padding-bottom: 0 !important; 198 | } 199 | 200 | .kanban-expand-button{ 201 | cursor: pointer; 202 | margin-left: 8px; 203 | } 204 | 205 | .kanban-comment{ 206 | margin-bottom: 10px; 207 | border-left: solid 2px silver; 208 | padding-left: 8px; 209 | overflow: hidden; 210 | text-overflow: ellipsis; 211 | } 212 | 213 | .compact .kanban-comment{ 214 | margin-bottom: 5px; 215 | padding-left: 5px; 216 | } 217 | 218 | #kanban-new-label-name{ 219 | width: 200px; 220 | margin-right: 4px; 221 | } 222 | 223 | #kanban-new-label-color-holder{ 224 | width: 100px; 225 | } 226 | 227 | #kanban-new-label-color{ 228 | width: 100px; 229 | } 230 | -------------------------------------------------------------------------------- /src/main/resources/plugins/labelkanban/assets/plugin-labelkanban.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | var apiBasepath; 4 | var basePath; 5 | var prefix; 6 | var addIssuePath; 7 | var closeIssuePath; 8 | 9 | const compactStyleIssuesCount = 10; 10 | const cookieMaxAge = 30; //day 11 | 12 | // Polyfill 13 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith 14 | if (!String.prototype.startsWith) { 15 | Object.defineProperty(String.prototype, 'startsWith', { 16 | value: function (search, pos) { 17 | pos = !pos || pos < 0 ? 0 : +pos; 18 | return this.substring(pos, pos + search.length) === search; 19 | } 20 | }); 21 | } 22 | 23 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find 24 | if (!Array.prototype.find) { 25 | Object.defineProperty(Array.prototype, 'find', { 26 | value: function (predicate) { 27 | if (this == null) { 28 | throw new TypeError('"this" is null or not defined'); 29 | } 30 | var o = Object(this); 31 | var len = o.length >>> 0; 32 | if (typeof predicate !== 'function') { 33 | throw new TypeError('predicate must be a function'); 34 | } 35 | var thisArg = arguments[1]; 36 | var k = 0; 37 | while (k < len) { 38 | var kValue = o[k]; 39 | if (predicate.call(thisArg, kValue, k, o)) { 40 | return kValue; 41 | } 42 | k++; 43 | } 44 | return undefined; 45 | }, 46 | configurable: true, 47 | writable: true 48 | }); 49 | } 50 | 51 | // https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/findIndex 52 | if (!Array.prototype.findIndex) { 53 | Array.prototype.findIndex = function(predicate) { 54 | if (this === null) { 55 | throw new TypeError('Array.prototype.findIndex called on null or undefined'); 56 | } 57 | if (typeof predicate !== 'function') { 58 | throw new TypeError('predicate must be a function'); 59 | } 60 | var list = Object(this); 61 | var length = list.length >>> 0; 62 | var thisArg = arguments[1]; 63 | var value; 64 | 65 | for (var i = 0; i < length; i++) { 66 | value = list[i]; 67 | if (predicate.call(thisArg, value, i, list)) { 68 | return i; 69 | } 70 | } 71 | return -1; 72 | }; 73 | } 74 | 75 | function getCookie(name) { 76 | var cookieName = encodeURIComponent(name) + '='; 77 | var allcookies = document.cookie; 78 | 79 | var position = allcookies.indexOf(cookieName); 80 | if (position < 0) { 81 | return null; 82 | } 83 | 84 | var startIndex = position + cookieName.length; 85 | 86 | var endIndex = allcookies.indexOf(';', startIndex); 87 | if (endIndex < 0) { 88 | endIndex = allcookies.length; 89 | } 90 | 91 | return decodeURIComponent( 92 | allcookies.substring(startIndex, endIndex)); 93 | } 94 | 95 | function setCookie(name, value) { 96 | var maxAge = 60 * 60 * 24 * cookieMaxAge; 97 | document.cookie = encodeURIComponent(name) + "=" + encodeURIComponent(value) + "; max-age=" + maxAge.toString(); 98 | } 99 | 100 | 101 | 102 | /** 103 | * 104 | * @param {string} prefix 105 | * @returns {string} 106 | */ 107 | function prefixToLaneKey(prefix) { 108 | return "Label:" + prefix; 109 | } 110 | 111 | var kanbanApp = new Vue({ 112 | el: "#kanban-app", 113 | data: { 114 | message: "" 115 | , 116 | /**@type {issue}*/ 117 | dragItem: undefined 118 | , 119 | /**@type {lane} */ 120 | targetRowLane: undefined 121 | , 122 | /**@type {lane} */ 123 | targetColLane: undefined 124 | , 125 | /**@type {lane} */ 126 | originRowLane: undefined 127 | , 128 | /**@type {lane} */ 129 | originColLane: undefined 130 | , 131 | /**@type {issue[]}*/ 132 | issues: [] 133 | , 134 | showNewLabelEditor: false 135 | , 136 | /**@type {string} */ 137 | newLabelName: "" 138 | , 139 | /**@type {string} */ 140 | newLabelColor: "#888888" 141 | , 142 | /**@type {Object.} */ 143 | lanes: {} 144 | , 145 | /**@type {string} */ 146 | colKey: "" 147 | , 148 | /**@type {string} */ 149 | rowKey: "" 150 | , 151 | /**@type {string} */ 152 | prefix: "" 153 | , 154 | /**@returns {Object} */ 155 | getLaneKeys: function () { 156 | return Object.keys(this.lanes); 157 | } 158 | , 159 | /** 160 | * @param {string} key 161 | * @param {boolean} dummyFirst 162 | * @returns {lane[]} 163 | */ 164 | getLanes: function (key, dummyFirst) { 165 | if (!this.lanes || !key || !this.lanes[key]) 166 | return null; 167 | 168 | if (dummyFirst) 169 | return this.lanes[key]; 170 | else 171 | return this.lanes[key].slice().reverse(); 172 | } 173 | , 174 | /** 175 | * @param {issue[]} issues 176 | * @param {string} key 177 | * @param {lane} lane 178 | * @returns {issue[]} 179 | */ 180 | getIssues: function (issues, key, lane) { 181 | return issues.filter(function (i) { 182 | return i.metrics[key] == lane.id; 183 | }); 184 | } 185 | , 186 | /** 187 | * @param {string} key 188 | * @param {string} value 189 | * @returns {string} 190 | */ 191 | iconClass: function (key, value) { 192 | var lane = this.lanes[key].find(function(l){return l.id === value;}); 193 | return lane ? lane.icon : ""; 194 | } 195 | , 196 | /** 197 | * @param {string} key 198 | * @param {string} value 199 | * @returns {string} 200 | */ 201 | iconColor: function (key,value) { 202 | var lane = this.lanes[key].find(function(l){return l.id === value;}); 203 | return lane ? "#" + lane.color : "#333333"; 204 | } 205 | , 206 | /** 207 | * @param {string} key 208 | * @param {string} value 209 | * @returns {string} 210 | */ 211 | laneName: function (key,value) { 212 | var lane = this.lanes[key].find(function(l){return l.id === value;}); 213 | return lane ? lane.name : ""; 214 | } 215 | , 216 | /** 217 | * @param {string} key 218 | * @param {string} value 219 | * @returns {string} 220 | */ 221 | laneUrl: function (key,value) { 222 | var lane = this.lanes[key].find(function(l){return l.id === value;}); 223 | return lane ? lane.htmlUrl : ""; 224 | } 225 | , 226 | /** 227 | * @param {string} key 228 | * @param {string} value 229 | * @returns {string} 230 | */ 231 | laneImageUrl: function (key,value) { 232 | var lane = this.lanes[key].find(function(l){return l.id === value;}); 233 | return lane ? lane.iconImage : ""; 234 | } 235 | , 236 | /** 237 | * @returns {string} 238 | */ 239 | getColWidth: function () { 240 | var width = (100 - 2) / this.lanes[this.colKey].length - 2; 241 | return Math.round(width) + "%" 242 | } 243 | , 244 | /**@param {lane} lane */ 245 | getLaneUrl: function (lane) { 246 | return lane ? lane.htmlUrl : ""; 247 | } 248 | , 249 | /**@returns {Object} */ 250 | getContainerStyle: function () { 251 | return { 252 | "display": "grid", 253 | "grid-template-rows": "100px 50px", 254 | "grid-template-columns": "150px 1fr" 255 | }; 256 | } 257 | , 258 | /**@returns {boolean} */ 259 | isCompact: function () { 260 | return this.issues.length > compactStyleIssuesCount; 261 | } 262 | } 263 | , 264 | methods: { 265 | saveCookie: function () { 266 | setCookie("kanban.rowKey", this.rowKey); 267 | setCookie("kanban.colKey", this.colKey); 268 | for (var key in this.lanes) { 269 | setCookie("kanban.order." + key, JSON.stringify(this.lanes[key].map(function (item) { 270 | return { id: item.id, order: item.order }; 271 | }))); 272 | } 273 | } 274 | , 275 | loadCookie: function () { 276 | this.rowKey = getCookie("kanban.rowKey") || this.rowKey; 277 | this.colKey = getCookie("kanban.colKey") || this.colKey; 278 | 279 | for (var key in this.lanes) { 280 | var orders = JSON.parse(getCookie("kanban.order." + key)); 281 | if (!orders || !orders.map) { 282 | continue; 283 | } 284 | 285 | orders.map(function (item) { 286 | var targetLane = this.lanes[key].find(function (lane) { 287 | return lane.id == item.id; 288 | }); 289 | if (!!targetLane) { 290 | targetLane.order = item.order; 291 | } 292 | }, this); 293 | 294 | this.lanes[key].sort(function (a, b) { 295 | return a.order - b.order; 296 | }); 297 | 298 | // fix irregular condition 299 | for(var i=0;i').text('No milestone')); 80 | $('#milestone-progress-area').empty(); 81 | } else { 82 | $('#label-milestone').html($('').text(title) 83 | .attr('href', '/root/test6/issues?milestone=' + encodeURIComponent(title) + '&state=open')); 84 | if (progress) { 85 | $('#milestone-progress-area').html(progress); 86 | } 87 | $('a.milestone[data-id=' + milestoneId + '] i').addClass('octicon-check'); 88 | } 89 | } 90 | , 91 | displayPriority: function (priorityName, priorityId, description, color, fontColor) { 92 | $('a.priority i.octicon-check').removeClass('octicon-check'); 93 | if (priorityId == '') { 94 | $('#label-priority').html($('').text('No priority')); 95 | } else { 96 | $('#label-priority').html($('').text(priorityName) 97 | .attr('href', '/root/test6/issues?priority=' + encodeURIComponent(priorityName) + '&state=open') 98 | .attr('title', description) 99 | .css({ 100 | "background-color": color, 101 | "color": fontColor 102 | })); 103 | 104 | $('a.priority[data-id=' + priorityId + '] i').addClass('octicon-check'); 105 | } 106 | } 107 | , 108 | displayAssignee: function (assignees) { 109 | $('a.assign i.octicon-check').removeClass('octicon-check'); 110 | if(assignees.length == 0){ 111 | $('#label-assigned').html($('').text('No one assigned')); 112 | } else { 113 | $('#label-assigned').empty(); 114 | for (const userName of assignees) { 115 | $('#label-assigned').append($('
').append( 116 | $('a.toggle-assign').parent().find("img.avatar-mini[alt='@" + userName + "']").clone(false), 117 | ' ', 118 | $('').attr('href', '/' + userName).text(userName))); 119 | } 120 | } 121 | } 122 | }; 123 | -------------------------------------------------------------------------------- /src/main/resources/plugins/labelkanban/assets/vue.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Vue.js v2.5.17 3 | * (c) 2014-2018 Evan You 4 | * Released under the MIT License. 5 | */ 6 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.Vue=t()}(this,function(){"use strict";var y=Object.freeze({});function M(e){return null==e}function D(e){return null!=e}function S(e){return!0===e}function T(e){return"string"==typeof e||"number"==typeof e||"symbol"==typeof e||"boolean"==typeof e}function P(e){return null!==e&&"object"==typeof e}var r=Object.prototype.toString;function l(e){return"[object Object]"===r.call(e)}function i(e){var t=parseFloat(String(e));return 0<=t&&Math.floor(t)===t&&isFinite(e)}function t(e){return null==e?"":"object"==typeof e?JSON.stringify(e,null,2):String(e)}function F(e){var t=parseFloat(e);return isNaN(t)?e:t}function s(e,t){for(var n=Object.create(null),r=e.split(","),i=0;ie.id;)n--;bt.splice(n+1,0,e)}else bt.push(e);Ct||(Ct=!0,Ze(At))}}(this)},St.prototype.run=function(){if(this.active){var e=this.get();if(e!==this.value||P(e)||this.deep){var t=this.value;if(this.value=e,this.user)try{this.cb.call(this.vm,e,t)}catch(e){Fe(e,this.vm,'callback for watcher "'+this.expression+'"')}else this.cb.call(this.vm,e,t)}}},St.prototype.evaluate=function(){this.value=this.get(),this.dirty=!1},St.prototype.depend=function(){for(var e=this.deps.length;e--;)this.deps[e].depend()},St.prototype.teardown=function(){if(this.active){this.vm._isBeingDestroyed||f(this.vm._watchers,this);for(var e=this.deps.length;e--;)this.deps[e].removeSub(this);this.active=!1}};var Tt={enumerable:!0,configurable:!0,get:$,set:$};function Et(e,t,n){Tt.get=function(){return this[t][n]},Tt.set=function(e){this[t][n]=e},Object.defineProperty(e,n,Tt)}function jt(e){e._watchers=[];var t=e.$options;t.props&&function(n,r){var i=n.$options.propsData||{},o=n._props={},a=n.$options._propKeys=[];n.$parent&&ge(!1);var e=function(e){a.push(e);var t=Ie(e,r,i,n);Ce(o,e,t),e in n||Et(n,"_props",e)};for(var t in r)e(t);ge(!0)}(e,t.props),t.methods&&function(e,t){e.$options.props;for(var n in t)e[n]=null==t[n]?$:v(t[n],e)}(e,t.methods),t.data?function(e){var t=e.$options.data;l(t=e._data="function"==typeof t?function(e,t){se();try{return e.call(t,t)}catch(e){return Fe(e,t,"data()"),{}}finally{ce()}}(t,e):t||{})||(t={});var n=Object.keys(t),r=e.$options.props,i=(e.$options.methods,n.length);for(;i--;){var o=n[i];r&&p(r,o)||(void 0,36!==(a=(o+"").charCodeAt(0))&&95!==a&&Et(e,"_data",o))}var a;we(t,!0)}(e):we(e._data={},!0),t.computed&&function(e,t){var n=e._computedWatchers=Object.create(null),r=Y();for(var i in t){var o=t[i],a="function"==typeof o?o:o.get;r||(n[i]=new St(e,a||$,$,Nt)),i in e||Lt(e,i,o)}}(e,t.computed),t.watch&&t.watch!==G&&function(e,t){for(var n in t){var r=t[n];if(Array.isArray(r))for(var i=0;iparseInt(this.max)&&bn(a,s[0],s,this._vnode)),t.data.keepAlive=!0}return t||e&&e[0]}}};$n=hn,Cn={get:function(){return j}},Object.defineProperty($n,"config",Cn),$n.util={warn:re,extend:m,mergeOptions:Ne,defineReactive:Ce},$n.set=xe,$n.delete=ke,$n.nextTick=Ze,$n.options=Object.create(null),k.forEach(function(e){$n.options[e+"s"]=Object.create(null)}),m(($n.options._base=$n).options.components,kn),$n.use=function(e){var t=this._installedPlugins||(this._installedPlugins=[]);if(-1=a&&l()};setTimeout(function(){c\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/,oo="[a-zA-Z_][\\w\\-\\.]*",ao="((?:"+oo+"\\:)?"+oo+")",so=new RegExp("^<"+ao),co=/^\s*(\/?)>/,lo=new RegExp("^<\\/"+ao+"[^>]*>"),uo=/^]+>/i,fo=/^",""":'"',"&":"&"," ":"\n"," ":"\t"},go=/&(?:lt|gt|quot|amp);/g,_o=/&(?:lt|gt|quot|amp|#10|#9);/g,bo=s("pre,textarea",!0),$o=function(e,t){return e&&bo(e)&&"\n"===t[0]};var wo,Co,xo,ko,Ao,Oo,So,To,Eo=/^@|^v-on:/,jo=/^v-|^@|^:/,No=/([^]*?)\s+(?:in|of)\s+([^]*)/,Lo=/,([^,\}\]]*)(?:,([^,\}\]]*))?$/,Io=/^\(|\)$/g,Mo=/:(.*)$/,Do=/^:|^v-bind:/,Po=/\.[^.]+/g,Fo=e(eo);function Ro(e,t,n){return{type:1,tag:e,attrsList:t,attrsMap:function(e){for(var t={},n=0,r=e.length;n]*>)","i")),n=i.replace(t,function(e,t,n){return r=n.length,ho(o)||"noscript"===o||(t=t.replace(//g,"$1").replace(//g,"$1")),$o(o,t)&&(t=t.slice(1)),d.chars&&d.chars(t),""});a+=i.length-n.length,i=n,A(o,a-r,a)}else{var s=i.indexOf("<");if(0===s){if(fo.test(i)){var c=i.indexOf("--\x3e");if(0<=c){d.shouldKeepComment&&d.comment(i.substring(4,c)),C(c+3);continue}}if(po.test(i)){var l=i.indexOf("]>");if(0<=l){C(l+2);continue}}var u=i.match(uo);if(u){C(u[0].length);continue}var f=i.match(lo);if(f){var p=a;C(f[0].length),A(f[1],p,a);continue}var _=x();if(_){k(_),$o(v,i)&&C(1);continue}}var b=void 0,$=void 0,w=void 0;if(0<=s){for($=i.slice(s);!(lo.test($)||so.test($)||fo.test($)||po.test($)||(w=$.indexOf("<",1))<0);)s+=w,$=i.slice(s);b=i.substring(0,s),C(s)}s<0&&(b=i,i=""),d.chars&&b&&d.chars(b)}if(i===e){d.chars&&d.chars(i);break}}function C(e){a+=e,i=i.substring(e)}function x(){var e=i.match(so);if(e){var t,n,r={tagName:e[1],attrs:[],start:a};for(C(e[0].length);!(t=i.match(co))&&(n=i.match(io));)C(n[0].length),r.attrs.push(n);if(t)return r.unarySlash=t[1],C(t[0].length),r.end=a,r}}function k(e){var t=e.tagName,n=e.unarySlash;m&&("p"===v&&ro(t)&&A(v),g(t)&&v===t&&A(t));for(var r,i,o,a=y(t)||!!n,s=e.attrs.length,c=new Array(s),l=0;l-1"+("true"===d?":("+l+")":":_q("+l+","+d+")")),Ar(c,"change","var $$a="+l+",$$el=$event.target,$$c=$$el.checked?("+d+"):("+v+");if(Array.isArray($$a)){var $$v="+(f?"_n("+p+")":p)+",$$i=_i($$a,$$v);if($$el.checked){$$i<0&&("+Er(l,"$$a.concat([$$v])")+")}else{$$i>-1&&("+Er(l,"$$a.slice(0,$$i).concat($$a.slice($$i+1))")+")}}else{"+Er(l,"$$c")+"}",null,!0);else if("input"===$&&"radio"===w)r=e,i=_,a=(o=b)&&o.number,s=Or(r,"value")||"null",Cr(r,"checked","_q("+i+","+(s=a?"_n("+s+")":s)+")"),Ar(r,"change",Er(i,s),null,!0);else if("input"===$||"textarea"===$)!function(e,t,n){var r=e.attrsMap.type,i=n||{},o=i.lazy,a=i.number,s=i.trim,c=!o&&"range"!==r,l=o?"change":"range"===r?Pr:"input",u="$event.target.value";s&&(u="$event.target.value.trim()"),a&&(u="_n("+u+")");var f=Er(t,u);c&&(f="if($event.target.composing)return;"+f),Cr(e,"value","("+t+")"),Ar(e,l,f,null,!0),(s||a)&&Ar(e,"blur","$forceUpdate()")}(e,_,b);else if(!j.isReservedTag($))return Tr(e,_,b),!1;return!0},text:function(e,t){t.value&&Cr(e,"textContent","_s("+t.value+")")},html:function(e,t){t.value&&Cr(e,"innerHTML","_s("+t.value+")")}},isPreTag:function(e){return"pre"===e},isUnaryTag:to,mustUseProp:Sn,canBeLeftOpenTag:no,isReservedTag:Un,getTagNamespace:Vn,staticKeys:(Go=Wo,Go.reduce(function(e,t){return e.concat(t.staticKeys||[])},[]).join(","))},Qo=e(function(e){return s("type,tag,attrsList,attrsMap,plain,parent,children,attrs"+(e?","+e:""))});function ea(e,t){e&&(Zo=Qo(t.staticKeys||""),Xo=t.isReservedTag||O,function e(t){t.static=function(e){if(2===e.type)return!1;if(3===e.type)return!0;return!(!e.pre&&(e.hasBindings||e.if||e.for||c(e.tag)||!Xo(e.tag)||function(e){for(;e.parent;){if("template"!==(e=e.parent).tag)return!1;if(e.for)return!0}return!1}(e)||!Object.keys(e).every(Zo)))}(t);if(1===t.type){if(!Xo(t.tag)&&"slot"!==t.tag&&null==t.attrsMap["inline-template"])return;for(var n=0,r=t.children.length;n|^function\s*\(/,na=/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/,ra={esc:27,tab:9,enter:13,space:32,up:38,left:37,right:39,down:40,delete:[8,46]},ia={esc:"Escape",tab:"Tab",enter:"Enter",space:" ",up:["Up","ArrowUp"],left:["Left","ArrowLeft"],right:["Right","ArrowRight"],down:["Down","ArrowDown"],delete:["Backspace","Delete"]},oa=function(e){return"if("+e+")return null;"},aa={stop:"$event.stopPropagation();",prevent:"$event.preventDefault();",self:oa("$event.target !== $event.currentTarget"),ctrl:oa("!$event.ctrlKey"),shift:oa("!$event.shiftKey"),alt:oa("!$event.altKey"),meta:oa("!$event.metaKey"),left:oa("'button' in $event && $event.button !== 0"),middle:oa("'button' in $event && $event.button !== 1"),right:oa("'button' in $event && $event.button !== 2")};function sa(e,t,n){var r=t?"nativeOn:{":"on:{";for(var i in e)r+='"'+i+'":'+ca(i,e[i])+",";return r.slice(0,-1)+"}"}function ca(t,e){if(!e)return"function(){}";if(Array.isArray(e))return"["+e.map(function(e){return ca(t,e)}).join(",")+"]";var n=na.test(e.value),r=ta.test(e.value);if(e.modifiers){var i="",o="",a=[];for(var s in e.modifiers)if(aa[s])o+=aa[s],ra[s]&&a.push(s);else if("exact"===s){var c=e.modifiers;o+=oa(["ctrl","shift","alt","meta"].filter(function(e){return!c[e]}).map(function(e){return"$event."+e+"Key"}).join("||"))}else a.push(s);return a.length&&(i+="if(!('button' in $event)&&"+a.map(la).join("&&")+")return null;"),o&&(i+=o),"function($event){"+i+(n?"return "+e.value+"($event)":r?"return ("+e.value+")($event)":e.value)+"}"}return n||r?e.value:"function($event){"+e.value+"}"}function la(e){var t=parseInt(e,10);if(t)return"$event.keyCode!=="+t;var n=ra[e],r=ia[e];return"_k($event.keyCode,"+JSON.stringify(e)+","+JSON.stringify(n)+",$event.key,"+JSON.stringify(r)+")"}var ua={on:function(e,t){e.wrapListeners=function(e){return"_g("+e+","+t.value+")"}},bind:function(t,n){t.wrapData=function(e){return"_b("+e+",'"+t.tag+"',"+n.value+","+(n.modifiers&&n.modifiers.prop?"true":"false")+(n.modifiers&&n.modifiers.sync?",true":"")+")"}},cloak:$},fa=function(e){this.options=e,this.warn=e.warn||$r,this.transforms=wr(e.modules,"transformCode"),this.dataGenFns=wr(e.modules,"genData"),this.directives=m(m({},ua),e.directives);var t=e.isReservedTag||O;this.maybeComponent=function(e){return!t(e.tag)},this.onceId=0,this.staticRenderFns=[]};function pa(e,t){var n=new fa(t);return{render:"with(this){return "+(e?da(e,n):'_c("div")')+"}",staticRenderFns:n.staticRenderFns}}function da(e,t){if(e.staticRoot&&!e.staticProcessed)return va(e,t);if(e.once&&!e.onceProcessed)return ha(e,t);if(e.for&&!e.forProcessed)return f=t,v=(u=e).for,h=u.alias,m=u.iterator1?","+u.iterator1:"",y=u.iterator2?","+u.iterator2:"",u.forProcessed=!0,(d||"_l")+"(("+v+"),function("+h+m+y+"){return "+(p||da)(u,f)+"})";if(e.if&&!e.ifProcessed)return ma(e,t);if("template"!==e.tag||e.slotTarget){if("slot"===e.tag)return function(e,t){var n=e.slotName||'"default"',r=_a(e,t),i="_t("+n+(r?","+r:""),o=e.attrs&&"{"+e.attrs.map(function(e){return g(e.name)+":"+e.value}).join(",")+"}",a=e.attrsMap["v-bind"];!o&&!a||r||(i+=",null");o&&(i+=","+o);a&&(i+=(o?"":",null")+","+a);return i+")"}(e,t);var n;if(e.component)a=e.component,c=t,l=(s=e).inlineTemplate?null:_a(s,c,!0),n="_c("+a+","+ya(s,c)+(l?","+l:"")+")";else{var r=e.plain?void 0:ya(e,t),i=e.inlineTemplate?null:_a(e,t,!0);n="_c('"+e.tag+"'"+(r?","+r:"")+(i?","+i:"")+")"}for(var o=0;o':'
',0 new LabelKanbanController() 45 | ) 46 | 47 | override val assetsMappings = Seq("/labelkanban" -> "/plugins/labelkanban/assets") 48 | 49 | // "override val" is difficult to resolve url 50 | 51 | override val globalMenus = Seq( 52 | (context: Context) => if (context.loginAccount.isDefined) 53 | Some(Link("summarykanban", "Summary board", s"summarykanban/${context.loginAccount.get.userName}/")) 54 | else 55 | None 56 | ) 57 | 58 | override val repositoryMenus = Seq( 59 | (repositoryInfo: RepositoryInfo, context: Context) => if(repositoryInfo.repository.options.issuesOption != "DISABLE") 60 | Some(Link("labelkanban", "Kanban", "/labelkanban", Some("inbox"))) 61 | else 62 | None 63 | ) 64 | 65 | override val profileTabs = Seq( 66 | (account: Account, context: Context) => Some(Link("summarykanban", "Summary board", s"summarykanban/${account.userName}/profile")) 67 | ) 68 | 69 | override val dashboardTabs = Seq( 70 | (context: Context) => if (context.loginAccount.isDefined) 71 | Some(Link("summarykanban", "Summary board", s"summarykanban/${context.loginAccount.get.userName}/")) 72 | else 73 | None 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/main/scala/io/github/gitbucket/labelkanban/api/ApiAssigneeKanban.scala: -------------------------------------------------------------------------------- 1 | package io.github.gitbucket.labelkanban.api 2 | 3 | import gitbucket.core.api.{ApiPath, FieldSerializable} 4 | import gitbucket.core.util.RepositoryName 5 | import gitbucket.core.view.helpers 6 | 7 | case class ApiAssigneeKanban(userName: String)(repositoryName: RepositoryName) 8 | extends FieldSerializable { 9 | val html_url = ApiPath(s"/${repositoryName.fullName}/issues?assigned=${helpers.urlEncode(userName)}&state=open") 10 | val detach_url = "" 11 | val attach_url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/plugin/labelkanban/assignee/${userName}/attach/issue/") 12 | } 13 | 14 | -------------------------------------------------------------------------------- /src/main/scala/io/github/gitbucket/labelkanban/api/ApiDatasetKanban.scala: -------------------------------------------------------------------------------- 1 | package io.github.gitbucket.labelkanban.api 2 | 3 | import gitbucket.core.api.{ApiPath, FieldSerializable} 4 | import gitbucket.core.util.RepositoryName 5 | import gitbucket.core.view.helpers 6 | 7 | import scala.collection.mutable 8 | 9 | case class ApiDataSetKanban( 10 | issues: List[ApiIssueKanban], 11 | lanes: mutable.LinkedHashMap[String,List[ApiLaneKanban]] 12 | ) 13 | extends FieldSerializable { 14 | 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/main/scala/io/github/gitbucket/labelkanban/api/ApiIssueKanban.scala: -------------------------------------------------------------------------------- 1 | package io.github.gitbucket.labelkanban.api 2 | 3 | import gitbucket.core.api._ 4 | import gitbucket.core.model.{Issue, Label, Priority} 5 | import gitbucket.core.util.RepositoryName 6 | 7 | case class ApiIssueKanban( 8 | userName: String, 9 | issueId: Int, 10 | openedUserName: String, 11 | milestoneId: Int, 12 | priorityId: Int, 13 | assignedUserName: String, 14 | title: String, 15 | content: String, 16 | closed: Boolean, 17 | registeredDate: java.util.Date, 18 | updatedDate: java.util.Date, 19 | isPullRequest: Boolean, 20 | labelNames: List[String], 21 | metrics: Map[String, String], 22 | show: Boolean = false 23 | )(repositoryName: RepositoryName) 24 | extends FieldSerializable { 25 | val htmlUrl = ApiPath(s"/${repositoryName.fullName}/issues/${issueId}") 26 | val comments_url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/issues/${issueId}/comments") 27 | } 28 | 29 | object ApiIssueKanban { 30 | def apply( 31 | issue: Issue, 32 | assignedUserName: Option[String], 33 | labels: List[Label], 34 | prefix: String, 35 | repositoryName: RepositoryName 36 | ): ApiIssueKanban = 37 | ApiIssueKanban( 38 | userName = issue.userName, 39 | issueId = issue.issueId, 40 | openedUserName = issue.openedUserName, 41 | milestoneId = issue.milestoneId.getOrElse(-1), 42 | priorityId = issue.priorityId.getOrElse(-1), 43 | assignedUserName = assignedUserName.getOrElse(""), 44 | title = issue.title, 45 | content = issue.content.getOrElse(""), 46 | closed = issue.closed, 47 | registeredDate = issue.registeredDate, 48 | updatedDate = issue.updatedDate, 49 | isPullRequest = issue.isPullRequest, 50 | labelNames = labels.map(_.labelName), 51 | metrics = createMetrics( 52 | milestoneId = issue.milestoneId, 53 | priorityId = issue.priorityId, 54 | assignedUserName = assignedUserName, 55 | labels = labels, 56 | prefix 57 | ) 58 | )(repositoryName) 59 | 60 | def applySummary( 61 | issue: Issue, 62 | assignedUserName: Option[String], 63 | labels: List[Label], 64 | prefix: String, 65 | priorities: List[Priority] 66 | ): ApiIssueKanban = 67 | ApiIssueKanban( 68 | userName = issue.userName, 69 | issueId = issue.issueId, 70 | openedUserName = issue.openedUserName, 71 | milestoneId = issue.milestoneId.getOrElse(-1), 72 | priorityId = issue.priorityId.getOrElse(-1), 73 | assignedUserName = assignedUserName.getOrElse(""), 74 | title = issue.title, 75 | content = issue.content.getOrElse(""), 76 | closed = issue.closed, 77 | registeredDate = issue.registeredDate, 78 | updatedDate = issue.updatedDate, 79 | isPullRequest = issue.isPullRequest, 80 | labelNames = labels.map(_.labelName), 81 | metrics = createSummaryMetrics( 82 | labels = labels, 83 | prefix, 84 | priority = priorities.find(p => p.priorityId == issue.priorityId.getOrElse(-1)), 85 | assignedUserName, 86 | repository = issue.repositoryName 87 | ) 88 | )(RepositoryName(issue.userName, issue.repositoryName)) 89 | 90 | def createMetrics( 91 | milestoneId: Option[Int], 92 | priorityId: Option[Int], 93 | assignedUserName: Option[String], 94 | labels: List[Label], 95 | prefix: String 96 | ): Map[String, String] = Map( 97 | "None" -> "0", 98 | "Label:" + prefix -> labels.find(_.labelName.startsWith(prefix)).map(_.labelId).getOrElse(0).toString, 99 | "Milestones" -> milestoneId.getOrElse(0).toString, 100 | "Priorities" -> priorityId.getOrElse(0).toString, 101 | "Assignees" -> (assignedUserName match { 102 | case Some(s) if s.nonEmpty => s 103 | case _ => "-" 104 | }) 105 | ) 106 | 107 | def createSummaryMetrics( 108 | labels: List[Label], 109 | prefix: String, 110 | priority: Option[Priority], 111 | assignedUserName: Option[String], 112 | repository: String 113 | ): Map[String, String] = Map( 114 | "None" -> "-", 115 | "Label:" + prefix -> labels.find(_.labelName.startsWith(prefix)).map(_.labelName).getOrElse("-"), 116 | "Priorities" -> priority.map(_.priorityName).getOrElse("-"), 117 | "Assignees" -> assignedUserName.getOrElse("-"), 118 | "Repositories" -> repository 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /src/main/scala/io/github/gitbucket/labelkanban/api/ApiLabelKanban.scala: -------------------------------------------------------------------------------- 1 | package io.github.gitbucket.labelkanban.api 2 | 3 | import gitbucket.core.api.{ApiPath, FieldSerializable} 4 | import gitbucket.core.model.Label 5 | import gitbucket.core.util.RepositoryName 6 | import gitbucket.core.view.helpers 7 | 8 | case class ApiLabelKanban( 9 | userName: String, 10 | labelId: Int = 0, 11 | labelName: String, 12 | color: String 13 | )(repositoryName: RepositoryName) extends FieldSerializable { 14 | val html_url = ApiPath(s"/${repositoryName.fullName}/issues?labels=${helpers.urlEncode(labelName)}&state=open") 15 | val detach_url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/plugin/labelkanban/label/${labelId}/detach/issue/") 16 | val attach_url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/plugin/labelkanban/label/${labelId}/attach/issue/") 17 | } 18 | 19 | object ApiLabelKanban { 20 | def apply(label: Label, repositoryName: RepositoryName): ApiLabelKanban = 21 | ApiLabelKanban( 22 | userName = label.userName, 23 | labelId = label.labelId, 24 | labelName = label.labelName, 25 | color = label.color 26 | )(repositoryName) 27 | } -------------------------------------------------------------------------------- /src/main/scala/io/github/gitbucket/labelkanban/api/ApiLaneKanban.scala: -------------------------------------------------------------------------------- 1 | package io.github.gitbucket.labelkanban.api 2 | 3 | import gitbucket.core.api.{ApiPath, FieldSerializable} 4 | import gitbucket.core.controller.Context 5 | import gitbucket.core.model.{Label, Milestone, Priority} 6 | import gitbucket.core.util.RepositoryName 7 | import gitbucket.core.view.helpers 8 | import io.github.gitbucket.labelkanban.service.KanbanHelpers 9 | 10 | 11 | case class ApiLaneKanban( 12 | id : String, 13 | name : String, 14 | color : String, 15 | iconImage : String, 16 | icon : String, 17 | htmlUrl : Option[ApiPath], 18 | switchUrl : Option[ApiPath], 19 | paramKey: String, 20 | order: Int 21 | ) extends FieldSerializable { 22 | } 23 | 24 | object ApiLaneKanban { 25 | def apply(label: Label, repositoryName: RepositoryName, order: Int): ApiLaneKanban = 26 | ApiLaneKanban( 27 | id = label.labelId.toString, 28 | name = label.labelName, 29 | color = label.color, 30 | iconImage = "", 31 | icon = "octicon octicon-tag", 32 | htmlUrl = Some(ApiPath(s"/${repositoryName.fullName}/issues?labels=${helpers.urlEncode(label.labelName)}&state=open")), 33 | switchUrl = Some(ApiPath(s"/api/v3/repos/${repositoryName.fullName}/plugin/labelkanban/label/${label.labelId}/switch/issue/")), 34 | paramKey = "label", 35 | order = order 36 | ) 37 | 38 | def apply(milestone: Milestone, repositoryName: RepositoryName, order: Int): ApiLaneKanban = 39 | ApiLaneKanban( 40 | id = milestone.milestoneId.toString, 41 | name = milestone.title, 42 | color = KanbanHelpers.toColorString(milestone.title), 43 | iconImage = "", 44 | icon = "octicon octicon-milestone", 45 | htmlUrl = Some(ApiPath(s"/${repositoryName.fullName}/issues?milestone=${helpers.urlEncode(milestone.title)}&state=open")), 46 | switchUrl = Some(ApiPath(s"/api/v3/repos/${repositoryName.fullName}/plugin/labelkanban/milestone/${milestone.milestoneId}/switch/issue/")), 47 | paramKey = "milestone", 48 | order = order 49 | ) 50 | 51 | def apply(userName: String, repositoryName: RepositoryName, order: Int)(implicit context :Context): ApiLaneKanban = 52 | ApiLaneKanban( 53 | id = userName, 54 | name = userName, 55 | color = KanbanHelpers.toColorString(userName), 56 | iconImage = s"""${context.path}/${userName}/_avatar""", 57 | icon = "octicon octicon-person", 58 | htmlUrl = Some(ApiPath(s"/${repositoryName.fullName}/issues?assigned=${helpers.urlEncode(userName)}&state=open")), 59 | switchUrl = Some(ApiPath(s"/api/v3/repos/${repositoryName.fullName}/plugin/labelkanban/assignee/${userName}/switch/issue/")), 60 | paramKey = "assignee", 61 | order = order 62 | ) 63 | 64 | def apply(priority: Priority, repositoryName: RepositoryName, order: Int): ApiLaneKanban = 65 | ApiLaneKanban( 66 | id = priority.priorityId.toString(), 67 | name = priority.priorityName, 68 | color = priority.color, 69 | iconImage = "", 70 | icon = "octicon octicon-flame", 71 | htmlUrl = Some(ApiPath(s"/${repositoryName.fullName}/issues?priority=${helpers.urlEncode(priority.priorityName)}&state=open")), 72 | switchUrl = Some(ApiPath(s"/api/v3/repos/${repositoryName.fullName}/plugin/labelkanban/priority/${priority.priorityId}/switch/issue/")), 73 | paramKey = "priority", 74 | order = order 75 | ) 76 | } 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/main/scala/io/github/gitbucket/labelkanban/api/ApiMilestoneKanban.scala: -------------------------------------------------------------------------------- 1 | package io.github.gitbucket.labelkanban.api 2 | 3 | import gitbucket.core.api.{ApiPath, FieldSerializable} 4 | import gitbucket.core.model.Milestone 5 | import gitbucket.core.util.RepositoryName 6 | import gitbucket.core.view.helpers 7 | import gitbucket.core.view.helpers.urlEncode 8 | 9 | case class ApiMilestoneKanban( 10 | userName: String, 11 | milestoneId: Int = 0, 12 | title: String, 13 | description: Option[String], 14 | dueDate: Option[java.util.Date], 15 | closedDate: Option[java.util.Date] 16 | )( 17 | repositoryName: RepositoryName 18 | ) extends FieldSerializable 19 | { 20 | val html_url = ApiPath(s"/${repositoryName.fullName}/issues?milestone=${helpers.urlEncode(title)}&state=open") 21 | val detach_url = ApiPath("") 22 | val attach_url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/plugin/labelkanban/milestone/${milestoneId}/switch/issue/") 23 | } 24 | 25 | object ApiMilestoneKanban { 26 | def apply(milestone: Milestone, repositoryName: RepositoryName): ApiMilestoneKanban = 27 | ApiMilestoneKanban( 28 | userName = milestone.userName, 29 | milestoneId = milestone.milestoneId, 30 | title = milestone.title, 31 | description = milestone.description, 32 | dueDate = milestone.dueDate, 33 | closedDate = milestone.closedDate 34 | )(repositoryName) 35 | } -------------------------------------------------------------------------------- /src/main/scala/io/github/gitbucket/labelkanban/api/ApiPriorityKanban.scala: -------------------------------------------------------------------------------- 1 | package io.github.gitbucket.labelkanban.api 2 | 3 | import gitbucket.core.api.{ApiPath, FieldSerializable} 4 | import gitbucket.core.util.RepositoryName 5 | import gitbucket.core.model.Priority 6 | import gitbucket.core.view.helpers.urlEncode 7 | 8 | case class ApiPriorityKanban( 9 | userName: String, 10 | priorityId: Int = 0, 11 | priorityName: String, 12 | description: String, 13 | isDefault: Boolean, 14 | ordering: Int = 0, 15 | color: String 16 | )(repositoryName: RepositoryName) extends FieldSerializable { 17 | val html_url = ApiPath(s"/${repositoryName.fullName}/issues?priority=${urlEncode(priorityName)}&state=open") 18 | val detach_url = "" 19 | val attach_url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/plugin/labelkanban/priority/${priorityId}/switch/issue/") 20 | } 21 | 22 | object ApiPriorityKanban { 23 | def apply(priority: Priority, repositoryName: RepositoryName): ApiPriorityKanban = 24 | ApiPriorityKanban( 25 | userName = priority.userName, 26 | priorityId = priority.priorityId, 27 | priorityName = priority.priorityName, 28 | description = priority.description.getOrElse(""), 29 | isDefault = priority.isDefault, 30 | ordering = priority.ordering, 31 | color = priority.color)(repositoryName: RepositoryName) 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/main/scala/io/github/gitbucket/labelkanban/controller/LabelKanbanController.scala: -------------------------------------------------------------------------------- 1 | package io.github.gitbucket.labelkanban.controller 2 | 3 | import gitbucket.core.api._ 4 | import gitbucket.core.controller.ControllerBase 5 | import gitbucket.core.model.{Label, Priority} 6 | import gitbucket.core.service.IssuesService._ 7 | import gitbucket.core.service.RepositoryService.RepositoryInfo 8 | import gitbucket.core.service._ 9 | import gitbucket.core.util.Implicits._ 10 | import gitbucket.core.util._ 11 | import io.github.gitbucket.labelkanban.api._ 12 | import io.github.gitbucket.labelkanban.service.KanbanHelpers 13 | import labelkanban.gitbucket.html 14 | import org.apache.commons.io.ByteOrderMark 15 | import org.scalatra.util.UrlCodingUtils._ 16 | 17 | import java.nio.charset.StandardCharsets 18 | import java.util.Date 19 | import scala.collection.mutable 20 | 21 | case class kanbanOrder(id: String, order: Int) 22 | 23 | class LabelKanbanController 24 | extends labelKanbanControllerBase 25 | with IssuesService 26 | with RepositoryService 27 | with AccountService 28 | with LabelsService 29 | with MilestonesService 30 | with ActivityService 31 | with IssueCreationService 32 | with CustomFieldsService 33 | with WebHookIssueCommentService 34 | with WebHookPullRequestService 35 | with ReadableUsersAuthenticator 36 | with ReferrerAuthenticator 37 | with WritableUsersAuthenticator 38 | with PrioritiesService 39 | with PullRequestService 40 | with CommitsService 41 | with WebHookService 42 | with MergeService 43 | with WebHookPullRequestReviewCommentService 44 | with HandleCommentService 45 | with RequestCache 46 | 47 | trait labelKanbanControllerBase extends ControllerBase { 48 | 49 | self: IssuesService 50 | with RepositoryService 51 | with AccountService 52 | with LabelsService 53 | with MilestonesService 54 | with ActivityService 55 | with IssueCreationService 56 | with CustomFieldsService 57 | with WebHookIssueCommentService 58 | with WebHookPullRequestService 59 | with ReadableUsersAuthenticator 60 | with ReferrerAuthenticator 61 | with WritableUsersAuthenticator 62 | with HandleCommentService 63 | with PrioritiesService => 64 | 65 | val prefix = "@" 66 | 67 | get("/:owner/:repository/labelkanban")( 68 | referrersOnly { repository: RepositoryInfo => 69 | { 70 | if (repository.repository.options.issuesOption == "DISABLE") { 71 | repository.repository.options.externalIssuesUrl match { 72 | case Some(value) => redirect(value) 73 | case None => NotFound() 74 | } 75 | } else { 76 | html.repository( 77 | prefix, 78 | repository 79 | ) 80 | } 81 | } 82 | } 83 | ) 84 | 85 | get("/summarykanban/:owner") { 86 | val account = getAccountByUserName(params("owner")) 87 | val repos = getVisibleRepositories(context.loginAccount, withoutPhysicalInfo = true) 88 | 89 | html.summary(prefix, repos, account.get) 90 | } 91 | 92 | get("/summarykanban/:owner/kanban.csv") { 93 | val user = params("owner") 94 | 95 | val lanes = createSummaryLanes(user) 96 | val issues = createSummaryApiIssueKanbans(user) 97 | 98 | downloadCsv(lanes, issues) 99 | } 100 | 101 | get("/summarykanban/:owner/profile") { 102 | val owner = params("owner") 103 | getAccountByUserName(owner) 104 | .map { account => 105 | val extraMailAddresses = getAccountExtraMailAddresses(owner) 106 | html.profile( 107 | prefix, 108 | account, 109 | if (account.isGroupAccount) Nil else getGroupsByUserName(owner), 110 | extraMailAddresses 111 | ) 112 | } 113 | .getOrElse(NotFound()) 114 | } 115 | 116 | get("/summarykanban/:owner/profile/kanban.csv") { 117 | val user = params("owner") 118 | 119 | val lanes = createSummaryLanes(user) 120 | val issues = createSummaryApiIssueKanbans(user) 121 | 122 | downloadCsv(lanes, issues) 123 | } 124 | 125 | get("/:owner/:repository/labelkanban/issues/new")(readableUsersOnly { repository => 126 | if (repository.repository.options.issuesOption == "DISABLE") 127 | notFound() 128 | else if (isIssueEditable(repository)) { 129 | val labelIds = multiParams("label") 130 | val milestoneId = params.get("milestone") 131 | val priorityId = params.get("priority") 132 | val assigneeName = params.get("assignee") 133 | 134 | html.newissue( 135 | getAssignableUserNames(repository.owner, repository.name), 136 | getMilestones(repository.owner, repository.name), 137 | getPriorities(repository.owner, repository.name), 138 | getDefaultPriority(repository.owner, repository.name), 139 | getLabels(repository.owner, repository.name), 140 | isIssueManageable(repository), 141 | getContentTemplate(repository, "ISSUE_TEMPLATE"), 142 | repository, 143 | assigneeName, 144 | milestoneId, 145 | priorityId, 146 | labelIds, 147 | getCustomFields(repository.owner, repository.name).filter(_.enableForIssues).map((_, None)) 148 | ) 149 | } else Unauthorized() 150 | }) 151 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/issues/close/:iid")(readableUsersOnly { repository => 152 | if (repository.repository.options.issuesOption == "DISABLE") 153 | notFound() 154 | else if (isIssueEditable(repository)) { 155 | val issueId = params("iid") 156 | val owner = params("owner") 157 | val issue = getIssue(owner, repository.name, issueId) 158 | 159 | handleComment(issue.get, None, repository, Some("close")) 160 | } else Unauthorized() 161 | }) 162 | get("/:owner/:repository/labelkanban/kanban.csv")(referrersOnly { repository => 163 | val lanes = createLanes(repository) 164 | val issues = createApiIssueKanbans(repository) 165 | 166 | downloadCsv(lanes, issues) 167 | }) 168 | 169 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/dataset")(referrersOnly { repository => 170 | JsonFormat( 171 | ApiDataSetKanban( 172 | createApiIssueKanbans(repository), 173 | createLanes(repository) 174 | ) 175 | ) 176 | }) 177 | 178 | get("/api/v3/:owner/plugin/summarykanban/dataset") { 179 | val user = params("owner") 180 | val groups = user :: getGroupsByUserName(user) 181 | val repositories = getVisibleRepositories(context.loginAccount, withoutPhysicalInfo = true) 182 | .filter(r => 183 | (r.repository.options.issuesOption != "DISABLE" && 184 | groups.contains(r.owner) || 185 | getCollaborators(r.owner, r.repository.repositoryName).exists(c => c._1.collaboratorName == user)) && 186 | countIssue(IssueSearchCondition(), IssueSearchOption.Issues, (r.owner, r.repository.repositoryName)) > 0 187 | ) 188 | 189 | JsonFormat( 190 | ApiDataSetKanban( 191 | createSummaryApiIssueKanbans(user), 192 | createSummaryLanes(user) 193 | ) 194 | ) 195 | } 196 | 197 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/issues")(referrersOnly { repository => 198 | JsonFormat( 199 | getOpenIssues(repository.owner, repository.name).map(issue => 200 | ApiIssueKanban( 201 | issue, 202 | getIssueAssignees(repository.owner, repository.name, issue.issueId).headOption.map(_.assigneeUserName), 203 | getIssueLabels(repository.owner, repository.name, issue.issueId), 204 | prefix, 205 | RepositoryName(repository) 206 | ) 207 | ) 208 | ) 209 | }) 210 | 211 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/labels")(referrersOnly { repository => 212 | JsonFormat( 213 | getLabels(repository.owner, repository.name) 214 | .sortBy(label => label.labelId) 215 | .map(label => ApiLabelKanban(label, RepositoryName(repository))) 216 | ) 217 | }) 218 | 219 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/milestones")(referrersOnly { repository => 220 | JsonFormat( 221 | getMilestonesWithIssueCount(repository.owner, repository.name) 222 | .filter(items => 223 | items._2 > 0 || items._3 == 0 || (items._1.dueDate.isDefined && items._1.dueDate.get.after(new Date)) 224 | ) 225 | .reverse 226 | .map(items => ApiMilestoneKanban(items._1, RepositoryName(repository))) 227 | ) 228 | }) 229 | 230 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/priorities")(referrersOnly { repository => 231 | JsonFormat( 232 | getPriorities(repository.owner, repository.name).reverse 233 | .map(priority => ApiPriorityKanban(priority, RepositoryName(repository))) 234 | ) 235 | }) 236 | 237 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/assignees")(referrersOnly { repository => 238 | JsonFormat( 239 | getAssignableUserNames(repository.owner, repository.name).map(assignee => 240 | ApiAssigneeKanban(assignee)(RepositoryName(repository)) 241 | ) 242 | ) 243 | }) 244 | 245 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/priority/:pid/switch/issue/:iid")(readableUsersOnly { 246 | repository => 247 | val issueId = params("iid").toInt 248 | val priorityId = tryToInt(params("pid")) match { 249 | case Some(i) if i > 0 => Some(i) 250 | case _ => None 251 | } 252 | 253 | updatePriorityId(repository.owner, repository.name, issueId, priorityId, insertComment = true) 254 | getApiIssue(issueId, repository) 255 | }) 256 | 257 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/milestone/:mid/switch/issue/:iid")(readableUsersOnly { 258 | repository => 259 | val issueId = params("iid").toInt 260 | val milestoneId = tryToInt(params("mid")) match { 261 | case Some(i) if i > 0 => Some(i) 262 | case _ => None 263 | } 264 | 265 | updateMilestoneId(repository.owner, repository.name, issueId, milestoneId, insertComment = true) 266 | 267 | getApiIssue(issueId, repository) 268 | }) 269 | 270 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/assignee/:assignee/switch/issue/:iid")(readableUsersOnly { 271 | repository => 272 | val issueId = params("iid").toInt 273 | val assignee = params("assignee") match { 274 | case "-" => None 275 | case s: String if s.nonEmpty => Some(s) 276 | case _ => None 277 | } 278 | 279 | getIssueAssignees(repository.owner, repository.name, issueId).foreach(a => 280 | deleteIssueAssignee(repository.owner, repository.name, issueId, a.assigneeUserName, insertComment = true) 281 | ) 282 | 283 | if (assignee.isDefined) { 284 | registerIssueAssignee(repository.owner, repository.name, issueId, assignee.getOrElse(""), insertComment = true) 285 | } 286 | 287 | getApiIssue(issueId, repository) 288 | }) 289 | 290 | get("/api/v3/repos/:owner/:repository/plugin/labelkanban/label/:lid/switch/issue/:iid")(readableUsersOnly { 291 | repository => 292 | val issueId = params("iid").toInt 293 | val labelId = tryToInt(params("lid"), 0) 294 | 295 | getIssueLabels(repository.owner, repository.name, issueId) 296 | .filter(_.labelName.startsWith(prefix)) 297 | .map(label => deleteIssueLabel(repository.owner, repository.name, issueId, label.labelId, insertComment = true)) 298 | 299 | if (labelId > 0) 300 | registerIssueLabel(repository.owner, repository.name, issueId, labelId, insertComment = true) 301 | 302 | getApiIssue(issueId, repository) 303 | }) 304 | 305 | def createDummyLane(key: String, id: String, repository: RepositoryInfo): ApiLaneKanban = { 306 | ApiLaneKanban( 307 | id = id, 308 | name = "", 309 | color = "333333", 310 | iconImage = "", 311 | icon = "", 312 | htmlUrl = None, 313 | switchUrl = key match { 314 | case s if s.nonEmpty => 315 | Some( 316 | ApiPath(s"/api/v3/repos/${RepositoryName(repository).fullName}/plugin/labelkanban/${key}/-/switch/issue/") 317 | ) 318 | case _ => 319 | None 320 | }, 321 | paramKey = "", 322 | order = 0 323 | ) 324 | } 325 | 326 | def createSummaryDummyLane(id: String): ApiLaneKanban = { 327 | ApiLaneKanban( 328 | id = id, 329 | name = "", 330 | color = "333333", 331 | iconImage = "", 332 | icon = "", 333 | htmlUrl = None, 334 | switchUrl = None, 335 | paramKey = "", 336 | order = 0 337 | ) 338 | } 339 | 340 | def createLanes(repository: RepositoryInfo): mutable.LinkedHashMap[String, List[ApiLaneKanban]] = { 341 | mutable.LinkedHashMap( 342 | "None" -> 343 | List[ApiLaneKanban](createDummyLane("", "0", repository)), 344 | "Label:" + prefix -> ( 345 | createDummyLane("label", "0", repository) :: 346 | getLabels(repository.owner, repository.name) 347 | .filter(label => label.labelName.startsWith(prefix)) 348 | .sortBy(label => label.labelId) 349 | .zipWithIndex 350 | .map { 351 | case (label, index) => 352 | ApiLaneKanban(label, RepositoryName(repository), index + 1) 353 | } 354 | ), 355 | "Priorities" -> ( 356 | createDummyLane("priority", "0", repository) :: 357 | getPriorities(repository.owner, repository.name).reverse.zipWithIndex 358 | .map { 359 | case (priority, index) => 360 | ApiLaneKanban(priority, RepositoryName(repository), index + 1) 361 | } 362 | ), 363 | "Milestones" -> ( 364 | createDummyLane("milestone", "0", repository) :: 365 | getMilestonesWithIssueCount(repository.owner, repository.name) 366 | .filter(items => 367 | items._2 > 0 || items._3 == 0 || (items._1.dueDate.isDefined && items._1.dueDate.get.after(new Date)) 368 | ) 369 | .reverse 370 | .zipWithIndex 371 | .map { 372 | case (items, index) => 373 | ApiLaneKanban(items._1, RepositoryName(repository), index + 1) 374 | } 375 | ), 376 | "Assignees" -> ( 377 | createDummyLane("assignee", "-", repository) :: 378 | getAssignableUserNames(repository.owner, repository.name).zipWithIndex 379 | .map { 380 | case (assignee, index) => 381 | ApiLaneKanban(assignee, RepositoryName(repository), index + 1) 382 | } 383 | ) 384 | ) 385 | } 386 | 387 | def createSummaryLanes(user: String): mutable.LinkedHashMap[String, List[ApiLaneKanban]] = { 388 | val repositories = getRelatedRepositories(user) 389 | 390 | mutable.LinkedHashMap( 391 | "None" -> 392 | List[ApiLaneKanban](createSummaryDummyLane("-")), 393 | "Label:" + prefix -> ( 394 | createSummaryDummyLane("-") :: 395 | repositories 396 | .flatMap(repository => getLabels(repository.owner, repository.name)) 397 | .filter(label => label.labelName.startsWith(prefix)) 398 | .sortBy(label => label.labelName) 399 | .reverse 400 | .foldLeft(Nil: List[Label]) { (acc, next) => 401 | if (acc.exists(_.labelName == next.labelName)) acc else next :: acc 402 | } 403 | .zipWithIndex 404 | .map { 405 | case (label, index) => 406 | ApiLaneKanban( 407 | id = label.labelName, 408 | name = label.labelName, 409 | color = label.color, 410 | iconImage = "", 411 | icon = "octicon octicon-tag", 412 | htmlUrl = None, 413 | switchUrl = None, 414 | paramKey = "", 415 | order = index + 1 416 | ) 417 | } 418 | ), 419 | "Priorities" -> ( 420 | createSummaryDummyLane("-") :: 421 | repositories 422 | .flatMap(repository => getPriorities(repository.owner, repository.name)) 423 | .foldLeft(Nil: List[Priority]) { (acc, next) => 424 | if (acc.exists(_.priorityName == next.priorityName)) acc else next :: acc 425 | } 426 | .zipWithIndex 427 | .map { 428 | case (priority, index) => 429 | ApiLaneKanban( 430 | id = priority.priorityName, // avoid priorityName == "-" 431 | name = priority.priorityName, 432 | color = priority.color, 433 | iconImage = "", 434 | icon = "octicon octicon-flame", 435 | htmlUrl = None, 436 | switchUrl = None, 437 | paramKey = "", 438 | order = index + 1 439 | ) 440 | } 441 | ), 442 | "Assignees" -> ( 443 | createSummaryDummyLane("-") :: 444 | repositories 445 | .flatMap(repository => getAssignableUserNames(repository.owner, repository.name)) 446 | .foldLeft(Nil: List[String]) { (acc, next) => 447 | if (acc.contains(next)) acc else next :: acc 448 | } 449 | .zipWithIndex 450 | .map { 451 | case (assignee, index) => 452 | ApiLaneKanban( 453 | id = assignee, 454 | name = assignee, 455 | color = KanbanHelpers.toColorString(assignee), 456 | iconImage = s"""${context.path}/${assignee}/_avatar""", 457 | icon = "octicon octicon-person", 458 | htmlUrl = None, 459 | switchUrl = None, 460 | paramKey = "", 461 | order = index + 1 462 | ) 463 | } 464 | ), 465 | "Repositories" -> 466 | repositories.zipWithIndex 467 | .map { 468 | case (repository, index) => 469 | ApiLaneKanban( 470 | id = repository.name, 471 | name = repository.name, 472 | color = KanbanHelpers.toColorString(repository.name), 473 | iconImage = "", 474 | icon = "octicon octicon-repo", 475 | htmlUrl = Some(ApiPath(s"/${RepositoryName(repository).fullName}")), 476 | switchUrl = None, 477 | paramKey = "", 478 | order = index 479 | ) 480 | } 481 | ) 482 | } 483 | 484 | def getApiIssue(issueId: Int, repository: RepositoryInfo): String = { 485 | val issue = getIssue(repository.owner, repository.name, issueId.toString).get 486 | 487 | JsonFormat( 488 | ApiIssueKanban( 489 | issue, 490 | getIssueAssignees(repository.owner, repository.name, issue.issueId).headOption.map(_.assigneeUserName), 491 | getIssueLabels(repository.owner, repository.name, issue.issueId), 492 | prefix, 493 | RepositoryName(repository) 494 | ) 495 | ) 496 | } 497 | 498 | def tryToInt(text: String): Option[Int] = 499 | try { 500 | Some(text.toInt) 501 | } catch { 502 | case _: java.lang.NumberFormatException => None 503 | } 504 | 505 | def tryToInt(text: String, default: Int): Int = 506 | try { 507 | text.toInt 508 | } catch { 509 | case _: java.lang.NumberFormatException => default 510 | } 511 | 512 | def toLaneName(lanes: List[ApiLaneKanban], id: String): String = { 513 | lanes 514 | .find(l => l.id == id) 515 | .map(l => if (l.name.isEmpty) "None" else l.name) 516 | .getOrElse("None") 517 | .replace("\"", "\"\"") 518 | } 519 | 520 | def downloadCsv( 521 | laneMap: mutable.LinkedHashMap[String, List[ApiLaneKanban]], 522 | issues: List[ApiIssueKanban] 523 | ): Array[Byte] = { 524 | contentType = "text/csv" 525 | 526 | val rowKeyEnc = cookies.get("kanban.rowKey").getOrElse("None") 527 | val colKeyEnc = cookies.get("kanban.colKey").getOrElse("None") 528 | val rowKey = urlDecode(rowKeyEnc) 529 | val colKey = urlDecode(colKeyEnc) 530 | 531 | val rowOrderStr = urlDecode(cookies.get("kanban.order." + rowKeyEnc).getOrElse("[]")) 532 | val colOrderStr = urlDecode(cookies.get("kanban.order." + colKeyEnc).getOrElse("[]")) 533 | 534 | val rowOrders = org.json4s.jackson.parseJson(rowOrderStr).extract[List[kanbanOrder]] 535 | val colOrders = org.json4s.jackson.parseJson(colOrderStr).extract[List[kanbanOrder]] 536 | 537 | var csv = "\"" + rowKey.replace("\"", "\"\"") + "/" + colKey.replace("\"", "\"\"") + "\",\"" + colOrders 538 | .map(c => toLaneName(laneMap(colKey), c.id)) 539 | .mkString("\",\"") + "\"\n" 540 | 541 | for (rowOrder <- rowOrders.reverse) { 542 | csv += "\"" + toLaneName(laneMap(rowKey), rowOrder.id) + "\"" 543 | for (colOrder <- colOrders) { 544 | csv += ",\"" + issues 545 | .filter(issue => issue.metrics(rowKey) == rowOrder.id && issue.metrics(colKey) == colOrder.id) 546 | .map(issue => issue.title.replace("\"", "\"\"")) 547 | .mkString("\n") + "\"" 548 | } 549 | csv += "\n" 550 | } 551 | ByteOrderMark.UTF_8.getBytes ++ csv.getBytes(StandardCharsets.UTF_8) 552 | } 553 | 554 | def createApiIssueKanbans(repository: RepositoryInfo): List[ApiIssueKanban] = { 555 | getOpenIssues(repository.owner, repository.name) 556 | .map(issue => 557 | ApiIssueKanban( 558 | issue, 559 | getIssueAssignees(repository.owner, repository.name, issue.issueId).headOption.map(_.assigneeUserName), 560 | getIssueLabels(repository.owner, repository.name, issue.issueId), 561 | prefix, 562 | RepositoryName(repository) 563 | ) 564 | ) 565 | } 566 | 567 | def getRelatedRepositories(user: String): List[RepositoryInfo] = { 568 | val groups = user :: getGroupsByUserName(user) 569 | 570 | getVisibleRepositories(context.loginAccount, withoutPhysicalInfo = true) 571 | .filter(r => 572 | (r.repository.options.issuesOption != "DISABLE" && 573 | groups.contains(r.owner) || 574 | getCollaborators(r.owner, r.repository.repositoryName).exists(c => c._1.collaboratorName == user)) && 575 | countIssue(IssueSearchCondition(), IssueSearchOption.Issues, (r.owner, r.repository.repositoryName)) > 0 576 | ) 577 | } 578 | 579 | def createSummaryApiIssueKanbans(user: String): List[ApiIssueKanban] = { 580 | val repositories = getRelatedRepositories(user) 581 | 582 | repositories 583 | .flatMap(repository => 584 | getOpenIssues(repository.owner, repository.name) 585 | .map(issue => 586 | ApiIssueKanban.applySummary( 587 | issue, 588 | getIssueAssignees(repository.owner, repository.name, issue.issueId).headOption.map(_.assigneeUserName), 589 | getIssueLabels(repository.owner, repository.name, issue.issueId), 590 | prefix, 591 | getPriorities(repository.owner, repository.name) 592 | ) 593 | ) 594 | ) 595 | } 596 | } 597 | -------------------------------------------------------------------------------- /src/main/scala/io/github/gitbucket/labelkanban/service/KanbanHelpers.scala: -------------------------------------------------------------------------------- 1 | package io.github.gitbucket.labelkanban.service 2 | 3 | import java.security.MessageDigest 4 | 5 | package object KanbanHelpers { 6 | def toColorString(text:String):String = 7 | MessageDigest 8 | .getInstance("MD5") 9 | .digest(text.getBytes) 10 | .map("%02x".format(_)) 11 | .mkString 12 | .toUpperCase 13 | .substring(0, 6) 14 | } 15 | -------------------------------------------------------------------------------- /src/main/twirl/labelkanban/gitbucket/core.scala.html: -------------------------------------------------------------------------------- 1 | @(editable:Boolean)(implicit context: gitbucket.core.controller.Context) 2 | @import context._ 3 | @import gitbucket.core.view.helpers._ 4 | @import java.util.{Date,Calendar} 5 | @import gitbucket.core.model.{Issue,IssueComment} 6 | @import scala.collection.mutable.ListBuffer 7 | @import gitbucket.core.view.helpers 8 | 9 | 14 | 23 | 180 | 181 | 182 | -------------------------------------------------------------------------------- /src/main/twirl/labelkanban/gitbucket/newissue.scala.html: -------------------------------------------------------------------------------- 1 | @(collaborators: List[String], 2 | milestones: List[gitbucket.core.model.Milestone], 3 | priorities: List[gitbucket.core.model.Priority], 4 | defaultPriority: Option[gitbucket.core.model.Priority], 5 | labels: List[gitbucket.core.model.Label], 6 | isManageable: Boolean, 7 | content: String, 8 | repository: gitbucket.core.service.RepositoryService.RepositoryInfo, 9 | assigneeName: Option[String], 10 | milestoneId: Option[String], 11 | priorityId: Option[String], 12 | labelIds: Seq[String], 13 | customFields: List[(gitbucket.core.model.CustomField, Option[gitbucket.core.model.IssueCustomField])] 14 | )(implicit context: gitbucket.core.controller.Context) 15 | @import gitbucket.core.view.helpers 16 | 17 | @gitbucket.core.html.main(s"New Issue - ${repository.owner}/${repository.name}", Some(repository)){ 18 | @gitbucket.core.html.menu("issues", repository){ 19 |
20 |
21 |
22 | 23 | 24 | @gitbucket.core.helper.html.preview( 25 | repository = repository, 26 | content = content, 27 | enableWikiLink = false, 28 | enableRefsLink = true, 29 | enableLineBreaks = true, 30 | enableTaskList = true, 31 | hasWritePermission = isManageable, 32 | completionContext = "issues", 33 | style = "height: 200px; max-height: 500px;", 34 | elastic = true 35 | ) 36 |
37 | 38 |
39 |
40 |
41 | @gitbucket.core.issues.html.issueinfo(None, Nil, Nil, Nil, collaborators, milestones.map(x => (x, 0, 0)), priorities, defaultPriority, labels, customFields, isManageable, repository) 42 |
43 |
44 |
45 | 46 | 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/twirl/labelkanban/gitbucket/profile.scala.html: -------------------------------------------------------------------------------- 1 | @( 2 | prefix:String, 3 | account: gitbucket.core.model.Account, 4 | groupNames: List[String], 5 | extraMailAddresses: List[String])(implicit context: gitbucket.core.controller.Context) 6 | 7 | @import gitbucket.core.view.helpers 8 | @gitbucket.core.account.html.main(account, groupNames, "summarykanban", extraMailAddresses){ 9 | 10 |
11 | @core(false) 12 |
13 | 14 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/twirl/labelkanban/gitbucket/repository.scala.html: -------------------------------------------------------------------------------- 1 | @( 2 | prefix:String, 3 | repository: gitbucket.core.service.RepositoryService.RepositoryInfo 4 | )(implicit context: gitbucket.core.controller.Context) 5 | @import gitbucket.core.view.helpers 6 | 7 | @gitbucket.core.html.main("Kanban", Some(repository)) { 8 | @gitbucket.core.html.menu("labelkanban", repository) { 9 | @core(true) 10 | 11 | 18 | 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/twirl/labelkanban/gitbucket/summary.scala.html: -------------------------------------------------------------------------------- 1 | @( 2 | prefix:String, 3 | recentRepositories: List[gitbucket.core.service.RepositoryService.RepositoryInfo], 4 | account: gitbucket.core.model.Account 5 | )(implicit context: gitbucket.core.controller.Context) 6 | @import gitbucket.core.view.helpers 7 | 8 | @gitbucket.core.html.main("Repositories"){ 9 | @gitbucket.core.dashboard.html.sidebar(recentRepositories){ 10 | @gitbucket.core.dashboard.html.tab("summarykanban") 11 | 12 |
13 | @core(false) 14 |
15 | 16 | 21 | } 22 | } 23 | --------------------------------------------------------------------------------