diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54aaab7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +.idea/ +build/ +teamcity-sourceforge.iml diff --git a/COPYING.txt b/COPYING.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/COPYING.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9950cc5 --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +SourceForge Integration +======================= + +This is a plugin for TeamCity that integrates SourceForge as issue tracker. + + + +Table of Contents +----------------- +* [Installation](#installation) +* [Setup](#setup) +* [Usage](#usage) +* [License](#license) + + + +Installation +------------ + +1. Download the ZIP file from the [latest release] and place it as-is into + the `plugins` directory of your TeamCity data directory. Do not extract the ZIP file. + You can for example + * put the ZIP file manually into the data directory, if you know where it is located and how to access it + * go to `Administration -> Plugins List -> Upload plugin zip` and upload the ZIP via web interface + * go to `Administration -> Diagnostics -> Browse Data Directory`, + press `Upload new file` and upload the ZIP via web interface to the `plugins` directory + +1. Delete the ZIP file of the old version from the `plugins` directory, if you are updating from a previous version. + +1. After the ZIP file is placed where it is supposed to be, restart your TeamCity server, + as it does not recognize plugin changes until restart. + + + +Setup +----- + +The plugin adds the issue tracker type `SourceForge` to TeamCity. + +To configure a connection to SourceForge: + +1. Go to `Administration -> + -> Issue Trackers -> Create new connection` + +1. Choose `SourceForge`as type + +1. Enter some display name to distinguish this connection instance from others you might configure + +1. Enter the unix name of the SourceForge project from which you want to add a ticket tool. + This can be a project prefixed by `p/` or a user prefixed with `u/`. + If you press `Save`, the project is checked for existence and an error is shown if it does not exist. + **_Examples:_** `p/jedit`, `u/vampire0` + +1. Enter the mount point of the ticket tool that you want to add. + If you press `Save`, the ticket tool is checked for existence in the given project + and the valid ticket tools are listed in the error message if it does not exist. + **_Examples:_** `bugs`, `features` + +1. Enter a Java-flavour regular expression as issue ID pattern which will be used + to find issue IDs in commit messages and whereelse supported by TeamCity. + The pattern is also used to extract the actual issue ID from the match. + If the given pattern has at least one match group, the content of the first match group is used as issue ID, + otherwise the full match is used. The given pattern is compiled in a case-insensitive manner. + If you press `Save`, the pattern is validated for syntactical correctness and for not matching the empty string. + **_Examples:_** `bug #(\d+)`, `\d+` + +1. *Optionally* enter a SourceForge search query that returns all resolved issues. + This query is used to determine whether an issue is to be considered resolved or not. + This manifests in the display style of the issue popup. + The syntax of the search query is the same as for [the search on SourceForge] itself. + If you press `Save`, the search query is validated for syntactical correctness. + **_Examples:_** `status:closed-fixed || status:closed-invalid` + +1. *Optionally* enter a SourceForge search query that returns all feature request issues + or `true` if all issues from this ticket tool are feature requests. + This query is used to determine whether an issue is a feature request or not. + This manifests in the display style of the issue popup. + The syntax of the search query is the same as for [the search on SourceForge] itself. + If you press `Save`, the search query is validated for syntactical correctness. + **_Examples:_** `true`, `label:feature` + +1. *Optionally* enter how to determine the type of an issues from the configured ticket tool. + + Allowed syntax for the field value: +
+
labels:<regex>[:<default>]
+
+ The value is defined by one or more labels.
+ If multiple labels are found, they are joined together with commas.
+ If no label is found, the default value is used, if one is defined.
+ The regex must not contain any colons. If you need to match a colon, use '\u003a' instead. +
    +
  • If no regex is given, all labels are used, e. g. 'labels:' or 'labels::bug'
  • +
  • + If a regex without group is given, all labels matching the regex are used completely, + e. g. 'labels:.+_bug' or 'labels:.+_bug:general_bug' +
  • +
  • + If a regex with groups is given, all labels matching the regex are used, but only their first group, + e. g. 'labels:type_(.+)' or 'labels:type_(.+):bug' +
  • +
+
+ +
custom:<custom field name>[:<default>]
+
+ The value is defined by the value of a custom field, e. g. 'custom:_type' or 'custom:_type:bug'
+ If the custom field is not found, not set or empty, the default value is used, if one is defined. +
+ +
<fixed string>
+
All issues have the same value defined here, e. g. 'bug'
+
+ If you press `Save`, the value is validated for syntactical correctness and in the labels case + with regular expression, that it does not match the empty string. + **_Examples:_** `labels:type_(.+):bug`, `custom:_type`, `feature` + +1. *Optionally* enter how to determine the priority of an issues from the configured ticket tool. + Allowed syntax for the field value is [the same as for type](#custom-value-syntax). + If you press `Save`, the value is validated for syntactical correctness and in the labels case + with regular expression, that it does not match the empty string. + **_Examples:_** `labels:"important\u003a .+":important: no`, `custom:_priority`, `important: no` + +1. *Optionally* enter how to determine the severity of an issues from the configured ticket tool. + Allowed syntax for the field value is [the same as for type](#custom-value-syntax). + If you press `Save`, the value is validated for syntactical correctness and in the labels case + with regular expression, that it does not match the empty string. + **_Examples:_** `labels:severity_(.+)`, `custom:_severity:not: severe`, `major` + + + +Usage +----- + +After the connection - or connections if you have multiple ticket tools - is configured, +the [issue tracker integration of TeamCity] can be used. + +To sum up what you get, here a quick list: + +* Issue mentions in commit comments are transformed into links to the issue in the issue tracker +* Next to issue mentions in commit comments is an arrow that triggers a pop-up with further + information about the respective issue +* Build results pages get a new Tab `Issues`, that lists the issues that were mentioned + in a check-in included in the build, if there were any +* Build configuration pages get a new Tab `Issue Log`, that lists all issues that were mentioned + in a check-in in a list, together with the builds of that build configuration + You can also filter this list by build number range and whether to show only resolved issues, + if you have set up the search query for finding resolved issues in the connection settings + + + +License +------- + +``` +This project is licensed under the Apache License, Version 2.0 (the "License"); +you may not use this project except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + + + +[latest release]: https://github.com/Vampire/teamcity-sourceforge/releases/latest +[the search on SourceForge]: https://sourceforge.net/p/allura/tickets/search_help/ +[issue tracker integration of TeamCity]: https://confluence.jetbrains.com/display/TCD9/Integrating+TeamCity+with+Issue+Tracker#IntegratingTeamCitywithIssueTracker-DedicatedSupportforIssueTrackers diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..39a0433 --- /dev/null +++ b/build.gradle @@ -0,0 +1,61 @@ +/* + * Copyright 2015 Björn Kautler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.apache.tools.ant.filters.ReplaceTokens + +plugins { + id "nebula.provided-base" version "2.2.2" +} + +apply plugin: 'java' + +version '1.0.0' +description 'Integrate SourceForge into TeamCity' + +repositories { + mavenCentral() + maven { + url 'http://repository.jetbrains.com/all' + } +} + +dependencies { + provided 'org.jetbrains.teamcity:server-api:9.0.3' + // needed for EhCacheUtil in the constructor of AbstractIssueFetcher + provided 'org.jetbrains.teamcity.internal:server:9.0.3' +} + +compileJava.options.encoding = 'UTF-8' +sourceCompatibility = '1.6' +archivesBaseName = 'sourceforge' + +task zip(type: Zip, dependsOn: jar) { + description 'Builds the ZIP archive to be uploaded to TeamCity' + inputs.property 'version', version + inputs.property 'description', project.description + into('server') { + from jar + from configurations.runtime.files - configurations.provided.files + } + from('resources/teamcity-plugin.xml') { + filter ReplaceTokens, tokens: [VERSION: version, DESCRIPTION: project.description] + } +} +defaultTasks 'zip' + +artifacts { + archives zip +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b5166da Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8c20553 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,22 @@ +# +# Copyright 2015 Björn Kautler +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +#Mon Jun 01 19:50:08 CEST 2015 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.4-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..91a7e26 --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..aec9973 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/resources/teamcity-plugin.xml b/resources/teamcity-plugin.xml new file mode 100644 index 0000000..418fd68 --- /dev/null +++ b/resources/teamcity-plugin.xml @@ -0,0 +1,32 @@ + + + + + + sourceforge + SourceForge Integration + @VERSION@ + @DESCRIPTION@ + https://github.com/Vampire/teamcity-sourceforge/releases/latest + Bjoern@Kautler.net + + Björn Kautler + https://github.com/Vampire/teamcity-sourceforge + + + + diff --git a/src/main/java/net/kautler/teamcity/sourceforge/SourceForgeIssueFetcher.java b/src/main/java/net/kautler/teamcity/sourceforge/SourceForgeIssueFetcher.java new file mode 100644 index 0000000..f44e2d3 --- /dev/null +++ b/src/main/java/net/kautler/teamcity/sourceforge/SourceForgeIssueFetcher.java @@ -0,0 +1,364 @@ +/* + * Copyright 2015 Björn Kautler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.kautler.teamcity.sourceforge; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.google.gson.Gson; +import jetbrains.buildServer.issueTracker.AbstractIssueFetcher; +import jetbrains.buildServer.issueTracker.IssueData; +import jetbrains.buildServer.issueTracker.errors.RetrieveIssueException; +import jetbrains.buildServer.util.cache.EhCacheUtil; +import net.kautler.teamcity.sourceforge.model.SearchResult; +import net.kautler.teamcity.sourceforge.model.Ticket; +import net.kautler.teamcity.sourceforge.model.TicketWrapper; +import org.apache.commons.httpclient.Credentials; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static java.lang.String.format; +import static java.util.Collections.emptyList; +import static jetbrains.buildServer.issueTracker.IssueData.PRIORITY_FIELD; +import static jetbrains.buildServer.issueTracker.IssueData.SEVERITY_FIELD; +import static jetbrains.buildServer.issueTracker.IssueData.STATE_FIELD; +import static jetbrains.buildServer.issueTracker.IssueData.SUMMARY_FIELD; +import static jetbrains.buildServer.issueTracker.IssueData.TYPE_FIELD; +import static net.kautler.teamcity.sourceforge.SourceForgeIssueProvider.safeCompilePattern; +import static net.kautler.teamcity.sourceforge.model.DataVehicle.getFeatureRequestQuery; +import static net.kautler.teamcity.sourceforge.model.DataVehicle.getPriority; +import static net.kautler.teamcity.sourceforge.model.DataVehicle.getProject; +import static net.kautler.teamcity.sourceforge.model.DataVehicle.getResolvedQuery; +import static net.kautler.teamcity.sourceforge.model.DataVehicle.getSeverity; +import static net.kautler.teamcity.sourceforge.model.DataVehicle.getTicketTool; +import static net.kautler.teamcity.sourceforge.model.DataVehicle.getType; +import static org.apache.commons.lang.StringUtils.isEmpty; +import static org.apache.commons.lang.StringUtils.isNotBlank; +import static org.apache.commons.lang.StringUtils.isNotEmpty; +import static org.apache.commons.lang.StringUtils.join; + +/** + * An {@code IssueFetcher}, that fetches SourceForge issues. + */ +public class SourceForgeIssueFetcher extends AbstractIssueFetcher { + public static final String LABELS_FIELD = "Labels"; + public static final String VOTES_FIELD = "Votes"; + private static final Gson GSON = new Gson(); + + public SourceForgeIssueFetcher(@NotNull EhCacheUtil cacheUtil) { + super(cacheUtil); + } + + @NotNull + @Override + public IssueData getIssue(@NotNull final String dataVehicleJson, @NotNull final String id, @Nullable Credentials credentials) throws Exception { + final String issueUrl = getIssueUrl(dataVehicleJson, id, true); + return getFromCacheOrFetch(issueUrl, new FetchFunction() { + @NotNull + @Override + public IssueData fetch() throws IOException { + InputStream issueStream = fetchHttpFile(issueUrl); + Ticket ticket = GSON.fromJson(new InputStreamReader(issueStream), TicketWrapper.class).getTicket(); + return getIssueData(ticket, dataVehicleJson); + } + }); + } + + @NotNull + @Override + public String getUrl(@NotNull String dataVehicleJson, @NotNull String id) { + return getIssueUrl(dataVehicleJson, id, false); + } + + /** + * Constructs the URL to the issue with the specified ID, either as browsing variant, or as API variant. + * + * @param dataVehicleJson the {@code JSON} representation of the data vehicle transporting the configuration data + * @param id the ID of the issue to construct the URL for + * @param rest whether to build the browsing variant ({@code false}) or the API variant ({@code true}) + * @return the constructed URL as string + */ + @NotNull + private String getIssueUrl(@NotNull String dataVehicleJson, @NotNull String id, boolean rest) { + return format("%s/%s", getTicketToolUrl(getProject(dataVehicleJson), getTicketTool(dataVehicleJson), rest), id); + } + + /** + * Constructs the URL to the search through the API for the specified project and ticket tool and with the specified search query. + * The search query is automatically URL encoded and must not be already encoded. + * + * @param project the project to construct the URL for + * @param ticketTool the ticket tool to construct the URL for + * @param query the search query to constuct the URL for + * @return the constructed URL as string + */ + @NotNull + String getSearchUrl(@NotNull String project, @NotNull String ticketTool, @NotNull String query) { + try { + return format("%s/search?q=%s", getTicketToolUrl(project, ticketTool, true), URLEncoder.encode(query, "UTF-8")); + } catch (UnsupportedEncodingException e) { + throw new AssertionError("UTF-8 should be supported on all JVMs", e); + } + } + + /** + * Constructs the URL to the specified ticket tool in the specified project, either as browsing variant, or as API variant. + * + * @param project the project to construct the URL for + * @param ticketTool the ticket tool to construct the URL for + * @param rest whether to build the browsing variant ({@code false}) or the API variant ({@code true}) + * @return the constructed URL as string + */ + @NotNull + private String getTicketToolUrl(@NotNull String project, @NotNull String ticketTool, boolean rest) { + return format("%s/%s", getProjectUrl(project, rest), ticketTool); + } + + /** + * Constructs the URL to the specified project, either as browsing variant, or as API variant. + * + * @param project the project to construct the URL for + * @param rest whether to build the browsing variant ({@code false}) or the API variant ({@code true}) + * @return the constructed URL as string + */ + @NotNull + String getProjectUrl(@NotNull String project, boolean rest) { + return format("https://sourceforge.net/%s%s", rest ? "rest/" : "", project); + } + + /** + * Fetches the issues corresponding to the specified IDs that are not cached already as one batch operation + * from the remote issue tracker. + * + * @param dataVehicleJson the {@code JSON} representation of the data vehicle transporting the configuration data + * @param ids the IDs to fetch the issues for + * @param credentials the credentials to use for authentication + * @return the fetched issues + */ + @Nullable + @Override + public Collection getIssuesInBatch(@NotNull final String dataVehicleJson, @NotNull Collection ids, @Nullable Credentials credentials) { + return super.defaultGetIssuesInBatch(dataVehicleJson, ids, new BatchFetchFunction() { + @NotNull + @Override + public List batchFetch(@NotNull Collection ids) { + try { + StringBuilder queryBuilder = new StringBuilder(); + for (String id : ids) { + queryBuilder.append("ticket_num:").append(id).append(" || "); + } + if (ids.size() > 0) { + queryBuilder.delete(queryBuilder.length() - 4, queryBuilder.length()); + } + String searchUrl = getSearchUrl(getProject(dataVehicleJson), getTicketTool(dataVehicleJson), queryBuilder.toString()); + InputStream issueStream = fetchHttpFile(searchUrl); + Collection tickets = GSON.fromJson(new InputStreamReader(issueStream), SearchResult.class).getTickets(); + List result = new ArrayList(tickets.size()); + for (Ticket ticket : tickets) { + result.add(getIssueData(ticket, dataVehicleJson)); + } + return result; + } catch (IOException e) { + return emptyList(); + } + } + }); + } + + /** + * Transforms a {@code Ticket} into an {@code IssueData}. + * + * @param ticket the ticket to be transformed + * @param dataVehicleJson the {@code JSON} representation of the data vehicle transporting the configuration data + * @return the transformed issue data + */ + @NotNull + private IssueData getIssueData(@NotNull Ticket ticket, @NotNull String dataVehicleJson) { + Map data = new HashMap(); + data.put(TYPE_FIELD, getCustomValue(getType(dataVehicleJson), ticket)); + data.put(SUMMARY_FIELD, ticket.getSummary()); + data.put(STATE_FIELD, ticket.getStatus()); + data.put(PRIORITY_FIELD, getCustomValue(getPriority(dataVehicleJson), ticket)); + data.put(SEVERITY_FIELD, getCustomValue(getSeverity(dataVehicleJson), ticket)); + data.put(VOTES_FIELD, String.valueOf(ticket.getVotes())); + data.put(LABELS_FIELD, join(ticket.getLabels().iterator(), ", ")); + + String ticketNum = ticket.getTicketNum(); + + boolean resolved = false; + String resolvedQuery = getResolvedQuery(dataVehicleJson); + if (isNotBlank(resolvedQuery)) { + resolved = checkSearchCondition(dataVehicleJson, ticketNum, resolvedQuery, false); + } + + boolean featureRequest = false; + String featureRequestQuery = getFeatureRequestQuery(dataVehicleJson); + if ("true".equals(featureRequestQuery)) { + featureRequest = true; + } else if (isNotEmpty(featureRequestQuery)) { + featureRequest = checkSearchCondition(dataVehicleJson, ticketNum, featureRequestQuery, false); + } + + return new IssueData(ticketNum, data, resolved, featureRequest, getUrl(dataVehicleJson, ticketNum)); + } + + /** + * Retrieve the custom value for some field from the specified ticket. + *

+ * Allowed syntax for the field value: + *

+ *
labels:<regex>[:<default>]
+ *
+ * The value is defined by one or more labels.
+ * If multiple labels are found, they are joined together with commas.
+ * If no label is found, the default value is used, if one is defined.
+ * The regex must not contain any colons. If you need to match a colon, use '\u005c003a' instead. + *
    + *
  • If no regex is given, all labels are used, e. g. 'labels:' or 'labels::bug'
  • + *
  • + * If a regex without group is given, all labels matching the regex are used completely, + * e. g. 'labels:.+_bug' or 'labels:.+_bug:general_bug' + *
  • + *
  • + * If a regex with groups is given, all labels matching the regex are used, but only their first group, + * e. g. 'labels:type_(.+)' or 'labels:type_(.+):bug' + *
  • + *
+ *
+ *
custom:<custom field name>[:<default>]
+ *
+ * The value is defined by the value of a custom field, e. g. 'custom:_type' or 'custom:_type:bug'
+ * If the custom field is not found, not set or empty, the default value is used, if one is defined. + *
+ *
<fixed string>
+ *
All issues have the same value defined here, e. g. 'bug'
+ *
+ * + * @param fieldValue the custom value field specification + * @param ticket the ticket to retrieve the data from + * @return the retrieved custom value + */ + @Nullable + private String getCustomValue(@NotNull String fieldValue, @NotNull Ticket ticket) { + String[] fieldValueParts = fieldValue.split(":", 3); + + // if there is no colon, this is the fixed string case, so simple return the specification + if (fieldValueParts.length == 1) { + return fieldValue; + } + + if (fieldValueParts[0].equals("labels")) { + String labelRegex = fieldValueParts[1]; + if (isEmpty(labelRegex)) { + // if no regex is given, just use all labels + String labels = join(ticket.getLabels().iterator(), ", "); + if (isNotBlank(labels)) { + return labels; + } + // if there are no labels present, return the default value if specified + if (fieldValueParts.length > 2) { + return fieldValueParts[2]; + } + } else { + // if there is a regex given, search all matching labels + Pattern labelPattern = safeCompilePattern(labelRegex); + List matchingLabels = new ArrayList(); + for (String label : ticket.getLabels()) { + Matcher labelMatcher = labelPattern.matcher(label); + if (labelMatcher.matches()) { + // use the first group if present, or the whole match otherwise + if (labelMatcher.groupCount() > 0) { + matchingLabels.add(labelMatcher.group(1)); + } else { + matchingLabels.add(labelMatcher.group()); + } + } + } + // join the matched labels together + String labels = join(matchingLabels.iterator(), ", "); + if (isNotBlank(labels)) { + return labels; + } + // if no labels were found, return the default value if specified + if (fieldValueParts.length > 2) { + return fieldValueParts[2]; + } + } + } else if (fieldValueParts[0].equals("custom")) { + String customFieldValue = ticket.getCustomFields().get(fieldValueParts[1]); + if (isNotBlank(customFieldValue)) { + return customFieldValue; + } + // if the custom field is not found, not set or empty, return the default value if specified + if (fieldValueParts.length > 2) { + return fieldValueParts[2]; + } + } else { + // there is a colon present, but none of the defined prefixes matches, + // so we are in the fixed string case again, simply return specification + return fieldValue; + } + + return null; + } + + /** + * Check whether the ticket with the specified ticket number is included in the specified search query. + * + * @param dataVehicleJson the {@code JSON} representation of the data vehicle transporting the configuration data + * @param ticketNum the number of the ticket to check against the search query + * @param searchQuery the search query to test the ticket against + * @param defaultValue the default value that should be returned if there was an unexpected server error + * @return whether the ticket is included in the search query or the default value in case of server error + */ + private boolean checkSearchCondition(@NotNull String dataVehicleJson, @NotNull String ticketNum, @NotNull String searchQuery, boolean defaultValue) { + try { + String searchUrl = getSearchUrl(getProject(dataVehicleJson), getTicketTool(dataVehicleJson), format("(%s) && ticket_num:%s", searchQuery, ticketNum)); + InputStream searchResultStream = fetchHttpFile(searchUrl); + SearchResult searchResult = GSON.fromJson(new InputStreamReader(searchResultStream), SearchResult.class); + return searchResult.didFind(); + } catch (RetrieveIssueException e) { + return defaultValue; + } catch (IOException e) { + return defaultValue; + } + } + + /** + * Does the same as {@link AbstractIssueFetcher#fetchHttpFile(String, Credentials)} with {@code null} as second parameter. + * This method is mainly present to expose the functionality to other classes in this package. + * + * @param url the url of file to fetch + * @return result input stream, or null in case of HTTP error + * + * @throws IOException if I/O error occurs + */ + @NotNull + InputStream fetchHttpFile(@NotNull String url) throws IOException { + return super.fetchHttpFile(url, null); + } +} diff --git a/src/main/java/net/kautler/teamcity/sourceforge/SourceForgeIssueProvider.java b/src/main/java/net/kautler/teamcity/sourceforge/SourceForgeIssueProvider.java new file mode 100644 index 0000000..edd8d25 --- /dev/null +++ b/src/main/java/net/kautler/teamcity/sourceforge/SourceForgeIssueProvider.java @@ -0,0 +1,275 @@ +/* + * Copyright 2015 Björn Kautler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.kautler.teamcity.sourceforge; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.google.gson.Gson; +import jetbrains.buildServer.issueTracker.AbstractIssueProvider; +import jetbrains.buildServer.issueTracker.IssueData; +import jetbrains.buildServer.issueTracker.IssueFetcher; +import jetbrains.buildServer.issueTracker.errors.NotFoundException; +import jetbrains.buildServer.issueTracker.errors.RetrieveIssueException; +import jetbrains.buildServer.serverSide.InvalidProperty; +import jetbrains.buildServer.serverSide.PropertiesProcessor; +import net.kautler.teamcity.sourceforge.model.DataVehicle; +import net.kautler.teamcity.sourceforge.model.Project; +import net.kautler.teamcity.sourceforge.model.Ticket; +import net.kautler.teamcity.sourceforge.model.Tool; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static java.lang.String.format; +import static java.util.Collections.sort; +import static org.apache.commons.lang.StringUtils.isEmpty; +import static org.apache.commons.lang.StringUtils.isNotEmpty; + +/** + * An {@code IssueProvider}, that provides SourceForge issues and validates the settings for the issue tracker that is edited. + */ +public class SourceForgeIssueProvider extends AbstractIssueProvider { + private static final String MOUNT_POINT_PATTERN = "[a-zA-Z0-9-]+"; + private static final String PROJECT_PATTERN = "(?:u|p)/" + MOUNT_POINT_PATTERN; + private static final Gson GSON = new Gson(); + + public SourceForgeIssueProvider(String type, IssueFetcher fetcher) { + super(type, fetcher); + } + + @NotNull + @Override + public PropertiesProcessor getPropertiesProcessor() { + return new PropertiesProcessor() { + @Override + public Collection process(Map properties) { + // check the standard properties, that are used in the superclass + List result = new ArrayList(SourceForgeIssueProvider.super.getPropertiesProcessor().process(properties)); + + SourceForgeIssueFetcher sfFetcher = (SourceForgeIssueFetcher) SourceForgeIssueProvider.this.myFetcher; + + String projectName = null; + InputStream projectStream = null; + if (properties.containsKey("project")) { + projectName = properties.get("project"); + if (projectName.length() == 0) { + result.add(new InvalidProperty("project", "SF project must be specified")); + } else if (!projectName.matches(PROJECT_PATTERN)) { + result.add(new InvalidProperty("project", "SF project may only contain letters, digits and dashes")); + } else { + try { + // request the project from the API to see if it exists + projectStream = sfFetcher.fetchHttpFile(sfFetcher.getProjectUrl(projectName, true)); + } catch (NotFoundException e) { + result.add(new InvalidProperty("project", "The specified SF project could not be found")); + } catch (RetrieveIssueException e) { + result.add(new InvalidProperty("project", format("A valid SF project must be specified [%s]", e.getMessage()))); + } catch (IOException e) { + result.add(new InvalidProperty("project", format("A valid SF project must be specified [%s]", e.getMessage()))); + } + } + } + + String ticketToolName = null; + boolean validTicketTool = false; + if (properties.containsKey("ticketTool")) { + ticketToolName = properties.get("ticketTool"); + if (ticketToolName.length() == 0) { + result.add(new InvalidProperty("ticketTool", "The Ticket tool mount point must be specified")); + } else if (!ticketToolName.matches(MOUNT_POINT_PATTERN)) { + result.add(new InvalidProperty("ticketTool", "Ticket tool mount point may only contain letters, digits and dashes")); + } else { + // if a valid project was specified, the stream to the API was opened, + // so we can decode the project JSON to verify the specified ticket tool mount point + if (projectStream != null) { + // decode the project JSON + Project project = GSON.fromJson(new InputStreamReader(projectStream), Project.class); + + // search through the ticket tools of the project for the specified mount point and build + // a list of valid ticket tool mount points for the error message if the mount point is not valid + StringBuilder validTicketToolsBuilder = new StringBuilder(); + List tools = new ArrayList(project.getTools()); + // sort the found tools, so that they are listed alphabetically in the error message + sort(tools, new Comparator() { + @Override + public int compare(Tool o1, Tool o2) { + return o1.getMountPoint().compareTo(o2.getMountPoint()); + } + }); + for (Tool tool : tools) { + if (tool.getName().equals("tickets")) { + if (tool.getMountPoint().equals(ticketToolName)) { + validTicketTool = true; + break; + } else { + validTicketToolsBuilder.append(tool.getMountPoint()).append(", "); + } + } + } + if (!validTicketTool) { + String validTicketTools = validTicketToolsBuilder.substring(0, validTicketToolsBuilder.length() - 2); + result.add(new InvalidProperty("ticketTool", "The specified ticket tool mount point does not exist in the specified SF project, " + + "valid ticket tool mount points are: " + validTicketTools)); + } + } else { + result.add(new InvalidProperty("ticketTool", "SF project is not valid, ticket tool cannot be verified")); + } + } + } + + if (properties.containsKey("resolvedQuery") && isNotEmpty(properties.get("resolvedQuery"))) { + if (projectStream == null) { + result.add(new InvalidProperty("resolvedQuery", "SF project and is not valid, resolved query cannot be verified")); + } else if (!validTicketTool) { + result.add(new InvalidProperty("resolvedQuery", "Ticket tool is not valid, resolved query cannot be verified")); + } else { + String resolvedQuery = properties.get("resolvedQuery"); + try { + // trigger the specified search through the API to validate the syntactical correctness of the + // specified search query. Add the condition that ticket_num equals 1 to speed up the search, + // as we are only interested in syntax here, not in the actual result. + sfFetcher.fetchHttpFile(sfFetcher.getSearchUrl(projectName, ticketToolName, format("(%s) && ticket_num:1", resolvedQuery))); + } catch (RetrieveIssueException e) { + result.add(new InvalidProperty("resolvedQuery", format("A valid SF search query must be specified [%s]", e.getMessage()))); + } catch (IOException e) { + result.add(new InvalidProperty("resolvedQuery", format("A valid SF search query must be specified [%s]", e.getMessage()))); + } + } + } + + if (properties.containsKey("featureRequestQuery") && isNotEmpty(properties.get("featureRequestQuery"))) { + String featureRequestQuery = properties.get("featureRequestQuery"); + // a value of "true" means that all tickets from this ticket tool are feature requests + // this is useful if you have separate ticket tools for bugs and feature requests + if (!featureRequestQuery.equals("true")) { + if (projectStream == null) { + result.add(new InvalidProperty("featureRequestQuery", "SF project and is not valid, feature request query cannot be verified")); + } else if (!validTicketTool) { + result.add(new InvalidProperty("featureRequestQuery", "Ticket tool is not valid, feature request query cannot be verified")); + } else { + try { + // trigger the specified search through the API to validate the syntactical correctness of the + // specified search query. Add the condition that ticket_num equals 1 to speed up the search, + // as we are only interested in syntax here, not in the actual result. + sfFetcher.fetchHttpFile(sfFetcher.getSearchUrl(projectName, ticketToolName, format("(%s) && ticket_num:1", featureRequestQuery))); + } catch (RetrieveIssueException e) { + result.add(new InvalidProperty("featureRequestQuery", format("A valid SF search query or 'true' must be specified [%s]", e.getMessage()))); + } catch (IOException e) { + result.add(new InvalidProperty("featureRequestQuery", format("A valid SF search query or 'true' must be specified [%s]", e.getMessage()))); + } + } + } + } + + validateCustomValueField(properties, "type", result); + validateCustomValueField(properties, "priority", result); + validateCustomValueField(properties, "severity", result); + + return result; + } + + /** + * Validates the correctness of a custom value field. + *

+ * Allowed syntax can be seen at {@link SourceForgeIssueFetcher#getCustomValue(String, Ticket)} + *

+ * This method verifies that a given regex for the labels case is valid and does not match the empty string + * and that for the custom case a custom field name is given. + * + * @param properties the properties object where the field is stored + * @param fieldName the name of the field that is to be validated + * @param result the result list to which violations should be added + */ + private void validateCustomValueField(@NotNull Map properties, @NotNull String fieldName, @NotNull List result) { + if (properties.containsKey(fieldName) && isNotEmpty(properties.get(fieldName))) { + String fieldValue = properties.get(fieldName); + String[] fieldValueParts = fieldValue.split(":", 3); + // if there is at least one colon, otherwise it is a fixed string setting + if (fieldValueParts.length > 1) { + if (fieldValueParts[0].equals("labels")) { + if (isNotEmpty(fieldValueParts[1]) && safeCompile(fieldValueParts[1]).equals(EMPTY_PATTERN)) { + result.add(new InvalidProperty(fieldName, "A correct regex pattern or nothing must be specified as second colon-separated token for 'labels:'")); + } + } else if (fieldValueParts[0].equals("custom") && isEmpty(fieldValueParts[1])) { + result.add(new InvalidProperty(fieldName, "A custom field name must be specified as second colon-separated token for 'custom:'")); + } + } + } + } + }; + } + + @Override + public void setProperties(@NotNull Map map) { + super.setProperties(map); + // encode all data that we need in the issue fetcher into + // a JSON string and transport it via the host variable + myHost = new DataVehicle(myProperties.get("project"), + myProperties.get("ticketTool"), + myProperties.get("resolvedQuery"), + myProperties.get("featureRequestQuery"), + myProperties.get("type"), + myProperties.get("priority"), + myProperties.get("severity")).toJson(); + } + + @Override + public boolean isBatchFetchSupported() { + return true; + } + + @Nullable + @Override + public Map findIssuesByIds(@NotNull Collection ids) { + return findIssuesByIdsImpl(ids); + } + + @NotNull + @Override + protected String extractId(@NotNull String match) { + Matcher matcher = myPattern.matcher(match); + if (!matcher.matches()) { + throw new AssertionError(format("Match '%s' should match the pattern '%s', but does not", match, myPattern)); + } + // use the first group if present, or the whole match otherwise + if (matcher.groupCount() > 0) { + return matcher.group(1); + } else { + return matcher.group(); + } + } + + /** + * Does the same as {@link AbstractIssueProvider#safeCompile(String)}. + * This method is just present to expose the functionality to other classes in this package. + * + * @param pattern the pattern to compile + * @return a compiled pattern + */ + @NotNull + static Pattern safeCompilePattern(@NotNull String pattern) { + return safeCompile(pattern); + } +} diff --git a/src/main/java/net/kautler/teamcity/sourceforge/SourceForgeIssueProviderFactory.java b/src/main/java/net/kautler/teamcity/sourceforge/SourceForgeIssueProviderFactory.java new file mode 100644 index 0000000..a6add2f --- /dev/null +++ b/src/main/java/net/kautler/teamcity/sourceforge/SourceForgeIssueProviderFactory.java @@ -0,0 +1,37 @@ +/* + * Copyright 2015 Björn Kautler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.kautler.teamcity.sourceforge; + +import jetbrains.buildServer.issueTracker.AbstractIssueProviderFactory; +import jetbrains.buildServer.issueTracker.IssueFetcher; +import jetbrains.buildServer.issueTracker.IssueProvider; +import org.jetbrains.annotations.NotNull; + +/** + * A factory that creates {@link SourceForgeIssueProvider}s with the given {@code IssueFetcher} from the constructor. + */ +public class SourceForgeIssueProviderFactory extends AbstractIssueProviderFactory { + protected SourceForgeIssueProviderFactory(@NotNull IssueFetcher fetcher) { + super(fetcher, "sourceforge", "SourceForge"); + } + + @NotNull + @Override + public IssueProvider createProvider() { + return new SourceForgeIssueProvider(getType(), myFetcher); + } +} diff --git a/src/main/java/net/kautler/teamcity/sourceforge/model/DataVehicle.java b/src/main/java/net/kautler/teamcity/sourceforge/model/DataVehicle.java new file mode 100644 index 0000000..2a5470c --- /dev/null +++ b/src/main/java/net/kautler/teamcity/sourceforge/model/DataVehicle.java @@ -0,0 +1,165 @@ +/* + * Copyright 2015 Björn Kautler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.kautler.teamcity.sourceforge.model; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.gson.Gson; + +/** + * A vehicle to transport various data from the {@code IssueProvider} to the {@code IssueFetcher} in one {@code String}. + * The data of this vehicle is represented as {@code JSON} string. + */ +public class DataVehicle { + private static final Gson GSON = new Gson(); + private static Map cache = new ConcurrentHashMap(); + + private String project; + private String ticketTool; + private String resolvedQuery; + private String featureRequestQuery; + private String type; + private String priority; + private String severity; + + public DataVehicle(String project, String ticketTool, String resolvedQuery, String featureRequestQuery, String type, String priority, String severity) { + this.project = project; + this.ticketTool = ticketTool; + this.resolvedQuery = resolvedQuery; + this.featureRequestQuery = featureRequestQuery; + this.type = type; + this.priority = priority; + this.severity = severity; + } + + /** + * Generates and returns the {@code JSON} representation of this data vehicle. + * + * @return the {@code JSON} representation of this data vehicle + */ + public String toJson() { + String dataVehicleJson = GSON.toJson(this); + cache.put(dataVehicleJson, this); + return dataVehicleJson; + } + + /** + * Decodes the given {@code JSON} representation of a data vehicle and returns its project value. + * + * @param dataVehicleJson the {@code JSON} representation of a data vehicle + * @return the project value of the given data vehicle + */ + public static String getProject(String dataVehicleJson) { + if (cache.containsKey(dataVehicleJson)) { + return cache.get(dataVehicleJson).project; + } + DataVehicle dataVehicle = GSON.fromJson(dataVehicleJson, DataVehicle.class); + cache.put(dataVehicleJson, dataVehicle); + return dataVehicle.project; + } + + /** + * Decodes the given {@code JSON} representation of a data vehicle and returns its ticket tool value. + * + * @param dataVehicleJson the {@code JSON} representation of a data vehicle + * @return the ticket tool value of the given data vehicle + */ + public static String getTicketTool(String dataVehicleJson) { + if (cache.containsKey(dataVehicleJson)) { + return cache.get(dataVehicleJson).ticketTool; + } + DataVehicle dataVehicle = GSON.fromJson(dataVehicleJson, DataVehicle.class); + cache.put(dataVehicleJson, dataVehicle); + return dataVehicle.ticketTool; + } + + /** + * Decodes the given {@code JSON} representation of a data vehicle and returns its resolved query value. + * + * @param dataVehicleJson the {@code JSON} representation of a data vehicle + * @return the resolved query value of the given data vehicle + */ + public static String getResolvedQuery(String dataVehicleJson) { + if (cache.containsKey(dataVehicleJson)) { + return cache.get(dataVehicleJson).resolvedQuery; + } + DataVehicle dataVehicle = GSON.fromJson(dataVehicleJson, DataVehicle.class); + cache.put(dataVehicleJson, dataVehicle); + return dataVehicle.resolvedQuery; + } + + /** + * Decodes the given {@code JSON} representation of a data vehicle and returns its feature request query value. + * + * @param dataVehicleJson the {@code JSON} representation of a data vehicle + * @return the feature request query value of the given data vehicle + */ + public static String getFeatureRequestQuery(String dataVehicleJson) { + if (cache.containsKey(dataVehicleJson)) { + return cache.get(dataVehicleJson).featureRequestQuery; + } + DataVehicle dataVehicle = GSON.fromJson(dataVehicleJson, DataVehicle.class); + cache.put(dataVehicleJson, dataVehicle); + return dataVehicle.featureRequestQuery; + } + + /** + * Decodes the given {@code JSON} representation of a data vehicle and returns its type value. + * + * @param dataVehicleJson the {@code JSON} representation of a data vehicle + * @return the type value of the given data vehicle + */ + public static String getType(String dataVehicleJson) { + if (cache.containsKey(dataVehicleJson)) { + return cache.get(dataVehicleJson).type; + } + DataVehicle dataVehicle = GSON.fromJson(dataVehicleJson, DataVehicle.class); + cache.put(dataVehicleJson, dataVehicle); + return dataVehicle.type; + } + + /** + * Decodes the given {@code JSON} representation of a data vehicle and returns its priority value. + * + * @param dataVehicleJson the {@code JSON} representation of a data vehicle + * @return the priority value of the given data vehicle + */ + public static String getPriority(String dataVehicleJson) { + if (cache.containsKey(dataVehicleJson)) { + return cache.get(dataVehicleJson).priority; + } + DataVehicle dataVehicle = GSON.fromJson(dataVehicleJson, DataVehicle.class); + cache.put(dataVehicleJson, dataVehicle); + return dataVehicle.priority; + } + + /** + * Decodes the given {@code JSON} representation of a data vehicle and returns its severity value. + * + * @param dataVehicleJson the {@code JSON} representation of a data vehicle + * @return the severity value of the given data vehicle + */ + public static String getSeverity(String dataVehicleJson) { + if (cache.containsKey(dataVehicleJson)) { + return cache.get(dataVehicleJson).severity; + } + DataVehicle dataVehicle = GSON.fromJson(dataVehicleJson, DataVehicle.class); + cache.put(dataVehicleJson, dataVehicle); + return dataVehicle.severity; + } +} diff --git a/src/main/java/net/kautler/teamcity/sourceforge/model/Project.java b/src/main/java/net/kautler/teamcity/sourceforge/model/Project.java new file mode 100644 index 0000000..246c4bf --- /dev/null +++ b/src/main/java/net/kautler/teamcity/sourceforge/model/Project.java @@ -0,0 +1,30 @@ +/* + * Copyright 2015 Björn Kautler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.kautler.teamcity.sourceforge.model; + +import java.util.Collection; + +/** + * A SourceForge project as returned via the API with selected fields. + */ +public class Project { + private Collection tools; + + public Collection getTools() { + return tools; + } +} diff --git a/src/main/java/net/kautler/teamcity/sourceforge/model/SearchResult.java b/src/main/java/net/kautler/teamcity/sourceforge/model/SearchResult.java new file mode 100644 index 0000000..22dbf07 --- /dev/null +++ b/src/main/java/net/kautler/teamcity/sourceforge/model/SearchResult.java @@ -0,0 +1,39 @@ +/* + * Copyright 2015 Björn Kautler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.kautler.teamcity.sourceforge.model; + +import java.util.Collection; + +/** + * A SourceForge search result as returned via the API with selected fields. + */ +public class SearchResult { + private Collection tickets; + + public Collection getTickets() { + return tickets; + } + + /** + * Returns whether any tickets were found by the search which is represented by this search result. + * + * @return whether any tickets were found by the search which is represented by this search result + */ + public boolean didFind() { + return !tickets.isEmpty(); + } +} diff --git a/src/main/java/net/kautler/teamcity/sourceforge/model/Ticket.java b/src/main/java/net/kautler/teamcity/sourceforge/model/Ticket.java new file mode 100644 index 0000000..c9f1ac1 --- /dev/null +++ b/src/main/java/net/kautler/teamcity/sourceforge/model/Ticket.java @@ -0,0 +1,63 @@ +/* + * Copyright 2015 Björn Kautler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.kautler.teamcity.sourceforge.model; + +import java.util.Collection; +import java.util.Map; + +/** + * A SourceForge ticket as returned via the API with selected fields. + */ +public class Ticket { + private String status; + private String ticket_num; + private String summary; + private Map custom_fields; + private int votes_down; + private int votes_up; + private Collection labels; + + public String getStatus() { + return status; + } + + public String getTicketNum() { + return ticket_num; + } + + public String getSummary() { + return summary; + } + + public Map getCustomFields() { + return custom_fields; + } + + /** + * Calculates the sum of votes for this ticket and returns the result. + * The downvotes are subtracted from the upvotes and the result is returned. + * + * @return the sum of the votes for this ticket + */ + public int getVotes() { + return votes_up - votes_down; + } + + public Collection getLabels() { + return labels; + } +} diff --git a/src/main/java/net/kautler/teamcity/sourceforge/model/TicketWrapper.java b/src/main/java/net/kautler/teamcity/sourceforge/model/TicketWrapper.java new file mode 100644 index 0000000..2f4277b --- /dev/null +++ b/src/main/java/net/kautler/teamcity/sourceforge/model/TicketWrapper.java @@ -0,0 +1,28 @@ +/* + * Copyright 2015 Björn Kautler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.kautler.teamcity.sourceforge.model; + +/** + * A wrapper for a SourceForge ticket as the API wraps the ticket in an object. + */ +public class TicketWrapper { + private Ticket ticket; + + public Ticket getTicket() { + return ticket; + } +} diff --git a/src/main/java/net/kautler/teamcity/sourceforge/model/Tool.java b/src/main/java/net/kautler/teamcity/sourceforge/model/Tool.java new file mode 100644 index 0000000..c522f78 --- /dev/null +++ b/src/main/java/net/kautler/teamcity/sourceforge/model/Tool.java @@ -0,0 +1,33 @@ +/* + * Copyright 2015 Björn Kautler + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.kautler.teamcity.sourceforge.model; + +/** + * A SourceForge tool as returned via the API with selected fields. + */ +public class Tool { + private String mount_point; + private String name; + + public String getMountPoint() { + return mount_point; + } + + public String getName() { + return name; + } +} diff --git a/src/main/resources/META-INF/build-server-plugin-sourceforge.xml b/src/main/resources/META-INF/build-server-plugin-sourceforge.xml new file mode 100644 index 0000000..bbdf101 --- /dev/null +++ b/src/main/resources/META-INF/build-server-plugin-sourceforge.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/src/main/resources/buildServerResources/admin/editIssueProvider.jsp b/src/main/resources/buildServerResources/admin/editIssueProvider.jsp new file mode 100644 index 0000000..a9fca2d --- /dev/null +++ b/src/main/resources/buildServerResources/admin/editIssueProvider.jsp @@ -0,0 +1,146 @@ +<%@ include file="/include.jsp" %> +<%@ taglib prefix="props" tagdir="/WEB-INF/tags/props" %> + +<%-- + ~ Copyright 2015 Björn Kautler + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --%> + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
labels:<regex>[:<default>]
+
+ The value is defined by one or more labels.
+ If multiple labels are found, they are joined together with commas.
+ If no label is found, the default value is used, if one is defined.
+ The regex must not contain any colons. If you need to match a colon, use '\u003a' instead. +
    +
  • If no regex is given, all labels are used, e. g. 'labels:' or 'labels::bug'
  • +
  • + If a regex without group is given, all labels matching the regex are used completely, + e. g. 'labels:.+_bug' or 'labels:.+_bug:general_bug' +
  • +
  • + If a regex with groups is given, all labels matching the regex are used, but only their first group, + e. g. 'labels:type_(.+)' or 'labels:type_(.+):bug' +
  • +
+
+ +
custom:<custom field name>[:<default>]
+
+ The value is defined by the value of a custom field, e. g. 'custom:_type' or 'custom:_type:bug'
+ If the custom field is not found, not set or empty, the default value is used, if one is defined. +
+ +
<fixed string>
+
All issues have the same value defined here, e. g. 'bug'
+
+
+
+
+
+ + + + + + + + + + + + +
SourceForge
+ + +
+ + + The unix name of the project, e. g. 'p/jedit' or 'u/vampire0' +
+ + + The mount point of the ticket tool +
+ + + + The regex pattern issue ids have to match, + e. g. '#(\d+)', the first group is taken as issue id, or the whole match if no group is found + +
+ + + + A SF search query to find all tickets that are considered 'resolved', + e. g. 'status:closed-fixed || status:closed-invalid' + +
+ + + + A SF search query to find all tickets that are considered feature requests, + e. g. 'labels:"editor core"', or 'true' if all tickets are feature requests + +
+ + + The type of issues coming from this ticket tool. ${possibleValuesHelpLink} +
+ + + The priority of issues coming from this ticket tool. ${possibleValuesHelpLink} +
+ + + The severity of issues coming from this ticket tool. ${possibleValuesHelpLink} +
+
diff --git a/src/main/resources/buildServerResources/popup.jsp b/src/main/resources/buildServerResources/popup.jsp new file mode 100644 index 0000000..d486dab --- /dev/null +++ b/src/main/resources/buildServerResources/popup.jsp @@ -0,0 +1,41 @@ +<%@ page import="net.kautler.teamcity.sourceforge.SourceForgeIssueFetcher" %> +<%@ include file="/include.jsp" %> + +<%-- + ~ Copyright 2015 Björn Kautler + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --%> + + + + +<%=SourceForgeIssueFetcher.VOTES_FIELD%> +<%=SourceForgeIssueFetcher.LABELS_FIELD%> + + + + <%-- + Votes are always delivered via API, no matter whether votes are enabled + or not. So just do not show votes for tickets where the vote sums up to 0. + --%> + + ${votes} + + + + + ${labels} + + +