From 5e3521f8496511db4df45f011ea72f25623ad90f Mon Sep 17 00:00:00 2001 From: James Moger Date: Mon, 9 Dec 2013 17:19:03 -0500 Subject: [PATCH] Ticket tracker with patchset contributions A basic issue tracker styled as a hybrid of GitHub and BitBucket issues. You may attach commits to an existing ticket or you can push a single commit to create a *proposal* ticket. Tickets keep track of patchsets (one or more commits) and allow patchset rewriting (rebase, amend, squash) by detecing the non-fast-forward update and assigning a new patchset number to the new commits. Ticket tracker -------------- The ticket tracker stores tickets as an append-only journal of changes. The journals are deserialized and a ticket is built by applying the journal entries. Tickets are indexed using Apache Lucene and all queries and searches are executed against this Lucene index. There is one trade-off to this persistence design: user attributions are non-relational. What does that mean? Each journal entry stores the username of the author. If the username changes in the user service, the journal entry will not reflect that change because the values are hard-coded. Here are a few reasons/justifications for this design choice: 1. commit identifications (author, committer, tagger) are non-relational 2. maintains the KISS principle 3. your favorite text editor can still be your administration tool Persistence Choices ------------------- **FileTicketService**: stores journals on the filesystem **BranchTicketService**: stores journals on an orphan branch **RedisTicketService**: stores journals in a Redis key-value datastore It should be relatively straight-forward to develop other backends (MongoDB, etc) as long as the journal design is preserved. Pushing Commits --------------- Each push to a ticket is identified as a patchset revision. A patchset revision may add commits to the patchset (fast-forward) OR a patchset revision may rewrite history (rebase, squash, rebase+squash, or amend). Patchset authors should not be afraid to polish, revise, and rewrite their code before merging into the proposed branch. Gitblit will create one ref for each patchset. These refs are updated for fast-forward pushes or created for rewrites. They are formatted as `refs/tickets/{shard}/{id}/{patchset}`. The *shard* is the last two digits of the id. If the id < 10, prefix a 0. The *shard* is always two digits long. The shard's purpose is to ensure Gitblit doesn't exceed any filesystem directory limits for file creation. **Creating a Proposal Ticket** You may create a new change proposal ticket just by pushing a **single commit** to `refs/for/{branch}` where branch is the proposed integration branch OR `refs/for/new` or `refs/for/default` which both will use the default repository branch. git push origin HEAD:refs/for/new **Updating a Patchset** The safe way to update an existing patchset is to push to the patchset ref. git push origin HEAD:refs/heads/ticket/{id} This ensures you do not accidentally create a new patchset in the event that the patchset was updated after you last pulled. The not-so-safe way to update an existing patchset is to push using the magic ref. git push origin HEAD:refs/for/{id} This push ref will update an exisitng patchset OR create a new patchset if the update is non-fast-forward. **Rebasing, Squashing, Amending** Gitblit makes rebasing, squashing, and amending patchsets easy. Normally, pushing a non-fast-forward update would require rewind (RW+) repository permissions. Gitblit provides a magic ref which will allow ticket participants to rewrite a ticket patchset as long as the ticket is open. git push origin HEAD:refs/for/{id} Pushing changes to this ref allows the patchset authors to rebase, squash, or amend the patchset commits without requiring client-side use of the *--force* flag on push AND without requiring RW+ permission to the repository. Since each patchset is tracked with a ref it is easy to recover from accidental non-fast-forward updates. Features -------- - Ticket tracker with status changes and responsible assignments - Patchset revision scoring mechanism - Update/Rewrite patchset handling - Close-on-push detection - Server-side Merge button for simple merges - Comments with Markdown syntax support - Rich mail notifications - Voting - Mentions - Watch lists - Querying - Searches - Partial miletones support - Multiple backend options --- .classpath | 2 + NOTICE | 17 +- build.moxie | 1 + build.xml | 14 + gitblit.iml | 22 + releases.moxie | 6 + src/main/distrib/data/clientapps.json | 12 + src/main/distrib/data/gitblit.properties | 70 + src/main/distrib/linux/reindex-tickets.sh | 15 + src/main/distrib/win/reindex-tickets.cmd | 13 + src/main/java/WEB-INF/web.xml | 16 +- src/main/java/com/gitblit/Constants.java | 8 +- src/main/java/com/gitblit/GitBlit.java | 154 ++ src/main/java/com/gitblit/ReindexTickets.java | 183 ++ .../gitblit/client/EditRepositoryDialog.java | 21 + .../com/gitblit/git/GitblitReceivePack.java | 11 + .../git/GitblitReceivePackFactory.java | 11 +- .../java/com/gitblit/git/PatchsetCommand.java | 324 ++++ .../com/gitblit/git/PatchsetReceivePack.java | 1129 ++++++++++++ .../com/gitblit/manager/GitblitManager.java | 10 + .../java/com/gitblit/manager/IGitblit.java | 8 + .../gitblit/manager/RepositoryManager.java | 12 + .../com/gitblit/models/RepositoryModel.java | 11 + .../java/com/gitblit/models/TicketModel.java | 1286 ++++++++++++++ .../java/com/gitblit/models/UserModel.java | 12 + .../java/com/gitblit/servlet/PtServlet.java | 201 +++ .../gitblit/tickets/BranchTicketService.java | 799 +++++++++ .../gitblit/tickets/FileTicketService.java | 467 +++++ .../com/gitblit/tickets/ITicketService.java | 1088 ++++++++++++ .../gitblit/tickets/NullTicketService.java | 129 ++ .../com/gitblit/tickets/QueryBuilder.java | 222 +++ .../java/com/gitblit/tickets/QueryResult.java | 114 ++ .../gitblit/tickets/RedisTicketService.java | 534 ++++++ .../com/gitblit/tickets/TicketIndexer.java | 657 +++++++ .../java/com/gitblit/tickets/TicketLabel.java | 77 + .../com/gitblit/tickets/TicketMilestone.java | 53 + .../com/gitblit/tickets/TicketNotifier.java | 617 +++++++ .../gitblit/tickets/TicketResponsible.java | 59 + .../com/gitblit/tickets/TicketSerializer.java | 175 ++ src/main/java/com/gitblit/tickets/commands.md | 11 + src/main/java/com/gitblit/tickets/email.css | 38 + .../java/com/gitblit/utils/JGitUtils.java | 207 +++ .../java/com/gitblit/utils/JsonUtils.java | 4 +- .../java/com/gitblit/utils/MarkdownUtils.java | 4 + .../java/com/gitblit/utils/RefLogUtils.java | 40 +- .../com/gitblit/wicket/GitBlitWebApp.java | 15 + .../gitblit/wicket/GitBlitWebApp.properties | 141 +- .../com/gitblit/wicket/pages/BasePage.java | 25 + .../wicket/pages/EditRepositoryPage.html | 31 +- .../wicket/pages/EditRepositoryPage.java | 11 +- .../gitblit/wicket/pages/EditTicketPage.html | 66 + .../gitblit/wicket/pages/EditTicketPage.java | 290 ++++ .../wicket/pages/ExportTicketPage.java | 82 + .../gitblit/wicket/pages/NewTicketPage.html | 66 + .../gitblit/wicket/pages/NewTicketPage.java | 202 +++ .../gitblit/wicket/pages/NoTicketsPage.html | 21 + .../gitblit/wicket/pages/NoTicketsPage.java | 44 + .../gitblit/wicket/pages/RepositoryPage.html | 1 + .../gitblit/wicket/pages/RepositoryPage.java | 20 +- .../gitblit/wicket/pages/TicketBasePage.java | 124 ++ .../com/gitblit/wicket/pages/TicketPage.html | 577 +++++++ .../com/gitblit/wicket/pages/TicketPage.java | 1527 +++++++++++++++++ .../com/gitblit/wicket/pages/TicketsPage.html | 215 +++ .../com/gitblit/wicket/pages/TicketsPage.java | 878 ++++++++++ .../com/gitblit/wicket/pages/propose_git.md | 6 + .../com/gitblit/wicket/pages/propose_pt.md | 5 + .../gitblit/wicket/panels/CommentPanel.html | 29 + .../gitblit/wicket/panels/CommentPanel.java | 110 ++ .../gitblit/wicket/panels/DigestsPanel.java | 539 +++--- .../gitblit/wicket/panels/GravatarImage.java | 142 +- .../wicket/panels/MarkdownTextArea.java | 118 ++ .../gitblit/wicket/panels/ReflogPanel.html | 4 +- .../gitblit/wicket/panels/ReflogPanel.java | 638 +++---- .../com/gitblit/wicket/panels/RefsPanel.java | 27 +- src/main/java/pt.cmd | 1 + src/main/java/pt.py | 701 ++++++++ src/main/java/pt.txt | 49 + src/main/resources/barnum_32x32.png | Bin 0 -> 1436 bytes src/main/resources/gitblit.css | 414 ++++- src/site/design.mkd | 1 + src/site/tickets_barnum.mkd | 79 + src/site/tickets_overview.mkd | 145 ++ src/site/tickets_setup.mkd | 119 ++ src/site/tickets_using.mkd | 155 ++ .../tests/BranchTicketServiceTest.java | 68 + .../gitblit/tests/FileTicketServiceTest.java | 67 + .../java/com/gitblit/tests/GitBlitSuite.java | 8 +- .../gitblit/tests/RedisTicketServiceTest.java | 75 + .../com/gitblit/tests/TicketServiceTest.java | 351 ++++ 89 files changed, 16359 insertions(+), 692 deletions(-) create mode 100644 src/main/distrib/linux/reindex-tickets.sh create mode 100644 src/main/distrib/win/reindex-tickets.cmd create mode 100644 src/main/java/com/gitblit/ReindexTickets.java create mode 100644 src/main/java/com/gitblit/git/PatchsetCommand.java create mode 100644 src/main/java/com/gitblit/git/PatchsetReceivePack.java create mode 100644 src/main/java/com/gitblit/models/TicketModel.java create mode 100644 src/main/java/com/gitblit/servlet/PtServlet.java create mode 100644 src/main/java/com/gitblit/tickets/BranchTicketService.java create mode 100644 src/main/java/com/gitblit/tickets/FileTicketService.java create mode 100644 src/main/java/com/gitblit/tickets/ITicketService.java create mode 100644 src/main/java/com/gitblit/tickets/NullTicketService.java create mode 100644 src/main/java/com/gitblit/tickets/QueryBuilder.java create mode 100644 src/main/java/com/gitblit/tickets/QueryResult.java create mode 100644 src/main/java/com/gitblit/tickets/RedisTicketService.java create mode 100644 src/main/java/com/gitblit/tickets/TicketIndexer.java create mode 100644 src/main/java/com/gitblit/tickets/TicketLabel.java create mode 100644 src/main/java/com/gitblit/tickets/TicketMilestone.java create mode 100644 src/main/java/com/gitblit/tickets/TicketNotifier.java create mode 100644 src/main/java/com/gitblit/tickets/TicketResponsible.java create mode 100644 src/main/java/com/gitblit/tickets/TicketSerializer.java create mode 100644 src/main/java/com/gitblit/tickets/commands.md create mode 100644 src/main/java/com/gitblit/tickets/email.css create mode 100644 src/main/java/com/gitblit/wicket/pages/EditTicketPage.html create mode 100644 src/main/java/com/gitblit/wicket/pages/EditTicketPage.java create mode 100644 src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java create mode 100644 src/main/java/com/gitblit/wicket/pages/NewTicketPage.html create mode 100644 src/main/java/com/gitblit/wicket/pages/NewTicketPage.java create mode 100644 src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html create mode 100644 src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java create mode 100644 src/main/java/com/gitblit/wicket/pages/TicketBasePage.java create mode 100644 src/main/java/com/gitblit/wicket/pages/TicketPage.html create mode 100644 src/main/java/com/gitblit/wicket/pages/TicketPage.java create mode 100644 src/main/java/com/gitblit/wicket/pages/TicketsPage.html create mode 100644 src/main/java/com/gitblit/wicket/pages/TicketsPage.java create mode 100644 src/main/java/com/gitblit/wicket/pages/propose_git.md create mode 100644 src/main/java/com/gitblit/wicket/pages/propose_pt.md create mode 100644 src/main/java/com/gitblit/wicket/panels/CommentPanel.html create mode 100644 src/main/java/com/gitblit/wicket/panels/CommentPanel.java create mode 100644 src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java create mode 100644 src/main/java/pt.cmd create mode 100644 src/main/java/pt.py create mode 100644 src/main/java/pt.txt create mode 100644 src/main/resources/barnum_32x32.png create mode 100644 src/site/tickets_barnum.mkd create mode 100644 src/site/tickets_overview.mkd create mode 100644 src/site/tickets_setup.mkd create mode 100644 src/site/tickets_using.mkd create mode 100644 src/test/java/com/gitblit/tests/BranchTicketServiceTest.java create mode 100644 src/test/java/com/gitblit/tests/FileTicketServiceTest.java create mode 100644 src/test/java/com/gitblit/tests/RedisTicketServiceTest.java create mode 100644 src/test/java/com/gitblit/tests/TicketServiceTest.java diff --git a/.classpath b/.classpath index 500283ef..462ac8c3 100644 --- a/.classpath +++ b/.classpath @@ -70,6 +70,8 @@ + + diff --git a/NOTICE b/NOTICE index 29c28aaa..1417ecaf 100644 --- a/NOTICE +++ b/NOTICE @@ -326,4 +326,19 @@ font-awesome SIL OFL 1.1. https://github.com/FortAwesome/Font-Awesome - \ No newline at end of file + +--------------------------------------------------------------------------- +AUI (excerpts) +--------------------------------------------------------------------------- + AUI, release under the + Apache License 2.0 + + https://bitbucket.org/atlassian/aui + +--------------------------------------------------------------------------- +Jedis +--------------------------------------------------------------------------- + Jedis, release under the + MIT license + + https://github.com/xetorthio/jedis diff --git a/build.moxie b/build.moxie index 697e0547..02066b4c 100644 --- a/build.moxie +++ b/build.moxie @@ -170,6 +170,7 @@ dependencies: - compile 'com.github.dblock.waffle:waffle-jna:1.5' :war - compile 'org.kohsuke:libpam4j:1.7' :war - compile 'commons-codec:commons-codec:1.7' :war +- compile 'redis.clients:jedis:2.3.1' :war - test 'junit' # Dependencies for Selenium web page testing - test 'org.seleniumhq.selenium:selenium-java:${selenium.version}' @jar diff --git a/build.xml b/build.xml index 69b30e28..9f776106 100644 --- a/build.xml +++ b/build.xml @@ -566,6 +566,13 @@ + + + + + + + @@ -890,6 +897,13 @@ + + + + + + + diff --git a/gitblit.iml b/gitblit.iml index 19003661..7ebe2e89 100644 --- a/gitblit.iml +++ b/gitblit.iml @@ -724,6 +724,28 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/releases.moxie b/releases.moxie index f7af6cb2..f64e648a 100644 --- a/releases.moxie +++ b/releases.moxie @@ -59,6 +59,7 @@ r20: { - Added an optional MirrorService which will periodically fetch ref updates from source repositories for mirrors (issue-5). Repositories must be manually cloned using native git and "--mirror". - Added branch graph image servlet based on EGit's branch graph renderer (issue-194) - Added option to render Markdown commit messages (issue-203) + - Added Ticket tracker and Patchset collaboration feature (issue-215) - Added setting to control creating a repository as --shared on Unix servers (issue-263) - Set Link: ; rel="canonical" http header for SEO (issue-304) - Added raw links to the commit, commitdiff, and compare pages (issue-319) @@ -86,6 +87,7 @@ r20: { - added Dagger 1.1.0 - added Eclipse WikiText libraries for processing confluence, mediawiki, textile, tracwiki, and twiki - added FontAwesome 4.0.3 + - added Jedis 2.3.1 settings: - { name: 'git.createRepositoriesShared', defaultValue: 'false' } - { name: 'git.allowAnonymousPushes', defaultValue: 'false' } @@ -105,6 +107,10 @@ r20: { - { name: 'web.showBranchGraph', defaultValue: 'true' } - { name: 'web.summaryShowReadme', defaultValue: 'false' } - { name: 'server.redirectToHttpsPort', defaultValue: 'false' } + - { name: 'tickets.service', defaultValue: ' ' } + - { name: 'tickets.acceptNewTickets', defaultValue: 'true' } + - { name: 'tickets.acceptNewPatchsets', defaultValue: 'true' } + - { name: 'tickets.requireApproval', defaultValue: 'false' } contributors: - James Moger - Robin Rosenberg diff --git a/src/main/distrib/data/clientapps.json b/src/main/distrib/data/clientapps.json index 2b15cd38..31e53efd 100644 --- a/src/main/distrib/data/clientapps.json +++ b/src/main/distrib/data/clientapps.json @@ -9,6 +9,17 @@ "icon": "git-black_32x32.png", "isActive": true }, + { + "name": "Barnum", + "title": "Barnum", + "description": "a command-line Git companion for Gitblit Tickets", + "legal": "released under the Apache 2.0 License", + "command": "pt clone ${repoUrl}", + "productUrl": "http://barnum.gitblit.com", + "transports": [ "ssh" ], + "icon": "barnum_32x32.png", + "isActive": false + }, { "name": "SmartGit/Hg", "title": "syntevo SmartGit/Hg\u2122", @@ -73,6 +84,7 @@ "legal": "released under the GPLv3 open source license", "cloneUrl": "sparkleshare://addProject/${baseUrl}/sparkleshare/${repoUrl}.xml", "productUrl": "http://sparkleshare.org", + "transports": [ "ssh" ], "platforms": [ "windows", "macintosh", "linux" ], "icon": "sparkleshare_32x32.png", "minimumPermission" : "RW+", diff --git a/src/main/distrib/data/gitblit.properties b/src/main/distrib/data/gitblit.properties index 5a083264..73c6ebd5 100644 --- a/src/main/distrib/data/gitblit.properties +++ b/src/main/distrib/data/gitblit.properties @@ -429,6 +429,76 @@ git.streamFileThreshold = 50m # RESTART REQUIRED git.packedGitMmap = false +# Use the Gitblit patch receive pack for processing contributions and tickets. +# This allows the user to push a patch using the familiar Gerrit syntax: +# +# git push HEAD:refs/for/ +# +# NOTE: +# This requires git.enableGitServlet = true AND it requires an authenticated +# git transport connection (http/https) when pushing from a client. +# +# Valid services include: +# com.gitblit.tickets.FileTicketService +# com.gitblit.tickets.BranchTicketService +# com.gitblit.tickets.RedisTicketService +# +# SINCE 1.4.0 +# RESTART REQUIRED +tickets.service = + +# Globally enable or disable creation of new bug, enhancement, task, etc tickets +# for all repositories. +# +# If false, no tickets can be created through the ui for any repositories. +# If true, each repository can control if they allow new tickets to be created. +# +# NOTE: +# If a repository is accepting patchsets, new proposal tickets can be created +# regardless of this setting. +# +# SINCE 1.4.0 +tickets.acceptNewTickets = true + +# Globally enable or disable pushing patchsets to all repositories. +# +# If false, no patchsets will be accepted for any repositories. +# If true, each repository can control if they accept new patchsets. +# +# NOTE: +# If a repository is accepting patchsets, new proposal tickets can be created +# regardless of the acceptNewTickets setting. +# +# SINCE 1.4.0 +tickets.acceptNewPatchsets = true + +# Default setting to control patchset merge through the web ui. If true, patchsets +# must have an approval score to enable the merge button. This setting can be +# overriden per-repository. +# +# SINCE 1.4.0 +tickets.requireApproval = false + +# Specify the location of the Lucene Ticket index +# +# SINCE 1.4.0 +# RESTART REQUIRED +tickets.indexFolder = ${baseFolder}/tickets/lucene + +# Define the url for the Redis server. +# +# e.g. redis://localhost:6379 +# redis://:foobared@localhost:6379/2 +# +# SINCE 1.4.0 +# RESTART REQUIRED +tickets.redis.url = + +# The number of tickets to display on a page. +# +# SINCE 1.4.0 +tickets.perPage = 25 + # # Groovy Integration # diff --git a/src/main/distrib/linux/reindex-tickets.sh b/src/main/distrib/linux/reindex-tickets.sh new file mode 100644 index 00000000..15939291 --- /dev/null +++ b/src/main/distrib/linux/reindex-tickets.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# -------------------------------------------------------------------------- +# This is for reindexing Tickets with Lucene. +# +# Since the Tickets feature is undergoing massive churn it may be necessary +# to reindex tickets due to model or index changes. +# +# usage: +# +# reindex-tickets.sh +# +# -------------------------------------------------------------------------- + +java -cp gitblit.jar:./ext/* com.gitblit.ReindexTickets --baseFolder $1 + diff --git a/src/main/distrib/win/reindex-tickets.cmd b/src/main/distrib/win/reindex-tickets.cmd new file mode 100644 index 00000000..e28f45f5 --- /dev/null +++ b/src/main/distrib/win/reindex-tickets.cmd @@ -0,0 +1,13 @@ +@REM -------------------------------------------------------------------------- +@REM This is for reindexing Tickets with Lucene. +@REM +@REM Since the Tickets feature is undergoing massive churn it may be necessary +@REM to reindex tickets due to model or index changes. +@REM +@REM Always use forward-slashes for the path separator in your parameters!! +@REM +@REM Set FOLDER to the baseFolder. +@REM -------------------------------------------------------------------------- +@SET FOLDER=data + +@java -cp gitblit.jar;"%CD%\ext\*" com.gitblit.ReindexTickets --baseFolder %FOLDER% diff --git a/src/main/java/WEB-INF/web.xml b/src/main/java/WEB-INF/web.xml index 8f51c21b..1451ec63 100644 --- a/src/main/java/WEB-INF/web.xml +++ b/src/main/java/WEB-INF/web.xml @@ -161,6 +161,20 @@ /logo.png + + + + PtServlet + com.gitblit.servlet.PtServlet + + + PtServlet + /pt + + + @@ -300,7 +314,7 @@ * PagesFilter * PagesServlet * com.gitblit.Constants.PAGES_PATH --> - r/,git/,feed/,zip/,federation/,rpc/,pages/,robots.txt,logo.png,graph/,sparkleshare/ + r/,git/,pt,feed/,zip/,federation/,rpc/,pages/,robots.txt,logo.png,graph/,sparkleshare/ diff --git a/src/main/java/com/gitblit/Constants.java b/src/main/java/com/gitblit/Constants.java index d425cdac..5b71eeb9 100644 --- a/src/main/java/com/gitblit/Constants.java +++ b/src/main/java/com/gitblit/Constants.java @@ -108,12 +108,18 @@ public class Constants { public static final String R_CHANGES = "refs/changes/"; - public static final String R_PULL= "refs/pull/"; + public static final String R_PULL = "refs/pull/"; public static final String R_TAGS = "refs/tags/"; public static final String R_REMOTES = "refs/remotes/"; + public static final String R_FOR = "refs/for/"; + + public static final String R_TICKET = "refs/heads/ticket/"; + + public static final String R_TICKETS_PATCHSETS = "refs/tickets/"; + public static String getVersion() { String v = Constants.class.getPackage().getImplementationVersion(); if (v == null) { diff --git a/src/main/java/com/gitblit/GitBlit.java b/src/main/java/com/gitblit/GitBlit.java index da80a746..a1abfcd1 100644 --- a/src/main/java/com/gitblit/GitBlit.java +++ b/src/main/java/com/gitblit/GitBlit.java @@ -19,12 +19,14 @@ import java.text.MessageFormat; import java.util.ArrayList; import java.util.List; +import javax.inject.Singleton; import javax.servlet.http.HttpServletRequest; import com.gitblit.Constants.AccessPermission; import com.gitblit.manager.GitblitManager; import com.gitblit.manager.IAuthenticationManager; import com.gitblit.manager.IFederationManager; +import com.gitblit.manager.IGitblit; import com.gitblit.manager.INotificationManager; import com.gitblit.manager.IProjectManager; import com.gitblit.manager.IRepositoryManager; @@ -34,8 +36,17 @@ import com.gitblit.manager.ServicesManager; import com.gitblit.models.RepositoryModel; import com.gitblit.models.RepositoryUrl; import com.gitblit.models.UserModel; +import com.gitblit.tickets.BranchTicketService; +import com.gitblit.tickets.FileTicketService; +import com.gitblit.tickets.ITicketService; +import com.gitblit.tickets.NullTicketService; +import com.gitblit.tickets.RedisTicketService; import com.gitblit.utils.StringUtils; +import dagger.Module; +import dagger.ObjectGraph; +import dagger.Provides; + /** * GitBlit is the aggregate manager for the Gitblit webapp. It provides all * management functions and also manages some long-running services. @@ -45,8 +56,12 @@ import com.gitblit.utils.StringUtils; */ public class GitBlit extends GitblitManager { + private final ObjectGraph injector; + private final ServicesManager servicesManager; + private ITicketService ticketService; + public GitBlit( IRuntimeManager runtimeManager, INotificationManager notificationManager, @@ -64,6 +79,8 @@ public class GitBlit extends GitblitManager { projectManager, federationManager); + this.injector = ObjectGraph.create(getModules()); + this.servicesManager = new ServicesManager(this); } @@ -72,6 +89,7 @@ public class GitBlit extends GitblitManager { super.start(); logger.info("Starting services manager..."); servicesManager.start(); + configureTicketService(); return this; } @@ -79,9 +97,14 @@ public class GitBlit extends GitblitManager { public GitBlit stop() { super.stop(); servicesManager.stop(); + ticketService.stop(); return this; } + protected Object [] getModules() { + return new Object [] { new GitBlitModule()}; + } + /** * Returns a list of repository URLs and the user access permission. * @@ -131,4 +154,135 @@ public class GitBlit extends GitblitManager { } return list; } + + /** + * Detect renames and reindex as appropriate. + */ + @Override + public void updateRepositoryModel(String repositoryName, RepositoryModel repository, + boolean isCreate) throws GitBlitException { + RepositoryModel oldModel = null; + boolean isRename = !isCreate && !repositoryName.equalsIgnoreCase(repository.name); + if (isRename) { + oldModel = repositoryManager.getRepositoryModel(repositoryName); + } + + super.updateRepositoryModel(repositoryName, repository, isCreate); + + if (isRename && ticketService != null) { + ticketService.rename(oldModel, repository); + } + } + + /** + * Delete the repository and all associated tickets. + */ + @Override + public boolean deleteRepository(String repositoryName) { + RepositoryModel repository = repositoryManager.getRepositoryModel(repositoryName); + boolean success = repositoryManager.deleteRepository(repositoryName); + if (success && ticketService != null) { + return ticketService.deleteAll(repository); + } + return success; + } + + /** + * Returns the configured ticket service. + * + * @return a ticket service + */ + @Override + public ITicketService getTicketService() { + return ticketService; + } + + protected void configureTicketService() { + String clazz = settings.getString(Keys.tickets.service, NullTicketService.class.getName()); + if (StringUtils.isEmpty(clazz)) { + clazz = NullTicketService.class.getName(); + } + try { + Class serviceClass = (Class) Class.forName(clazz); + ticketService = injector.get(serviceClass).start(); + if (ticketService.isReady()) { + logger.info("{} is ready.", ticketService); + } else { + logger.warn("{} is disabled.", ticketService); + } + } catch (Exception e) { + logger.error("failed to create ticket service " + clazz, e); + ticketService = injector.get(NullTicketService.class).start(); + } + } + + /** + * A nested Dagger graph is used for constructor dependency injection of + * complex classes. + * + * @author James Moger + * + */ + @Module( + library = true, + injects = { + IStoredSettings.class, + + // core managers + IRuntimeManager.class, + INotificationManager.class, + IUserManager.class, + IAuthenticationManager.class, + IRepositoryManager.class, + IProjectManager.class, + IFederationManager.class, + + // the monolithic manager + IGitblit.class, + + // ticket services + NullTicketService.class, + FileTicketService.class, + BranchTicketService.class, + RedisTicketService.class + } + ) + class GitBlitModule { + + @Provides @Singleton IStoredSettings provideSettings() { + return settings; + } + + @Provides @Singleton IRuntimeManager provideRuntimeManager() { + return runtimeManager; + } + + @Provides @Singleton INotificationManager provideNotificationManager() { + return notificationManager; + } + + @Provides @Singleton IUserManager provideUserManager() { + return userManager; + } + + @Provides @Singleton IAuthenticationManager provideAuthenticationManager() { + return authenticationManager; + } + + @Provides @Singleton IRepositoryManager provideRepositoryManager() { + return repositoryManager; + } + + @Provides @Singleton IProjectManager provideProjectManager() { + return projectManager; + } + + @Provides @Singleton IFederationManager provideFederationManager() { + return federationManager; + } + + @Provides @Singleton IGitblit provideGitblit() { + return GitBlit.this; + } + } } diff --git a/src/main/java/com/gitblit/ReindexTickets.java b/src/main/java/com/gitblit/ReindexTickets.java new file mode 100644 index 00000000..af3ca0b2 --- /dev/null +++ b/src/main/java/com/gitblit/ReindexTickets.java @@ -0,0 +1,183 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit; + +import java.io.File; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; + +import com.beust.jcommander.JCommander; +import com.beust.jcommander.Parameter; +import com.beust.jcommander.ParameterException; +import com.beust.jcommander.Parameters; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.RepositoryManager; +import com.gitblit.manager.RuntimeManager; +import com.gitblit.tickets.BranchTicketService; +import com.gitblit.tickets.FileTicketService; +import com.gitblit.tickets.ITicketService; +import com.gitblit.tickets.RedisTicketService; +import com.gitblit.utils.StringUtils; + +/** + * A command-line tool to reindex all tickets in all repositories when the + * indexes needs to be rebuilt. + * + * @author James Moger + * + */ +public class ReindexTickets { + + public static void main(String... args) { + ReindexTickets reindex = new ReindexTickets(); + + // filter out the baseFolder parameter + List filtered = new ArrayList(); + String folder = "data"; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if (arg.equals("--baseFolder")) { + if (i + 1 == args.length) { + System.out.println("Invalid --baseFolder parameter!"); + System.exit(-1); + } else if (!".".equals(args[i + 1])) { + folder = args[i + 1]; + } + i = i + 1; + } else { + filtered.add(arg); + } + } + + Params.baseFolder = folder; + Params params = new Params(); + JCommander jc = new JCommander(params); + try { + jc.parse(filtered.toArray(new String[filtered.size()])); + if (params.help) { + reindex.usage(jc, null); + return; + } + } catch (ParameterException t) { + reindex.usage(jc, t); + return; + } + + // load the settings + FileSettings settings = params.FILESETTINGS; + if (!StringUtils.isEmpty(params.settingsfile)) { + if (new File(params.settingsfile).exists()) { + settings = new FileSettings(params.settingsfile); + } + } + + // reindex tickets + reindex.reindex(new File(Params.baseFolder), settings); + System.exit(0); + } + + /** + * Display the command line usage of ReindexTickets. + * + * @param jc + * @param t + */ + protected final void usage(JCommander jc, ParameterException t) { + System.out.println(Constants.BORDER); + System.out.println(Constants.getGitBlitVersion()); + System.out.println(Constants.BORDER); + System.out.println(); + if (t != null) { + System.out.println(t.getMessage()); + System.out.println(); + } + if (jc != null) { + jc.usage(); + System.out + .println("\nExample:\n java -gitblit.jar com.gitblit.ReindexTickets --baseFolder c:\\gitblit-data"); + } + System.exit(0); + } + + /** + * Reindex all tickets + * + * @param settings + */ + protected void reindex(File baseFolder, IStoredSettings settings) { + // disable some services + settings.overrideSetting(Keys.web.allowLuceneIndexing, false); + settings.overrideSetting(Keys.git.enableGarbageCollection, false); + settings.overrideSetting(Keys.git.enableMirroring, false); + settings.overrideSetting(Keys.web.activityCacheDays, 0); + + IRuntimeManager runtimeManager = new RuntimeManager(settings, baseFolder).start(); + IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, null).start(); + + String serviceName = settings.getString(Keys.tickets.service, BranchTicketService.class.getSimpleName()); + if (StringUtils.isEmpty(serviceName)) { + System.err.println(MessageFormat.format("Please define a ticket service in \"{0}\"", Keys.tickets.service)); + System.exit(1); + } + ITicketService ticketService = null; + try { + Class serviceClass = Class.forName(serviceName); + if (RedisTicketService.class.isAssignableFrom(serviceClass)) { + // Redis ticket service + ticketService = new RedisTicketService(runtimeManager, null, null, repositoryManager).start(); + } else if (BranchTicketService.class.isAssignableFrom(serviceClass)) { + // Branch ticket service + ticketService = new BranchTicketService(runtimeManager, null, null, repositoryManager).start(); + } else if (FileTicketService.class.isAssignableFrom(serviceClass)) { + // File ticket service + ticketService = new FileTicketService(runtimeManager, null, null, repositoryManager).start(); + } else { + System.err.println("Unknown ticket service " + serviceName); + System.exit(1); + } + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + + ticketService.reindex(); + ticketService.stop(); + repositoryManager.stop(); + runtimeManager.stop(); + } + + /** + * JCommander Parameters. + */ + @Parameters(separators = " ") + public static class Params { + + public static String baseFolder; + + @Parameter(names = { "-h", "--help" }, description = "Show this help") + public Boolean help = false; + + private final FileSettings FILESETTINGS = new FileSettings(new File(baseFolder, Constants.PROPERTIES_FILE).getAbsolutePath()); + + @Parameter(names = { "--repositoriesFolder" }, description = "Git Repositories Folder") + public String repositoriesFolder = FILESETTINGS.getString(Keys.git.repositoriesFolder, "git"); + + @Parameter(names = { "--settings" }, description = "Path to alternative settings") + public String settingsfile; + } +} diff --git a/src/main/java/com/gitblit/client/EditRepositoryDialog.java b/src/main/java/com/gitblit/client/EditRepositoryDialog.java index ce22d72f..c3690f37 100644 --- a/src/main/java/com/gitblit/client/EditRepositoryDialog.java +++ b/src/main/java/com/gitblit/client/EditRepositoryDialog.java @@ -88,6 +88,12 @@ public class EditRepositoryDialog extends JDialog { private JTextField descriptionField; + private JCheckBox acceptNewPatchsets; + + private JCheckBox acceptNewTickets; + + private JCheckBox requireApproval; + private JCheckBox useIncrementalPushTags; private JCheckBox showRemoteBranches; @@ -205,6 +211,12 @@ public class EditRepositoryDialog extends JDialog { ownersPalette = new JPalette(true); + acceptNewTickets = new JCheckBox(Translation.get("gb.acceptsNewTicketsDescription"), + anRepository.acceptNewTickets); + acceptNewPatchsets = new JCheckBox(Translation.get("gb.acceptsNewPatchsetsDescription"), + anRepository.acceptNewPatchsets); + requireApproval = new JCheckBox(Translation.get("gb.requireApprovalDescription"), + anRepository.requireApproval); useIncrementalPushTags = new JCheckBox(Translation.get("gb.useIncrementalPushTagsDescription"), anRepository.useIncrementalPushTags); showRemoteBranches = new JCheckBox( @@ -298,6 +310,12 @@ public class EditRepositoryDialog extends JDialog { fieldsPanel.add(newFieldPanel(Translation.get("gb.gcPeriod"), gcPeriod)); fieldsPanel.add(newFieldPanel(Translation.get("gb.gcThreshold"), gcThreshold)); + fieldsPanel.add(newFieldPanel(Translation.get("gb.acceptsNewTickets"), + acceptNewTickets)); + fieldsPanel.add(newFieldPanel(Translation.get("gb.acceptsNewPatchsets"), + acceptNewPatchsets)); + fieldsPanel.add(newFieldPanel(Translation.get("gb.requireApproval"), + requireApproval)); fieldsPanel .add(newFieldPanel(Translation.get("gb.enableIncrementalPushTags"), useIncrementalPushTags)); fieldsPanel.add(newFieldPanel(Translation.get("gb.showRemoteBranches"), @@ -552,6 +570,9 @@ public class EditRepositoryDialog extends JDialog { : headRefField.getSelectedItem().toString(); repository.gcPeriod = (Integer) gcPeriod.getSelectedItem(); repository.gcThreshold = gcThreshold.getText(); + repository.acceptNewPatchsets = acceptNewPatchsets.isSelected(); + repository.acceptNewTickets = acceptNewTickets.isSelected(); + repository.requireApproval = requireApproval.isSelected(); repository.useIncrementalPushTags = useIncrementalPushTags.isSelected(); repository.showRemoteBranches = showRemoteBranches.isSelected(); repository.skipSizeCalculation = skipSizeCalculation.isSelected(); diff --git a/src/main/java/com/gitblit/git/GitblitReceivePack.java b/src/main/java/com/gitblit/git/GitblitReceivePack.java index 35f0d866..3a0eff22 100644 --- a/src/main/java/com/gitblit/git/GitblitReceivePack.java +++ b/src/main/java/com/gitblit/git/GitblitReceivePack.java @@ -50,6 +50,7 @@ import com.gitblit.client.Translation; import com.gitblit.manager.IGitblit; import com.gitblit.models.RepositoryModel; import com.gitblit.models.UserModel; +import com.gitblit.tickets.BranchTicketService; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.ClientLogger; import com.gitblit.utils.CommitCache; @@ -236,6 +237,16 @@ public class GitblitReceivePack extends ReceivePack implements PreReceiveHook, P default: break; } + } else if (ref.equals(BranchTicketService.BRANCH)) { + // ensure pushing user is an administrator OR an owner + // i.e. prevent ticket tampering + boolean permitted = user.canAdmin() || repository.isOwner(user.username); + if (!permitted) { + sendRejection(cmd, "{0} is not permitted to push to {1}", user.username, ref); + } + } else if (ref.startsWith(Constants.R_FOR)) { + // prevent accidental push to refs/for + sendRejection(cmd, "{0} is not configured to receive patchsets", repository.name); } } diff --git a/src/main/java/com/gitblit/git/GitblitReceivePackFactory.java b/src/main/java/com/gitblit/git/GitblitReceivePackFactory.java index b8b49bcd..7976fe56 100644 --- a/src/main/java/com/gitblit/git/GitblitReceivePackFactory.java +++ b/src/main/java/com/gitblit/git/GitblitReceivePackFactory.java @@ -100,10 +100,17 @@ public class GitblitReceivePackFactory implements ReceivePackFactory { if (StringUtils.isEmpty(url)) { url = gitblitUrl; } - + final RepositoryModel repository = gitblit.getRepositoryModel(repositoryName); - final GitblitReceivePack rp = new GitblitReceivePack(gitblit, db, repository, user); + // Determine which receive pack to use for pushes + final GitblitReceivePack rp; + if (gitblit.getTicketService().isAcceptingNewPatchsets(repository)) { + rp = new PatchsetReceivePack(gitblit, db, repository, user); + } else { + rp = new GitblitReceivePack(gitblit, db, repository, user); + } + rp.setGitblitUrl(url); rp.setRefLogIdent(new PersonIdent(user.username, user.username + "@" + origin)); rp.setTimeout(timeout); diff --git a/src/main/java/com/gitblit/git/PatchsetCommand.java b/src/main/java/com/gitblit/git/PatchsetCommand.java new file mode 100644 index 00000000..21d2ac45 --- /dev/null +++ b/src/main/java/com/gitblit/git/PatchsetCommand.java @@ -0,0 +1,324 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.git; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.ReceiveCommand; + +import com.gitblit.Constants; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.PatchsetType; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.StringUtils; + +/** + * + * A subclass of ReceiveCommand which constructs a ticket change based on a + * patchset and data derived from the push ref. + * + * @author James Moger + * + */ +public class PatchsetCommand extends ReceiveCommand { + + public static final String TOPIC = "t="; + + public static final String RESPONSIBLE = "r="; + + public static final String WATCH = "cc="; + + public static final String MILESTONE = "m="; + + protected final Change change; + + protected boolean isNew; + + protected long ticketId; + + public static String getBasePatchsetBranch(long ticketNumber) { + StringBuilder sb = new StringBuilder(); + sb.append(Constants.R_TICKETS_PATCHSETS); + long m = ticketNumber % 100L; + if (m < 10) { + sb.append('0'); + } + sb.append(m); + sb.append('/'); + sb.append(ticketNumber); + sb.append('/'); + return sb.toString(); + } + + public static String getTicketBranch(long ticketNumber) { + return Constants.R_TICKET + ticketNumber; + } + + public static String getReviewBranch(long ticketNumber) { + return "ticket-" + ticketNumber; + } + + public static String getPatchsetBranch(long ticketId, long patchset) { + return getBasePatchsetBranch(ticketId) + patchset; + } + + public static long getTicketNumber(String ref) { + if (ref.startsWith(Constants.R_TICKETS_PATCHSETS)) { + // patchset revision + + // strip changes ref + String p = ref.substring(Constants.R_TICKETS_PATCHSETS.length()); + // strip shard id + p = p.substring(p.indexOf('/') + 1); + // strip revision + p = p.substring(0, p.indexOf('/')); + // parse ticket number + return Long.parseLong(p); + } else if (ref.startsWith(Constants.R_TICKET)) { + String p = ref.substring(Constants.R_TICKET.length()); + // parse ticket number + return Long.parseLong(p); + } + return 0L; + } + + public PatchsetCommand(String username, Patchset patchset) { + super(patchset.isFF() ? ObjectId.fromString(patchset.parent) : ObjectId.zeroId(), + ObjectId.fromString(patchset.tip), null); + this.change = new Change(username); + this.change.patchset = patchset; + } + + public PatchsetType getPatchsetType() { + return change.patchset.type; + } + + public boolean isNewTicket() { + return isNew; + } + + public long getTicketId() { + return ticketId; + } + + public Change getChange() { + return change; + } + + /** + * Creates a "new ticket" change for the proposal. + * + * @param commit + * @param mergeTo + * @param ticketId + * @parem pushRef + */ + public void newTicket(RevCommit commit, String mergeTo, long ticketId, String pushRef) { + this.ticketId = ticketId; + isNew = true; + change.setField(Field.title, getTitle(commit)); + change.setField(Field.body, getBody(commit)); + change.setField(Field.status, Status.New); + change.setField(Field.mergeTo, mergeTo); + change.setField(Field.type, TicketModel.Type.Proposal); + + Set watchSet = new TreeSet(); + watchSet.add(change.author); + + // identify parameters passed in the push ref + if (!StringUtils.isEmpty(pushRef)) { + List watchers = getOptions(pushRef, WATCH); + if (!ArrayUtils.isEmpty(watchers)) { + for (String cc : watchers) { + watchSet.add(cc.toLowerCase()); + } + } + + String milestone = getSingleOption(pushRef, MILESTONE); + if (!StringUtils.isEmpty(milestone)) { + // user provided milestone + change.setField(Field.milestone, milestone); + } + + String responsible = getSingleOption(pushRef, RESPONSIBLE); + if (!StringUtils.isEmpty(responsible)) { + // user provided responsible + change.setField(Field.responsible, responsible); + watchSet.add(responsible); + } + + String topic = getSingleOption(pushRef, TOPIC); + if (!StringUtils.isEmpty(topic)) { + // user provided topic + change.setField(Field.topic, topic); + } + } + + // set the watchers + change.watch(watchSet.toArray(new String[watchSet.size()])); + } + + /** + * + * @param commit + * @param mergeTo + * @param ticket + * @param pushRef + */ + public void updateTicket(RevCommit commit, String mergeTo, TicketModel ticket, String pushRef) { + + this.ticketId = ticket.number; + + if (ticket.isClosed()) { + // re-opening a closed ticket + change.setField(Field.status, Status.Open); + } + + // ticket may or may not already have an integration branch + if (StringUtils.isEmpty(ticket.mergeTo) || !ticket.mergeTo.equals(mergeTo)) { + change.setField(Field.mergeTo, mergeTo); + } + + if (ticket.isProposal() && change.patchset.commits == 1 && change.patchset.type.isRewrite()) { + + // Gerrit-style title and description updates from the commit + // message + String title = getTitle(commit); + String body = getBody(commit); + + if (!ticket.title.equals(title)) { + // title changed + change.setField(Field.title, title); + } + + if (!ticket.body.equals(body)) { + // description changed + change.setField(Field.body, body); + } + } + + Set watchSet = new TreeSet(); + watchSet.add(change.author); + + // update the patchset command metadata + if (!StringUtils.isEmpty(pushRef)) { + List watchers = getOptions(pushRef, WATCH); + if (!ArrayUtils.isEmpty(watchers)) { + for (String cc : watchers) { + watchSet.add(cc.toLowerCase()); + } + } + + String milestone = getSingleOption(pushRef, MILESTONE); + if (!StringUtils.isEmpty(milestone) && !milestone.equals(ticket.milestone)) { + // user specified a (different) milestone + change.setField(Field.milestone, milestone); + } + + String responsible = getSingleOption(pushRef, RESPONSIBLE); + if (!StringUtils.isEmpty(responsible) && !responsible.equals(ticket.responsible)) { + // user specified a (different) responsible + change.setField(Field.responsible, responsible); + watchSet.add(responsible); + } + + String topic = getSingleOption(pushRef, TOPIC); + if (!StringUtils.isEmpty(topic) && !topic.equals(ticket.topic)) { + // user specified a (different) topic + change.setField(Field.topic, topic); + } + } + + // update the watchers + watchSet.removeAll(ticket.getWatchers()); + if (!watchSet.isEmpty()) { + change.watch(watchSet.toArray(new String[watchSet.size()])); + } + } + + @Override + public String getRefName() { + return getPatchsetBranch(); + } + + public String getPatchsetBranch() { + return getBasePatchsetBranch(ticketId) + change.patchset.number; + } + + public String getTicketBranch() { + return getTicketBranch(ticketId); + } + + private String getTitle(RevCommit commit) { + String title = commit.getShortMessage(); + return title; + } + + /** + * Returns the body of the commit message + * + * @return + */ + private String getBody(RevCommit commit) { + String body = commit.getFullMessage().substring(commit.getShortMessage().length()).trim(); + return body; + } + + /** Extracts a ticket field from the ref name */ + private static List getOptions(String refName, String token) { + if (refName.indexOf('%') > -1) { + List list = new ArrayList(); + String [] strings = refName.substring(refName.indexOf('%') + 1).split(","); + for (String str : strings) { + if (str.toLowerCase().startsWith(token)) { + String val = str.substring(token.length()); + list.add(val); + } + } + return list; + } + return null; + } + + /** Extracts a ticket field from the ref name */ + private static String getSingleOption(String refName, String token) { + List list = getOptions(refName, token); + if (list != null && list.size() > 0) { + return list.get(0); + } + return null; + } + + /** Extracts a ticket field from the ref name */ + public static String getSingleOption(ReceiveCommand cmd, String token) { + return getSingleOption(cmd.getRefName(), token); + } + + /** Extracts a ticket field from the ref name */ + public static List getOptions(ReceiveCommand cmd, String token) { + return getOptions(cmd.getRefName(), token); + } + +} \ No newline at end of file diff --git a/src/main/java/com/gitblit/git/PatchsetReceivePack.java b/src/main/java/com/gitblit/git/PatchsetReceivePack.java new file mode 100644 index 00000000..ae429d2e --- /dev/null +++ b/src/main/java/com/gitblit/git/PatchsetReceivePack.java @@ -0,0 +1,1129 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.git; + +import static org.eclipse.jgit.transport.BasePackPushConnection.CAPABILITY_SIDE_BAND_64K; + +import java.io.IOException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jgit.lib.AnyObjectId; +import org.eclipse.jgit.lib.BatchRefUpdate; +import org.eclipse.jgit.lib.NullProgressMonitor; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.ProgressMonitor; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevSort; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.ReceiveCommand; +import org.eclipse.jgit.transport.ReceiveCommand.Result; +import org.eclipse.jgit.transport.ReceiveCommand.Type; +import org.eclipse.jgit.transport.ReceivePack; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.gitblit.Constants; +import com.gitblit.Keys; +import com.gitblit.manager.IGitblit; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.PatchsetType; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.UserModel; +import com.gitblit.tickets.ITicketService; +import com.gitblit.tickets.TicketMilestone; +import com.gitblit.tickets.TicketNotifier; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.DiffUtils; +import com.gitblit.utils.DiffUtils.DiffStat; +import com.gitblit.utils.JGitUtils; +import com.gitblit.utils.JGitUtils.MergeResult; +import com.gitblit.utils.JGitUtils.MergeStatus; +import com.gitblit.utils.RefLogUtils; +import com.gitblit.utils.StringUtils; + + +/** + * PatchsetReceivePack processes receive commands and allows for creating, updating, + * and closing Gitblit tickets. It also executes Groovy pre- and post- receive + * hooks. + * + * The patchset mechanism defined in this class is based on the ReceiveCommits class + * from the Gerrit code review server. + * + * The general execution flow is: + *
    + *
  1. onPreReceive()
  2. + *
  3. executeCommands()
  4. + *
  5. onPostReceive()
  6. + *
+ * + * @author Android Open Source Project + * @author James Moger + * + */ +public class PatchsetReceivePack extends GitblitReceivePack { + + protected static final List MAGIC_REFS = Arrays.asList(Constants.R_FOR, Constants.R_TICKET); + + protected static final Pattern NEW_PATCHSET = + Pattern.compile("^refs/tickets/(?:[0-9a-zA-Z][0-9a-zA-Z]/)?([1-9][0-9]*)(?:/new)?$"); + + private static final Logger LOGGER = LoggerFactory.getLogger(PatchsetReceivePack.class); + + protected final ITicketService ticketService; + + protected final TicketNotifier ticketNotifier; + + private boolean requireCleanMerge; + + public PatchsetReceivePack(IGitblit gitblit, Repository db, RepositoryModel repository, UserModel user) { + super(gitblit, db, repository, user); + this.ticketService = gitblit.getTicketService(); + this.ticketNotifier = ticketService.createNotifier(); + } + + /** Returns the patchset ref root from the ref */ + private String getPatchsetRef(String refName) { + for (String patchRef : MAGIC_REFS) { + if (refName.startsWith(patchRef)) { + return patchRef; + } + } + return null; + } + + /** Checks if the supplied ref name is a patchset ref */ + private boolean isPatchsetRef(String refName) { + return !StringUtils.isEmpty(getPatchsetRef(refName)); + } + + /** Checks if the supplied ref name is a change ref */ + private boolean isTicketRef(String refName) { + return refName.startsWith(Constants.R_TICKETS_PATCHSETS); + } + + /** Extracts the integration branch from the ref name */ + private String getIntegrationBranch(String refName) { + String patchsetRef = getPatchsetRef(refName); + String branch = refName.substring(patchsetRef.length()); + if (branch.indexOf('%') > -1) { + branch = branch.substring(0, branch.indexOf('%')); + } + + String defaultBranch = "master"; + try { + defaultBranch = getRepository().getBranch(); + } catch (Exception e) { + LOGGER.error("failed to determine default branch for " + repository.name, e); + } + + long ticketId = 0L; + try { + ticketId = Long.parseLong(branch); + } catch (Exception e) { + // not a number + } + if (ticketId > 0 || branch.equalsIgnoreCase("default") || branch.equalsIgnoreCase("new")) { + return defaultBranch; + } + return branch; + } + + /** Extracts the ticket id from the ref name */ + private long getTicketId(String refName) { + if (refName.startsWith(Constants.R_FOR)) { + String ref = refName.substring(Constants.R_FOR.length()); + if (ref.indexOf('%') > -1) { + ref = ref.substring(0, ref.indexOf('%')); + } + try { + return Long.parseLong(ref); + } catch (Exception e) { + // not a number + } + } else if (refName.startsWith(Constants.R_TICKET) || + refName.startsWith(Constants.R_TICKETS_PATCHSETS)) { + return PatchsetCommand.getTicketNumber(refName); + } + return 0L; + } + + /** Returns true if the ref namespace exists */ + private boolean hasRefNamespace(String ref) { + Map blockingFors; + try { + blockingFors = getRepository().getRefDatabase().getRefs(ref); + } catch (IOException err) { + sendError("Cannot scan refs in {0}", repository.name); + LOGGER.error("Error!", err); + return true; + } + if (!blockingFors.isEmpty()) { + sendError("{0} needs the following refs removed to receive patchsets: {1}", + repository.name, blockingFors.keySet()); + return true; + } + return false; + } + + /** Removes change ref receive commands */ + private List excludeTicketCommands(Collection commands) { + List filtered = new ArrayList(); + for (ReceiveCommand cmd : commands) { + if (!isTicketRef(cmd.getRefName())) { + // this is not a ticket ref update + filtered.add(cmd); + } + } + return filtered; + } + + /** Removes patchset receive commands for pre- and post- hook integrations */ + private List excludePatchsetCommands(Collection commands) { + List filtered = new ArrayList(); + for (ReceiveCommand cmd : commands) { + if (!isPatchsetRef(cmd.getRefName())) { + // this is a non-patchset ref update + filtered.add(cmd); + } + } + return filtered; + } + + /** Process receive commands EXCEPT for Patchset commands. */ + @Override + public void onPreReceive(ReceivePack rp, Collection commands) { + Collection filtered = excludePatchsetCommands(commands); + super.onPreReceive(rp, filtered); + } + + /** Process receive commands EXCEPT for Patchset commands. */ + @Override + public void onPostReceive(ReceivePack rp, Collection commands) { + Collection filtered = excludePatchsetCommands(commands); + super.onPostReceive(rp, filtered); + + // send all queued ticket notifications after processing all patchsets + ticketNotifier.sendAll(); + } + + @Override + protected void validateCommands() { + // workaround for JGit's awful scoping choices + // + // set the patchset refs to OK to bypass checks in the super implementation + for (final ReceiveCommand cmd : filterCommands(Result.NOT_ATTEMPTED)) { + if (isPatchsetRef(cmd.getRefName())) { + if (cmd.getType() == ReceiveCommand.Type.CREATE) { + cmd.setResult(Result.OK); + } + } + } + + super.validateCommands(); + } + + /** Execute commands to update references. */ + @Override + protected void executeCommands() { + // workaround for JGit's awful scoping choices + // + // reset the patchset refs to NOT_ATTEMPTED (see validateCommands) + for (ReceiveCommand cmd : filterCommands(Result.OK)) { + if (isPatchsetRef(cmd.getRefName())) { + cmd.setResult(Result.NOT_ATTEMPTED); + } + } + + List toApply = filterCommands(Result.NOT_ATTEMPTED); + if (toApply.isEmpty()) { + return; + } + + ProgressMonitor updating = NullProgressMonitor.INSTANCE; + boolean sideBand = isCapabilityEnabled(CAPABILITY_SIDE_BAND_64K); + if (sideBand) { + SideBandProgressMonitor pm = new SideBandProgressMonitor(msgOut); + pm.setDelayStart(250, TimeUnit.MILLISECONDS); + updating = pm; + } + + BatchRefUpdate batch = getRepository().getRefDatabase().newBatchUpdate(); + batch.setAllowNonFastForwards(isAllowNonFastForwards()); + batch.setRefLogIdent(getRefLogIdent()); + batch.setRefLogMessage("push", true); + + ReceiveCommand patchsetRefCmd = null; + PatchsetCommand patchsetCmd = null; + for (ReceiveCommand cmd : toApply) { + if (Result.NOT_ATTEMPTED != cmd.getResult()) { + // Already rejected by the core receive process. + continue; + } + + if (isPatchsetRef(cmd.getRefName())) { + if (ticketService == null) { + sendRejection(cmd, "Sorry, the ticket service is unavailable and can not accept patchsets at this time."); + continue; + } + + if (!ticketService.isReady()) { + sendRejection(cmd, "Sorry, the ticket service can not accept patchsets at this time."); + continue; + } + + if (UserModel.ANONYMOUS.equals(user)) { + // server allows anonymous pushes, but anonymous patchset + // contributions are prohibited by design + sendRejection(cmd, "Sorry, anonymous patchset contributions are prohibited."); + continue; + } + + final Matcher m = NEW_PATCHSET.matcher(cmd.getRefName()); + if (m.matches()) { + // prohibit pushing directly to a patchset ref + long id = getTicketId(cmd.getRefName()); + sendError("You may not directly push directly to a patchset ref!"); + sendError("Instead, please push to one the following:"); + sendError(" - {0}{1,number,0}", Constants.R_FOR, id); + sendError(" - {0}{1,number,0}", Constants.R_TICKET, id); + sendRejection(cmd, "protected ref"); + continue; + } + + if (hasRefNamespace(Constants.R_FOR)) { + // the refs/for/ namespace exists and it must not + LOGGER.error("{} already has refs in the {} namespace", + repository.name, Constants.R_FOR); + sendRejection(cmd, "Sorry, a repository administrator will have to remove the {} namespace", Constants.R_FOR); + continue; + } + + if (patchsetRefCmd != null) { + sendRejection(cmd, "You may only push one patchset at a time."); + continue; + } + + // responsible verification + String responsible = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.RESPONSIBLE); + if (!StringUtils.isEmpty(responsible)) { + UserModel assignee = gitblit.getUserModel(responsible); + if (assignee == null) { + // no account by this name + sendRejection(cmd, "{0} can not be assigned any tickets because there is no user account by that name", responsible); + continue; + } else if (!assignee.canPush(repository)) { + // account does not have RW permissions + sendRejection(cmd, "{0} ({1}) can not be assigned any tickets because the user does not have RW permissions for {2}", + assignee.getDisplayName(), assignee.username, repository.name); + continue; + } + } + + // milestone verification + String milestone = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.MILESTONE); + if (!StringUtils.isEmpty(milestone)) { + TicketMilestone milestoneModel = ticketService.getMilestone(repository, milestone); + if (milestoneModel == null) { + // milestone does not exist + sendRejection(cmd, "Sorry, \"{0}\" is not a valid milestone!", milestone); + continue; + } + } + + // watcher verification + List watchers = PatchsetCommand.getOptions(cmd, PatchsetCommand.WATCH); + if (!ArrayUtils.isEmpty(watchers)) { + for (String watcher : watchers) { + UserModel user = gitblit.getUserModel(watcher); + if (user == null) { + // watcher does not exist + sendRejection(cmd, "Sorry, \"{0}\" is not a valid username for the watch list!", watcher); + continue; + } + } + } + + patchsetRefCmd = cmd; + patchsetCmd = preparePatchset(cmd); + if (patchsetCmd != null) { + batch.addCommand(patchsetCmd); + } + continue; + } + + batch.addCommand(cmd); + } + + if (!batch.getCommands().isEmpty()) { + try { + batch.execute(getRevWalk(), updating); + } catch (IOException err) { + for (ReceiveCommand cmd : toApply) { + if (cmd.getResult() == Result.NOT_ATTEMPTED) { + sendRejection(cmd, "lock error: {0}", err.getMessage()); + } + } + } + } + + // + // set the results into the patchset ref receive command + // + if (patchsetRefCmd != null && patchsetCmd != null) { + if (!patchsetCmd.getResult().equals(Result.OK)) { + // patchset command failed! + LOGGER.error(patchsetCmd.getType() + " " + patchsetCmd.getRefName() + + " " + patchsetCmd.getResult()); + patchsetRefCmd.setResult(patchsetCmd.getResult(), patchsetCmd.getMessage()); + } else { + // all patchset commands were applied + patchsetRefCmd.setResult(Result.OK); + + // update the ticket branch ref + RefUpdate ru = updateRef(patchsetCmd.getTicketBranch(), patchsetCmd.getNewId()); + updateReflog(ru); + + TicketModel ticket = processPatchset(patchsetCmd); + if (ticket != null) { + ticketNotifier.queueMailing(ticket); + } + } + } + + // + // if there are standard ref update receive commands that were + // successfully processed, process referenced tickets, if any + // + List allUpdates = ReceiveCommand.filter(batch.getCommands(), Result.OK); + List refUpdates = excludePatchsetCommands(allUpdates); + List stdUpdates = excludeTicketCommands(refUpdates); + if (!stdUpdates.isEmpty()) { + int ticketsProcessed = 0; + for (ReceiveCommand cmd : stdUpdates) { + switch (cmd.getType()) { + case CREATE: + case UPDATE: + case UPDATE_NONFASTFORWARD: + Collection tickets = processMergedTickets(cmd); + ticketsProcessed += tickets.size(); + for (TicketModel ticket : tickets) { + ticketNotifier.queueMailing(ticket); + } + break; + default: + break; + } + } + + if (ticketsProcessed == 1) { + sendInfo("1 ticket updated"); + } else if (ticketsProcessed > 1) { + sendInfo("{0} tickets updated", ticketsProcessed); + } + } + + // reset the ticket caches for the repository + ticketService.resetCaches(repository); + } + + /** + * Prepares a patchset command. + * + * @param cmd + * @return the patchset command + */ + private PatchsetCommand preparePatchset(ReceiveCommand cmd) { + String branch = getIntegrationBranch(cmd.getRefName()); + long number = getTicketId(cmd.getRefName()); + + TicketModel ticket = null; + if (number > 0 && ticketService.hasTicket(repository, number)) { + ticket = ticketService.getTicket(repository, number); + } + + if (ticket == null) { + if (number > 0) { + // requested ticket does not exist + sendError("Sorry, {0} does not have ticket {1,number,0}!", repository.name, number); + sendRejection(cmd, "Invalid ticket number"); + return null; + } + } else { + if (ticket.isMerged()) { + // ticket already merged & resolved + Change mergeChange = null; + for (Change change : ticket.changes) { + if (change.isMerge()) { + mergeChange = change; + break; + } + } + sendError("Sorry, {0} already merged {1} from ticket {2,number,0} to {3}!", + mergeChange.author, mergeChange.patchset, number, ticket.mergeTo); + sendRejection(cmd, "Ticket {0,number,0} already resolved", number); + return null; + } else if (!StringUtils.isEmpty(ticket.mergeTo)) { + // ticket specifies integration branch + branch = ticket.mergeTo; + } + } + + final int shortCommitIdLen = settings.getInteger(Keys.web.shortCommitIdLength, 6); + final String shortTipId = cmd.getNewId().getName().substring(0, shortCommitIdLen); + final RevCommit tipCommit = JGitUtils.getCommit(getRepository(), cmd.getNewId().getName()); + final String forBranch = branch; + RevCommit mergeBase = null; + Ref forBranchRef = getAdvertisedRefs().get(Constants.R_HEADS + forBranch); + if (forBranchRef == null || forBranchRef.getObjectId() == null) { + // unknown integration branch + sendError("Sorry, there is no integration branch named ''{0}''.", forBranch); + sendRejection(cmd, "Invalid integration branch specified"); + return null; + } else { + // determine the merge base for the patchset on the integration branch + String base = JGitUtils.getMergeBase(getRepository(), forBranchRef.getObjectId(), tipCommit.getId()); + if (StringUtils.isEmpty(base)) { + sendError(""); + sendError("There is no common ancestry between {0} and {1}.", forBranch, shortTipId); + sendError("Please reconsider your proposed integration branch, {0}.", forBranch); + sendError(""); + sendRejection(cmd, "no merge base for patchset and {0}", forBranch); + return null; + } + mergeBase = JGitUtils.getCommit(getRepository(), base); + } + + // ensure that the patchset can be cleanly merged right now + MergeStatus status = JGitUtils.canMerge(getRepository(), tipCommit.getName(), forBranch); + switch (status) { + case ALREADY_MERGED: + sendError(""); + sendError("You have already merged this patchset.", forBranch); + sendError(""); + sendRejection(cmd, "everything up-to-date"); + return null; + case MERGEABLE: + break; + default: + if (ticket == null || requireCleanMerge) { + sendError(""); + sendError("Your patchset can not be cleanly merged into {0}.", forBranch); + sendError("Please rebase your patchset and push again."); + sendError("NOTE:", number); + sendError("You should push your rebase to refs/for/{0,number,0}", number); + sendError(""); + sendError(" git push origin HEAD:refs/for/{0,number,0}", number); + sendError(""); + sendRejection(cmd, "patchset not mergeable"); + return null; + } + } + + // check to see if this commit is already linked to a ticket + long id = identifyTicket(tipCommit, false); + if (id > 0) { + sendError("{0} has already been pushed to ticket {1,number,0}.", shortTipId, id); + sendRejection(cmd, "everything up-to-date"); + return null; + } + + PatchsetCommand psCmd; + if (ticket == null) { + /* + * NEW TICKET + */ + Patchset patchset = newPatchset(null, mergeBase.getName(), tipCommit.getName()); + + int minLength = 10; + int maxLength = 100; + String minTitle = MessageFormat.format(" minimum length of a title is {0} characters.", minLength); + String maxTitle = MessageFormat.format(" maximum length of a title is {0} characters.", maxLength); + + if (patchset.commits > 1) { + sendError(""); + sendError("To create a proposal ticket, please squash your commits and"); + sendError("provide a meaningful commit message with a short title &"); + sendError("an optional description/body."); + sendError(""); + sendError(minTitle); + sendError(maxTitle); + sendError(""); + sendRejection(cmd, "please squash to one commit"); + return null; + } + + // require a reasonable title/subject + String title = tipCommit.getFullMessage().trim().split("\n")[0]; + if (title.length() < minLength) { + // reject, title too short + sendError(""); + sendError("Please supply a longer title in your commit message!"); + sendError(""); + sendError(minTitle); + sendError(maxTitle); + sendError(""); + sendRejection(cmd, "ticket title is too short [{0}/{1}]", title.length(), maxLength); + return null; + } + if (title.length() > maxLength) { + // reject, title too long + sendError(""); + sendError("Please supply a more concise title in your commit message!"); + sendError(""); + sendError(minTitle); + sendError(maxTitle); + sendError(""); + sendRejection(cmd, "ticket title is too long [{0}/{1}]", title.length(), maxLength); + return null; + } + + // assign new id + long ticketId = ticketService.assignNewId(repository); + + // create the patchset command + psCmd = new PatchsetCommand(user.username, patchset); + psCmd.newTicket(tipCommit, forBranch, ticketId, cmd.getRefName()); + } else { + /* + * EXISTING TICKET + */ + Patchset patchset = newPatchset(ticket, mergeBase.getName(), tipCommit.getName()); + psCmd = new PatchsetCommand(user.username, patchset); + psCmd.updateTicket(tipCommit, forBranch, ticket, cmd.getRefName()); + } + + // confirm user can push the patchset + boolean pushPermitted = ticket == null + || !ticket.hasPatchsets() + || ticket.isAuthor(user.username) + || ticket.isPatchsetAuthor(user.username) + || ticket.isResponsible(user.username) + || user.canPush(repository); + + switch (psCmd.getPatchsetType()) { + case Proposal: + // proposals (first patchset) are always acceptable + break; + case FastForward: + // patchset updates must be permitted + if (!pushPermitted) { + // reject + sendError(""); + sendError("To push a patchset to this ticket one of the following must be true:"); + sendError(" 1. you created the ticket"); + sendError(" 2. you created the first patchset"); + sendError(" 3. you are specified as responsible for the ticket"); + sendError(" 4. you are listed as a reviewer for the ticket"); + sendError(" 5. you have push (RW) permission to {0}", repository.name); + sendError(""); + sendRejection(cmd, "not permitted to push to ticket {0,number,0}", ticket.number); + return null; + } + break; + default: + // non-fast-forward push + if (!pushPermitted) { + // reject + sendRejection(cmd, "non-fast-forward ({0})", psCmd.getPatchsetType()); + return null; + } + break; + } + return psCmd; + } + + /** + * Creates or updates an ticket with the specified patchset. + * + * @param cmd + * @return a ticket if the creation or update was successful + */ + private TicketModel processPatchset(PatchsetCommand cmd) { + Change change = cmd.getChange(); + + if (cmd.isNewTicket()) { + // create the ticket object + TicketModel ticket = ticketService.createTicket(repository, cmd.getTicketId(), change); + if (ticket != null) { + sendInfo(""); + sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG)); + sendInfo("created proposal ticket from patchset"); + sendInfo(ticketService.getTicketUrl(ticket)); + sendInfo(""); + + // log the new patch ref + RefLogUtils.updateRefLog(user, getRepository(), + Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName()))); + + return ticket; + } else { + sendError("FAILED to create ticket"); + } + } else { + // update an existing ticket + TicketModel ticket = ticketService.updateTicket(repository, cmd.getTicketId(), change); + if (ticket != null) { + sendInfo(""); + sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG)); + if (change.patchset.rev == 1) { + // new patchset + sendInfo("uploaded patchset {0} ({1})", change.patchset.number, change.patchset.type.toString()); + } else { + // updated patchset + sendInfo("added {0} {1} to patchset {2}", + change.patchset.added, + change.patchset.added == 1 ? "commit" : "commits", + change.patchset.number); + } + sendInfo(ticketService.getTicketUrl(ticket)); + sendInfo(""); + + // log the new patchset ref + RefLogUtils.updateRefLog(user, getRepository(), + Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName()))); + + // return the updated ticket + return ticket; + } else { + sendError("FAILED to upload {0} for ticket {1,number,0}", change.patchset, cmd.getTicketId()); + } + } + + return null; + } + + /** + * Automatically closes open tickets that have been merged to their integration + * branch by a client. + * + * @param cmd + */ + private Collection processMergedTickets(ReceiveCommand cmd) { + Map mergedTickets = new LinkedHashMap(); + final RevWalk rw = getRevWalk(); + try { + rw.reset(); + rw.markStart(rw.parseCommit(cmd.getNewId())); + if (!ObjectId.zeroId().equals(cmd.getOldId())) { + rw.markUninteresting(rw.parseCommit(cmd.getOldId())); + } + + RevCommit c; + while ((c = rw.next()) != null) { + rw.parseBody(c); + long ticketNumber = identifyTicket(c, true); + if (ticketNumber == 0L || mergedTickets.containsKey(ticketNumber)) { + continue; + } + + TicketModel ticket = ticketService.getTicket(repository, ticketNumber); + String integrationBranch; + if (StringUtils.isEmpty(ticket.mergeTo)) { + // unspecified integration branch + integrationBranch = null; + } else { + // specified integration branch + integrationBranch = Constants.R_HEADS + ticket.mergeTo; + } + + // ticket must be open and, if specified, the ref must match the integration branch + if (ticket.isClosed() || (integrationBranch != null && !integrationBranch.equals(cmd.getRefName()))) { + continue; + } + + String baseRef = PatchsetCommand.getBasePatchsetBranch(ticket.number); + boolean knownPatchset = false; + Set refs = getRepository().getAllRefsByPeeledObjectId().get(c.getId()); + if (refs != null) { + for (Ref ref : refs) { + if (ref.getName().startsWith(baseRef)) { + knownPatchset = true; + break; + } + } + } + + String mergeSha = c.getName(); + String mergeTo = Repository.shortenRefName(cmd.getRefName()); + Change change; + Patchset patchset; + if (knownPatchset) { + // identify merged patchset by the patchset tip + patchset = null; + for (Patchset ps : ticket.getPatchsets()) { + if (ps.tip.equals(mergeSha)) { + patchset = ps; + break; + } + } + + if (patchset == null) { + // should not happen - unless ticket has been hacked + sendError("Failed to find the patchset for {0} in ticket {1,number,0}?!", + mergeSha, ticket.number); + continue; + } + + // create a new change + change = new Change(user.username); + } else { + // new patchset pushed by user + String base = cmd.getOldId().getName(); + patchset = newPatchset(ticket, base, mergeSha); + PatchsetCommand psCmd = new PatchsetCommand(user.username, patchset); + psCmd.updateTicket(c, mergeTo, ticket, null); + + // create a ticket patchset ref + updateRef(psCmd.getPatchsetBranch(), c.getId()); + RefUpdate ru = updateRef(psCmd.getTicketBranch(), c.getId()); + updateReflog(ru); + + // create a change from the patchset command + change = psCmd.getChange(); + } + + // set the common change data about the merge + change.setField(Field.status, Status.Merged); + change.setField(Field.mergeSha, mergeSha); + change.setField(Field.mergeTo, mergeTo); + + if (StringUtils.isEmpty(ticket.responsible)) { + // unassigned tickets are assigned to the closer + change.setField(Field.responsible, user.username); + } + + ticket = ticketService.updateTicket(repository, ticket.number, change); + if (ticket != null) { + sendInfo(""); + sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG)); + sendInfo("closed by push of {0} to {1}", patchset, mergeTo); + sendInfo(ticketService.getTicketUrl(ticket)); + sendInfo(""); + mergedTickets.put(ticket.number, ticket); + } else { + String shortid = mergeSha.substring(0, settings.getInteger(Keys.web.shortCommitIdLength, 6)); + sendError("FAILED to close ticket {0,number,0} by push of {1}", ticketNumber, shortid); + } + } + } catch (IOException e) { + LOGGER.error("Can't scan for changes to close", e); + } finally { + rw.reset(); + } + + return mergedTickets.values(); + } + + /** + * Try to identify a ticket id from the commit. + * + * @param commit + * @param parseMessage + * @return a ticket id or 0 + */ + private long identifyTicket(RevCommit commit, boolean parseMessage) { + // try lookup by change ref + Map> map = getRepository().getAllRefsByPeeledObjectId(); + Set refs = map.get(commit.getId()); + if (!ArrayUtils.isEmpty(refs)) { + for (Ref ref : refs) { + long number = PatchsetCommand.getTicketNumber(ref.getName()); + if (number > 0) { + return number; + } + } + } + + if (parseMessage) { + // parse commit message looking for fixes/closes #n + Pattern p = Pattern.compile("(?:fixes|closes)[\\s-]+#?(\\d+)", Pattern.CASE_INSENSITIVE); + Matcher m = p.matcher(commit.getFullMessage()); + while (m.find()) { + String val = m.group(); + return Long.parseLong(val); + } + } + return 0L; + } + + private int countCommits(String baseId, String tipId) { + int count = 0; + RevWalk walk = getRevWalk(); + walk.reset(); + walk.sort(RevSort.TOPO); + walk.sort(RevSort.REVERSE, true); + try { + RevCommit tip = walk.parseCommit(getRepository().resolve(tipId)); + RevCommit base = walk.parseCommit(getRepository().resolve(baseId)); + walk.markStart(tip); + walk.markUninteresting(base); + for (;;) { + RevCommit c = walk.next(); + if (c == null) { + break; + } + count++; + } + } catch (IOException e) { + // Should never happen, the core receive process would have + // identified the missing object earlier before we got control. + LOGGER.error("failed to get commit count", e); + return 0; + } finally { + walk.release(); + } + return count; + } + + /** + * Creates a new patchset with metadata. + * + * @param ticket + * @param mergeBase + * @param tip + */ + private Patchset newPatchset(TicketModel ticket, String mergeBase, String tip) { + int totalCommits = countCommits(mergeBase, tip); + + Patchset newPatchset = new Patchset(); + newPatchset.tip = tip; + newPatchset.base = mergeBase; + newPatchset.commits = totalCommits; + + Patchset currPatchset = ticket == null ? null : ticket.getCurrentPatchset(); + if (currPatchset == null) { + /* + * PROPOSAL PATCHSET + * patchset 1, rev 1 + */ + newPatchset.number = 1; + newPatchset.rev = 1; + newPatchset.type = PatchsetType.Proposal; + + // diffstat from merge base + DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip); + newPatchset.insertions = diffStat.getInsertions(); + newPatchset.deletions = diffStat.getDeletions(); + } else { + /* + * PATCHSET UPDATE + */ + int added = totalCommits - currPatchset.commits; + boolean ff = JGitUtils.isMergedInto(getRepository(), currPatchset.tip, tip); + boolean squash = added < 0; + boolean rebase = !currPatchset.base.equals(mergeBase); + + // determine type, number and rev of the patchset + if (ff) { + /* + * FAST-FORWARD + * patchset number preserved, rev incremented + */ + + boolean merged = JGitUtils.isMergedInto(getRepository(), currPatchset.tip, ticket.mergeTo); + if (merged) { + // current patchset was already merged + // new patchset, mark as rebase + newPatchset.type = PatchsetType.Rebase; + newPatchset.number = currPatchset.number + 1; + newPatchset.rev = 1; + + // diffstat from parent + DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip); + newPatchset.insertions = diffStat.getInsertions(); + newPatchset.deletions = diffStat.getDeletions(); + } else { + // FF update to patchset + newPatchset.type = PatchsetType.FastForward; + newPatchset.number = currPatchset.number; + newPatchset.rev = currPatchset.rev + 1; + newPatchset.parent = currPatchset.tip; + + // diffstat from parent + DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), currPatchset.tip, tip); + newPatchset.insertions = diffStat.getInsertions(); + newPatchset.deletions = diffStat.getDeletions(); + } + } else { + /* + * NON-FAST-FORWARD + * new patchset, rev 1 + */ + if (rebase && squash) { + newPatchset.type = PatchsetType.Rebase_Squash; + newPatchset.number = currPatchset.number + 1; + newPatchset.rev = 1; + } else if (squash) { + newPatchset.type = PatchsetType.Squash; + newPatchset.number = currPatchset.number + 1; + newPatchset.rev = 1; + } else if (rebase) { + newPatchset.type = PatchsetType.Rebase; + newPatchset.number = currPatchset.number + 1; + newPatchset.rev = 1; + } else { + newPatchset.type = PatchsetType.Amend; + newPatchset.number = currPatchset.number + 1; + newPatchset.rev = 1; + } + + // diffstat from merge base + DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip); + newPatchset.insertions = diffStat.getInsertions(); + newPatchset.deletions = diffStat.getDeletions(); + } + + if (added > 0) { + // ignore squash (negative add) + newPatchset.added = added; + } + } + + return newPatchset; + } + + private RefUpdate updateRef(String ref, ObjectId newId) { + ObjectId ticketRefId = ObjectId.zeroId(); + try { + ticketRefId = getRepository().resolve(ref); + } catch (Exception e) { + // ignore + } + + try { + RefUpdate ru = getRepository().updateRef(ref, false); + ru.setRefLogIdent(getRefLogIdent()); + ru.setForceUpdate(true); + ru.setExpectedOldObjectId(ticketRefId); + ru.setNewObjectId(newId); + RefUpdate.Result result = ru.update(getRevWalk()); + if (result == RefUpdate.Result.LOCK_FAILURE) { + sendError("Failed to obtain lock when updating {0}:{1}", repository.name, ref); + sendError("Perhaps an administrator should remove {0}/{1}.lock?", getRepository().getDirectory(), ref); + return null; + } + return ru; + } catch (IOException e) { + LOGGER.error("failed to update ref " + ref, e); + sendError("There was an error updating ref {0}:{1}", repository.name, ref); + } + return null; + } + + private void updateReflog(RefUpdate ru) { + if (ru == null) { + return; + } + + ReceiveCommand.Type type = null; + switch (ru.getResult()) { + case NEW: + type = Type.CREATE; + break; + case FAST_FORWARD: + type = Type.UPDATE; + break; + case FORCED: + type = Type.UPDATE_NONFASTFORWARD; + break; + default: + LOGGER.error(MessageFormat.format("unexpected ref update type {0} for {1}", + ru.getResult(), ru.getName())); + return; + } + ReceiveCommand cmd = new ReceiveCommand(ru.getOldObjectId(), ru.getNewObjectId(), ru.getName(), type); + RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(cmd)); + } + + /** + * Merge the specified patchset to the integration branch. + * + * @param ticket + * @param patchset + * @return true, if successful + */ + public MergeStatus merge(TicketModel ticket) { + PersonIdent committer = new PersonIdent(user.getDisplayName(), StringUtils.isEmpty(user.emailAddress) ? (user.username + "@gitblit") : user.emailAddress); + Patchset patchset = ticket.getCurrentPatchset(); + String message = MessageFormat.format("Merged #{0,number,0} \"{1}\"", ticket.number, ticket.title); + Ref oldRef = null; + try { + oldRef = getRepository().getRef(ticket.mergeTo); + } catch (IOException e) { + LOGGER.error("failed to get ref for " + ticket.mergeTo, e); + } + MergeResult mergeResult = JGitUtils.merge( + getRepository(), + patchset.tip, + ticket.mergeTo, + committer, + message); + + if (StringUtils.isEmpty(mergeResult.sha)) { + LOGGER.error("FAILED to merge {} to {} ({})", new Object [] { patchset, ticket.mergeTo, mergeResult.status.name() }); + return mergeResult.status; + } + Change change = new Change(user.username); + change.setField(Field.status, Status.Merged); + change.setField(Field.mergeSha, mergeResult.sha); + change.setField(Field.mergeTo, ticket.mergeTo); + + if (StringUtils.isEmpty(ticket.responsible)) { + // unassigned tickets are assigned to the closer + change.setField(Field.responsible, user.username); + } + + long ticketId = ticket.number; + ticket = ticketService.updateTicket(repository, ticket.number, change); + if (ticket != null) { + ticketNotifier.queueMailing(ticket); + + // update the reflog with the merge + if (oldRef != null) { + ReceiveCommand cmd = new ReceiveCommand(oldRef.getObjectId(), + ObjectId.fromString(mergeResult.sha), oldRef.getName()); + RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(cmd)); + } + return mergeResult.status; + } else { + LOGGER.error("FAILED to resolve ticket {} by merge from web ui", ticketId); + } + return mergeResult.status; + } + + public void sendAll() { + ticketNotifier.sendAll(); + } +} diff --git a/src/main/java/com/gitblit/manager/GitblitManager.java b/src/main/java/com/gitblit/manager/GitblitManager.java index 6eb60236..b27d650d 100644 --- a/src/main/java/com/gitblit/manager/GitblitManager.java +++ b/src/main/java/com/gitblit/manager/GitblitManager.java @@ -62,6 +62,7 @@ import com.gitblit.models.ServerStatus; import com.gitblit.models.SettingModel; import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; +import com.gitblit.tickets.ITicketService; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.HttpUtils; import com.gitblit.utils.JGitUtils; @@ -483,6 +484,15 @@ public class GitblitManager implements IGitblit { } } + /** + * Throws an exception if trying to get a ticket service. + * + */ + @Override + public ITicketService getTicketService() { + throw new RuntimeException("This class does not have a ticket service!"); + } + /* * ISTOREDSETTINGS * diff --git a/src/main/java/com/gitblit/manager/IGitblit.java b/src/main/java/com/gitblit/manager/IGitblit.java index aa091226..50210e9d 100644 --- a/src/main/java/com/gitblit/manager/IGitblit.java +++ b/src/main/java/com/gitblit/manager/IGitblit.java @@ -26,6 +26,7 @@ import com.gitblit.models.RepositoryModel; import com.gitblit.models.RepositoryUrl; import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; +import com.gitblit.tickets.ITicketService; public interface IGitblit extends IManager, IRuntimeManager, @@ -101,4 +102,11 @@ public interface IGitblit extends IManager, */ Collection getClientApplications(); + /** + * Returns the ticket service. + * + * @return a ticket service + */ + ITicketService getTicketService(); + } \ No newline at end of file diff --git a/src/main/java/com/gitblit/manager/RepositoryManager.java b/src/main/java/com/gitblit/manager/RepositoryManager.java index e412deba..1e917984 100644 --- a/src/main/java/com/gitblit/manager/RepositoryManager.java +++ b/src/main/java/com/gitblit/manager/RepositoryManager.java @@ -801,6 +801,9 @@ public class RepositoryManager implements IRepositoryManager { model.description = getConfig(config, "description", ""); model.originRepository = getConfig(config, "originRepository", null); model.addOwners(ArrayUtils.fromString(getConfig(config, "owner", ""))); + model.acceptNewPatchsets = getConfig(config, "acceptNewPatchsets", true); + model.acceptNewTickets = getConfig(config, "acceptNewTickets", true); + model.requireApproval = getConfig(config, "requireApproval", settings.getBoolean(Keys.tickets.requireApproval, false)); model.useIncrementalPushTags = getConfig(config, "useIncrementalPushTags", false); model.incrementalPushTagPrefix = getConfig(config, "incrementalPushTagPrefix", null); model.allowForks = getConfig(config, "allowForks", true); @@ -1406,6 +1409,15 @@ public class RepositoryManager implements IRepositoryManager { config.setString(Constants.CONFIG_GITBLIT, null, "description", repository.description); config.setString(Constants.CONFIG_GITBLIT, null, "originRepository", repository.originRepository); config.setString(Constants.CONFIG_GITBLIT, null, "owner", ArrayUtils.toString(repository.owners)); + config.setBoolean(Constants.CONFIG_GITBLIT, null, "acceptNewPatchsets", repository.acceptNewPatchsets); + config.setBoolean(Constants.CONFIG_GITBLIT, null, "acceptNewTickets", repository.acceptNewTickets); + if (settings.getBoolean(Keys.tickets.requireApproval, false) == repository.requireApproval) { + // use default + config.unset(Constants.CONFIG_GITBLIT, null, "requireApproval"); + } else { + // override default + config.setBoolean(Constants.CONFIG_GITBLIT, null, "requireApproval", repository.requireApproval); + } config.setBoolean(Constants.CONFIG_GITBLIT, null, "useIncrementalPushTags", repository.useIncrementalPushTags); if (StringUtils.isEmpty(repository.incrementalPushTagPrefix) || repository.incrementalPushTagPrefix.equals(settings.getString(Keys.git.defaultIncrementalPushTagPrefix, "r"))) { diff --git a/src/main/java/com/gitblit/models/RepositoryModel.java b/src/main/java/com/gitblit/models/RepositoryModel.java index b76e9bc6..5bd2ec03 100644 --- a/src/main/java/com/gitblit/models/RepositoryModel.java +++ b/src/main/java/com/gitblit/models/RepositoryModel.java @@ -85,6 +85,9 @@ public class RepositoryModel implements Serializable, Comparable metricAuthorExclusions; public CommitMessageRenderer commitMessageRenderer; + public boolean acceptNewPatchsets; + public boolean acceptNewTickets; + public boolean requireApproval; public transient boolean isCollectingGarbage; public Date lastGC; @@ -105,6 +108,8 @@ public class RepositoryModel implements Serializable, Comparable(); this.isBare = true; + this.acceptNewTickets = true; + this.acceptNewPatchsets = true; addOwner(owner); } @@ -140,6 +145,10 @@ public class RepositoryModel implements Serializable, Comparable { + + private static final long serialVersionUID = 1L; + + public String project; + + public String repository; + + public long number; + + public Date created; + + public String createdBy; + + public Date updated; + + public String updatedBy; + + public String title; + + public String body; + + public String topic; + + public Type type; + + public Status status; + + public String responsible; + + public String milestone; + + public String mergeSha; + + public String mergeTo; + + public List changes; + + public Integer insertions; + + public Integer deletions; + + /** + * Builds an effective ticket from the collection of changes. A change may + * Add or Subtract information from a ticket, but the collection of changes + * is only additive. + * + * @param changes + * @return the effective ticket + */ + public static TicketModel buildTicket(Collection changes) { + TicketModel ticket; + List effectiveChanges = new ArrayList(); + Map comments = new HashMap(); + for (Change change : changes) { + if (change.comment != null) { + if (comments.containsKey(change.comment.id)) { + Change original = comments.get(change.comment.id); + Change clone = copy(original); + clone.comment.text = change.comment.text; + clone.comment.deleted = change.comment.deleted; + int idx = effectiveChanges.indexOf(original); + effectiveChanges.remove(original); + effectiveChanges.add(idx, clone); + comments.put(clone.comment.id, clone); + } else { + effectiveChanges.add(change); + comments.put(change.comment.id, change); + } + } else { + effectiveChanges.add(change); + } + } + + // effective ticket + ticket = new TicketModel(); + for (Change change : effectiveChanges) { + if (!change.hasComment()) { + // ensure we do not include a deleted comment + change.comment = null; + } + ticket.applyChange(change); + } + return ticket; + } + + public TicketModel() { + // the first applied change set the date appropriately + created = new Date(0); + changes = new ArrayList(); + status = Status.New; + type = Type.defaultType; + } + + public boolean isOpen() { + return !status.isClosed(); + } + + public boolean isClosed() { + return status.isClosed(); + } + + public boolean isMerged() { + return isClosed() && !isEmpty(mergeSha); + } + + public boolean isProposal() { + return Type.Proposal == type; + } + + public boolean isBug() { + return Type.Bug == type; + } + + public Date getLastUpdated() { + return updated == null ? created : updated; + } + + public boolean hasPatchsets() { + return getPatchsets().size() > 0; + } + + /** + * Returns true if multiple participants are involved in discussing a ticket. + * The ticket creator is excluded from this determination because a + * discussion requires more than one participant. + * + * @return true if this ticket has a discussion + */ + public boolean hasDiscussion() { + for (Change change : getComments()) { + if (!change.author.equals(createdBy)) { + return true; + } + } + return false; + } + + /** + * Returns the list of changes with comments. + * + * @return + */ + public List getComments() { + List list = new ArrayList(); + for (Change change : changes) { + if (change.hasComment()) { + list.add(change); + } + } + return list; + } + + /** + * Returns the list of participants for the ticket. + * + * @return the list of participants + */ + public List getParticipants() { + Set set = new LinkedHashSet(); + for (Change change : changes) { + if (change.isParticipantChange()) { + set.add(change.author); + } + } + if (responsible != null && responsible.length() > 0) { + set.add(responsible); + } + return new ArrayList(set); + } + + public boolean hasLabel(String label) { + return getLabels().contains(label); + } + + public List getLabels() { + return getList(Field.labels); + } + + public boolean isResponsible(String username) { + return username.equals(responsible); + } + + public boolean isAuthor(String username) { + return username.equals(createdBy); + } + + public boolean isReviewer(String username) { + return getReviewers().contains(username); + } + + public List getReviewers() { + return getList(Field.reviewers); + } + + public boolean isWatching(String username) { + return getWatchers().contains(username); + } + + public List getWatchers() { + return getList(Field.watchers); + } + + public boolean isVoter(String username) { + return getVoters().contains(username); + } + + public List getVoters() { + return getList(Field.voters); + } + + public List getMentions() { + return getList(Field.mentions); + } + + protected List getList(Field field) { + Set set = new TreeSet(); + for (Change change : changes) { + if (change.hasField(field)) { + String values = change.getString(field); + for (String value : values.split(",")) { + switch (value.charAt(0)) { + case '+': + set.add(value.substring(1)); + break; + case '-': + set.remove(value.substring(1)); + break; + default: + set.add(value); + } + } + } + } + if (!set.isEmpty()) { + return new ArrayList(set); + } + return Collections.emptyList(); + } + + public Attachment getAttachment(String name) { + Attachment attachment = null; + for (Change change : changes) { + if (change.hasAttachments()) { + Attachment a = change.getAttachment(name); + if (a != null) { + attachment = a; + } + } + } + return attachment; + } + + public boolean hasAttachments() { + for (Change change : changes) { + if (change.hasAttachments()) { + return true; + } + } + return false; + } + + public List getAttachments() { + List list = new ArrayList(); + for (Change change : changes) { + if (change.hasAttachments()) { + list.addAll(change.attachments); + } + } + return list; + } + + public List getPatchsets() { + List list = new ArrayList(); + for (Change change : changes) { + if (change.patchset != null) { + list.add(change.patchset); + } + } + return list; + } + + public List getPatchsetRevisions(int number) { + List list = new ArrayList(); + for (Change change : changes) { + if (change.patchset != null) { + if (number == change.patchset.number) { + list.add(change.patchset); + } + } + } + return list; + } + + public Patchset getPatchset(String sha) { + for (Change change : changes) { + if (change.patchset != null) { + if (sha.equals(change.patchset.tip)) { + return change.patchset; + } + } + } + return null; + } + + public Patchset getPatchset(int number, int rev) { + for (Change change : changes) { + if (change.patchset != null) { + if (number == change.patchset.number && rev == change.patchset.rev) { + return change.patchset; + } + } + } + return null; + } + + public Patchset getCurrentPatchset() { + Patchset patchset = null; + for (Change change : changes) { + if (change.patchset != null) { + if (patchset == null) { + patchset = change.patchset; + } else if (patchset.compareTo(change.patchset) == 1) { + patchset = change.patchset; + } + } + } + return patchset; + } + + public boolean isCurrent(Patchset patchset) { + if (patchset == null) { + return false; + } + Patchset curr = getCurrentPatchset(); + if (curr == null) { + return false; + } + return curr.equals(patchset); + } + + public List getReviews(Patchset patchset) { + if (patchset == null) { + return Collections.emptyList(); + } + // collect the patchset reviews by author + // the last review by the author is the + // official review + Map reviews = new LinkedHashMap(); + for (Change change : changes) { + if (change.hasReview()) { + if (change.review.isReviewOf(patchset)) { + reviews.put(change.author, change); + } + } + } + return new ArrayList(reviews.values()); + } + + + public boolean isApproved(Patchset patchset) { + if (patchset == null) { + return false; + } + boolean approved = false; + boolean vetoed = false; + for (Change change : getReviews(patchset)) { + if (change.hasReview()) { + if (change.review.isReviewOf(patchset)) { + if (Score.approved == change.review.score) { + approved = true; + } else if (Score.vetoed == change.review.score) { + vetoed = true; + } + } + } + } + return approved && !vetoed; + } + + public boolean isVetoed(Patchset patchset) { + if (patchset == null) { + return false; + } + for (Change change : getReviews(patchset)) { + if (change.hasReview()) { + if (change.review.isReviewOf(patchset)) { + if (Score.vetoed == change.review.score) { + return true; + } + } + } + } + return false; + } + + public Review getReviewBy(String username) { + for (Change change : getReviews(getCurrentPatchset())) { + if (change.author.equals(username)) { + return change.review; + } + } + return null; + } + + public boolean isPatchsetAuthor(String username) { + for (Change change : changes) { + if (change.hasPatchset()) { + if (change.author.equals(username)) { + return true; + } + } + } + return false; + } + + public void applyChange(Change change) { + if (changes.size() == 0) { + // first change created the ticket + created = change.date; + createdBy = change.author; + status = Status.New; + } else if (created == null || change.date.after(created)) { + // track last ticket update + updated = change.date; + updatedBy = change.author; + } + + if (change.isMerge()) { + // identify merge patchsets + if (isEmpty(responsible)) { + responsible = change.author; + } + status = Status.Merged; + } + + if (change.hasFieldChanges()) { + for (Map.Entry entry : change.fields.entrySet()) { + Field field = entry.getKey(); + Object value = entry.getValue(); + switch (field) { + case type: + type = TicketModel.Type.fromObject(value, type); + break; + case status: + status = TicketModel.Status.fromObject(value, status); + break; + case title: + title = toString(value); + break; + case body: + body = toString(value); + break; + case topic: + topic = toString(value); + break; + case responsible: + responsible = toString(value); + break; + case milestone: + milestone = toString(value); + break; + case mergeTo: + mergeTo = toString(value); + break; + case mergeSha: + mergeSha = toString(value); + break; + default: + // unknown + break; + } + } + } + + // add the change to the ticket + changes.add(change); + } + + protected String toString(Object value) { + if (value == null) { + return null; + } + return value.toString(); + } + + public String toIndexableString() { + StringBuilder sb = new StringBuilder(); + if (!isEmpty(title)) { + sb.append(title).append('\n'); + } + if (!isEmpty(body)) { + sb.append(body).append('\n'); + } + for (Change change : changes) { + if (change.hasComment()) { + sb.append(change.comment.text); + sb.append('\n'); + } + } + return sb.toString(); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("#"); + sb.append(number); + sb.append(": " + title + "\n"); + for (Change change : changes) { + sb.append(change); + sb.append('\n'); + } + return sb.toString(); + } + + @Override + public int compareTo(TicketModel o) { + return o.created.compareTo(created); + } + + @Override + public boolean equals(Object o) { + if (o instanceof TicketModel) { + return number == ((TicketModel) o).number; + } + return super.equals(o); + } + + @Override + public int hashCode() { + return (repository + number).hashCode(); + } + + /** + * Encapsulates a ticket change + */ + public static class Change implements Serializable, Comparable { + + private static final long serialVersionUID = 1L; + + public final Date date; + + public final String author; + + public Comment comment; + + public Map fields; + + public Set attachments; + + public Patchset patchset; + + public Review review; + + private transient String id; + + public Change(String author) { + this(author, new Date()); + } + + public Change(String author, Date date) { + this.date = date; + this.author = author; + } + + public boolean isStatusChange() { + return hasField(Field.status); + } + + public Status getStatus() { + Status state = Status.fromObject(getField(Field.status), null); + return state; + } + + public boolean isMerge() { + return hasField(Field.status) && hasField(Field.mergeSha); + } + + public boolean hasPatchset() { + return patchset != null; + } + + public boolean hasReview() { + return review != null; + } + + public boolean hasComment() { + return comment != null && !comment.isDeleted(); + } + + public Comment comment(String text) { + comment = new Comment(text); + comment.id = TicketModel.getSHA1(date.toString() + author + text); + + try { + Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)"); + Matcher m = mentions.matcher(text); + while (m.find()) { + String username = m.group(1); + plusList(Field.mentions, username); + } + } catch (Exception e) { + // ignore + } + return comment; + } + + public Review review(Patchset patchset, Score score, boolean addReviewer) { + if (addReviewer) { + plusList(Field.reviewers, author); + } + review = new Review(patchset.number, patchset.rev); + review.score = score; + return review; + } + + public boolean hasAttachments() { + return !TicketModel.isEmpty(attachments); + } + + public void addAttachment(Attachment attachment) { + if (attachments == null) { + attachments = new LinkedHashSet(); + } + attachments.add(attachment); + } + + public Attachment getAttachment(String name) { + if (attachments != null) { + for (Attachment attachment : attachments) { + if (attachment.name.equalsIgnoreCase(name)) { + return attachment; + } + } + } + return null; + } + + public boolean isParticipantChange() { + if (hasComment() + || hasReview() + || hasPatchset() + || hasAttachments()) { + return true; + } + + if (TicketModel.isEmpty(fields)) { + return false; + } + + // identify real ticket field changes + Map map = new HashMap(fields); + map.remove(Field.watchers); + map.remove(Field.voters); + return !map.isEmpty(); + } + + public boolean hasField(Field field) { + return !TicketModel.isEmpty(getString(field)); + } + + public boolean hasFieldChanges() { + return !TicketModel.isEmpty(fields); + } + + public String getField(Field field) { + if (fields != null) { + return fields.get(field); + } + return null; + } + + public void setField(Field field, Object value) { + if (fields == null) { + fields = new LinkedHashMap(); + } + if (value == null) { + fields.put(field, null); + } else if (Enum.class.isAssignableFrom(value.getClass())) { + fields.put(field, ((Enum) value).name()); + } else { + fields.put(field, value.toString()); + } + } + + public void remove(Field field) { + if (fields != null) { + fields.remove(field); + } + } + + public String getString(Field field) { + String value = getField(field); + if (value == null) { + return null; + } + return value; + } + + public void watch(String... username) { + plusList(Field.watchers, username); + } + + public void unwatch(String... username) { + minusList(Field.watchers, username); + } + + public void vote(String... username) { + plusList(Field.voters, username); + } + + public void unvote(String... username) { + minusList(Field.voters, username); + } + + public void label(String... label) { + plusList(Field.labels, label); + } + + public void unlabel(String... label) { + minusList(Field.labels, label); + } + + protected void plusList(Field field, String... items) { + modList(field, "+", items); + } + + protected void minusList(Field field, String... items) { + modList(field, "-", items); + } + + private void modList(Field field, String prefix, String... items) { + List list = new ArrayList(); + for (String item : items) { + list.add(prefix + item); + } + setField(field, join(list, ",")); + } + + public String getId() { + if (id == null) { + id = getSHA1(Long.toHexString(date.getTime()) + author); + } + return id; + } + + @Override + public int compareTo(Change c) { + return date.compareTo(c.date); + } + + @Override + public int hashCode() { + return getId().hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Change) { + return getId().equals(((Change) o).getId()); + } + return false; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(RelativeDateFormatter.format(date)); + if (hasComment()) { + sb.append(" commented on by "); + } else if (hasPatchset()) { + sb.append(MessageFormat.format(" {0} uploaded by ", patchset)); + } else { + sb.append(" changed by "); + } + sb.append(author).append(" - "); + if (hasComment()) { + if (comment.isDeleted()) { + sb.append("(deleted) "); + } + sb.append(comment.text).append(" "); + } + + if (hasFieldChanges()) { + for (Map.Entry entry : fields.entrySet()) { + sb.append("\n "); + sb.append(entry.getKey().name()); + sb.append(':'); + sb.append(entry.getValue()); + } + } + return sb.toString(); + } + } + + /** + * Returns true if the string is null or empty. + * + * @param value + * @return true if string is null or empty + */ + static boolean isEmpty(String value) { + return value == null || value.trim().length() == 0; + } + + /** + * Returns true if the collection is null or empty + * + * @param collection + * @return + */ + static boolean isEmpty(Collection collection) { + return collection == null || collection.size() == 0; + } + + /** + * Returns true if the map is null or empty + * + * @param map + * @return + */ + static boolean isEmpty(Map map) { + return map == null || map.size() == 0; + } + + /** + * Calculates the SHA1 of the string. + * + * @param text + * @return sha1 of the string + */ + static String getSHA1(String text) { + try { + byte[] bytes = text.getBytes("iso-8859-1"); + return getSHA1(bytes); + } catch (UnsupportedEncodingException u) { + throw new RuntimeException(u); + } + } + + /** + * Calculates the SHA1 of the byte array. + * + * @param bytes + * @return sha1 of the byte array + */ + static String getSHA1(byte[] bytes) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + md.update(bytes, 0, bytes.length); + byte[] digest = md.digest(); + return toHex(digest); + } catch (NoSuchAlgorithmException t) { + throw new RuntimeException(t); + } + } + + /** + * Returns the hex representation of the byte array. + * + * @param bytes + * @return byte array as hex string + */ + static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (int i = 0; i < bytes.length; i++) { + if ((bytes[i] & 0xff) < 0x10) { + sb.append('0'); + } + sb.append(Long.toString(bytes[i] & 0xff, 16)); + } + return sb.toString(); + } + + /** + * Join the list of strings into a single string with a space separator. + * + * @param values + * @return joined list + */ + static String join(Collection values) { + return join(values, " "); + } + + /** + * Join the list of strings into a single string with the specified + * separator. + * + * @param values + * @param separator + * @return joined list + */ + static String join(String[] values, String separator) { + return join(Arrays.asList(values), separator); + } + + /** + * Join the list of strings into a single string with the specified + * separator. + * + * @param values + * @param separator + * @return joined list + */ + static String join(Collection values, String separator) { + StringBuilder sb = new StringBuilder(); + for (String value : values) { + sb.append(value).append(separator); + } + if (sb.length() > 0) { + // truncate trailing separator + sb.setLength(sb.length() - separator.length()); + } + return sb.toString().trim(); + } + + + /** + * Produce a deep copy of the given object. Serializes the entire object to + * a byte array in memory. Recommended for relatively small objects. + */ + @SuppressWarnings("unchecked") + static T copy(T original) { + T o = null; + try { + ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(byteOut); + oos.writeObject(original); + ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(byteIn); + try { + o = (T) ois.readObject(); + } catch (ClassNotFoundException cex) { + // actually can not happen in this instance + } + } catch (IOException iox) { + // doesn't seem likely to happen as these streams are in memory + throw new RuntimeException(iox); + } + return o; + } + + public static class Patchset implements Serializable, Comparable { + + private static final long serialVersionUID = 1L; + + public int number; + public int rev; + public String tip; + public String parent; + public String base; + public int insertions; + public int deletions; + public int commits; + public int added; + public PatchsetType type; + + public boolean isFF() { + return PatchsetType.FastForward == type; + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Patchset) { + return hashCode() == o.hashCode(); + } + return false; + } + + @Override + public int compareTo(Patchset p) { + if (number > p.number) { + return -1; + } else if (p.number > number) { + return 1; + } else { + // same patchset, different revision + if (rev > p.rev) { + return -1; + } else if (p.rev > rev) { + return 1; + } else { + // same patchset & revision + return 0; + } + } + } + + @Override + public String toString() { + return "patchset " + number + " revision " + rev; + } + } + + public static class Comment implements Serializable { + + private static final long serialVersionUID = 1L; + + public String text; + + public String id; + + public Boolean deleted; + + public CommentSource src; + + public String replyTo; + + Comment(String text) { + this.text = text; + } + + public boolean isDeleted() { + return deleted != null && deleted; + } + + @Override + public String toString() { + return text; + } + } + + public static class Attachment implements Serializable { + + private static final long serialVersionUID = 1L; + + public final String name; + public long size; + public byte[] content; + public Boolean deleted; + + public Attachment(String name) { + this.name = name; + } + + public boolean isDeleted() { + return deleted != null && deleted; + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Attachment) { + return name.equalsIgnoreCase(((Attachment) o).name); + } + return false; + } + + @Override + public String toString() { + return name; + } + } + + public static class Review implements Serializable { + + private static final long serialVersionUID = 1L; + + public final int patchset; + + public final int rev; + + public Score score; + + public Review(int patchset, int revision) { + this.patchset = patchset; + this.rev = revision; + } + + public boolean isReviewOf(Patchset p) { + return patchset == p.number && rev == p.rev; + } + + @Override + public String toString() { + return "review of patchset " + patchset + " rev " + rev + ":" + score; + } + } + + public static enum Score { + approved(2), looks_good(1), not_reviewed(0), needs_improvement(-1), vetoed(-2); + + final int value; + + Score(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + @Override + public String toString() { + return name().toLowerCase().replace('_', ' '); + } + } + + public static enum Field { + title, body, responsible, type, status, milestone, mergeSha, mergeTo, + topic, labels, watchers, reviewers, voters, mentions; + } + + public static enum Type { + Enhancement, Task, Bug, Proposal, Question; + + public static Type defaultType = Task; + + public static Type [] choices() { + return new Type [] { Enhancement, Task, Bug, Question }; + } + + @Override + public String toString() { + return name().toLowerCase().replace('_', ' '); + } + + public static Type fromObject(Object o, Type defaultType) { + if (o instanceof Type) { + // cast and return + return (Type) o; + } else if (o instanceof String) { + // find by name + for (Type type : values()) { + String str = o.toString(); + if (type.name().equalsIgnoreCase(str) + || type.toString().equalsIgnoreCase(str)) { + return type; + } + } + } else if (o instanceof Number) { + // by ordinal + int id = ((Number) o).intValue(); + if (id >= 0 && id < values().length) { + return values()[id]; + } + } + + return defaultType; + } + } + + public static enum Status { + New, Open, Resolved, Fixed, Merged, Wontfix, Declined, Duplicate, Invalid, On_Hold; + + public static Status [] requestWorkflow = { Open, Resolved, Declined, Duplicate, Invalid, On_Hold }; + + public static Status [] bugWorkflow = { Open, Fixed, Wontfix, Duplicate, Invalid, On_Hold }; + + public static Status [] proposalWorkflow = { Open, Declined, On_Hold}; + + @Override + public String toString() { + return name().toLowerCase().replace('_', ' '); + } + + public static Status fromObject(Object o, Status defaultStatus) { + if (o instanceof Status) { + // cast and return + return (Status) o; + } else if (o instanceof String) { + // find by name + String name = o.toString(); + for (Status state : values()) { + if (state.name().equalsIgnoreCase(name) + || state.toString().equalsIgnoreCase(name)) { + return state; + } + } + } else if (o instanceof Number) { + // by ordinal + int id = ((Number) o).intValue(); + if (id >= 0 && id < values().length) { + return values()[id]; + } + } + + return defaultStatus; + } + + public boolean isClosed() { + return ordinal() > Open.ordinal(); + } + } + + public static enum CommentSource { + Comment, Email + } + + public static enum PatchsetType { + Proposal, FastForward, Rebase, Squash, Rebase_Squash, Amend; + + public boolean isRewrite() { + return (this != FastForward) && (this != Proposal); + } + + @Override + public String toString() { + return name().toLowerCase().replace('_', '+'); + } + + public static PatchsetType fromObject(Object o) { + if (o instanceof PatchsetType) { + // cast and return + return (PatchsetType) o; + } else if (o instanceof String) { + // find by name + String name = o.toString(); + for (PatchsetType type : values()) { + if (type.name().equalsIgnoreCase(name) + || type.toString().equalsIgnoreCase(name)) { + return type; + } + } + } else if (o instanceof Number) { + // by ordinal + int id = ((Number) o).intValue(); + if (id >= 0 && id < values().length) { + return values()[id]; + } + } + + return null; + } + } +} diff --git a/src/main/java/com/gitblit/models/UserModel.java b/src/main/java/com/gitblit/models/UserModel.java index 6419cce9..63208f35 100644 --- a/src/main/java/com/gitblit/models/UserModel.java +++ b/src/main/java/com/gitblit/models/UserModel.java @@ -446,6 +446,18 @@ public class UserModel implements Principal, Serializable, Comparable return canAdmin() || model.isUsersPersonalRepository(username) || model.isOwner(username); } + public boolean canReviewPatchset(RepositoryModel model) { + return isAuthenticated && canClone(model); + } + + public boolean canApprovePatchset(RepositoryModel model) { + return isAuthenticated && canPush(model); + } + + public boolean canVetoPatchset(RepositoryModel model) { + return isAuthenticated && canPush(model); + } + /** * This returns true if the user has fork privileges or the user has fork * privileges because of a team membership. diff --git a/src/main/java/com/gitblit/servlet/PtServlet.java b/src/main/java/com/gitblit/servlet/PtServlet.java new file mode 100644 index 00000000..e9cbaa5b --- /dev/null +++ b/src/main/java/com/gitblit/servlet/PtServlet.java @@ -0,0 +1,201 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.servlet; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.apache.commons.compress.compressors.CompressorOutputStream; +import org.apache.commons.compress.compressors.CompressorStreamFactory; +import org.apache.wicket.util.io.ByteArrayOutputStream; +import org.eclipse.jgit.lib.FileMode; + +import com.gitblit.dagger.DaggerServlet; +import com.gitblit.manager.IRuntimeManager; + +import dagger.ObjectGraph; + +/** + * Handles requests for the Barnum pt (patchset tool). + * + * The user-agent determines the content and compression format. + * + * @author James Moger + * + */ +public class PtServlet extends DaggerServlet { + + private static final long serialVersionUID = 1L; + + private static final long lastModified = System.currentTimeMillis(); + + private IRuntimeManager runtimeManager; + + @Override + protected void inject(ObjectGraph dagger) { + this.runtimeManager = dagger.get(IRuntimeManager.class); + } + + @Override + protected long getLastModified(HttpServletRequest req) { + File file = runtimeManager.getFileOrFolder("tickets.pt", "${baseFolder}/pt.py"); + if (file.exists()) { + return Math.max(lastModified, file.lastModified()); + } else { + return lastModified; + } + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + try { + response.setContentType("application/octet-stream"); + response.setDateHeader("Last-Modified", lastModified); + response.setHeader("Cache-Control", "none"); + response.setHeader("Pragma", "no-cache"); + response.setDateHeader("Expires", 0); + + boolean windows = false; + try { + String useragent = request.getHeader("user-agent").toString(); + windows = useragent.toLowerCase().contains("windows"); + } catch (Exception e) { + } + + byte[] pyBytes; + File file = runtimeManager.getFileOrFolder("tickets.pt", "${baseFolder}/pt.py"); + if (file.exists()) { + // custom script + pyBytes = readAll(new FileInputStream(file)); + } else { + // default script + pyBytes = readAll(getClass().getResourceAsStream("/pt.py")); + } + + if (windows) { + // windows: download zip file with pt.py and pt.cmd + response.setHeader("Content-Disposition", "attachment; filename=\"pt.zip\""); + + OutputStream os = response.getOutputStream(); + ZipArchiveOutputStream zos = new ZipArchiveOutputStream(os); + + // add the Python script + ZipArchiveEntry pyEntry = new ZipArchiveEntry("pt.py"); + pyEntry.setSize(pyBytes.length); + pyEntry.setUnixMode(FileMode.EXECUTABLE_FILE.getBits()); + pyEntry.setTime(lastModified); + zos.putArchiveEntry(pyEntry); + zos.write(pyBytes); + zos.closeArchiveEntry(); + + // add a Python launch cmd file + byte [] cmdBytes = readAll(getClass().getResourceAsStream("/pt.cmd")); + ZipArchiveEntry cmdEntry = new ZipArchiveEntry("pt.cmd"); + cmdEntry.setSize(cmdBytes.length); + cmdEntry.setUnixMode(FileMode.REGULAR_FILE.getBits()); + cmdEntry.setTime(lastModified); + zos.putArchiveEntry(cmdEntry); + zos.write(cmdBytes); + zos.closeArchiveEntry(); + + // add a brief readme + byte [] txtBytes = readAll(getClass().getResourceAsStream("/pt.txt")); + ZipArchiveEntry txtEntry = new ZipArchiveEntry("readme.txt"); + txtEntry.setSize(txtBytes.length); + txtEntry.setUnixMode(FileMode.REGULAR_FILE.getBits()); + txtEntry.setTime(lastModified); + zos.putArchiveEntry(txtEntry); + zos.write(txtBytes); + zos.closeArchiveEntry(); + + // cleanup + zos.finish(); + zos.close(); + os.flush(); + } else { + // unix: download a tar.gz file with pt.py set with execute permissions + response.setHeader("Content-Disposition", "attachment; filename=\"pt.tar.gz\""); + + OutputStream os = response.getOutputStream(); + CompressorOutputStream cos = new CompressorStreamFactory().createCompressorOutputStream(CompressorStreamFactory.GZIP, os); + TarArchiveOutputStream tos = new TarArchiveOutputStream(cos); + tos.setAddPaxHeadersForNonAsciiNames(true); + tos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + + // add the Python script + TarArchiveEntry pyEntry = new TarArchiveEntry("pt"); + pyEntry.setMode(FileMode.EXECUTABLE_FILE.getBits()); + pyEntry.setModTime(lastModified); + pyEntry.setSize(pyBytes.length); + tos.putArchiveEntry(pyEntry); + tos.write(pyBytes); + tos.closeArchiveEntry(); + + // add a brief readme + byte [] txtBytes = readAll(getClass().getResourceAsStream("/pt.txt")); + TarArchiveEntry txtEntry = new TarArchiveEntry("README"); + txtEntry.setMode(FileMode.REGULAR_FILE.getBits()); + txtEntry.setModTime(lastModified); + txtEntry.setSize(txtBytes.length); + tos.putArchiveEntry(txtEntry); + tos.write(txtBytes); + tos.closeArchiveEntry(); + + // cleanup + tos.finish(); + tos.close(); + cos.close(); + os.flush(); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + byte [] readAll(InputStream is) { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + try { + byte [] buffer = new byte[4096]; + int len = 0; + while ((len = is.read(buffer)) > -1) { + os.write(buffer, 0, len); + } + return os.toByteArray(); + } catch (IOException e) { + e.printStackTrace(); + } finally { + try { + os.close(); + is.close(); + } catch (Exception e) { + // ignore + } + } + return new byte[0]; + } +} diff --git a/src/main/java/com/gitblit/tickets/BranchTicketService.java b/src/main/java/com/gitblit/tickets/BranchTicketService.java new file mode 100644 index 00000000..14ed8094 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/BranchTicketService.java @@ -0,0 +1,799 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.io.IOException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import javax.inject.Inject; + +import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException; +import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.dircache.DirCache; +import org.eclipse.jgit.dircache.DirCacheBuilder; +import org.eclipse.jgit.dircache.DirCacheEntry; +import org.eclipse.jgit.internal.JGitText; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectInserter; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.RefUpdate.Result; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevTree; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; +import org.eclipse.jgit.treewalk.TreeWalk; + +import com.gitblit.Constants; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.PathModel; +import com.gitblit.models.RefModel; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.JGitUtils; +import com.gitblit.utils.StringUtils; + +/** + * Implementation of a ticket service based on an orphan branch. All tickets + * are serialized as a list of JSON changes and persisted in a hashed directory + * structure, similar to the standard git loose object structure. + * + * @author James Moger + * + */ +public class BranchTicketService extends ITicketService { + + public static final String BRANCH = "refs/gitblit/tickets"; + + private static final String JOURNAL = "journal.json"; + + private static final String ID_PATH = "id/"; + + private final Map lastAssignedId; + + @Inject + public BranchTicketService( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager) { + + super(runtimeManager, + notificationManager, + userManager, + repositoryManager); + + lastAssignedId = new ConcurrentHashMap(); + } + + @Override + public BranchTicketService start() { + return this; + } + + @Override + protected void resetCachesImpl() { + lastAssignedId.clear(); + } + + @Override + protected void resetCachesImpl(RepositoryModel repository) { + if (lastAssignedId.containsKey(repository.name)) { + lastAssignedId.get(repository.name).set(0); + } + } + + @Override + protected void close() { + } + + /** + * Returns a RefModel for the refs/gitblit/tickets branch in the repository. + * If the branch can not be found, null is returned. + * + * @return a refmodel for the gitblit tickets branch or null + */ + private RefModel getTicketsBranch(Repository db) { + List refs = JGitUtils.getRefs(db, Constants.R_GITBLIT); + for (RefModel ref : refs) { + if (ref.reference.getName().equals(BRANCH)) { + return ref; + } + } + return null; + } + + /** + * Creates the refs/gitblit/tickets branch. + * @param db + */ + private void createTicketsBranch(Repository db) { + JGitUtils.createOrphanBranch(db, BRANCH, null); + } + + /** + * Returns the ticket path. This follows the same scheme as Git's object + * store path where the first two characters of the hash id are the root + * folder with the remaining characters as a subfolder within that folder. + * + * @param ticketId + * @return the root path of the ticket content on the refs/gitblit/tickets branch + */ + private String toTicketPath(long ticketId) { + StringBuilder sb = new StringBuilder(); + sb.append(ID_PATH); + long m = ticketId % 100L; + if (m < 10) { + sb.append('0'); + } + sb.append(m); + sb.append('/'); + sb.append(ticketId); + return sb.toString(); + } + + /** + * Returns the path to the attachment for the specified ticket. + * + * @param ticketId + * @param filename + * @return the path to the specified attachment + */ + private String toAttachmentPath(long ticketId, String filename) { + return toTicketPath(ticketId) + "/attachments/" + filename; + } + + /** + * Reads a file from the tickets branch. + * + * @param db + * @param file + * @return the file content or null + */ + private String readTicketsFile(Repository db, String file) { + RevWalk rw = null; + try { + ObjectId treeId = db.resolve(BRANCH + "^{tree}"); + if (treeId == null) { + return null; + } + rw = new RevWalk(db); + RevTree tree = rw.lookupTree(treeId); + if (tree != null) { + return JGitUtils.getStringContent(db, tree, file, Constants.ENCODING); + } + } catch (IOException e) { + log.error("failed to read " + file, e); + } finally { + if (rw != null) { + rw.release(); + } + } + return null; + } + + /** + * Writes a file to the tickets branch. + * + * @param db + * @param file + * @param content + * @param createdBy + * @param msg + */ + private void writeTicketsFile(Repository db, String file, String content, String createdBy, String msg) { + if (getTicketsBranch(db) == null) { + createTicketsBranch(db); + } + + DirCache newIndex = DirCache.newInCore(); + DirCacheBuilder builder = newIndex.builder(); + ObjectInserter inserter = db.newObjectInserter(); + + try { + // create an index entry for the revised index + final DirCacheEntry idIndexEntry = new DirCacheEntry(file); + idIndexEntry.setLength(content.length()); + idIndexEntry.setLastModified(System.currentTimeMillis()); + idIndexEntry.setFileMode(FileMode.REGULAR_FILE); + + // insert new ticket index + idIndexEntry.setObjectId(inserter.insert(org.eclipse.jgit.lib.Constants.OBJ_BLOB, + content.getBytes(Constants.ENCODING))); + + // add to temporary in-core index + builder.add(idIndexEntry); + + Set ignorePaths = new HashSet(); + ignorePaths.add(file); + + for (DirCacheEntry entry : getTreeEntries(db, ignorePaths)) { + builder.add(entry); + } + + // finish temporary in-core index used for this commit + builder.finish(); + + // commit the change + commitIndex(db, newIndex, createdBy, msg); + + } catch (ConcurrentRefUpdateException e) { + log.error("", e); + } catch (IOException e) { + log.error("", e); + } finally { + inserter.release(); + } + } + + /** + * Ensures that we have a ticket for this ticket id. + * + * @param repository + * @param ticketId + * @return true if the ticket exists + */ + @Override + public boolean hasTicket(RepositoryModel repository, long ticketId) { + boolean hasTicket = false; + Repository db = repositoryManager.getRepository(repository.name); + try { + RefModel ticketsBranch = getTicketsBranch(db); + if (ticketsBranch == null) { + return false; + } + String ticketPath = toTicketPath(ticketId); + RevCommit tip = JGitUtils.getCommit(db, BRANCH); + hasTicket = !JGitUtils.getFilesInPath(db, ticketPath, tip).isEmpty(); + } finally { + db.close(); + } + return hasTicket; + } + + /** + * Assigns a new ticket id. + * + * @param repository + * @return a new long id + */ + @Override + public synchronized long assignNewId(RepositoryModel repository) { + long newId = 0L; + Repository db = repositoryManager.getRepository(repository.name); + try { + if (getTicketsBranch(db) == null) { + createTicketsBranch(db); + } + + // identify current highest ticket id by scanning the paths in the tip tree + if (!lastAssignedId.containsKey(repository.name)) { + lastAssignedId.put(repository.name, new AtomicLong(0)); + } + AtomicLong lastId = lastAssignedId.get(repository.name); + if (lastId.get() <= 0) { + List paths = JGitUtils.getDocuments(db, Arrays.asList("json"), BRANCH); + for (PathModel path : paths) { + String name = path.name.substring(path.name.lastIndexOf('/') + 1); + if (!JOURNAL.equals(name)) { + continue; + } + String tid = path.path.split("/")[2]; + long ticketId = Long.parseLong(tid); + if (ticketId > lastId.get()) { + lastId.set(ticketId); + } + } + } + + // assign the id and touch an empty journal to hold it's place + newId = lastId.incrementAndGet(); + String journalPath = toTicketPath(newId) + "/" + JOURNAL; + writeTicketsFile(db, journalPath, "", "gitblit", "assigned id #" + newId); + } finally { + db.close(); + } + return newId; + } + + /** + * Returns all the tickets in the repository. Querying tickets from the + * repository requires deserializing all tickets. This is an expensive + * process and not recommended. Tickets are indexed by Lucene and queries + * should be executed against that index. + * + * @param repository + * @param filter + * optional filter to only return matching results + * @return a list of tickets + */ + @Override + public List getTickets(RepositoryModel repository, TicketFilter filter) { + List list = new ArrayList(); + + Repository db = repositoryManager.getRepository(repository.name); + try { + RefModel ticketsBranch = getTicketsBranch(db); + if (ticketsBranch == null) { + return list; + } + + // Collect the set of all json files + List paths = JGitUtils.getDocuments(db, Arrays.asList("json"), BRANCH); + + // Deserialize each ticket and optionally filter out unwanted tickets + for (PathModel path : paths) { + String name = path.name.substring(path.name.lastIndexOf('/') + 1); + if (!JOURNAL.equals(name)) { + continue; + } + String json = readTicketsFile(db, path.path); + if (StringUtils.isEmpty(json)) { + // journal was touched but no changes were written + continue; + } + try { + // Reconstruct ticketId from the path + // id/26/326/journal.json + String tid = path.path.split("/")[2]; + long ticketId = Long.parseLong(tid); + List changes = TicketSerializer.deserializeJournal(json); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, path.path); + continue; + } + TicketModel ticket = TicketModel.buildTicket(changes); + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + + // add the ticket, conditionally, to the list + if (filter == null) { + list.add(ticket); + } else { + if (filter.accept(ticket)) { + list.add(ticket); + } + } + } catch (Exception e) { + log.error("failed to deserialize {}/{}\n{}", + new Object [] { repository, path.path, e.getMessage()}); + log.error(null, e); + } + } + + // sort the tickets by creation + Collections.sort(list); + return list; + } finally { + db.close(); + } + } + + /** + * Retrieves the ticket from the repository by first looking-up the changeId + * associated with the ticketId. + * + * @param repository + * @param ticketId + * @return a ticket, if it exists, otherwise null + */ + @Override + protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) { + Repository db = repositoryManager.getRepository(repository.name); + try { + List changes = getJournal(db, ticketId); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, ticketId); + return null; + } + TicketModel ticket = TicketModel.buildTicket(changes); + if (ticket != null) { + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + } + return ticket; + } finally { + db.close(); + } + } + + /** + * Returns the journal for the specified ticket. + * + * @param db + * @param ticketId + * @return a list of changes + */ + private List getJournal(Repository db, long ticketId) { + RefModel ticketsBranch = getTicketsBranch(db); + if (ticketsBranch == null) { + return new ArrayList(); + } + + if (ticketId <= 0L) { + return new ArrayList(); + } + + String journalPath = toTicketPath(ticketId) + "/" + JOURNAL; + String json = readTicketsFile(db, journalPath); + if (StringUtils.isEmpty(json)) { + return new ArrayList(); + } + List list = TicketSerializer.deserializeJournal(json); + return list; + } + + @Override + public boolean supportsAttachments() { + return true; + } + + /** + * Retrieves the specified attachment from a ticket. + * + * @param repository + * @param ticketId + * @param filename + * @return an attachment, if found, null otherwise + */ + @Override + public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) { + if (ticketId <= 0L) { + return null; + } + + // deserialize the ticket model so that we have the attachment metadata + TicketModel ticket = getTicket(repository, ticketId); + Attachment attachment = ticket.getAttachment(filename); + + // attachment not found + if (attachment == null) { + return null; + } + + // retrieve the attachment content + Repository db = repositoryManager.getRepository(repository.name); + try { + String attachmentPath = toAttachmentPath(ticketId, attachment.name); + RevTree tree = JGitUtils.getCommit(db, BRANCH).getTree(); + byte[] content = JGitUtils.getByteContent(db, tree, attachmentPath, false); + attachment.content = content; + attachment.size = content.length; + return attachment; + } finally { + db.close(); + } + } + + /** + * Deletes a ticket from the repository. + * + * @param ticket + * @return true if successful + */ + @Override + protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) { + if (ticket == null) { + throw new RuntimeException("must specify a ticket!"); + } + + boolean success = false; + Repository db = repositoryManager.getRepository(ticket.repository); + try { + RefModel ticketsBranch = getTicketsBranch(db); + + if (ticketsBranch == null) { + throw new RuntimeException(BRANCH + " does not exist!"); + } + String ticketPath = toTicketPath(ticket.number); + + TreeWalk treeWalk = null; + try { + ObjectId treeId = db.resolve(BRANCH + "^{tree}"); + + // Create the in-memory index of the new/updated ticket + DirCache index = DirCache.newInCore(); + DirCacheBuilder builder = index.builder(); + + // Traverse HEAD to add all other paths + treeWalk = new TreeWalk(db); + int hIdx = -1; + if (treeId != null) { + hIdx = treeWalk.addTree(treeId); + } + treeWalk.setRecursive(true); + while (treeWalk.next()) { + String path = treeWalk.getPathString(); + CanonicalTreeParser hTree = null; + if (hIdx != -1) { + hTree = treeWalk.getTree(hIdx, CanonicalTreeParser.class); + } + if (!path.startsWith(ticketPath)) { + // add entries from HEAD for all other paths + if (hTree != null) { + final DirCacheEntry entry = new DirCacheEntry(path); + entry.setObjectId(hTree.getEntryObjectId()); + entry.setFileMode(hTree.getEntryFileMode()); + + // add to temporary in-core index + builder.add(entry); + } + } + } + + // finish temporary in-core index used for this commit + builder.finish(); + + success = commitIndex(db, index, deletedBy, "- " + ticket.number); + + } catch (Throwable t) { + log.error(MessageFormat.format("Failed to delete ticket {0,number,0} from {1}", + ticket.number, db.getDirectory()), t); + } finally { + // release the treewalk + treeWalk.release(); + } + } finally { + db.close(); + } + return success; + } + + /** + * Commit a ticket change to the repository. + * + * @param repository + * @param ticketId + * @param change + * @return true, if the change was committed + */ + @Override + protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) { + boolean success = false; + + Repository db = repositoryManager.getRepository(repository.name); + try { + DirCache index = createIndex(db, ticketId, change); + success = commitIndex(db, index, change.author, "#" + ticketId); + + } catch (Throwable t) { + log.error(MessageFormat.format("Failed to commit ticket {0,number,0} to {1}", + ticketId, db.getDirectory()), t); + } finally { + db.close(); + } + return success; + } + + /** + * Creates an in-memory index of the ticket change. + * + * @param changeId + * @param change + * @return an in-memory index + * @throws IOException + */ + private DirCache createIndex(Repository db, long ticketId, Change change) + throws IOException, ClassNotFoundException, NoSuchFieldException { + + String ticketPath = toTicketPath(ticketId); + DirCache newIndex = DirCache.newInCore(); + DirCacheBuilder builder = newIndex.builder(); + ObjectInserter inserter = db.newObjectInserter(); + + Set ignorePaths = new TreeSet(); + try { + // create/update the journal + // exclude the attachment content + List changes = getJournal(db, ticketId); + changes.add(change); + String journal = TicketSerializer.serializeJournal(changes).trim(); + + byte [] journalBytes = journal.getBytes(Constants.ENCODING); + String journalPath = ticketPath + "/" + JOURNAL; + final DirCacheEntry journalEntry = new DirCacheEntry(journalPath); + journalEntry.setLength(journalBytes.length); + journalEntry.setLastModified(change.date.getTime()); + journalEntry.setFileMode(FileMode.REGULAR_FILE); + journalEntry.setObjectId(inserter.insert(org.eclipse.jgit.lib.Constants.OBJ_BLOB, journalBytes)); + + // add journal to index + builder.add(journalEntry); + ignorePaths.add(journalEntry.getPathString()); + + // Add any attachments to the index + if (change.hasAttachments()) { + for (Attachment attachment : change.attachments) { + // build a path name for the attachment and mark as ignored + String path = toAttachmentPath(ticketId, attachment.name); + ignorePaths.add(path); + + // create an index entry for this attachment + final DirCacheEntry entry = new DirCacheEntry(path); + entry.setLength(attachment.content.length); + entry.setLastModified(change.date.getTime()); + entry.setFileMode(FileMode.REGULAR_FILE); + + // insert object + entry.setObjectId(inserter.insert(org.eclipse.jgit.lib.Constants.OBJ_BLOB, attachment.content)); + + // add to temporary in-core index + builder.add(entry); + } + } + + for (DirCacheEntry entry : getTreeEntries(db, ignorePaths)) { + builder.add(entry); + } + + // finish the index + builder.finish(); + } finally { + inserter.release(); + } + return newIndex; + } + + /** + * Returns all tree entries that do not match the ignore paths. + * + * @param db + * @param ignorePaths + * @param dcBuilder + * @throws IOException + */ + private List getTreeEntries(Repository db, Collection ignorePaths) throws IOException { + List list = new ArrayList(); + TreeWalk tw = null; + try { + tw = new TreeWalk(db); + ObjectId treeId = db.resolve(BRANCH + "^{tree}"); + int hIdx = tw.addTree(treeId); + tw.setRecursive(true); + + while (tw.next()) { + String path = tw.getPathString(); + CanonicalTreeParser hTree = null; + if (hIdx != -1) { + hTree = tw.getTree(hIdx, CanonicalTreeParser.class); + } + if (!ignorePaths.contains(path)) { + // add all other tree entries + if (hTree != null) { + final DirCacheEntry entry = new DirCacheEntry(path); + entry.setObjectId(hTree.getEntryObjectId()); + entry.setFileMode(hTree.getEntryFileMode()); + list.add(entry); + } + } + } + } finally { + if (tw != null) { + tw.release(); + } + } + return list; + } + + private boolean commitIndex(Repository db, DirCache index, String author, String message) throws IOException, ConcurrentRefUpdateException { + boolean success = false; + + ObjectId headId = db.resolve(BRANCH + "^{commit}"); + if (headId == null) { + // create the branch + createTicketsBranch(db); + headId = db.resolve(BRANCH + "^{commit}"); + } + ObjectInserter odi = db.newObjectInserter(); + try { + // Create the in-memory index of the new/updated ticket + ObjectId indexTreeId = index.writeTree(odi); + + // Create a commit object + PersonIdent ident = new PersonIdent(author, "gitblit@localhost"); + CommitBuilder commit = new CommitBuilder(); + commit.setAuthor(ident); + commit.setCommitter(ident); + commit.setEncoding(Constants.ENCODING); + commit.setMessage(message); + commit.setParentId(headId); + commit.setTreeId(indexTreeId); + + // Insert the commit into the repository + ObjectId commitId = odi.insert(commit); + odi.flush(); + + RevWalk revWalk = new RevWalk(db); + try { + RevCommit revCommit = revWalk.parseCommit(commitId); + RefUpdate ru = db.updateRef(BRANCH); + ru.setNewObjectId(commitId); + ru.setExpectedOldObjectId(headId); + ru.setRefLogMessage("commit: " + revCommit.getShortMessage(), false); + Result rc = ru.forceUpdate(); + switch (rc) { + case NEW: + case FORCED: + case FAST_FORWARD: + success = true; + break; + case REJECTED: + case LOCK_FAILURE: + throw new ConcurrentRefUpdateException(JGitText.get().couldNotLockHEAD, + ru.getRef(), rc); + default: + throw new JGitInternalException(MessageFormat.format( + JGitText.get().updatingRefFailed, BRANCH, commitId.toString(), + rc)); + } + } finally { + revWalk.release(); + } + } finally { + odi.release(); + } + return success; + } + + @Override + protected boolean deleteAllImpl(RepositoryModel repository) { + Repository db = repositoryManager.getRepository(repository.name); + try { + RefModel branch = getTicketsBranch(db); + if (branch != null) { + return JGitUtils.deleteBranchRef(db, BRANCH); + } + return true; + } catch (Exception e) { + log.error(null, e); + } finally { + db.close(); + } + return false; + } + + @Override + protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) { + return true; + } + + @Override + public String toString() { + return getClass().getSimpleName(); + } +} diff --git a/src/main/java/com/gitblit/tickets/FileTicketService.java b/src/main/java/com/gitblit/tickets/FileTicketService.java new file mode 100644 index 00000000..8375a2ba --- /dev/null +++ b/src/main/java/com/gitblit/tickets/FileTicketService.java @@ -0,0 +1,467 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.io.File; +import java.io.IOException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import javax.inject.Inject; + +import org.eclipse.jgit.lib.Repository; + +import com.gitblit.Constants; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.FileUtils; +import com.gitblit.utils.StringUtils; + +/** + * Implementation of a ticket service based on a directory within the repository. + * All tickets are serialized as a list of JSON changes and persisted in a hashed + * directory structure, similar to the standard git loose object structure. + * + * @author James Moger + * + */ +public class FileTicketService extends ITicketService { + + private static final String JOURNAL = "journal.json"; + + private static final String TICKETS_PATH = "tickets/"; + + private final Map lastAssignedId; + + @Inject + public FileTicketService( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager) { + + super(runtimeManager, + notificationManager, + userManager, + repositoryManager); + + lastAssignedId = new ConcurrentHashMap(); + } + + @Override + public FileTicketService start() { + return this; + } + + @Override + protected void resetCachesImpl() { + lastAssignedId.clear(); + } + + @Override + protected void resetCachesImpl(RepositoryModel repository) { + if (lastAssignedId.containsKey(repository.name)) { + lastAssignedId.get(repository.name).set(0); + } + } + + @Override + protected void close() { + } + + /** + * Returns the ticket path. This follows the same scheme as Git's object + * store path where the first two characters of the hash id are the root + * folder with the remaining characters as a subfolder within that folder. + * + * @param ticketId + * @return the root path of the ticket content in the ticket directory + */ + private String toTicketPath(long ticketId) { + StringBuilder sb = new StringBuilder(); + sb.append(TICKETS_PATH); + long m = ticketId % 100L; + if (m < 10) { + sb.append('0'); + } + sb.append(m); + sb.append('/'); + sb.append(ticketId); + return sb.toString(); + } + + /** + * Returns the path to the attachment for the specified ticket. + * + * @param ticketId + * @param filename + * @return the path to the specified attachment + */ + private String toAttachmentPath(long ticketId, String filename) { + return toTicketPath(ticketId) + "/attachments/" + filename; + } + + /** + * Ensures that we have a ticket for this ticket id. + * + * @param repository + * @param ticketId + * @return true if the ticket exists + */ + @Override + public boolean hasTicket(RepositoryModel repository, long ticketId) { + boolean hasTicket = false; + Repository db = repositoryManager.getRepository(repository.name); + try { + String journalPath = toTicketPath(ticketId) + "/" + JOURNAL; + hasTicket = new File(db.getDirectory(), journalPath).exists(); + } finally { + db.close(); + } + return hasTicket; + } + + /** + * Assigns a new ticket id. + * + * @param repository + * @return a new long id + */ + @Override + public synchronized long assignNewId(RepositoryModel repository) { + long newId = 0L; + Repository db = repositoryManager.getRepository(repository.name); + try { + if (!lastAssignedId.containsKey(repository.name)) { + lastAssignedId.put(repository.name, new AtomicLong(0)); + } + AtomicLong lastId = lastAssignedId.get(repository.name); + if (lastId.get() <= 0) { + // identify current highest ticket id by scanning the paths in the tip tree + File dir = new File(db.getDirectory(), TICKETS_PATH); + dir.mkdirs(); + List journals = findAll(dir, JOURNAL); + for (File journal : journals) { + // Reconstruct ticketId from the path + // id/26/326/journal.json + String path = FileUtils.getRelativePath(dir, journal); + String tid = path.split("/")[1]; + long ticketId = Long.parseLong(tid); + if (ticketId > lastId.get()) { + lastId.set(ticketId); + } + } + } + + // assign the id and touch an empty journal to hold it's place + newId = lastId.incrementAndGet(); + String journalPath = toTicketPath(newId) + "/" + JOURNAL; + File journal = new File(db.getDirectory(), journalPath); + journal.getParentFile().mkdirs(); + journal.createNewFile(); + } catch (IOException e) { + log.error("failed to assign ticket id", e); + return 0L; + } finally { + db.close(); + } + return newId; + } + + /** + * Returns all the tickets in the repository. Querying tickets from the + * repository requires deserializing all tickets. This is an expensive + * process and not recommended. Tickets are indexed by Lucene and queries + * should be executed against that index. + * + * @param repository + * @param filter + * optional filter to only return matching results + * @return a list of tickets + */ + @Override + public List getTickets(RepositoryModel repository, TicketFilter filter) { + List list = new ArrayList(); + + Repository db = repositoryManager.getRepository(repository.name); + try { + // Collect the set of all json files + File dir = new File(db.getDirectory(), TICKETS_PATH); + List journals = findAll(dir, JOURNAL); + + // Deserialize each ticket and optionally filter out unwanted tickets + for (File journal : journals) { + String json = null; + try { + json = new String(FileUtils.readContent(journal), Constants.ENCODING); + } catch (Exception e) { + log.error(null, e); + } + if (StringUtils.isEmpty(json)) { + // journal was touched but no changes were written + continue; + } + try { + // Reconstruct ticketId from the path + // id/26/326/journal.json + String path = FileUtils.getRelativePath(dir, journal); + String tid = path.split("/")[1]; + long ticketId = Long.parseLong(tid); + List changes = TicketSerializer.deserializeJournal(json); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, journal); + continue; + } + TicketModel ticket = TicketModel.buildTicket(changes); + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + + // add the ticket, conditionally, to the list + if (filter == null) { + list.add(ticket); + } else { + if (filter.accept(ticket)) { + list.add(ticket); + } + } + } catch (Exception e) { + log.error("failed to deserialize {}/{}\n{}", + new Object [] { repository, journal, e.getMessage()}); + log.error(null, e); + } + } + + // sort the tickets by creation + Collections.sort(list); + return list; + } finally { + db.close(); + } + } + + private List findAll(File dir, String filename) { + List list = new ArrayList(); + for (File file : dir.listFiles()) { + if (file.isDirectory()) { + list.addAll(findAll(file, filename)); + } else if (file.isFile()) { + if (file.getName().equals(filename)) { + list.add(file); + } + } + } + return list; + } + + /** + * Retrieves the ticket from the repository by first looking-up the changeId + * associated with the ticketId. + * + * @param repository + * @param ticketId + * @return a ticket, if it exists, otherwise null + */ + @Override + protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) { + Repository db = repositoryManager.getRepository(repository.name); + try { + List changes = getJournal(db, ticketId); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, ticketId); + return null; + } + TicketModel ticket = TicketModel.buildTicket(changes); + if (ticket != null) { + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + } + return ticket; + } finally { + db.close(); + } + } + + /** + * Returns the journal for the specified ticket. + * + * @param db + * @param ticketId + * @return a list of changes + */ + private List getJournal(Repository db, long ticketId) { + if (ticketId <= 0L) { + return new ArrayList(); + } + + String journalPath = toTicketPath(ticketId) + "/" + JOURNAL; + File journal = new File(db.getDirectory(), journalPath); + if (!journal.exists()) { + return new ArrayList(); + } + + String json = null; + try { + json = new String(FileUtils.readContent(journal), Constants.ENCODING); + } catch (Exception e) { + log.error(null, e); + } + if (StringUtils.isEmpty(json)) { + return new ArrayList(); + } + List list = TicketSerializer.deserializeJournal(json); + return list; + } + + @Override + public boolean supportsAttachments() { + return true; + } + + /** + * Retrieves the specified attachment from a ticket. + * + * @param repository + * @param ticketId + * @param filename + * @return an attachment, if found, null otherwise + */ + @Override + public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) { + if (ticketId <= 0L) { + return null; + } + + // deserialize the ticket model so that we have the attachment metadata + TicketModel ticket = getTicket(repository, ticketId); + Attachment attachment = ticket.getAttachment(filename); + + // attachment not found + if (attachment == null) { + return null; + } + + // retrieve the attachment content + Repository db = repositoryManager.getRepository(repository.name); + try { + String attachmentPath = toAttachmentPath(ticketId, attachment.name); + File file = new File(db.getDirectory(), attachmentPath); + if (file.exists()) { + attachment.content = FileUtils.readContent(file); + attachment.size = attachment.content.length; + } + return attachment; + } finally { + db.close(); + } + } + + /** + * Deletes a ticket from the repository. + * + * @param ticket + * @return true if successful + */ + @Override + protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) { + if (ticket == null) { + throw new RuntimeException("must specify a ticket!"); + } + + boolean success = false; + Repository db = repositoryManager.getRepository(ticket.repository); + try { + String ticketPath = toTicketPath(ticket.number); + File dir = new File(db.getDirectory(), ticketPath); + if (dir.exists()) { + success = FileUtils.delete(dir); + } + success = true; + } finally { + db.close(); + } + return success; + } + + /** + * Commit a ticket change to the repository. + * + * @param repository + * @param ticketId + * @param change + * @return true, if the change was committed + */ + @Override + protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) { + boolean success = false; + + Repository db = repositoryManager.getRepository(repository.name); + try { + List changes = getJournal(db, ticketId); + changes.add(change); + String journal = TicketSerializer.serializeJournal(changes).trim(); + + String journalPath = toTicketPath(ticketId) + "/" + JOURNAL; + File file = new File(db.getDirectory(), journalPath); + file.getParentFile().mkdirs(); + FileUtils.writeContent(file, journal); + success = true; + } catch (Throwable t) { + log.error(MessageFormat.format("Failed to commit ticket {0,number,0} to {1}", + ticketId, db.getDirectory()), t); + } finally { + db.close(); + } + return success; + } + + @Override + protected boolean deleteAllImpl(RepositoryModel repository) { + Repository db = repositoryManager.getRepository(repository.name); + try { + File dir = new File(db.getDirectory(), TICKETS_PATH); + return FileUtils.delete(dir); + } catch (Exception e) { + log.error(null, e); + } finally { + db.close(); + } + return false; + } + + @Override + protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) { + return true; + } + + @Override + public String toString() { + return getClass().getSimpleName(); + } +} diff --git a/src/main/java/com/gitblit/tickets/ITicketService.java b/src/main/java/com/gitblit/tickets/ITicketService.java new file mode 100644 index 00000000..d04cd5e1 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/ITicketService.java @@ -0,0 +1,1088 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.io.IOException; +import java.text.MessageFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.StoredConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.gitblit.IStoredSettings; +import com.gitblit.Keys; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.tickets.TicketIndexer.Lucene; +import com.gitblit.utils.DiffUtils; +import com.gitblit.utils.DiffUtils.DiffStat; +import com.gitblit.utils.StringUtils; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; + +/** + * Abstract parent class of a ticket service that stubs out required methods + * and transparently handles Lucene indexing. + * + * @author James Moger + * + */ +public abstract class ITicketService { + + private static final String LABEL = "label"; + + private static final String MILESTONE = "milestone"; + + private static final String STATUS = "status"; + + private static final String COLOR = "color"; + + private static final String DUE = "due"; + + private static final String DUE_DATE_PATTERN = "yyyy-MM-dd"; + + /** + * Object filter interface to querying against all available ticket models. + */ + public interface TicketFilter { + + boolean accept(TicketModel ticket); + } + + protected final Logger log; + + protected final IStoredSettings settings; + + protected final IRuntimeManager runtimeManager; + + protected final INotificationManager notificationManager; + + protected final IUserManager userManager; + + protected final IRepositoryManager repositoryManager; + + protected final TicketIndexer indexer; + + private final Cache ticketsCache; + + private final Map> labelsCache; + + private final Map> milestonesCache; + + private static class TicketKey { + final String repository; + final long ticketId; + + TicketKey(RepositoryModel repository, long ticketId) { + this.repository = repository.name; + this.ticketId = ticketId; + } + + @Override + public int hashCode() { + return (repository + ticketId).hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof TicketKey) { + return o.hashCode() == hashCode(); + } + return false; + } + + @Override + public String toString() { + return repository + ":" + ticketId; + } + } + + + /** + * Creates a ticket service. + */ + public ITicketService( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager) { + + this.log = LoggerFactory.getLogger(getClass()); + this.settings = runtimeManager.getSettings(); + this.runtimeManager = runtimeManager; + this.notificationManager = notificationManager; + this.userManager = userManager; + this.repositoryManager = repositoryManager; + + this.indexer = new TicketIndexer(runtimeManager); + + CacheBuilder cb = CacheBuilder.newBuilder(); + this.ticketsCache = cb + .maximumSize(1000) + .expireAfterAccess(30, TimeUnit.MINUTES) + .build(); + + this.labelsCache = new ConcurrentHashMap>(); + this.milestonesCache = new ConcurrentHashMap>(); + } + + /** + * Start the service. + * + */ + public abstract ITicketService start(); + + /** + * Stop the service. + * + */ + public final ITicketService stop() { + indexer.close(); + ticketsCache.invalidateAll(); + repositoryManager.closeAll(); + close(); + return this; + } + + /** + * Creates a ticket notifier. The ticket notifier is not thread-safe! + * + */ + public TicketNotifier createNotifier() { + return new TicketNotifier( + runtimeManager, + notificationManager, + userManager, + repositoryManager, + this); + } + + /** + * Returns the ready status of the ticket service. + * + * @return true if the ticket service is ready + */ + public boolean isReady() { + return true; + } + + /** + * Returns true if the new patchsets can be accepted for this repository. + * + * @param repository + * @return true if patchsets are being accepted + */ + public boolean isAcceptingNewPatchsets(RepositoryModel repository) { + return isReady() + && settings.getBoolean(Keys.tickets.acceptNewPatchsets, true) + && repository.acceptNewPatchsets + && isAcceptingTicketUpdates(repository); + } + + /** + * Returns true if new tickets can be manually created for this repository. + * This is separate from accepting patchsets. + * + * @param repository + * @return true if tickets are being accepted + */ + public boolean isAcceptingNewTickets(RepositoryModel repository) { + return isReady() + && settings.getBoolean(Keys.tickets.acceptNewTickets, true) + && repository.acceptNewTickets + && isAcceptingTicketUpdates(repository); + } + + /** + * Returns true if ticket updates are allowed for this repository. + * + * @param repository + * @return true if tickets are allowed to be updated + */ + public boolean isAcceptingTicketUpdates(RepositoryModel repository) { + return isReady() + && repository.isBare + && !repository.isFrozen + && !repository.isMirror; + } + + /** + * Returns true if the repository has any tickets + * @param repository + * @return true if the repository has tickets + */ + public boolean hasTickets(RepositoryModel repository) { + return indexer.hasTickets(repository); + } + + /** + * Closes any open resources used by this service. + */ + protected abstract void close(); + + /** + * Reset all caches in the service. + */ + public final synchronized void resetCaches() { + ticketsCache.invalidateAll(); + labelsCache.clear(); + milestonesCache.clear(); + resetCachesImpl(); + } + + protected abstract void resetCachesImpl(); + + /** + * Reset any caches for the repository in the service. + */ + public final synchronized void resetCaches(RepositoryModel repository) { + List repoKeys = new ArrayList(); + for (TicketKey key : ticketsCache.asMap().keySet()) { + if (key.repository.equals(repository.name)) { + repoKeys.add(key); + } + } + ticketsCache.invalidateAll(repoKeys); + labelsCache.remove(repository.name); + milestonesCache.remove(repository.name); + resetCachesImpl(repository); + } + + protected abstract void resetCachesImpl(RepositoryModel repository); + + + /** + * Returns the list of labels for the repository. + * + * @param repository + * @return the list of labels + */ + public List getLabels(RepositoryModel repository) { + String key = repository.name; + if (labelsCache.containsKey(key)) { + return labelsCache.get(key); + } + List list = new ArrayList(); + Repository db = repositoryManager.getRepository(repository.name); + try { + StoredConfig config = db.getConfig(); + Set names = config.getSubsections(LABEL); + for (String name : names) { + TicketLabel label = new TicketLabel(name); + label.color = config.getString(LABEL, name, COLOR); + list.add(label); + } + labelsCache.put(key, Collections.unmodifiableList(list)); + } catch (Exception e) { + log.error("invalid tickets settings for " + repository, e); + } finally { + db.close(); + } + return list; + } + + /** + * Returns a TicketLabel object for a given label. If the label is not + * found, a ticket label object is created. + * + * @param repository + * @param label + * @return a TicketLabel + */ + public TicketLabel getLabel(RepositoryModel repository, String label) { + for (TicketLabel tl : getLabels(repository)) { + if (tl.name.equalsIgnoreCase(label)) { + String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.labels.matches(label)).build(); + tl.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true); + return tl; + } + } + return new TicketLabel(label); + } + + /** + * Creates a label. + * + * @param repository + * @param milestone + * @param createdBy + * @return the label + */ + public synchronized TicketLabel createLabel(RepositoryModel repository, String label, String createdBy) { + TicketLabel lb = new TicketMilestone(label); + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.setString(LABEL, label, COLOR, lb.color); + config.save(); + } catch (IOException e) { + log.error("failed to create label " + label + " in " + repository, e); + } finally { + db.close(); + } + return lb; + } + + /** + * Updates a label. + * + * @param repository + * @param label + * @param createdBy + * @return true if the update was successful + */ + public synchronized boolean updateLabel(RepositoryModel repository, TicketLabel label, String createdBy) { + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.setString(LABEL, label.name, COLOR, label.color); + config.save(); + + return true; + } catch (IOException e) { + log.error("failed to update label " + label + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + + /** + * Renames a label. + * + * @param repository + * @param oldName + * @param newName + * @param createdBy + * @return true if the rename was successful + */ + public synchronized boolean renameLabel(RepositoryModel repository, String oldName, String newName, String createdBy) { + if (StringUtils.isEmpty(newName)) { + throw new IllegalArgumentException("new label can not be empty!"); + } + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + TicketLabel label = getLabel(repository, oldName); + StoredConfig config = db.getConfig(); + config.unsetSection(LABEL, oldName); + config.setString(LABEL, newName, COLOR, label.color); + config.save(); + + for (QueryResult qr : label.tickets) { + Change change = new Change(createdBy); + change.unlabel(oldName); + change.label(newName); + updateTicket(repository, qr.number, change); + } + + return true; + } catch (IOException e) { + log.error("failed to rename label " + oldName + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + + /** + * Deletes a label. + * + * @param repository + * @param label + * @param createdBy + * @return true if the delete was successful + */ + public synchronized boolean deleteLabel(RepositoryModel repository, String label, String createdBy) { + if (StringUtils.isEmpty(label)) { + throw new IllegalArgumentException("label can not be empty!"); + } + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.unsetSection(LABEL, label); + config.save(); + + return true; + } catch (IOException e) { + log.error("failed to delete label " + label + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + + /** + * Returns the list of milestones for the repository. + * + * @param repository + * @return the list of milestones + */ + public List getMilestones(RepositoryModel repository) { + String key = repository.name; + if (milestonesCache.containsKey(key)) { + return milestonesCache.get(key); + } + List list = new ArrayList(); + Repository db = repositoryManager.getRepository(repository.name); + try { + StoredConfig config = db.getConfig(); + Set names = config.getSubsections(MILESTONE); + for (String name : names) { + TicketMilestone milestone = new TicketMilestone(name); + milestone.status = Status.fromObject(config.getString(MILESTONE, name, STATUS), milestone.status); + milestone.color = config.getString(MILESTONE, name, COLOR); + String due = config.getString(MILESTONE, name, DUE); + if (!StringUtils.isEmpty(due)) { + try { + milestone.due = new SimpleDateFormat(DUE_DATE_PATTERN).parse(due); + } catch (ParseException e) { + log.error("failed to parse {} milestone {} due date \"{}\"", + new Object [] { repository, name, due }); + } + } + list.add(milestone); + } + milestonesCache.put(key, Collections.unmodifiableList(list)); + } catch (Exception e) { + log.error("invalid tickets settings for " + repository, e); + } finally { + db.close(); + } + return list; + } + + /** + * Returns the list of milestones for the repository that match the status. + * + * @param repository + * @param status + * @return the list of milestones + */ + public List getMilestones(RepositoryModel repository, Status status) { + List matches = new ArrayList(); + for (TicketMilestone milestone : getMilestones(repository)) { + if (status == milestone.status) { + matches.add(milestone); + } + } + return matches; + } + + /** + * Returns the specified milestone or null if the milestone does not exist. + * + * @param repository + * @param milestone + * @return the milestone or null if it does not exist + */ + public TicketMilestone getMilestone(RepositoryModel repository, String milestone) { + for (TicketMilestone ms : getMilestones(repository)) { + if (ms.name.equalsIgnoreCase(milestone)) { + String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.milestone.matches(milestone)).build(); + ms.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true); + return ms; + } + } + return null; + } + + /** + * Creates a milestone. + * + * @param repository + * @param milestone + * @param createdBy + * @return the milestone + */ + public synchronized TicketMilestone createMilestone(RepositoryModel repository, String milestone, String createdBy) { + TicketMilestone ms = new TicketMilestone(milestone); + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.setString(MILESTONE, milestone, STATUS, ms.status.name()); + config.setString(MILESTONE, milestone, COLOR, ms.color); + config.save(); + + milestonesCache.remove(repository.name); + } catch (IOException e) { + log.error("failed to create milestone " + milestone + " in " + repository, e); + } finally { + db.close(); + } + return ms; + } + + /** + * Updates a milestone. + * + * @param repository + * @param milestone + * @param createdBy + * @return true if successful + */ + public synchronized boolean updateMilestone(RepositoryModel repository, TicketMilestone milestone, String createdBy) { + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.setString(MILESTONE, milestone.name, STATUS, milestone.status.name()); + config.setString(MILESTONE, milestone.name, COLOR, milestone.color); + if (milestone.due != null) { + config.setString(MILESTONE, milestone.name, DUE, + new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due)); + } + config.save(); + + milestonesCache.remove(repository.name); + return true; + } catch (IOException e) { + log.error("failed to update milestone " + milestone + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + + /** + * Renames a milestone. + * + * @param repository + * @param oldName + * @param newName + * @param createdBy + * @return true if successful + */ + public synchronized boolean renameMilestone(RepositoryModel repository, String oldName, String newName, String createdBy) { + if (StringUtils.isEmpty(newName)) { + throw new IllegalArgumentException("new milestone can not be empty!"); + } + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + TicketMilestone milestone = getMilestone(repository, oldName); + StoredConfig config = db.getConfig(); + config.unsetSection(MILESTONE, oldName); + config.setString(MILESTONE, newName, STATUS, milestone.status.name()); + config.setString(MILESTONE, newName, COLOR, milestone.color); + if (milestone.due != null) { + config.setString(MILESTONE, milestone.name, DUE, + new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due)); + } + config.save(); + + milestonesCache.remove(repository.name); + + TicketNotifier notifier = createNotifier(); + for (QueryResult qr : milestone.tickets) { + Change change = new Change(createdBy); + change.setField(Field.milestone, newName); + TicketModel ticket = updateTicket(repository, qr.number, change); + notifier.queueMailing(ticket); + } + notifier.sendAll(); + + return true; + } catch (IOException e) { + log.error("failed to rename milestone " + oldName + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + /** + * Deletes a milestone. + * + * @param repository + * @param milestone + * @param createdBy + * @return true if successful + */ + public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone, String createdBy) { + if (StringUtils.isEmpty(milestone)) { + throw new IllegalArgumentException("milestone can not be empty!"); + } + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.unsetSection(MILESTONE, milestone); + config.save(); + + milestonesCache.remove(repository.name); + + return true; + } catch (IOException e) { + log.error("failed to delete milestone " + milestone + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + + /** + * Assigns a new ticket id. + * + * @param repository + * @return a new ticket id + */ + public abstract long assignNewId(RepositoryModel repository); + + /** + * Ensures that we have a ticket for this ticket id. + * + * @param repository + * @param ticketId + * @return true if the ticket exists + */ + public abstract boolean hasTicket(RepositoryModel repository, long ticketId); + + /** + * Returns all tickets. This is not a Lucene search! + * + * @param repository + * @return all tickets + */ + public List getTickets(RepositoryModel repository) { + return getTickets(repository, null); + } + + /** + * Returns all tickets that satisfy the filter. Retrieving tickets from the + * service requires deserializing all journals and building ticket models. + * This is an expensive process and not recommended. Instead, the queryFor + * method should be used which executes against the Lucene index. + * + * @param repository + * @param filter + * optional issue filter to only return matching results + * @return a list of tickets + */ + public abstract List getTickets(RepositoryModel repository, TicketFilter filter); + + /** + * Retrieves the ticket. + * + * @param repository + * @param ticketId + * @return a ticket, if it exists, otherwise null + */ + public final TicketModel getTicket(RepositoryModel repository, long ticketId) { + TicketKey key = new TicketKey(repository, ticketId); + TicketModel ticket = ticketsCache.getIfPresent(key); + + if (ticket == null) { + // load & cache ticket + ticket = getTicketImpl(repository, ticketId); + if (ticket.hasPatchsets()) { + Repository r = repositoryManager.getRepository(repository.name); + try { + Patchset patchset = ticket.getCurrentPatchset(); + DiffStat diffStat = DiffUtils.getDiffStat(r, patchset.base, patchset.tip); + // diffstat could be null if we have ticket data without the + // commit objects. e.g. ticket replication without repo + // mirroring + if (diffStat != null) { + ticket.insertions = diffStat.getInsertions(); + ticket.deletions = diffStat.getDeletions(); + } + } finally { + r.close(); + } + } + if (ticket != null) { + ticketsCache.put(key, ticket); + } + } + return ticket; + } + + /** + * Retrieves the ticket. + * + * @param repository + * @param ticketId + * @return a ticket, if it exists, otherwise null + */ + protected abstract TicketModel getTicketImpl(RepositoryModel repository, long ticketId); + + /** + * Get the ticket url + * + * @param ticket + * @return the ticket url + */ + public String getTicketUrl(TicketModel ticket) { + final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443"); + final String hrefPattern = "{0}/tickets?r={1}&h={2,number,0}"; + return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, ticket.number); + } + + /** + * Get the compare url + * + * @param base + * @param tip + * @return the compare url + */ + public String getCompareUrl(TicketModel ticket, String base, String tip) { + final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443"); + final String hrefPattern = "{0}/compare?r={1}&h={2}..{3}"; + return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, base, tip); + } + + /** + * Returns true if attachments are supported. + * + * @return true if attachments are supported + */ + public abstract boolean supportsAttachments(); + + /** + * Retrieves the specified attachment from a ticket. + * + * @param repository + * @param ticketId + * @param filename + * @return an attachment, if found, null otherwise + */ + public abstract Attachment getAttachment(RepositoryModel repository, long ticketId, String filename); + + /** + * Creates a ticket. Your change must include a repository, author & title, + * at a minimum. If your change does not have those minimum requirements a + * RuntimeException will be thrown. + * + * @param repository + * @param change + * @return true if successful + */ + public TicketModel createTicket(RepositoryModel repository, Change change) { + return createTicket(repository, 0L, change); + } + + /** + * Creates a ticket. Your change must include a repository, author & title, + * at a minimum. If your change does not have those minimum requirements a + * RuntimeException will be thrown. + * + * @param repository + * @param ticketId (if <=0 the ticket id will be assigned) + * @param change + * @return true if successful + */ + public TicketModel createTicket(RepositoryModel repository, long ticketId, Change change) { + + if (repository == null) { + throw new RuntimeException("Must specify a repository!"); + } + if (StringUtils.isEmpty(change.author)) { + throw new RuntimeException("Must specify a change author!"); + } + if (!change.hasField(Field.title)) { + throw new RuntimeException("Must specify a title!"); + } + + change.watch(change.author); + + if (ticketId <= 0L) { + ticketId = assignNewId(repository); + } + + change.setField(Field.status, Status.New); + + boolean success = commitChangeImpl(repository, ticketId, change); + if (success) { + TicketModel ticket = getTicket(repository, ticketId); + indexer.index(ticket); + return ticket; + } + return null; + } + + /** + * Updates a ticket. + * + * @param repository + * @param ticketId + * @param change + * @return the ticket model if successful + */ + public final TicketModel updateTicket(RepositoryModel repository, long ticketId, Change change) { + if (change == null) { + throw new RuntimeException("change can not be null!"); + } + + if (StringUtils.isEmpty(change.author)) { + throw new RuntimeException("must specify a change author!"); + } + + TicketKey key = new TicketKey(repository, ticketId); + ticketsCache.invalidate(key); + + boolean success = commitChangeImpl(repository, ticketId, change); + if (success) { + TicketModel ticket = getTicket(repository, ticketId); + ticketsCache.put(key, ticket); + indexer.index(ticket); + return ticket; + } + return null; + } + + /** + * Deletes all tickets in every repository. + * + * @return true if successful + */ + public boolean deleteAll() { + List repositories = repositoryManager.getRepositoryList(); + BitSet bitset = new BitSet(repositories.size()); + for (int i = 0; i < repositories.size(); i++) { + String name = repositories.get(i); + RepositoryModel repository = repositoryManager.getRepositoryModel(name); + boolean success = deleteAll(repository); + bitset.set(i, success); + } + boolean success = bitset.cardinality() == repositories.size(); + if (success) { + indexer.deleteAll(); + resetCaches(); + } + return success; + } + + /** + * Deletes all tickets in the specified repository. + * @param repository + * @return true if succesful + */ + public boolean deleteAll(RepositoryModel repository) { + boolean success = deleteAllImpl(repository); + if (success) { + resetCaches(repository); + indexer.deleteAll(repository); + } + return success; + } + + protected abstract boolean deleteAllImpl(RepositoryModel repository); + + /** + * Handles repository renames. + * + * @param oldRepositoryName + * @param newRepositoryName + * @return true if successful + */ + public boolean rename(RepositoryModel oldRepository, RepositoryModel newRepository) { + if (renameImpl(oldRepository, newRepository)) { + resetCaches(oldRepository); + indexer.deleteAll(oldRepository); + reindex(newRepository); + return true; + } + return false; + } + + protected abstract boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository); + + /** + * Deletes a ticket. + * + * @param repository + * @param ticketId + * @param deletedBy + * @return true if successful + */ + public boolean deleteTicket(RepositoryModel repository, long ticketId, String deletedBy) { + TicketModel ticket = getTicket(repository, ticketId); + boolean success = deleteTicketImpl(repository, ticket, deletedBy); + if (success) { + ticketsCache.invalidate(new TicketKey(repository, ticketId)); + indexer.delete(ticket); + return true; + } + return false; + } + + /** + * Deletes a ticket. + * + * @param repository + * @param ticket + * @param deletedBy + * @return true if successful + */ + protected abstract boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy); + + + /** + * Updates the text of an ticket comment. + * + * @param ticket + * @param commentId + * the id of the comment to revise + * @param updatedBy + * the author of the updated comment + * @param comment + * the revised comment + * @return the revised ticket if the change was successful + */ + public final TicketModel updateComment(TicketModel ticket, String commentId, + String updatedBy, String comment) { + Change revision = new Change(updatedBy); + revision.comment(comment); + revision.comment.id = commentId; + RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository); + TicketModel revisedTicket = updateTicket(repository, ticket.number, revision); + return revisedTicket; + } + + /** + * Deletes a comment from a ticket. + * + * @param ticket + * @param commentId + * the id of the comment to delete + * @param deletedBy + * the user deleting the comment + * @return the revised ticket if the deletion was successful + */ + public final TicketModel deleteComment(TicketModel ticket, String commentId, String deletedBy) { + Change deletion = new Change(deletedBy); + deletion.comment(""); + deletion.comment.id = commentId; + deletion.comment.deleted = true; + RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository); + TicketModel revisedTicket = updateTicket(repository, ticket.number, deletion); + return revisedTicket; + } + + /** + * Commit a ticket change to the repository. + * + * @param repository + * @param ticketId + * @param change + * @return true, if the change was committed + */ + protected abstract boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change); + + + /** + * Searches for the specified text. This will use the indexer, if available, + * or will fall back to brute-force retrieval of all tickets and string + * matching. + * + * @param repository + * @param text + * @param page + * @param pageSize + * @return a list of matching tickets + */ + public List searchFor(RepositoryModel repository, String text, int page, int pageSize) { + return indexer.searchFor(repository, text, page, pageSize); + } + + /** + * Queries the index for the matching tickets. + * + * @param query + * @param page + * @param pageSize + * @param sortBy + * @param descending + * @return a list of matching tickets or an empty list + */ + public List queryFor(String query, int page, int pageSize, String sortBy, boolean descending) { + return indexer.queryFor(query, page, pageSize, sortBy, descending); + } + + /** + * Destroys an existing index and reindexes all tickets. + * This operation may be expensive and time-consuming. + */ + public void reindex() { + long start = System.nanoTime(); + indexer.deleteAll(); + for (String name : repositoryManager.getRepositoryList()) { + RepositoryModel repository = repositoryManager.getRepositoryModel(name); + try { + List tickets = getTickets(repository); + if (!tickets.isEmpty()) { + log.info("reindexing {} tickets from {} ...", tickets.size(), repository); + indexer.index(tickets); + System.gc(); + } + } catch (Exception e) { + log.error("failed to reindex {}", repository.name); + log.error(null, e); + } + } + long end = System.nanoTime(); + long secs = TimeUnit.NANOSECONDS.toMillis(end - start); + log.info("reindexing completed in {} msecs.", secs); + } + + /** + * Destroys any existing index and reindexes all tickets. + * This operation may be expensive and time-consuming. + */ + public void reindex(RepositoryModel repository) { + long start = System.nanoTime(); + List tickets = getTickets(repository); + indexer.index(tickets); + log.info("reindexing {} tickets from {} ...", tickets.size(), repository); + long end = System.nanoTime(); + long secs = TimeUnit.NANOSECONDS.toMillis(end - start); + log.info("reindexing completed in {} msecs.", secs); + } + + /** + * Synchronously executes the runnable. This is used for special processing + * of ticket updates, namely merging from the web ui. + * + * @param runnable + */ + public synchronized void exec(Runnable runnable) { + runnable.run(); + } +} diff --git a/src/main/java/com/gitblit/tickets/NullTicketService.java b/src/main/java/com/gitblit/tickets/NullTicketService.java new file mode 100644 index 00000000..cc893025 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/NullTicketService.java @@ -0,0 +1,129 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.util.Collections; +import java.util.List; + +import javax.inject.Inject; + +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; + +/** + * Implementation of a ticket service that rejects everything. + * + * @author James Moger + * + */ +public class NullTicketService extends ITicketService { + + @Inject + public NullTicketService( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager) { + + super(runtimeManager, + notificationManager, + userManager, + repositoryManager); + } + + @Override + public boolean isReady() { + return false; + } + + @Override + public NullTicketService start() { + return this; + } + + @Override + protected void resetCachesImpl() { + } + + @Override + protected void resetCachesImpl(RepositoryModel repository) { + } + + @Override + protected void close() { + } + + @Override + public boolean hasTicket(RepositoryModel repository, long ticketId) { + return false; + } + + @Override + public synchronized long assignNewId(RepositoryModel repository) { + return 0L; + } + + @Override + public List getTickets(RepositoryModel repository, TicketFilter filter) { + return Collections.emptyList(); + } + + @Override + protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) { + return null; + } + + @Override + public boolean supportsAttachments() { + return false; + } + + @Override + public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) { + return null; + } + + @Override + protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) { + return false; + } + + @Override + protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) { + return false; + } + + @Override + protected boolean deleteAllImpl(RepositoryModel repository) { + return false; + } + + @Override + protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) { + return false; + } + + @Override + public String toString() { + return getClass().getSimpleName(); + } +} diff --git a/src/main/java/com/gitblit/tickets/QueryBuilder.java b/src/main/java/com/gitblit/tickets/QueryBuilder.java new file mode 100644 index 00000000..17aeb988 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/QueryBuilder.java @@ -0,0 +1,222 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.tickets; + +import com.gitblit.utils.StringUtils; + +/** + * A Lucene query builder. + * + * @author James Moger + * + */ +public class QueryBuilder { + + private final QueryBuilder parent; + private String q; + private transient StringBuilder sb; + private int opCount; + + public static QueryBuilder q(String kernel) { + return new QueryBuilder(kernel); + } + + private QueryBuilder(QueryBuilder parent) { + this.sb = new StringBuilder(); + this.parent = parent; + } + + public QueryBuilder() { + this(""); + } + + public QueryBuilder(String query) { + this.sb = new StringBuilder(query == null ? "" : query); + this.parent = null; + } + + public boolean containsField(String field) { + return sb.toString().contains(field + ":"); + } + + /** + * Creates a new AND subquery. Make sure to call endSubquery to + * get return *this* query. + * + * e.g. field:something AND (subquery) + * + * @return a subquery + */ + public QueryBuilder andSubquery() { + sb.append(" AND ("); + return new QueryBuilder(this); + } + + /** + * Creates a new OR subquery. Make sure to call endSubquery to + * get return *this* query. + * + * e.g. field:something OR (subquery) + * + * @return a subquery + */ + public QueryBuilder orSubquery() { + sb.append(" OR ("); + return new QueryBuilder(this); + } + + /** + * Ends a subquery and returns the parent query. + * + * @return the parent query + */ + public QueryBuilder endSubquery() { + this.q = sb.toString().trim(); + if (q.length() > 0) { + parent.sb.append(q).append(')'); + } + return parent; + } + + /** + * Append an OR condition. + * + * @param condition + * @return + */ + public QueryBuilder or(String condition) { + return op(condition, " OR "); + } + + /** + * Append an AND condition. + * + * @param condition + * @return + */ + public QueryBuilder and(String condition) { + return op(condition, " AND "); + } + + /** + * Append an AND NOT condition. + * + * @param condition + * @return + */ + public QueryBuilder andNot(String condition) { + return op(condition, " AND NOT "); + } + + /** + * Nest this query as a subquery. + * + * e.g. field:something AND field2:something else + * ==> (field:something AND field2:something else) + * + * @return this query nested as a subquery + */ + public QueryBuilder toSubquery() { + if (opCount > 1) { + sb.insert(0, '(').append(')'); + } + return this; + } + + /** + * Nest this query as an AND subquery of the condition + * + * @param condition + * @return the query nested as an AND subquery of the specified condition + */ + public QueryBuilder subqueryOf(String condition) { + if (!StringUtils.isEmpty(condition)) { + toSubquery().and(condition); + } + return this; + } + + /** + * Removes a condition from the query. + * + * @param condition + * @return the query + */ + public QueryBuilder remove(String condition) { + int start = sb.indexOf(condition); + if (start == 0) { + // strip first condition + sb.replace(0, condition.length(), ""); + } else if (start > 1) { + // locate condition in query + int space1 = sb.lastIndexOf(" ", start - 1); + int space0 = sb.lastIndexOf(" ", space1 - 1); + if (space0 > -1 && space1 > -1) { + String conjunction = sb.substring(space0, space1).trim(); + if ("OR".equals(conjunction) || "AND".equals(conjunction)) { + // remove the conjunction + sb.replace(space0, start + condition.length(), ""); + } else { + // unknown conjunction + sb.replace(start, start + condition.length(), ""); + } + } else { + sb.replace(start, start + condition.length(), ""); + } + } + return this; + } + + /** + * Generate the return the Lucene query. + * + * @return the generated query + */ + public String build() { + if (parent != null) { + throw new IllegalAccessError("You can not build a subquery! endSubquery() instead!"); + } + this.q = sb.toString().trim(); + + // cleanup paranthesis + while (q.contains("()")) { + q = q.replace("()", ""); + } + if (q.length() > 0) { + if (q.charAt(0) == '(' && q.charAt(q.length() - 1) == ')') { + // query is wrapped by unnecessary paranthesis + q = q.substring(1, q.length() - 1); + } + } + return q; + } + + private QueryBuilder op(String condition, String op) { + opCount++; + if (!StringUtils.isEmpty(condition)) { + if (sb.length() != 0) { + sb.append(op); + } + sb.append(condition); + } + return this; + } + + @Override + public String toString() { + return sb.toString().trim(); + } +} \ No newline at end of file diff --git a/src/main/java/com/gitblit/tickets/QueryResult.java b/src/main/java/com/gitblit/tickets/QueryResult.java new file mode 100644 index 00000000..9f5d3a55 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/QueryResult.java @@ -0,0 +1,114 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.TicketModel.Type; +import com.gitblit.utils.StringUtils; + +/** + * Represents the results of a query to the ticket index. + * + * @author James Moger + * + */ +public class QueryResult implements Serializable { + + private static final long serialVersionUID = 1L; + + public String project; + public String repository; + public long number; + public String createdBy; + public Date createdAt; + public String updatedBy; + public Date updatedAt; + public String dependsOn; + public String title; + public String body; + public Status status; + public String responsible; + public String milestone; + public String topic; + public Type type; + public String mergeSha; + public String mergeTo; + public List labels; + public List attachments; + public List participants; + public List watchedby; + public List mentions; + public Patchset patchset; + public int commentsCount; + public int votesCount; + public int approvalsCount; + + public int docId; + public int totalResults; + + public Date getDate() { + return updatedAt == null ? createdAt : updatedAt; + } + + public boolean isProposal() { + return type != null && Type.Proposal == type; + } + + public boolean isMerged() { + return Status.Merged == status && !StringUtils.isEmpty(mergeSha); + } + + public boolean isWatching(String username) { + return watchedby != null && watchedby.contains(username); + } + + public List getLabels() { + List list = new ArrayList(); + if (labels != null) { + list.addAll(labels); + } + if (topic != null) { + list.add(topic); + } + Collections.sort(list); + return list; + } + + @Override + public boolean equals(Object o) { + if (o instanceof QueryResult) { + return hashCode() == o.hashCode(); + } + return false; + } + + @Override + public int hashCode() { + return (repository + number).hashCode(); + } + + @Override + public String toString() { + return repository + "-" + number; + } +} diff --git a/src/main/java/com/gitblit/tickets/RedisTicketService.java b/src/main/java/com/gitblit/tickets/RedisTicketService.java new file mode 100644 index 00000000..5653f698 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/RedisTicketService.java @@ -0,0 +1,534 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; + +import org.apache.commons.pool2.impl.GenericObjectPoolConfig; + +import redis.clients.jedis.Client; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.Protocol; +import redis.clients.jedis.Transaction; +import redis.clients.jedis.exceptions.JedisException; + +import com.gitblit.Keys; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.StringUtils; + +/** + * Implementation of a ticket service based on a Redis key-value store. All + * tickets are persisted in the Redis store so it must be configured for + * durability otherwise tickets are lost on a flush or restart. Tickets are + * indexed with Lucene and all queries are executed against the Lucene index. + * + * @author James Moger + * + */ +public class RedisTicketService extends ITicketService { + + private final JedisPool pool; + + private enum KeyType { + journal, ticket, counter + } + + @Inject + public RedisTicketService( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager) { + + super(runtimeManager, + notificationManager, + userManager, + repositoryManager); + + String redisUrl = settings.getString(Keys.tickets.redis.url, ""); + this.pool = createPool(redisUrl); + } + + @Override + public RedisTicketService start() { + return this; + } + + @Override + protected void resetCachesImpl() { + } + + @Override + protected void resetCachesImpl(RepositoryModel repository) { + } + + @Override + protected void close() { + pool.destroy(); + } + + @Override + public boolean isReady() { + return pool != null; + } + + /** + * Constructs a key for use with a key-value data store. + * + * @param key + * @param repository + * @param id + * @return a key + */ + private String key(RepositoryModel repository, KeyType key, String id) { + StringBuilder sb = new StringBuilder(); + sb.append(repository.name).append(':'); + sb.append(key.name()); + if (!StringUtils.isEmpty(id)) { + sb.append(':'); + sb.append(id); + } + return sb.toString(); + } + + /** + * Constructs a key for use with a key-value data store. + * + * @param key + * @param repository + * @param id + * @return a key + */ + private String key(RepositoryModel repository, KeyType key, long id) { + return key(repository, key, "" + id); + } + + private boolean isNull(String value) { + return value == null || "nil".equals(value); + } + + private String getUrl() { + Jedis jedis = pool.getResource(); + try { + if (jedis != null) { + Client client = jedis.getClient(); + return client.getHost() + ":" + client.getPort() + "/" + client.getDB(); + } + } catch (JedisException e) { + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return null; + } + + /** + * Ensures that we have a ticket for this ticket id. + * + * @param repository + * @param ticketId + * @return true if the ticket exists + */ + @Override + public boolean hasTicket(RepositoryModel repository, long ticketId) { + if (ticketId <= 0L) { + return false; + } + Jedis jedis = pool.getResource(); + if (jedis == null) { + return false; + } + try { + Boolean exists = jedis.exists(key(repository, KeyType.journal, ticketId)); + return exists != null && !exists; + } catch (JedisException e) { + log.error("failed to check hasTicket from Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return false; + } + + /** + * Assigns a new ticket id. + * + * @param repository + * @return a new long ticket id + */ + @Override + public synchronized long assignNewId(RepositoryModel repository) { + Jedis jedis = pool.getResource(); + try { + String key = key(repository, KeyType.counter, null); + String val = jedis.get(key); + if (isNull(val)) { + jedis.set(key, "0"); + } + long ticketNumber = jedis.incr(key); + return ticketNumber; + } catch (JedisException e) { + log.error("failed to assign new ticket id in Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return 0L; + } + + /** + * Returns all the tickets in the repository. Querying tickets from the + * repository requires deserializing all tickets. This is an expensive + * process and not recommended. Tickets should be indexed by Lucene and + * queries should be executed against that index. + * + * @param repository + * @param filter + * optional filter to only return matching results + * @return a list of tickets + */ + @Override + public List getTickets(RepositoryModel repository, TicketFilter filter) { + Jedis jedis = pool.getResource(); + List list = new ArrayList(); + if (jedis == null) { + return list; + } + try { + // Deserialize each journal, build the ticket, and optionally filter + Set keys = jedis.keys(key(repository, KeyType.journal, "*")); + for (String key : keys) { + // {repo}:journal:{id} + String id = key.split(":")[2]; + long ticketId = Long.parseLong(id); + List changes = getJournal(jedis, repository, ticketId); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, ticketId); + continue; + } + TicketModel ticket = TicketModel.buildTicket(changes); + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + + // add the ticket, conditionally, to the list + if (filter == null) { + list.add(ticket); + } else { + if (filter.accept(ticket)) { + list.add(ticket); + } + } + } + + // sort the tickets by creation + Collections.sort(list); + } catch (JedisException e) { + log.error("failed to retrieve tickets from Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return list; + } + + /** + * Retrieves the ticket from the repository by first looking-up the changeId + * associated with the ticketId. + * + * @param repository + * @param ticketId + * @return a ticket, if it exists, otherwise null + */ + @Override + protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) { + Jedis jedis = pool.getResource(); + if (jedis == null) { + return null; + } + + try { + List changes = getJournal(jedis, repository, ticketId); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, ticketId); + return null; + } + TicketModel ticket = TicketModel.buildTicket(changes); + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + log.debug("rebuilt ticket {} from Redis @ {}", ticketId, getUrl()); + return ticket; + } catch (JedisException e) { + log.error("failed to retrieve ticket from Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return null; + } + + /** + * Returns the journal for the specified ticket. + * + * @param repository + * @param ticketId + * @return a list of changes + */ + private List getJournal(Jedis jedis, RepositoryModel repository, long ticketId) throws JedisException { + if (ticketId <= 0L) { + return new ArrayList(); + } + List entries = jedis.lrange(key(repository, KeyType.journal, ticketId), 0, -1); + if (entries.size() > 0) { + // build a json array from the individual entries + StringBuilder sb = new StringBuilder(); + sb.append("["); + for (String entry : entries) { + sb.append(entry).append(','); + } + sb.setLength(sb.length() - 1); + sb.append(']'); + String journal = sb.toString(); + + return TicketSerializer.deserializeJournal(journal); + } + return new ArrayList(); + } + + @Override + public boolean supportsAttachments() { + return false; + } + + /** + * Retrieves the specified attachment from a ticket. + * + * @param repository + * @param ticketId + * @param filename + * @return an attachment, if found, null otherwise + */ + @Override + public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) { + return null; + } + + /** + * Deletes a ticket. + * + * @param ticket + * @return true if successful + */ + @Override + protected boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) { + boolean success = false; + if (ticket == null) { + throw new RuntimeException("must specify a ticket!"); + } + + Jedis jedis = pool.getResource(); + if (jedis == null) { + return false; + } + + try { + // atomically remove ticket + Transaction t = jedis.multi(); + t.del(key(repository, KeyType.ticket, ticket.number)); + t.del(key(repository, KeyType.journal, ticket.number)); + t.exec(); + + success = true; + log.debug("deleted ticket {} from Redis @ {}", "" + ticket.number, getUrl()); + } catch (JedisException e) { + log.error("failed to delete ticket from Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + + return success; + } + + /** + * Commit a ticket change to the repository. + * + * @param repository + * @param ticketId + * @param change + * @return true, if the change was committed + */ + @Override + protected boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) { + Jedis jedis = pool.getResource(); + if (jedis == null) { + return false; + } + try { + List changes = getJournal(jedis, repository, ticketId); + changes.add(change); + // build a new effective ticket from the changes + TicketModel ticket = TicketModel.buildTicket(changes); + + String object = TicketSerializer.serialize(ticket); + String journal = TicketSerializer.serialize(change); + + // atomically store ticket + Transaction t = jedis.multi(); + t.set(key(repository, KeyType.ticket, ticketId), object); + t.rpush(key(repository, KeyType.journal, ticketId), journal); + t.exec(); + + log.debug("updated ticket {} in Redis @ {}", "" + ticketId, getUrl()); + return true; + } catch (JedisException e) { + log.error("failed to update ticket cache in Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return false; + } + + /** + * Deletes all Tickets for the rpeository from the Redis key-value store. + * + */ + @Override + protected boolean deleteAllImpl(RepositoryModel repository) { + Jedis jedis = pool.getResource(); + if (jedis == null) { + return false; + } + + boolean success = false; + try { + Set keys = jedis.keys(repository.name + ":*"); + if (keys.size() > 0) { + Transaction t = jedis.multi(); + t.del(keys.toArray(new String[keys.size()])); + t.exec(); + } + success = true; + } catch (JedisException e) { + log.error("failed to delete all tickets in Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return success; + } + + @Override + protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) { + Jedis jedis = pool.getResource(); + if (jedis == null) { + return false; + } + + boolean success = false; + try { + Set oldKeys = jedis.keys(oldRepository.name + ":*"); + Transaction t = jedis.multi(); + for (String oldKey : oldKeys) { + String newKey = newRepository.name + oldKey.substring(oldKey.indexOf(':')); + t.rename(oldKey, newKey); + } + t.exec(); + success = true; + } catch (JedisException e) { + log.error("failed to rename tickets in Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return success; + } + + private JedisPool createPool(String url) { + JedisPool pool = null; + if (!StringUtils.isEmpty(url)) { + try { + URI uri = URI.create(url); + if (uri.getScheme() != null && uri.getScheme().equalsIgnoreCase("redis")) { + int database = Protocol.DEFAULT_DATABASE; + String password = null; + if (uri.getUserInfo() != null) { + password = uri.getUserInfo().split(":", 2)[1]; + } + if (uri.getPath().indexOf('/') > -1) { + database = Integer.parseInt(uri.getPath().split("/", 2)[1]); + } + pool = new JedisPool(new GenericObjectPoolConfig(), uri.getHost(), uri.getPort(), Protocol.DEFAULT_TIMEOUT, password, database); + } else { + pool = new JedisPool(url); + } + } catch (JedisException e) { + log.error("failed to create a Redis pool!", e); + } + } + return pool; + } + + @Override + public String toString() { + String url = getUrl(); + return getClass().getSimpleName() + " (" + (url == null ? "DISABLED" : url) + ")"; + } +} diff --git a/src/main/java/com/gitblit/tickets/TicketIndexer.java b/src/main/java/com/gitblit/tickets/TicketIndexer.java new file mode 100644 index 00000000..3929a000 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketIndexer.java @@ -0,0 +1,657 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.io.File; +import java.io.IOException; +import java.text.MessageFormat; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.apache.lucene.analysis.standard.StandardAnalyzer; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field.Store; +import org.apache.lucene.document.IntField; +import org.apache.lucene.document.LongField; +import org.apache.lucene.document.TextField; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.IndexWriterConfig.OpenMode; +import org.apache.lucene.queryparser.classic.QueryParser; +import org.apache.lucene.search.BooleanClause.Occur; +import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.ScoreDoc; +import org.apache.lucene.search.Sort; +import org.apache.lucene.search.SortField; +import org.apache.lucene.search.SortField.Type; +import org.apache.lucene.search.TopFieldDocs; +import org.apache.lucene.search.TopScoreDocCollector; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.FSDirectory; +import org.apache.lucene.util.Version; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.gitblit.Keys; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.utils.FileUtils; +import com.gitblit.utils.StringUtils; + +/** + * Indexes tickets in a Lucene database. + * + * @author James Moger + * + */ +public class TicketIndexer { + + /** + * Fields in the Lucene index + */ + public static enum Lucene { + + rid(Type.STRING), + did(Type.STRING), + project(Type.STRING), + repository(Type.STRING), + number(Type.LONG), + title(Type.STRING), + body(Type.STRING), + topic(Type.STRING), + created(Type.LONG), + createdby(Type.STRING), + updated(Type.LONG), + updatedby(Type.STRING), + responsible(Type.STRING), + milestone(Type.STRING), + status(Type.STRING), + type(Type.STRING), + labels(Type.STRING), + participants(Type.STRING), + watchedby(Type.STRING), + mentions(Type.STRING), + attachments(Type.INT), + content(Type.STRING), + patchset(Type.STRING), + comments(Type.INT), + mergesha(Type.STRING), + mergeto(Type.STRING), + patchsets(Type.INT), + votes(Type.INT); + + final Type fieldType; + + Lucene(Type fieldType) { + this.fieldType = fieldType; + } + + public String colon() { + return name() + ":"; + } + + public String matches(String value) { + if (StringUtils.isEmpty(value)) { + return ""; + } + boolean not = value.charAt(0) == '!'; + if (not) { + return "!" + name() + ":" + escape(value.substring(1)); + } + return name() + ":" + escape(value); + } + + public String doesNotMatch(String value) { + if (StringUtils.isEmpty(value)) { + return ""; + } + return "NOT " + name() + ":" + escape(value); + } + + public String isNotNull() { + return matches("[* TO *]"); + } + + public SortField asSortField(boolean descending) { + return new SortField(name(), fieldType, descending); + } + + private String escape(String value) { + if (value.charAt(0) != '"') { + if (value.indexOf('/') > -1) { + return "\"" + value + "\""; + } + } + return value; + } + + public static Lucene fromString(String value) { + for (Lucene field : values()) { + if (field.name().equalsIgnoreCase(value)) { + return field; + } + } + return created; + } + } + + private final Logger log = LoggerFactory.getLogger(getClass()); + + private final Version luceneVersion = Version.LUCENE_46; + + private final File luceneDir; + + private IndexWriter writer; + + private IndexSearcher searcher; + + public TicketIndexer(IRuntimeManager runtimeManager) { + this.luceneDir = runtimeManager.getFileOrFolder(Keys.tickets.indexFolder, "${baseFolder}/tickets/lucene"); + } + + /** + * Close all writers and searchers used by the ticket indexer. + */ + public void close() { + closeSearcher(); + closeWriter(); + } + + /** + * Deletes the entire ticket index for all repositories. + */ + public void deleteAll() { + close(); + FileUtils.delete(luceneDir); + } + + /** + * Deletes all tickets for the the repository from the index. + */ + public boolean deleteAll(RepositoryModel repository) { + try { + IndexWriter writer = getWriter(); + StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion); + QueryParser qp = new QueryParser(luceneVersion, Lucene.rid.name(), analyzer); + BooleanQuery query = new BooleanQuery(); + query.add(qp.parse(repository.getRID()), Occur.MUST); + + int numDocsBefore = writer.numDocs(); + writer.deleteDocuments(query); + writer.commit(); + closeSearcher(); + int numDocsAfter = writer.numDocs(); + if (numDocsBefore == numDocsAfter) { + log.debug(MessageFormat.format("no records found to delete in {0}", repository)); + return false; + } else { + log.debug(MessageFormat.format("deleted {0} records in {1}", numDocsBefore - numDocsAfter, repository)); + return true; + } + } catch (Exception e) { + log.error("error", e); + } + return false; + } + + /** + * Bulk Add/Update tickets in the Lucene index + * + * @param tickets + */ + public void index(List tickets) { + try { + IndexWriter writer = getWriter(); + for (TicketModel ticket : tickets) { + Document doc = ticketToDoc(ticket); + writer.addDocument(doc); + } + writer.commit(); + closeSearcher(); + } catch (Exception e) { + log.error("error", e); + } + } + + /** + * Add/Update a ticket in the Lucene index + * + * @param ticket + */ + public void index(TicketModel ticket) { + try { + IndexWriter writer = getWriter(); + delete(ticket.repository, ticket.number, writer); + Document doc = ticketToDoc(ticket); + writer.addDocument(doc); + writer.commit(); + closeSearcher(); + } catch (Exception e) { + log.error("error", e); + } + } + + /** + * Delete a ticket from the Lucene index. + * + * @param ticket + * @throws Exception + * @return true, if deleted, false if no record was deleted + */ + public boolean delete(TicketModel ticket) { + try { + IndexWriter writer = getWriter(); + return delete(ticket.repository, ticket.number, writer); + } catch (Exception e) { + log.error("Failed to delete ticket " + ticket.number, e); + } + return false; + } + + /** + * Delete a ticket from the Lucene index. + * + * @param repository + * @param ticketId + * @throws Exception + * @return true, if deleted, false if no record was deleted + */ + private boolean delete(String repository, long ticketId, IndexWriter writer) throws Exception { + StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion); + QueryParser qp = new QueryParser(luceneVersion, Lucene.did.name(), analyzer); + BooleanQuery query = new BooleanQuery(); + query.add(qp.parse(StringUtils.getSHA1(repository + ticketId)), Occur.MUST); + + int numDocsBefore = writer.numDocs(); + writer.deleteDocuments(query); + writer.commit(); + closeSearcher(); + int numDocsAfter = writer.numDocs(); + if (numDocsBefore == numDocsAfter) { + log.debug(MessageFormat.format("no records found to delete in {0}", repository)); + return false; + } else { + log.debug(MessageFormat.format("deleted {0} records in {1}", numDocsBefore - numDocsAfter, repository)); + return true; + } + } + + /** + * Returns true if the repository has tickets in the index. + * + * @param repository + * @return true if there are indexed tickets + */ + public boolean hasTickets(RepositoryModel repository) { + return !queryFor(Lucene.rid.matches(repository.getRID()), 1, 0, null, true).isEmpty(); + } + + /** + * Search for tickets matching the query. The returned tickets are + * shadows of the real ticket, but suitable for a results list. + * + * @param repository + * @param text + * @param page + * @param pageSize + * @return search results + */ + public List searchFor(RepositoryModel repository, String text, int page, int pageSize) { + if (StringUtils.isEmpty(text)) { + return Collections.emptyList(); + } + Set results = new LinkedHashSet(); + StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion); + try { + // search the title, description and content + BooleanQuery query = new BooleanQuery(); + QueryParser qp; + + qp = new QueryParser(luceneVersion, Lucene.title.name(), analyzer); + qp.setAllowLeadingWildcard(true); + query.add(qp.parse(text), Occur.SHOULD); + + qp = new QueryParser(luceneVersion, Lucene.body.name(), analyzer); + qp.setAllowLeadingWildcard(true); + query.add(qp.parse(text), Occur.SHOULD); + + qp = new QueryParser(luceneVersion, Lucene.content.name(), analyzer); + qp.setAllowLeadingWildcard(true); + query.add(qp.parse(text), Occur.SHOULD); + + IndexSearcher searcher = getSearcher(); + Query rewrittenQuery = searcher.rewrite(query); + + log.debug(rewrittenQuery.toString()); + + TopScoreDocCollector collector = TopScoreDocCollector.create(5000, true); + searcher.search(rewrittenQuery, collector); + int offset = Math.max(0, (page - 1) * pageSize); + ScoreDoc[] hits = collector.topDocs(offset, pageSize).scoreDocs; + for (int i = 0; i < hits.length; i++) { + int docId = hits[i].doc; + Document doc = searcher.doc(docId); + QueryResult result = docToQueryResult(doc); + if (repository != null) { + if (!result.repository.equalsIgnoreCase(repository.name)) { + continue; + } + } + results.add(result); + } + } catch (Exception e) { + log.error(MessageFormat.format("Exception while searching for {0}", text), e); + } + return new ArrayList(results); + } + + /** + * Search for tickets matching the query. The returned tickets are + * shadows of the real ticket, but suitable for a results list. + * + * @param text + * @param page + * @param pageSize + * @param sortBy + * @param desc + * @return + */ + public List queryFor(String queryText, int page, int pageSize, String sortBy, boolean desc) { + if (StringUtils.isEmpty(queryText)) { + return Collections.emptyList(); + } + + Set results = new LinkedHashSet(); + StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion); + try { + QueryParser qp = new QueryParser(luceneVersion, Lucene.content.name(), analyzer); + Query query = qp.parse(queryText); + + IndexSearcher searcher = getSearcher(); + Query rewrittenQuery = searcher.rewrite(query); + + log.debug(rewrittenQuery.toString()); + + Sort sort; + if (sortBy == null) { + sort = new Sort(Lucene.created.asSortField(desc)); + } else { + sort = new Sort(Lucene.fromString(sortBy).asSortField(desc)); + } + int maxSize = 5000; + TopFieldDocs docs = searcher.search(rewrittenQuery, null, maxSize, sort, false, false); + int size = (pageSize <= 0) ? maxSize : pageSize; + int offset = Math.max(0, (page - 1) * size); + ScoreDoc[] hits = subset(docs.scoreDocs, offset, size); + for (int i = 0; i < hits.length; i++) { + int docId = hits[i].doc; + Document doc = searcher.doc(docId); + QueryResult result = docToQueryResult(doc); + result.docId = docId; + result.totalResults = docs.totalHits; + results.add(result); + } + } catch (Exception e) { + log.error(MessageFormat.format("Exception while searching for {0}", queryText), e); + } + return new ArrayList(results); + } + + private ScoreDoc [] subset(ScoreDoc [] docs, int offset, int size) { + if (docs.length >= (offset + size)) { + ScoreDoc [] set = new ScoreDoc[size]; + System.arraycopy(docs, offset, set, 0, set.length); + return set; + } else if (docs.length >= offset) { + ScoreDoc [] set = new ScoreDoc[docs.length - offset]; + System.arraycopy(docs, offset, set, 0, set.length); + return set; + } else { + return new ScoreDoc[0]; + } + } + + private IndexWriter getWriter() throws IOException { + if (writer == null) { + Directory directory = FSDirectory.open(luceneDir); + + if (!luceneDir.exists()) { + luceneDir.mkdirs(); + } + + StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion); + IndexWriterConfig config = new IndexWriterConfig(luceneVersion, analyzer); + config.setOpenMode(OpenMode.CREATE_OR_APPEND); + writer = new IndexWriter(directory, config); + } + return writer; + } + + private synchronized void closeWriter() { + try { + if (writer != null) { + writer.close(); + } + } catch (Exception e) { + log.error("failed to close writer!", e); + } finally { + writer = null; + } + } + + private IndexSearcher getSearcher() throws IOException { + if (searcher == null) { + searcher = new IndexSearcher(DirectoryReader.open(getWriter(), true)); + } + return searcher; + } + + private synchronized void closeSearcher() { + try { + if (searcher != null) { + searcher.getIndexReader().close(); + } + } catch (Exception e) { + log.error("failed to close searcher!", e); + } finally { + searcher = null; + } + } + + /** + * Creates a Lucene document from a ticket. + * + * @param ticket + * @return a Lucene document + */ + private Document ticketToDoc(TicketModel ticket) { + Document doc = new Document(); + // repository and document ids for Lucene querying + toDocField(doc, Lucene.rid, StringUtils.getSHA1(ticket.repository)); + toDocField(doc, Lucene.did, StringUtils.getSHA1(ticket.repository + ticket.number)); + + toDocField(doc, Lucene.project, ticket.project); + toDocField(doc, Lucene.repository, ticket.repository); + toDocField(doc, Lucene.number, ticket.number); + toDocField(doc, Lucene.title, ticket.title); + toDocField(doc, Lucene.body, ticket.body); + toDocField(doc, Lucene.created, ticket.created); + toDocField(doc, Lucene.createdby, ticket.createdBy); + toDocField(doc, Lucene.updated, ticket.updated); + toDocField(doc, Lucene.updatedby, ticket.updatedBy); + toDocField(doc, Lucene.responsible, ticket.responsible); + toDocField(doc, Lucene.milestone, ticket.milestone); + toDocField(doc, Lucene.topic, ticket.topic); + toDocField(doc, Lucene.status, ticket.status.name()); + toDocField(doc, Lucene.comments, ticket.getComments().size()); + toDocField(doc, Lucene.type, ticket.type == null ? null : ticket.type.name()); + toDocField(doc, Lucene.mergesha, ticket.mergeSha); + toDocField(doc, Lucene.mergeto, ticket.mergeTo); + toDocField(doc, Lucene.labels, StringUtils.flattenStrings(ticket.getLabels(), ";").toLowerCase()); + toDocField(doc, Lucene.participants, StringUtils.flattenStrings(ticket.getParticipants(), ";").toLowerCase()); + toDocField(doc, Lucene.watchedby, StringUtils.flattenStrings(ticket.getWatchers(), ";").toLowerCase()); + toDocField(doc, Lucene.mentions, StringUtils.flattenStrings(ticket.getMentions(), ";").toLowerCase()); + toDocField(doc, Lucene.votes, ticket.getVoters().size()); + + List attachments = new ArrayList(); + for (Attachment attachment : ticket.getAttachments()) { + attachments.add(attachment.name.toLowerCase()); + } + toDocField(doc, Lucene.attachments, StringUtils.flattenStrings(attachments, ";")); + + List patches = ticket.getPatchsets(); + if (!patches.isEmpty()) { + toDocField(doc, Lucene.patchsets, patches.size()); + Patchset patchset = patches.get(patches.size() - 1); + String flat = + patchset.number + ":" + + patchset.rev + ":" + + patchset.tip + ":" + + patchset.base + ":" + + patchset.commits; + doc.add(new org.apache.lucene.document.Field(Lucene.patchset.name(), flat, TextField.TYPE_STORED)); + } + + doc.add(new TextField(Lucene.content.name(), ticket.toIndexableString(), Store.NO)); + + return doc; + } + + private void toDocField(Document doc, Lucene lucene, Date value) { + if (value == null) { + return; + } + doc.add(new LongField(lucene.name(), value.getTime(), Store.YES)); + } + + private void toDocField(Document doc, Lucene lucene, long value) { + doc.add(new LongField(lucene.name(), value, Store.YES)); + } + + private void toDocField(Document doc, Lucene lucene, int value) { + doc.add(new IntField(lucene.name(), value, Store.YES)); + } + + private void toDocField(Document doc, Lucene lucene, String value) { + if (StringUtils.isEmpty(value)) { + return; + } + doc.add(new org.apache.lucene.document.Field(lucene.name(), value, TextField.TYPE_STORED)); + } + + /** + * Creates a query result from the Lucene document. This result is + * not a high-fidelity representation of the real ticket, but it is + * suitable for display in a table of search results. + * + * @param doc + * @return a query result + * @throws ParseException + */ + private QueryResult docToQueryResult(Document doc) throws ParseException { + QueryResult result = new QueryResult(); + result.project = unpackString(doc, Lucene.project); + result.repository = unpackString(doc, Lucene.repository); + result.number = unpackLong(doc, Lucene.number); + result.createdBy = unpackString(doc, Lucene.createdby); + result.createdAt = unpackDate(doc, Lucene.created); + result.updatedBy = unpackString(doc, Lucene.updatedby); + result.updatedAt = unpackDate(doc, Lucene.updated); + result.title = unpackString(doc, Lucene.title); + result.body = unpackString(doc, Lucene.body); + result.status = Status.fromObject(unpackString(doc, Lucene.status), Status.New); + result.responsible = unpackString(doc, Lucene.responsible); + result.milestone = unpackString(doc, Lucene.milestone); + result.topic = unpackString(doc, Lucene.topic); + result.type = TicketModel.Type.fromObject(unpackString(doc, Lucene.type), TicketModel.Type.defaultType); + result.mergeSha = unpackString(doc, Lucene.mergesha); + result.mergeTo = unpackString(doc, Lucene.mergeto); + result.commentsCount = unpackInt(doc, Lucene.comments); + result.votesCount = unpackInt(doc, Lucene.votes); + result.attachments = unpackStrings(doc, Lucene.attachments); + result.labels = unpackStrings(doc, Lucene.labels); + result.participants = unpackStrings(doc, Lucene.participants); + result.watchedby = unpackStrings(doc, Lucene.watchedby); + result.mentions = unpackStrings(doc, Lucene.mentions); + + if (!StringUtils.isEmpty(doc.get(Lucene.patchset.name()))) { + // unpack most recent patchset + String [] values = doc.get(Lucene.patchset.name()).split(":", 5); + + Patchset patchset = new Patchset(); + patchset.number = Integer.parseInt(values[0]); + patchset.rev = Integer.parseInt(values[1]); + patchset.tip = values[2]; + patchset.base = values[3]; + patchset.commits = Integer.parseInt(values[4]); + + result.patchset = patchset; + } + + return result; + } + + private String unpackString(Document doc, Lucene lucene) { + return doc.get(lucene.name()); + } + + private List unpackStrings(Document doc, Lucene lucene) { + if (!StringUtils.isEmpty(doc.get(lucene.name()))) { + return StringUtils.getStringsFromValue(doc.get(lucene.name()), ";"); + } + return null; + } + + private Date unpackDate(Document doc, Lucene lucene) { + String val = doc.get(lucene.name()); + if (!StringUtils.isEmpty(val)) { + long time = Long.parseLong(val); + Date date = new Date(time); + return date; + } + return null; + } + + private long unpackLong(Document doc, Lucene lucene) { + String val = doc.get(lucene.name()); + if (StringUtils.isEmpty(val)) { + return 0; + } + long l = Long.parseLong(val); + return l; + } + + private int unpackInt(Document doc, Lucene lucene) { + String val = doc.get(lucene.name()); + if (StringUtils.isEmpty(val)) { + return 0; + } + int i = Integer.parseInt(val); + return i; + } +} \ No newline at end of file diff --git a/src/main/java/com/gitblit/tickets/TicketLabel.java b/src/main/java/com/gitblit/tickets/TicketLabel.java new file mode 100644 index 00000000..686ce88b --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketLabel.java @@ -0,0 +1,77 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.io.Serializable; +import java.util.List; + +import com.gitblit.utils.StringUtils; + +/** + * A ticket label. + * + * @author James Moger + * + */ +public class TicketLabel implements Serializable { + + private static final long serialVersionUID = 1L; + + public final String name; + + public String color; + + public List tickets; + + + public TicketLabel(String name) { + this.name = name; + this.color = StringUtils.getColor(name); + } + + public int getTotalTickets() { + return tickets == null ? 0 : tickets.size(); + } + + public int getOpenTickets() { + int cnt = 0; + if (tickets != null) { + for (QueryResult ticket : tickets) { + if (!ticket.status.isClosed()) { + cnt++; + } + } + } + return cnt; + } + + public int getClosedTickets() { + int cnt = 0; + if (tickets != null) { + for (QueryResult ticket : tickets) { + if (ticket.status.isClosed()) { + cnt++; + } + } + } + return cnt; + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/com/gitblit/tickets/TicketMilestone.java b/src/main/java/com/gitblit/tickets/TicketMilestone.java new file mode 100644 index 00000000..c6b4fcca --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketMilestone.java @@ -0,0 +1,53 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.util.Date; + +import com.gitblit.models.TicketModel.Status; + +/** + * A ticket milestone. + * + * @author James Moger + * + */ +public class TicketMilestone extends TicketLabel { + + private static final long serialVersionUID = 1L; + + public Status status; + + public Date due; + + public TicketMilestone(String name) { + super(name); + status = Status.Open; + } + + public int getProgress() { + int total = getTotalTickets(); + if (total == 0) { + return 0; + } + return (int) (((getClosedTickets() * 1f) / (total * 1f)) * 100); + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/com/gitblit/tickets/TicketNotifier.java b/src/main/java/com/gitblit/tickets/TicketNotifier.java new file mode 100644 index 00000000..b4c3baeb --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketNotifier.java @@ -0,0 +1,617 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.io.IOException; +import java.io.InputStream; +import java.text.DateFormat; +import java.text.MessageFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.io.IOUtils; +import org.apache.log4j.Logger; +import org.eclipse.jgit.diff.DiffEntry.ChangeType; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.slf4j.LoggerFactory; + +import com.gitblit.Constants; +import com.gitblit.IStoredSettings; +import com.gitblit.Keys; +import com.gitblit.git.PatchsetCommand; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.Mailing; +import com.gitblit.models.PathModel.PathChangeModel; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.Review; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.UserModel; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.DiffUtils; +import com.gitblit.utils.DiffUtils.DiffStat; +import com.gitblit.utils.JGitUtils; +import com.gitblit.utils.MarkdownUtils; +import com.gitblit.utils.StringUtils; + +/** + * Formats and queues ticket/patch notifications for dispatch to the + * mail executor upon completion of a push or a ticket update. Messages are + * created as Markdown and then transformed to html. + * + * @author James Moger + * + */ +public class TicketNotifier { + + protected final Map queue = new TreeMap(); + + private final String SOFT_BRK = "\n"; + + private final String HARD_BRK = "\n\n"; + + private final String HR = "----\n\n"; + + private final IStoredSettings settings; + + private final INotificationManager notificationManager; + + private final IUserManager userManager; + + private final IRepositoryManager repositoryManager; + + private final ITicketService ticketService; + + private final String addPattern = "+{0}"; + private final String delPattern = "-{0}"; + + public TicketNotifier( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager, + ITicketService ticketService) { + + this.settings = runtimeManager.getSettings(); + this.notificationManager = notificationManager; + this.userManager = userManager; + this.repositoryManager = repositoryManager; + this.ticketService = ticketService; + } + + public void sendAll() { + for (Mailing mail : queue.values()) { + notificationManager.send(mail); + } + } + + public void sendMailing(TicketModel ticket) { + queueMailing(ticket); + sendAll(); + } + + /** + * Queues an update notification. + * + * @param ticket + * @return a notification object used for testing + */ + public Mailing queueMailing(TicketModel ticket) { + try { + // format notification message + String markdown = formatLastChange(ticket); + + StringBuilder html = new StringBuilder(); + html.append(""); + html.append(readStyle()); + html.append(""); + html.append(""); + html.append(MarkdownUtils.transformGFM(settings, markdown, ticket.repository)); + html.append(""); + + Mailing mailing = Mailing.newHtml(); + mailing.from = getUserModel(ticket.updatedBy == null ? ticket.createdBy : ticket.updatedBy).getDisplayName(); + mailing.subject = getSubject(ticket); + mailing.content = html.toString(); + mailing.id = "ticket." + ticket.number + "." + StringUtils.getSHA1(ticket.repository + ticket.number); + + setRecipients(ticket, mailing); + queue.put(ticket.number, mailing); + + return mailing; + } catch (Exception e) { + Logger.getLogger(getClass()).error("failed to queue mailing for #" + ticket.number, e); + } + return null; + } + + protected String getSubject(TicketModel ticket) { + Change lastChange = ticket.changes.get(ticket.changes.size() - 1); + boolean newTicket = lastChange.isStatusChange() && ticket.changes.size() == 1; + String re = newTicket ? "" : "Re: "; + String subject = MessageFormat.format("{0}[{1}] {2} (#{3,number,0})", + re, StringUtils.stripDotGit(ticket.repository), ticket.title, ticket.number); + return subject; + } + + protected String formatLastChange(TicketModel ticket) { + Change lastChange = ticket.changes.get(ticket.changes.size() - 1); + UserModel user = getUserModel(lastChange.author); + + // define the fields we do NOT want to see in an email notification + Set fieldExclusions = new HashSet(); + fieldExclusions.addAll(Arrays.asList(Field.watchers, Field.voters)); + + StringBuilder sb = new StringBuilder(); + boolean newTicket = false; + boolean isFastForward = true; + List commits = null; + DiffStat diffstat = null; + + String pattern; + if (lastChange.isStatusChange()) { + Status state = lastChange.getStatus(); + switch (state) { + case New: + // new ticket + newTicket = true; + fieldExclusions.add(Field.status); + fieldExclusions.add(Field.title); + fieldExclusions.add(Field.body); + if (lastChange.hasPatchset()) { + pattern = "**{0}** is proposing a change."; + } else { + pattern = "**{0}** created this ticket."; + } + sb.append(MessageFormat.format(pattern, user.getDisplayName())); + break; + default: + // some form of resolved + if (lastChange.hasField(Field.mergeSha)) { + // closed by push (merged patchset) + pattern = "**{0}** closed this ticket by pushing {1} to {2}."; + + // identify patch that closed the ticket + String merged = ticket.mergeSha; + for (Patchset patchset : ticket.getPatchsets()) { + if (patchset.tip.equals(ticket.mergeSha)) { + merged = patchset.toString(); + break; + } + } + sb.append(MessageFormat.format(pattern, user.getDisplayName(), merged, ticket.mergeTo)); + } else { + // workflow status change by user + pattern = "**{0}** changed the status of this ticket to **{1}**."; + sb.append(MessageFormat.format(pattern, user.getDisplayName(), lastChange.getStatus().toString().toUpperCase())); + } + break; + } + sb.append(HARD_BRK); + } else if (lastChange.hasPatchset()) { + // patchset uploaded + Patchset patchset = lastChange.patchset; + String base = ""; + // determine the changed paths + Repository repo = null; + try { + repo = repositoryManager.getRepository(ticket.repository); + if (patchset.isFF() && (patchset.rev > 1)) { + // fast-forward update, just show the new data + isFastForward = true; + Patchset prev = ticket.getPatchset(patchset.number, patchset.rev - 1); + base = prev.tip; + } else { + // proposal OR non-fast-forward update + isFastForward = false; + base = patchset.base; + } + + diffstat = DiffUtils.getDiffStat(repo, base, patchset.tip); + commits = JGitUtils.getRevLog(repo, base, patchset.tip); + } catch (Exception e) { + Logger.getLogger(getClass()).error("failed to get changed paths", e); + } finally { + repo.close(); + } + + // describe the patchset + String compareUrl = ticketService.getCompareUrl(ticket, base, patchset.tip); + if (patchset.isFF()) { + pattern = "**{0}** added {1} {2} to patchset {3}."; + sb.append(MessageFormat.format(pattern, user.getDisplayName(), patchset.added, patchset.added == 1 ? "commit" : "commits", patchset.number)); + } else { + pattern = "**{0}** uploaded patchset {1}. *({2})*"; + sb.append(MessageFormat.format(pattern, user.getDisplayName(), patchset.number, patchset.type.toString().toUpperCase())); + } + sb.append(HARD_BRK); + sb.append(MessageFormat.format("{0} {1}, {2} {3}, +{4} insertions, -{5} deletions from {6}. [compare]({7})", + commits.size(), commits.size() == 1 ? "commit" : "commits", + diffstat.paths.size(), + diffstat.paths.size() == 1 ? "file" : "files", + diffstat.getInsertions(), + diffstat.getDeletions(), + isFastForward ? "previous revision" : "merge base", + compareUrl)); + + // note commit additions on a rebase,if any + switch (lastChange.patchset.type) { + case Rebase: + if (lastChange.patchset.added > 0) { + sb.append(SOFT_BRK); + sb.append(MessageFormat.format("{0} {1} added.", lastChange.patchset.added, lastChange.patchset.added == 1 ? "commit" : "commits")); + } + break; + default: + break; + } + sb.append(HARD_BRK); + } else if (lastChange.hasReview()) { + // review + Review review = lastChange.review; + pattern = "**{0}** has reviewed patchset {1,number,0} revision {2,number,0}."; + sb.append(MessageFormat.format(pattern, user.getDisplayName(), review.patchset, review.rev)); + sb.append(HARD_BRK); + + String d = settings.getString(Keys.web.datestampShortFormat, "yyyy-MM-dd"); + String t = settings.getString(Keys.web.timeFormat, "HH:mm"); + DateFormat df = new SimpleDateFormat(d + " " + t); + List reviews = ticket.getReviews(ticket.getPatchset(review.patchset, review.rev)); + sb.append("| Date | Reviewer | Score | Description |\n"); + sb.append("| :--- | :------------ | :---: | :----------- |\n"); + for (Change change : reviews) { + String name = change.author; + UserModel u = userManager.getUserModel(change.author); + if (u != null) { + name = u.getDisplayName(); + } + String score; + switch (change.review.score) { + case approved: + score = MessageFormat.format(addPattern, change.review.score.getValue()); + break; + case vetoed: + score = MessageFormat.format(delPattern, Math.abs(change.review.score.getValue())); + break; + default: + score = "" + change.review.score.getValue(); + } + String date = df.format(change.date); + sb.append(String.format("| %1$s | %2$s | %3$s | %4$s |\n", + date, name, score, change.review.score.toString())); + } + sb.append(HARD_BRK); + } else if (lastChange.hasComment()) { + // comment update + sb.append(MessageFormat.format("**{0}** commented on this ticket.", user.getDisplayName())); + sb.append(HARD_BRK); + } else { + // general update + pattern = "**{0}** has updated this ticket."; + sb.append(MessageFormat.format(pattern, user.getDisplayName())); + sb.append(HARD_BRK); + } + + // ticket link + sb.append(MessageFormat.format("[view ticket {0,number,0}]({1})", + ticket.number, ticketService.getTicketUrl(ticket))); + sb.append(HARD_BRK); + + if (newTicket) { + // ticket title + sb.append(MessageFormat.format("### {0}", ticket.title)); + sb.append(HARD_BRK); + + // ticket description, on state change + if (StringUtils.isEmpty(ticket.body)) { + sb.append("no description entered"); + } else { + sb.append(ticket.body); + } + sb.append(HARD_BRK); + sb.append(HR); + } + + // field changes + if (lastChange.hasFieldChanges()) { + Map filtered = new HashMap(); + for (Map.Entry fc : lastChange.fields.entrySet()) { + if (!fieldExclusions.contains(fc.getKey())) { + // field is included + filtered.put(fc.getKey(), fc.getValue()); + } + } + + // sort by field ordinal + List fields = new ArrayList(filtered.keySet()); + Collections.sort(fields); + + if (filtered.size() > 0) { + sb.append(HARD_BRK); + sb.append("| Field Changes ||\n"); + sb.append("| ------------: | :----------- |\n"); + for (Field field : fields) { + String value; + if (filtered.get(field) == null) { + value = ""; + } else { + value = filtered.get(field).replace("\r\n", "
").replace("\n", "
").replace("|", "|"); + } + sb.append(String.format("| **%1$s:** | %2$s |\n", field.name(), value)); + } + sb.append(HARD_BRK); + } + } + + // new comment + if (lastChange.hasComment()) { + sb.append(HR); + sb.append(lastChange.comment.text); + sb.append(HARD_BRK); + } + + // insert the patchset details and review instructions + if (lastChange.hasPatchset() && ticket.isOpen()) { + if (commits != null && commits.size() > 0) { + // append the commit list + String title = isFastForward ? "Commits added to previous patchset revision" : "All commits in patchset"; + sb.append(MessageFormat.format("| {0} |||\n", title)); + sb.append("| SHA | Author | Title |\n"); + sb.append("| :-- | :----- | :---- |\n"); + for (RevCommit commit : commits) { + sb.append(MessageFormat.format("| {0} | {1} | {2} |\n", + commit.getName(), commit.getAuthorIdent().getName(), + StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG).replace("|", "|"))); + } + sb.append(HARD_BRK); + } + + if (diffstat != null) { + // append the changed path list + String title = isFastForward ? "Files changed since previous patchset revision" : "All files changed in patchset"; + sb.append(MessageFormat.format("| {0} |||\n", title)); + sb.append("| :-- | :----------- | :-: |\n"); + for (PathChangeModel path : diffstat.paths) { + String add = MessageFormat.format(addPattern, path.insertions); + String del = MessageFormat.format(delPattern, path.deletions); + String diff = null; + switch (path.changeType) { + case ADD: + diff = add; + break; + case DELETE: + diff = del; + break; + case MODIFY: + if (path.insertions > 0 && path.deletions > 0) { + // insertions & deletions + diff = add + "/" + del; + } else if (path.insertions > 0) { + // just insertions + diff = add; + } else { + // just deletions + diff = del; + } + break; + default: + diff = path.changeType.name(); + break; + } + sb.append(MessageFormat.format("| {0} | {1} | {2} |\n", + getChangeType(path.changeType), path.name, diff)); + } + sb.append(HARD_BRK); + } + + sb.append(formatPatchsetInstructions(ticket, lastChange.patchset)); + } + + return sb.toString(); + } + + protected String getChangeType(ChangeType type) { + String style = null; + switch (type) { + case ADD: + style = "color:darkgreen;"; + break; + case COPY: + style = ""; + break; + case DELETE: + style = "color:darkred;"; + break; + case MODIFY: + style = ""; + break; + case RENAME: + style = ""; + break; + default: + break; + } + String code = type.name().toUpperCase().substring(0, 1); + if (style == null) { + return code; + } else { + return MessageFormat.format("{1}", style, code); + } + } + + /** + * Generates patchset review instructions for command-line git + * + * @param patchset + * @return instructions + */ + protected String formatPatchsetInstructions(TicketModel ticket, Patchset patchset) { + String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443"); + String repositoryUrl = canonicalUrl + Constants.R_PATH + ticket.repository; + + String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number)); + String patchsetBranch = PatchsetCommand.getPatchsetBranch(ticket.number, patchset.number); + String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number); + + String instructions = readResource("commands.md"); + instructions = instructions.replace("${ticketId}", "" + ticket.number); + instructions = instructions.replace("${patchset}", "" + patchset.number); + instructions = instructions.replace("${repositoryUrl}", repositoryUrl); + instructions = instructions.replace("${ticketRef}", ticketBranch); + instructions = instructions.replace("${patchsetRef}", patchsetBranch); + instructions = instructions.replace("${reviewBranch}", reviewBranch); + + return instructions; + } + + /** + * Gets the usermodel for the username. Creates a temp model, if required. + * + * @param username + * @return a usermodel + */ + protected UserModel getUserModel(String username) { + UserModel user = userManager.getUserModel(username); + if (user == null) { + // create a temporary user model (for unit tests) + user = new UserModel(username); + } + return user; + } + + /** + * Set the proper recipients for a ticket. + * + * @param ticket + * @param mailing + */ + protected void setRecipients(TicketModel ticket, Mailing mailing) { + RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository); + + // + // Direct TO recipients + // + Set toAddresses = new TreeSet(); + for (String name : ticket.getParticipants()) { + UserModel user = userManager.getUserModel(name); + if (user != null) { + if (!StringUtils.isEmpty(user.emailAddress)) { + if (user.canView(repository)) { + toAddresses.add(user.emailAddress); + } else { + LoggerFactory.getLogger(getClass()).warn( + MessageFormat.format("ticket {0}-{1,number,0}: {2} can not receive notification", + repository.name, ticket.number, user.username)); + } + } + } + } + mailing.setRecipients(toAddresses); + + // + // CC recipients + // + Set ccs = new TreeSet(); + + // cc users mentioned in last comment + Change lastChange = ticket.changes.get(ticket.changes.size() - 1); + if (lastChange.hasComment()) { + Pattern p = Pattern.compile("\\s@([A-Za-z0-9-_]+)"); + Matcher m = p.matcher(lastChange.comment.text); + while (m.find()) { + String username = m.group(); + ccs.add(username); + } + } + + // cc users who are watching the ticket + ccs.addAll(ticket.getWatchers()); + + // TODO cc users who are watching the repository + + Set ccAddresses = new TreeSet(); + for (String name : ccs) { + UserModel user = userManager.getUserModel(name); + if (user != null) { + if (!StringUtils.isEmpty(user.emailAddress)) { + if (user.canView(repository)) { + ccAddresses.add(user.emailAddress); + } else { + LoggerFactory.getLogger(getClass()).warn( + MessageFormat.format("ticket {0}-{1,number,0}: {2} can not receive notification", + repository.name, ticket.number, user.username)); + } + } + } + } + + // cc repository mailing list addresses + if (!ArrayUtils.isEmpty(repository.mailingLists)) { + ccAddresses.addAll(repository.mailingLists); + } + ccAddresses.addAll(settings.getStrings(Keys.mail.mailingLists)); + + mailing.setCCs(ccAddresses); + } + + protected String readStyle() { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + return sb.toString(); + } + + protected String readResource(String resource) { + StringBuilder sb = new StringBuilder(); + InputStream is = null; + try { + is = getClass().getResourceAsStream(resource); + List lines = IOUtils.readLines(is); + for (String line : lines) { + sb.append(line).append('\n'); + } + } catch (IOException e) { + + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + } + } + } + return sb.toString(); + } +} diff --git a/src/main/java/com/gitblit/tickets/TicketResponsible.java b/src/main/java/com/gitblit/tickets/TicketResponsible.java new file mode 100644 index 00000000..12621c6c --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketResponsible.java @@ -0,0 +1,59 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.io.Serializable; + +import org.parboiled.common.StringUtils; + +import com.gitblit.models.UserModel; + +/** + * A ticket responsible. + * + * @author James Moger + * + */ +public class TicketResponsible implements Serializable, Comparable { + + private static final long serialVersionUID = 1L; + + public final String displayname; + + public final String username; + + public final String email; + + public TicketResponsible(UserModel user) { + this(user.getDisplayName(), user.username, user.emailAddress); + } + + public TicketResponsible(String displayname, String username, String email) { + this.displayname = displayname; + this.username = username; + this.email = email; + } + + @Override + public String toString() { + return displayname + (StringUtils.isEmpty(username) ? "" : (" (" + username + ")")); + } + + @Override + public int compareTo(TicketResponsible o) { + return toString().compareTo(o.toString()); + } +} diff --git a/src/main/java/com/gitblit/tickets/TicketSerializer.java b/src/main/java/com/gitblit/tickets/TicketSerializer.java new file mode 100644 index 00000000..2a71af33 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketSerializer.java @@ -0,0 +1,175 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.tickets; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; + +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Score; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.JsonUtils.ExcludeField; +import com.gitblit.utils.JsonUtils.GmtDateTypeAdapter; +import com.google.gson.ExclusionStrategy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; + +/** + * Serializes and deserializes tickets, change, and journals. + * + * @author James Moger + * + */ +public class TicketSerializer { + + protected static final Type JOURNAL_TYPE = new TypeToken>() {}.getType(); + + public static List deserializeJournal(String json) { + Collection list = gson().fromJson(json, JOURNAL_TYPE); + return new ArrayList(list); + } + + public static TicketModel deserializeTicket(String json) { + return gson().fromJson(json, TicketModel.class); + } + + public static TicketLabel deserializeLabel(String json) { + return gson().fromJson(json, TicketLabel.class); + } + + public static TicketMilestone deserializeMilestone(String json) { + return gson().fromJson(json, TicketMilestone.class); + } + + + public static String serializeJournal(List changes) { + try { + Gson gson = gson(); + return gson.toJson(changes); + } catch (Exception e) { + // won't happen + } + return null; + } + + public static String serialize(TicketModel ticket) { + if (ticket == null) { + return null; + } + try { + Gson gson = gson( + new ExcludeField("com.gitblit.models.TicketModel$Attachment.content"), + new ExcludeField("com.gitblit.models.TicketModel$Attachment.deleted"), + new ExcludeField("com.gitblit.models.TicketModel$Comment.deleted")); + return gson.toJson(ticket); + } catch (Exception e) { + // won't happen + } + return null; + } + + public static String serialize(Change change) { + if (change == null) { + return null; + } + try { + Gson gson = gson( + new ExcludeField("com.gitblit.models.TicketModel$Attachment.content")); + return gson.toJson(change); + } catch (Exception e) { + // won't happen + } + return null; + } + + public static String serialize(TicketLabel label) { + if (label == null) { + return null; + } + try { + Gson gson = gson(); + return gson.toJson(label); + } catch (Exception e) { + // won't happen + } + return null; + } + + public static String serialize(TicketMilestone milestone) { + if (milestone == null) { + return null; + } + try { + Gson gson = gson(); + return gson.toJson(milestone); + } catch (Exception e) { + // won't happen + } + return null; + } + + // build custom gson instance with GMT date serializer/deserializer + // http://code.google.com/p/google-gson/issues/detail?id=281 + public static Gson gson(ExclusionStrategy... strategies) { + GsonBuilder builder = new GsonBuilder(); + builder.registerTypeAdapter(Date.class, new GmtDateTypeAdapter()); + builder.registerTypeAdapter(Score.class, new ScoreTypeAdapter()); + if (!ArrayUtils.isEmpty(strategies)) { + builder.setExclusionStrategies(strategies); + } + return builder.create(); + } + + private static class ScoreTypeAdapter implements JsonSerializer, JsonDeserializer { + + private ScoreTypeAdapter() { + } + + @Override + public synchronized JsonElement serialize(Score score, Type type, + JsonSerializationContext jsonSerializationContext) { + return new JsonPrimitive(score.getValue()); + } + + @Override + public synchronized Score deserialize(JsonElement jsonElement, Type type, + JsonDeserializationContext jsonDeserializationContext) { + try { + int value = jsonElement.getAsInt(); + for (Score score : Score.values()) { + if (score.getValue() == value) { + return score; + } + } + return Score.not_reviewed; + } catch (Exception e) { + throw new JsonSyntaxException(jsonElement.getAsString(), e); + } + } + } +} diff --git a/src/main/java/com/gitblit/tickets/commands.md b/src/main/java/com/gitblit/tickets/commands.md new file mode 100644 index 00000000..25c24f4f --- /dev/null +++ b/src/main/java/com/gitblit/tickets/commands.md @@ -0,0 +1,11 @@ +#### To review with Git + +on a detached HEAD... + + git fetch ${repositoryUrl} ${ticketRef} && git checkout FETCH_HEAD + +on a new branch... + + git fetch ${repositoryUrl} ${ticketRef} && git checkout -B ${reviewBranch} FETCH_HEAD + + diff --git a/src/main/java/com/gitblit/tickets/email.css b/src/main/java/com/gitblit/tickets/email.css new file mode 100644 index 00000000..3b815420 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/email.css @@ -0,0 +1,38 @@ +table { + border:1px solid #ddd; + margin: 15px 0px; +} + +th { + font-weight: bold; + border-bottom: 1px solid #ddd; +} + +td, th { + padding: 4px 8px; + vertical-align: top; +} + +a { + color: #2F58A0; +} + +a:hover { + color: #002060; +} + +body { + color: black; +} + +pre { + background-color: rgb(250, 250, 250); + border: 1px solid rgb(221, 221, 221); + border-radius: 4px 4px 4px 4px; + display: block; + font-size: 12px; + line-height: 18px; + margin: 9px 0; + padding: 8.5px; + white-space: pre-wrap; +} diff --git a/src/main/java/com/gitblit/utils/JGitUtils.java b/src/main/java/com/gitblit/utils/JGitUtils.java index 6a6085e7..6f3b0856 100644 --- a/src/main/java/com/gitblit/utils/JGitUtils.java +++ b/src/main/java/com/gitblit/utils/JGitUtils.java @@ -59,6 +59,8 @@ import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.RepositoryCache.FileKey; import org.eclipse.jgit.lib.StoredConfig; import org.eclipse.jgit.lib.TreeFormatter; +import org.eclipse.jgit.merge.MergeStrategy; +import org.eclipse.jgit.merge.RecursiveMerger; import org.eclipse.jgit.revwalk.RevBlob; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevObject; @@ -82,6 +84,7 @@ import org.eclipse.jgit.util.FS; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.gitblit.GitBlitException; import com.gitblit.models.GitNote; import com.gitblit.models.PathModel; import com.gitblit.models.PathModel.PathChangeModel; @@ -2145,4 +2148,208 @@ public class JGitUtils { } return false; } + + /** + * Returns true if the commit identified by commitId is an ancestor or the + * the commit identified by tipId. + * + * @param repository + * @param commitId + * @param tipId + * @return true if there is the commit is an ancestor of the tip + */ + public static boolean isMergedInto(Repository repository, String commitId, String tipId) { + try { + return isMergedInto(repository, repository.resolve(commitId), repository.resolve(tipId)); + } catch (Exception e) { + LOGGER.error("Failed to determine isMergedInto", e); + } + return false; + } + + /** + * Returns true if the commit identified by commitId is an ancestor or the + * the commit identified by tipId. + * + * @param repository + * @param commitId + * @param tipId + * @return true if there is the commit is an ancestor of the tip + */ + public static boolean isMergedInto(Repository repository, ObjectId commitId, ObjectId tipCommitId) { + // traverse the revlog looking for a commit chain between the endpoints + RevWalk rw = new RevWalk(repository); + try { + // must re-lookup RevCommits to workaround undocumented RevWalk bug + RevCommit tip = rw.lookupCommit(tipCommitId); + RevCommit commit = rw.lookupCommit(commitId); + return rw.isMergedInto(commit, tip); + } catch (Exception e) { + LOGGER.error("Failed to determine isMergedInto", e); + } finally { + rw.dispose(); + } + return false; + } + + /** + * Returns the merge base of two commits or null if there is no common + * ancestry. + * + * @param repository + * @param commitIdA + * @param commitIdB + * @return the commit id of the merge base or null if there is no common base + */ + public static String getMergeBase(Repository repository, ObjectId commitIdA, ObjectId commitIdB) { + RevWalk rw = new RevWalk(repository); + try { + RevCommit a = rw.lookupCommit(commitIdA); + RevCommit b = rw.lookupCommit(commitIdB); + + rw.setRevFilter(RevFilter.MERGE_BASE); + rw.markStart(a); + rw.markStart(b); + RevCommit mergeBase = rw.next(); + if (mergeBase == null) { + return null; + } + return mergeBase.getName(); + } catch (Exception e) { + LOGGER.error("Failed to determine merge base", e); + } finally { + rw.dispose(); + } + return null; + } + + public static enum MergeStatus { + NOT_MERGEABLE, FAILED, ALREADY_MERGED, MERGEABLE, MERGED; + } + + /** + * Determines if we can cleanly merge one branch into another. Returns true + * if we can merge without conflict, otherwise returns false. + * + * @param repository + * @param src + * @param toBranch + * @return true if we can merge without conflict + */ + public static MergeStatus canMerge(Repository repository, String src, String toBranch) { + RevWalk revWalk = null; + try { + revWalk = new RevWalk(repository); + RevCommit branchTip = revWalk.lookupCommit(repository.resolve(toBranch)); + RevCommit srcTip = revWalk.lookupCommit(repository.resolve(src)); + if (revWalk.isMergedInto(srcTip, branchTip)) { + // already merged + return MergeStatus.ALREADY_MERGED; + } else if (revWalk.isMergedInto(branchTip, srcTip)) { + // fast-forward + return MergeStatus.MERGEABLE; + } + RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true); + boolean canMerge = merger.merge(branchTip, srcTip); + if (canMerge) { + return MergeStatus.MERGEABLE; + } + } catch (IOException e) { + LOGGER.error("Failed to determine canMerge", e); + } finally { + revWalk.release(); + } + return MergeStatus.NOT_MERGEABLE; + } + + + public static class MergeResult { + public final MergeStatus status; + public final String sha; + + MergeResult(MergeStatus status, String sha) { + this.status = status; + this.sha = sha; + } + } + + /** + * Tries to merge a commit into a branch. If there are conflicts, the merge + * will fail. + * + * @param repository + * @param src + * @param toBranch + * @param committer + * @param message + * @return the merge result + */ + public static MergeResult merge(Repository repository, String src, String toBranch, + PersonIdent committer, String message) { + + if (!toBranch.startsWith(Constants.R_REFS)) { + // branch ref doesn't start with ref, assume this is a branch head + toBranch = Constants.R_HEADS + toBranch; + } + + RevWalk revWalk = null; + try { + revWalk = new RevWalk(repository); + RevCommit branchTip = revWalk.lookupCommit(repository.resolve(toBranch)); + RevCommit srcTip = revWalk.lookupCommit(repository.resolve(src)); + if (revWalk.isMergedInto(srcTip, branchTip)) { + // already merged + return new MergeResult(MergeStatus.ALREADY_MERGED, null); + } + RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true); + boolean merged = merger.merge(branchTip, srcTip); + if (merged) { + // create a merge commit and a reference to track the merge commit + ObjectId treeId = merger.getResultTreeId(); + ObjectInserter odi = repository.newObjectInserter(); + try { + // Create a commit object + CommitBuilder commitBuilder = new CommitBuilder(); + commitBuilder.setCommitter(committer); + commitBuilder.setAuthor(committer); + commitBuilder.setEncoding(Constants.CHARSET); + if (StringUtils.isEmpty(message)) { + message = MessageFormat.format("merge {0} into {1}", srcTip.getName(), branchTip.getName()); + } + commitBuilder.setMessage(message); + commitBuilder.setParentIds(branchTip.getId(), srcTip.getId()); + commitBuilder.setTreeId(treeId); + + // Insert the merge commit into the repository + ObjectId mergeCommitId = odi.insert(commitBuilder); + odi.flush(); + + // set the merge ref to the merge commit + RevCommit mergeCommit = revWalk.parseCommit(mergeCommitId); + RefUpdate mergeRefUpdate = repository.updateRef(toBranch); + mergeRefUpdate.setNewObjectId(mergeCommitId); + mergeRefUpdate.setRefLogMessage("commit: " + mergeCommit.getShortMessage(), false); + RefUpdate.Result rc = mergeRefUpdate.forceUpdate(); + switch (rc) { + case FAST_FORWARD: + // successful, clean merge + break; + default: + throw new GitBlitException(MessageFormat.format("Unexpected result \"{0}\" when merging commit {1} into {2} in {3}", + rc.name(), srcTip.getName(), branchTip.getName(), repository.getDirectory())); + } + + // return the merge commit id + return new MergeResult(MergeStatus.MERGED, mergeCommitId.getName()); + } finally { + odi.release(); + } + } + } catch (IOException e) { + LOGGER.error("Failed to merge", e); + } finally { + revWalk.release(); + } + return new MergeResult(MergeStatus.FAILED, null); + } } diff --git a/src/main/java/com/gitblit/utils/JsonUtils.java b/src/main/java/com/gitblit/utils/JsonUtils.java index fdf68e52..be7148cb 100644 --- a/src/main/java/com/gitblit/utils/JsonUtils.java +++ b/src/main/java/com/gitblit/utils/JsonUtils.java @@ -274,10 +274,10 @@ public class JsonUtils { return builder.create(); } - private static class GmtDateTypeAdapter implements JsonSerializer, JsonDeserializer { + public static class GmtDateTypeAdapter implements JsonSerializer, JsonDeserializer { private final DateFormat dateFormat; - private GmtDateTypeAdapter() { + public GmtDateTypeAdapter() { dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US); dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); } diff --git a/src/main/java/com/gitblit/utils/MarkdownUtils.java b/src/main/java/com/gitblit/utils/MarkdownUtils.java index 2ce6f566..dcd79f16 100644 --- a/src/main/java/com/gitblit/utils/MarkdownUtils.java +++ b/src/main/java/com/gitblit/utils/MarkdownUtils.java @@ -132,6 +132,10 @@ public class MarkdownUtils { String mentionReplacement = String.format(" **@$1**", canonicalUrl); text = text.replaceAll("\\s@([A-Za-z0-9-_]+)", mentionReplacement); + // link ticket refs + String ticketReplacement = MessageFormat.format("$1[#$2]({0}/tickets?r={1}&h=$2)$3", canonicalUrl, repositoryName); + text = text.replaceAll("([\\s,]+)#(\\d+)([\\s,:\\.\\n])", ticketReplacement); + // link commit shas int shaLen = settings.getInteger(Keys.web.shortCommitIdLength, 6); String commitPattern = MessageFormat.format("\\s([A-Fa-f0-9]'{'{0}'}')([A-Fa-f0-9]'{'{1}'}')", shaLen, 40 - shaLen); diff --git a/src/main/java/com/gitblit/utils/RefLogUtils.java b/src/main/java/com/gitblit/utils/RefLogUtils.java index d19e892a..4c082d05 100644 --- a/src/main/java/com/gitblit/utils/RefLogUtils.java +++ b/src/main/java/com/gitblit/utils/RefLogUtils.java @@ -213,6 +213,22 @@ public class RefLogUtils { */ public static boolean updateRefLog(UserModel user, Repository repository, Collection commands) { + + // only track branches and tags + List filteredCommands = new ArrayList(); + for (ReceiveCommand cmd : commands) { + if (!cmd.getRefName().startsWith(Constants.R_HEADS) + && !cmd.getRefName().startsWith(Constants.R_TAGS)) { + continue; + } + filteredCommands.add(cmd); + } + + if (filteredCommands.isEmpty()) { + // nothing to log + return true; + } + RefModel reflogBranch = getRefLogBranch(repository); if (reflogBranch == null) { JGitUtils.createOrphanBranch(repository, GB_REFLOG, null); @@ -443,7 +459,15 @@ public class RefLogUtils { Date date = push.getAuthorIdent().getWhen(); RefLogEntry log = new RefLogEntry(repositoryName, date, user); - List changedRefs = JGitUtils.getFilesInCommit(repository, push); + + // only report HEADS and TAGS for now + List changedRefs = new ArrayList(); + for (PathChangeModel refChange : JGitUtils.getFilesInCommit(repository, push)) { + if (refChange.path.startsWith(Constants.R_HEADS) + || refChange.path.startsWith(Constants.R_TAGS)) { + changedRefs.add(refChange); + } + } if (changedRefs.isEmpty()) { // skip empty commits continue; @@ -466,12 +490,16 @@ public class RefLogUtils { // ref deletion continue; } - List pushedCommits = JGitUtils.getRevLog(repository, oldId, newId); - for (RevCommit pushedCommit : pushedCommits) { - RepositoryCommit repoCommit = log.addCommit(change.path, pushedCommit); - if (repoCommit != null) { - repoCommit.setRefs(allRefs.get(pushedCommit.getId())); + try { + List pushedCommits = JGitUtils.getRevLog(repository, oldId, newId); + for (RevCommit pushedCommit : pushedCommits) { + RepositoryCommit repoCommit = log.addCommit(change.path, pushedCommit); + if (repoCommit != null) { + repoCommit.setRefs(allRefs.get(pushedCommit.getId())); + } } + } catch (Exception e) { + } } } diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp.java b/src/main/java/com/gitblit/wicket/GitBlitWebApp.java index ab5ae2a2..445335ff 100644 --- a/src/main/java/com/gitblit/wicket/GitBlitWebApp.java +++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp.java @@ -38,6 +38,7 @@ import com.gitblit.manager.IProjectManager; import com.gitblit.manager.IRepositoryManager; import com.gitblit.manager.IRuntimeManager; import com.gitblit.manager.IUserManager; +import com.gitblit.tickets.ITicketService; import com.gitblit.utils.StringUtils; import com.gitblit.wicket.pages.ActivityPage; import com.gitblit.wicket.pages.BlamePage; @@ -49,6 +50,8 @@ import com.gitblit.wicket.pages.CommitPage; import com.gitblit.wicket.pages.ComparePage; import com.gitblit.wicket.pages.DocPage; import com.gitblit.wicket.pages.DocsPage; +import com.gitblit.wicket.pages.EditTicketPage; +import com.gitblit.wicket.pages.ExportTicketPage; import com.gitblit.wicket.pages.FederationRegistrationPage; import com.gitblit.wicket.pages.ForkPage; import com.gitblit.wicket.pages.ForksPage; @@ -59,6 +62,7 @@ import com.gitblit.wicket.pages.LogoutPage; import com.gitblit.wicket.pages.LuceneSearchPage; import com.gitblit.wicket.pages.MetricsPage; import com.gitblit.wicket.pages.MyDashboardPage; +import com.gitblit.wicket.pages.NewTicketPage; import com.gitblit.wicket.pages.OverviewPage; import com.gitblit.wicket.pages.PatchPage; import com.gitblit.wicket.pages.ProjectPage; @@ -70,6 +74,7 @@ import com.gitblit.wicket.pages.ReviewProposalPage; import com.gitblit.wicket.pages.SummaryPage; import com.gitblit.wicket.pages.TagPage; import com.gitblit.wicket.pages.TagsPage; +import com.gitblit.wicket.pages.TicketsPage; import com.gitblit.wicket.pages.TreePage; import com.gitblit.wicket.pages.UserPage; import com.gitblit.wicket.pages.UsersPage; @@ -168,6 +173,12 @@ public class GitBlitWebApp extends WebApplication { mount("/users", UsersPage.class); mount("/logout", LogoutPage.class); + // setup ticket urls + mount("/tickets", TicketsPage.class, "r", "h"); + mount("/tickets/new", NewTicketPage.class, "r"); + mount("/tickets/edit", EditTicketPage.class, "r", "h"); + mount("/tickets/export", ExportTicketPage.class, "r", "h"); + // setup the markup document urls mount("/docs", DocsPage.class, "r"); mount("/doc", DocPage.class, "r", "h", "f"); @@ -285,6 +296,10 @@ public class GitBlitWebApp extends WebApplication { return gitblit; } + public ITicketService tickets() { + return gitblit.getTicketService(); + } + public TimeZone getTimezone() { return runtimeManager.getTimezone(); } diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties index 8f3a6aaf..86dd585f 100644 --- a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties +++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties @@ -18,7 +18,7 @@ gb.object = object gb.ticketId = ticket id gb.ticketAssigned = assigned gb.ticketOpenDate = open date -gb.ticketState = state +gb.ticketStatus = status gb.ticketComments = comments gb.view = view gb.local = local @@ -511,4 +511,141 @@ gb.mirrorOf = mirror of {0} gb.mirrorWarning = this repository is a mirror and can not receive pushes gb.docsWelcome1 = You can use docs to document your repository. gb.docsWelcome2 = Commit a README.md or a HOME.md file to get started. -gb.createReadme = create a README \ No newline at end of file +gb.createReadme = create a README +gb.responsible = responsible +gb.createdThisTicket = created this ticket +gb.proposedThisChange = proposed this change +gb.uploadedPatchsetN = uploaded patchset {0} +gb.uploadedPatchsetNRevisionN = uploaded patchset {0} revision {1} +gb.mergedPatchset = merged patchset +gb.commented = commented +gb.noDescriptionGiven = no description given +gb.toBranch = to {0} +gb.createdBy = created by +gb.oneParticipant = {0} participant +gb.nParticipants = {0} participants +gb.noComments = no comments +gb.oneComment = {0} comment +gb.nComments = {0} comments +gb.oneAttachment = {0} attachment +gb.nAttachments = {0} attachments +gb.milestone = milestone +gb.compareToMergeBase = compare to merge base +gb.compareToN = compare to {0} +gb.open = open +gb.closed = closed +gb.merged = merged +gb.ticketPatchset = ticket {0}, patchset {1} +gb.patchsetMergeable = This patchset can be automatically merged into {0}. +gb.patchsetMergeableMore = This patchset may also be merged into {0} from the command line. +gb.patchsetAlreadyMerged = This patchset has been merged into {0}. +gb.patchsetNotMergeable = This patchset can not be automatically merged into {0}. +gb.patchsetNotMergeableMore = This patchset must be rebased or manually merged into {0} to resolve conflicts. +gb.patchsetNotApproved = This patchset revision has not been approved for merging into {0}. +gb.patchsetNotApprovedMore = A reviewer must approve this patchset. +gb.patchsetVetoedMore = A reviewer has vetoed this patchset. +gb.write = write +gb.comment = comment +gb.preview = preview +gb.leaveComment = leave a comment... +gb.showHideDetails = show/hide details +gb.acceptNewPatchsets = accept patchsets +gb.acceptNewPatchsetsDescription = accept patchsets pushed to this repository +gb.acceptNewTickets = allow new tickets +gb.acceptNewTicketsDescription = allow creation of bug, enhancement, task ,etc tickets +gb.requireApproval = require approvals +gb.requireApprovalDescription = patchsets must be approved before merge button is enabled +gb.topic = topic +gb.proposalTickets = proposed changes +gb.bugTickets = bugs +gb.enhancementTickets = enhancements +gb.taskTickets = tasks +gb.questionTickets = questions +gb.requestTickets = enhancements & tasks +gb.yourCreatedTickets = created by you +gb.yourWatchedTickets = watched by you +gb.mentionsMeTickets = mentioning you +gb.updatedBy = updated by +gb.sort = sort +gb.sortNewest = newest +gb.sortOldest = oldest +gb.sortMostRecentlyUpdated = recently updated +gb.sortLeastRecentlyUpdated = least recently updated +gb.sortMostComments = most comments +gb.sortLeastComments = least comments +gb.sortMostPatchsetRevisions = most patchset revisions +gb.sortLeastPatchsetRevisions = least patchset revisions +gb.sortMostVotes = most votes +gb.sortLeastVotes = least votes +gb.topicsAndLabels = topics & labels +gb.milestones = milestones +gb.noMilestoneSelected = no milestone selected +gb.notSpecified = not specified +gb.due = due +gb.queries = queries +gb.searchTicketsTooltip = search {0} tickets +gb.searchTickets = search tickets +gb.new = new +gb.newTicket = new ticket +gb.editTicket = edit ticket +gb.ticketsWelcome = You can use tickets to organize your todo list, discuss bugs, and to collaborate on patchsets. +gb.createFirstTicket = create your first ticket +gb.title = title +gb.changedStatus = changed the status +gb.discussion = discussion +gb.updated = updated +gb.proposePatchset = propose a patchset +gb.proposePatchsetNote = You are welcome to propose a patchset for this ticket. +gb.proposeInstructions = To start, craft a patchset and upload it with Git. Gitblit will link your patchset to this ticket by the id. +gb.proposeWith = propose a patchset with {0} +gb.revisionHistory = revision history +gb.merge = merge +gb.action = action +gb.patchset = patchset +gb.all = all +gb.mergeBase = merge base +gb.checkout = checkout +gb.checkoutViaCommandLine = Checkout via command line +gb.checkoutViaCommandLineNote = You can checkout and test these changes locally from your clone of this repository. +gb.checkoutStep1 = Fetch the current patchset \u2014 run this from your project directory +gb.checkoutStep2 = Checkout the patchset to a new branch and review +gb.mergingViaCommandLine = Merging via command line +gb.mergingViaCommandLineNote = If you do not want to use the merge button or an automatic merge cannot be performed, you can perform a manual merge on the command line. +gb.mergeStep1 = Check out a new branch to review the changes \u2014 run this from your project directory +gb.mergeStep2 = Bring in the proposed changes and review +gb.mergeStep3 = Merge the proposed changes and update the server +gb.download = download +gb.ptDescription = the Gitblit patchset tool +gb.ptCheckout = Fetch & checkout the current patchset to a review branch +gb.ptMerge = Fetch & merge the current patchset into your local branch +gb.ptDescription1 = Barnum is a command-line companion for Git that simplifies the syntax for working with Gitblit Tickets and Patchsets. +gb.ptSimplifiedCollaboration = simplified collaboration syntax +gb.ptSimplifiedMerge = simplified merge syntax +gb.ptDescription2 = Barnum requires Python 3 and native Git. It runs on Windows, Linux, and Mac OS X. +gb.stepN = Step {0} +gb.watchers = watchers +gb.votes = votes +gb.vote = vote for this {0} +gb.watch = watch this {0} +gb.removeVote = remove vote +gb.stopWatching = stop watching +gb.watching = watching +gb.comments = comments +gb.addComment = add comment +gb.export = export +gb.oneCommit = one commit +gb.nCommits = {0} commits +gb.addedOneCommit = added 1 commit +gb.addedNCommits = added {0} commits +gb.commitsInPatchsetN = commits in patchset {0} +gb.patchsetN = patchset {0} +gb.reviewedPatchsetRev = reviewed patchset {0} revision {1}: {2} +gb.review = review +gb.reviews = reviews +gb.veto = veto +gb.needsImprovement = needs improvement +gb.looksGood = looks good +gb.approve = approve +gb.hasNotReviewed = has not reviewed +gb.about = about +gb.ticketN = ticket #{0} \ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/BasePage.java b/src/main/java/com/gitblit/wicket/pages/BasePage.java index 24ffd813..3e3de535 100644 --- a/src/main/java/com/gitblit/wicket/pages/BasePage.java +++ b/src/main/java/com/gitblit/wicket/pages/BasePage.java @@ -15,6 +15,8 @@ */ package com.gitblit.wicket.pages; +import java.io.IOException; +import java.io.InputStream; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Calendar; @@ -31,6 +33,7 @@ import java.util.regex.Pattern; import javax.servlet.http.HttpServletRequest; +import org.apache.commons.io.IOUtils; import org.apache.wicket.Application; import org.apache.wicket.Page; import org.apache.wicket.PageParameters; @@ -460,4 +463,26 @@ public abstract class BasePage extends SessionPage { } error(message, true); } + + protected String readResource(String resource) { + StringBuilder sb = new StringBuilder(); + InputStream is = null; + try { + is = getClass().getResourceAsStream(resource); + List lines = IOUtils.readLines(is); + for (String line : lines) { + sb.append(line).append('\n'); + } + } catch (IOException e) { + + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + } + } + } + return sb.toString(); + } } diff --git a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html index 781cf29e..da19ca0f 100644 --- a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html +++ b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html @@ -34,15 +34,18 @@  
- - - - - -   + + + + + + + + + @@ -51,15 +54,15 @@
- + - + - - - + + + @@ -72,7 +75,7 @@





- +
diff --git a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java index c4f480bb..3a5f122f 100644 --- a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java +++ b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java @@ -410,12 +410,12 @@ public class EditRepositoryPage extends RootSubPage { } // save the repository - app().repositories().updateRepositoryModel(oldName, repositoryModel, isCreate); + app().gitblit().updateRepositoryModel(oldName, repositoryModel, isCreate); // repository access permissions if (repositoryModel.accessRestriction.exceeds(AccessRestrictionType.NONE)) { - app().repositories().setUserAccessPermissions(repositoryModel, repositoryUsers); - app().repositories().setTeamAccessPermissions(repositoryModel, repositoryTeams); + app().gitblit().setUserAccessPermissions(repositoryModel, repositoryUsers); + app().gitblit().setTeamAccessPermissions(repositoryModel, repositoryTeams); } } catch (GitBlitException e) { error(e.getMessage()); @@ -466,11 +466,14 @@ public class EditRepositoryPage extends RootSubPage { } form.add(new DropDownChoice("federationStrategy", federationStrategies, new FederationTypeRenderer())); + form.add(new CheckBox("acceptNewPatchsets")); + form.add(new CheckBox("acceptNewTickets")); + form.add(new CheckBox("requireApproval")); form.add(new CheckBox("useIncrementalPushTags")); form.add(new CheckBox("showRemoteBranches")); form.add(new CheckBox("skipSizeCalculation")); form.add(new CheckBox("skipSummaryMetrics")); - List maxActivityCommits = Arrays.asList(-1, 0, 25, 50, 75, 100, 150, 200, 250, 500 ); + List maxActivityCommits = Arrays.asList(-1, 0, 25, 50, 75, 100, 150, 200, 250, 500); form.add(new DropDownChoice("maxActivityCommits", maxActivityCommits, new MaxActivityCommitsRenderer())); metricAuthorExclusions = new Model(ArrayUtils.isEmpty(repositoryModel.metricAuthorExclusions) ? "" diff --git a/src/main/java/com/gitblit/wicket/pages/EditTicketPage.html b/src/main/java/com/gitblit/wicket/pages/EditTicketPage.html new file mode 100644 index 00000000..5d8f6829 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/EditTicketPage.html @@ -0,0 +1,66 @@ + + + + + + +
+ +
+ +
+ +
+
+
+ + + + + + + + +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
*
+
+
+ +
+
+
 
+
+
+
+
+ + + + + + + + + + +
+ \ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/EditTicketPage.java b/src/main/java/com/gitblit/wicket/pages/EditTicketPage.java new file mode 100644 index 00000000..5446dde3 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/EditTicketPage.java @@ -0,0 +1,290 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.wicket.pages; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import org.apache.wicket.PageParameters; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.Button; +import org.apache.wicket.markup.html.form.DropDownChoice; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.TextField; +import org.apache.wicket.markup.html.panel.Fragment; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.Model; + +import com.gitblit.Constants.AccessPermission; +import com.gitblit.models.RegistrantAccessPermission; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.TicketModel.Type; +import com.gitblit.models.UserModel; +import com.gitblit.tickets.TicketMilestone; +import com.gitblit.tickets.TicketNotifier; +import com.gitblit.tickets.TicketResponsible; +import com.gitblit.utils.StringUtils; +import com.gitblit.wicket.GitBlitWebSession; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.panels.MarkdownTextArea; + +/** + * Page for editing a ticket. + * + * @author James Moger + * + */ +public class EditTicketPage extends RepositoryPage { + + static final String NIL = ""; + + static final String ESC_NIL = StringUtils.escapeForHtml(NIL, false); + + private IModel typeModel; + + private IModel titleModel; + + private MarkdownTextArea descriptionEditor; + + private IModel topicModel; + + private IModel responsibleModel; + + private IModel milestoneModel; + + private Label descriptionPreview; + + public EditTicketPage(PageParameters params) { + super(params); + + UserModel currentUser = GitBlitWebSession.get().getUser(); + if (currentUser == null) { + currentUser = UserModel.ANONYMOUS; + } + + if (!currentUser.isAuthenticated || !app().tickets().isAcceptingTicketUpdates(getRepositoryModel())) { + // tickets prohibited + setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName)); + } + + long ticketId = 0L; + try { + String h = WicketUtils.getObject(params); + ticketId = Long.parseLong(h); + } catch (Exception e) { + setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName)); + } + + TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), ticketId); + if (ticket == null) { + setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName)); + } + + typeModel = Model.of(ticket.type); + titleModel = Model.of(ticket.title); + topicModel = Model.of(ticket.topic == null ? "" : ticket.topic); + responsibleModel = Model.of(); + milestoneModel = Model.of(); + + setStatelessHint(false); + setOutputMarkupId(true); + + Form form = new Form("editForm") { + + private static final long serialVersionUID = 1L; + + @Override + protected void onSubmit() { + long ticketId = 0L; + try { + String h = WicketUtils.getObject(getPageParameters()); + ticketId = Long.parseLong(h); + } catch (Exception e) { + setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName)); + } + + TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), ticketId); + + String createdBy = GitBlitWebSession.get().getUsername(); + Change change = new Change(createdBy); + + String title = titleModel.getObject(); + if (!ticket.title.equals(title)) { + // title change + change.setField(Field.title, title); + } + + String description = descriptionEditor.getText(); + if (!ticket.body.equals(description)) { + // description change + change.setField(Field.body, description); + } + + Type type = typeModel.getObject(); + if (!ticket.type.equals(type)) { + // type change + change.setField(Field.type, type); + } + + String topic = topicModel.getObject(); + if ((StringUtils.isEmpty(ticket.topic) && !StringUtils.isEmpty(topic)) + || (!StringUtils.isEmpty(topic) && !topic.equals(ticket.topic))) { + // topic change + change.setField(Field.topic, topic); + } + + TicketResponsible responsible = responsibleModel == null ? null : responsibleModel.getObject(); + if (responsible != null && !responsible.username.equals(ticket.responsible)) { + // responsible change + change.setField(Field.responsible, responsible.username); + if (!StringUtils.isEmpty(responsible.username)) { + if (!ticket.isWatching(responsible.username)) { + change.watch(responsible.username); + } + } + } + + TicketMilestone milestone = milestoneModel == null ? null : milestoneModel.getObject(); + if (milestone != null && !milestone.name.equals(ticket.milestone)) { + // milestone change + if (NIL.equals(milestone.name)) { + change.setField(Field.milestone, ""); + } else { + change.setField(Field.milestone, milestone.name); + } + } + + if (change.hasFieldChanges()) { + if (!ticket.isWatching(createdBy)) { + change.watch(createdBy); + } + ticket = app().tickets().updateTicket(getRepositoryModel(), ticket.number, change); + if (ticket != null) { + TicketNotifier notifier = app().tickets().createNotifier(); + notifier.sendMailing(ticket); + setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number)); + } else { + // TODO error + } + } else { + // nothing to change?! + setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number)); + } + } + }; + add(form); + + List typeChoices; + if (ticket.isProposal()) { + typeChoices = Arrays.asList(Type.Proposal); + } else { + typeChoices = Arrays.asList(TicketModel.Type.choices()); + } + form.add(new DropDownChoice("type", typeModel, typeChoices)); + form.add(new TextField("title", titleModel)); + form.add(new TextField("topic", topicModel)); + + final IModel markdownPreviewModel = new Model(); + descriptionPreview = new Label("descriptionPreview", markdownPreviewModel); + descriptionPreview.setEscapeModelStrings(false); + descriptionPreview.setOutputMarkupId(true); + form.add(descriptionPreview); + + descriptionEditor = new MarkdownTextArea("description", markdownPreviewModel, descriptionPreview); + descriptionEditor.setRepository(repositoryName); + descriptionEditor.setText(ticket.body); + form.add(descriptionEditor); + + if (currentUser != null && currentUser.isAuthenticated && currentUser.canPush(getRepositoryModel())) { + // responsible + Set userlist = new TreeSet(ticket.getParticipants()); + + for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) { + if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) { + userlist.add(rp.registrant); + } + } + + List responsibles = new ArrayList(); + for (String username : userlist) { + UserModel user = app().users().getUserModel(username); + if (user != null) { + TicketResponsible responsible = new TicketResponsible(user); + responsibles.add(responsible); + if (user.username.equals(ticket.responsible)) { + responsibleModel.setObject(responsible); + } + } + } + Collections.sort(responsibles); + responsibles.add(new TicketResponsible(NIL, "", "")); + Fragment responsible = new Fragment("responsible", "responsibleFragment", this); + responsible.add(new DropDownChoice("responsible", responsibleModel, responsibles)); + form.add(responsible.setVisible(!responsibles.isEmpty())); + + // milestone + List milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open); + for (TicketMilestone milestone : milestones) { + if (milestone.name.equals(ticket.milestone)) { + milestoneModel.setObject(milestone); + break; + } + } + if (!milestones.isEmpty()) { + milestones.add(new TicketMilestone(NIL)); + } + + Fragment milestone = new Fragment("milestone", "milestoneFragment", this); + + milestone.add(new DropDownChoice("milestone", milestoneModel, milestones)); + form.add(milestone.setVisible(!milestones.isEmpty())); + } else { + // user does not have permission to assign milestone or responsible + form.add(new Label("responsible").setVisible(false)); + form.add(new Label("milestone").setVisible(false)); + } + + form.add(new Button("update")); + Button cancel = new Button("cancel") { + private static final long serialVersionUID = 1L; + + @Override + public void onSubmit() { + setResponsePage(TicketsPage.class, getPageParameters()); + } + }; + cancel.setDefaultFormProcessing(false); + form.add(cancel); + + } + + @Override + protected String getPageName() { + return getString("gb.editTicket"); + } + + @Override + protected Class getRepoNavPageClass() { + return TicketsPage.class; + } +} diff --git a/src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java b/src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java new file mode 100644 index 00000000..57f61f78 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java @@ -0,0 +1,82 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.wicket.pages; + +import org.apache.wicket.IRequestTarget; +import org.apache.wicket.PageParameters; +import org.apache.wicket.RequestCycle; +import org.apache.wicket.protocol.http.WebResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.tickets.TicketSerializer; +import com.gitblit.utils.StringUtils; +import com.gitblit.wicket.WicketUtils; + +public class ExportTicketPage extends SessionPage { + + private final Logger logger = LoggerFactory.getLogger(getClass().getSimpleName()); + + String contentType; + + public ExportTicketPage(final PageParameters params) { + super(params); + + if (!params.containsKey("r")) { + error(getString("gb.repositoryNotSpecified")); + redirectToInterceptPage(new RepositoriesPage()); + } + + getRequestCycle().setRequestTarget(new IRequestTarget() { + @Override + public void detach(RequestCycle requestCycle) { + } + + @Override + public void respond(RequestCycle requestCycle) { + WebResponse response = (WebResponse) requestCycle.getResponse(); + + final String repositoryName = WicketUtils.getRepositoryName(params); + RepositoryModel repository = app().repositories().getRepositoryModel(repositoryName); + String objectId = WicketUtils.getObject(params).toLowerCase(); + if (objectId.endsWith(".json")) { + objectId = objectId.substring(0, objectId.length() - ".json".length()); + } + long id = Long.parseLong(objectId); + TicketModel ticket = app().tickets().getTicket(repository, id); + + String content = TicketSerializer.serialize(ticket); + contentType = "application/json; charset=UTF-8"; + response.setContentType(contentType); + try { + response.getOutputStream().write(content.getBytes("UTF-8")); + } catch (Exception e) { + logger.error("Failed to write text response", e); + } + } + }); + } + + @Override + protected void setHeaders(WebResponse response) { + super.setHeaders(response); + if (!StringUtils.isEmpty(contentType)) { + response.setContentType(contentType); + } + } +} diff --git a/src/main/java/com/gitblit/wicket/pages/NewTicketPage.html b/src/main/java/com/gitblit/wicket/pages/NewTicketPage.html new file mode 100644 index 00000000..71570df4 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/NewTicketPage.html @@ -0,0 +1,66 @@ + + + + + + +
+ +
+ +
+ +
+
+
+ + + + + + + + +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
*
+
+
+ +
+
+
 
+
+
+
+
+ + + + + + + + + + +
+ \ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/NewTicketPage.java b/src/main/java/com/gitblit/wicket/pages/NewTicketPage.java new file mode 100644 index 00000000..17ad1d1b --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/NewTicketPage.java @@ -0,0 +1,202 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.wicket.pages; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.wicket.PageParameters; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.Button; +import org.apache.wicket.markup.html.form.DropDownChoice; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.TextField; +import org.apache.wicket.markup.html.panel.Fragment; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.Model; + +import com.gitblit.Constants.AccessPermission; +import com.gitblit.models.RegistrantAccessPermission; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.UserModel; +import com.gitblit.tickets.TicketMilestone; +import com.gitblit.tickets.TicketNotifier; +import com.gitblit.tickets.TicketResponsible; +import com.gitblit.utils.StringUtils; +import com.gitblit.wicket.GitBlitWebSession; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.panels.MarkdownTextArea; + +/** + * Page for creating a new ticket. + * + * @author James Moger + * + */ +public class NewTicketPage extends RepositoryPage { + + private IModel typeModel; + + private IModel titleModel; + + private MarkdownTextArea descriptionEditor; + + private IModel topicModel; + + private IModel responsibleModel; + + private IModel milestoneModel; + + private Label descriptionPreview; + + public NewTicketPage(PageParameters params) { + super(params); + + UserModel currentUser = GitBlitWebSession.get().getUser(); + if (currentUser == null) { + currentUser = UserModel.ANONYMOUS; + } + + if (!currentUser.isAuthenticated || !app().tickets().isAcceptingNewTickets(getRepositoryModel())) { + // tickets prohibited + setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName)); + } + + typeModel = Model.of(TicketModel.Type.defaultType); + titleModel = Model.of(); + topicModel = Model.of(); + responsibleModel = Model.of(); + milestoneModel = Model.of(); + + setStatelessHint(false); + setOutputMarkupId(true); + + Form form = new Form("editForm") { + + private static final long serialVersionUID = 1L; + + @Override + protected void onSubmit() { + String createdBy = GitBlitWebSession.get().getUsername(); + Change change = new Change(createdBy); + change.setField(Field.title, titleModel.getObject()); + change.setField(Field.body, descriptionEditor.getText()); + String topic = topicModel.getObject(); + if (!StringUtils.isEmpty(topic)) { + change.setField(Field.topic, topic); + } + + // type + TicketModel.Type type = TicketModel.Type.defaultType; + if (typeModel.getObject() != null) { + type = typeModel.getObject(); + } + change.setField(Field.type, type); + + // responsible + TicketResponsible responsible = responsibleModel == null ? null : responsibleModel.getObject(); + if (responsible != null) { + change.setField(Field.responsible, responsible.username); + } + + // milestone + TicketMilestone milestone = milestoneModel == null ? null : milestoneModel.getObject(); + if (milestone != null) { + change.setField(Field.milestone, milestone.name); + } + + TicketModel ticket = app().tickets().createTicket(getRepositoryModel(), 0L, change); + if (ticket != null) { + TicketNotifier notifier = app().tickets().createNotifier(); + notifier.sendMailing(ticket); + setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number)); + } else { + // TODO error + } + } + }; + add(form); + + form.add(new DropDownChoice("type", typeModel, Arrays.asList(TicketModel.Type.choices()))); + form.add(new TextField("title", titleModel)); + form.add(new TextField("topic", topicModel)); + + final IModel markdownPreviewModel = new Model(); + descriptionPreview = new Label("descriptionPreview", markdownPreviewModel); + descriptionPreview.setEscapeModelStrings(false); + descriptionPreview.setOutputMarkupId(true); + form.add(descriptionPreview); + + descriptionEditor = new MarkdownTextArea("description", markdownPreviewModel, descriptionPreview); + descriptionEditor.setRepository(repositoryName); + form.add(descriptionEditor); + + if (currentUser != null && currentUser.isAuthenticated && currentUser.canPush(getRepositoryModel())) { + // responsible + List responsibles = new ArrayList(); + for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) { + if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) { + UserModel user = app().users().getUserModel(rp.registrant); + if (user != null) { + responsibles.add(new TicketResponsible(user)); + } + } + } + Collections.sort(responsibles); + Fragment responsible = new Fragment("responsible", "responsibleFragment", this); + responsible.add(new DropDownChoice("responsible", responsibleModel, responsibles)); + form.add(responsible.setVisible(!responsibles.isEmpty())); + + // milestone + List milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open); + Fragment milestone = new Fragment("milestone", "milestoneFragment", this); + milestone.add(new DropDownChoice("milestone", milestoneModel, milestones)); + form.add(milestone.setVisible(!milestones.isEmpty())); + } else { + // user does not have permission to assign milestone or responsible + form.add(new Label("responsible").setVisible(false)); + form.add(new Label("milestone").setVisible(false)); + } + + form.add(new Button("create")); + Button cancel = new Button("cancel") { + private static final long serialVersionUID = 1L; + + @Override + public void onSubmit() { + setResponsePage(TicketsPage.class, getPageParameters()); + } + }; + cancel.setDefaultFormProcessing(false); + form.add(cancel); + + } + + @Override + protected String getPageName() { + return getString("gb.newTicket"); + } + + @Override + protected Class getRepoNavPageClass() { + return TicketsPage.class; + } +} diff --git a/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html b/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html new file mode 100644 index 00000000..3eb56351 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html @@ -0,0 +1,21 @@ + + + + + +
+
+
+
+

+ +

+ +
+
+
+
+ \ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java b/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java new file mode 100644 index 00000000..8e98a00f --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java @@ -0,0 +1,44 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.wicket.pages; + +import org.apache.wicket.PageParameters; +import org.apache.wicket.markup.html.link.BookmarkablePageLink; + +import com.gitblit.models.UserModel; +import com.gitblit.wicket.GitBlitWebSession; +import com.gitblit.wicket.WicketUtils; + +public class NoTicketsPage extends RepositoryPage { + + public NoTicketsPage(PageParameters params) { + super(params); + + UserModel user = GitBlitWebSession.get().getUser(); + boolean isAuthenticated = user != null && user.isAuthenticated; + add(new BookmarkablePageLink("newticket", NewTicketPage.class, WicketUtils.newRepositoryParameter(repositoryName)).setVisible(isAuthenticated)); + } + + @Override + protected String getPageName() { + return getString("gb.tickets"); + } + + @Override + protected Class getRepoNavPageClass() { + return TicketsPage.class; + } +} diff --git a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html index 0acc6dbc..cb4f1b67 100644 --- a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html +++ b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html @@ -38,6 +38,7 @@
+ diff --git a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java index 079cb2e9..86df4565 100644 --- a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java +++ b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java @@ -56,6 +56,7 @@ import com.gitblit.models.UserModel; import com.gitblit.models.UserRepositoryPreferences; import com.gitblit.servlet.PagesServlet; import com.gitblit.servlet.SyndicationServlet; +import com.gitblit.tickets.TicketIndexer.Lucene; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.DeepCopier; import com.gitblit.utils.JGitUtils; @@ -95,7 +96,7 @@ public abstract class RepositoryPage extends RootPage { public RepositoryPage(PageParameters params) { super(params); repositoryName = WicketUtils.getRepositoryName(params); - String root =StringUtils.getFirstPathElement(repositoryName); + String root = StringUtils.getFirstPathElement(repositoryName); if (StringUtils.isEmpty(root)) { projectName = app().settings().getString(Keys.web.repositoryRootGroupName, "main"); } else { @@ -200,11 +201,18 @@ public abstract class RepositoryPage extends RootPage { } pages.put("commits", new PageRegistration("gb.commits", LogPage.class, params)); pages.put("tree", new PageRegistration("gb.tree", TreePage.class, params)); + if (app().tickets().isReady() && (app().tickets().isAcceptingNewTickets(getRepositoryModel()) || app().tickets().hasTickets(getRepositoryModel()))) { + PageParameters tParams = new PageParameters(params); + for (String state : TicketsPage.openStatii) { + tParams.add(Lucene.status.name(), state); + } + pages.put("tickets", new PageRegistration("gb.tickets", TicketsPage.class, tParams)); + } pages.put("docs", new PageRegistration("gb.docs", DocsPage.class, params, true)); - pages.put("compare", new PageRegistration("gb.compare", ComparePage.class, params, true)); if (app().settings().getBoolean(Keys.web.allowForking, true)) { pages.put("forks", new PageRegistration("gb.forks", ForksPage.class, params, true)); } + pages.put("compare", new PageRegistration("gb.compare", ComparePage.class, params, true)); // conditional links // per-repository extra page links @@ -288,6 +296,14 @@ public abstract class RepositoryPage extends RootPage { } } + // new ticket button + if (user.isAuthenticated && app().tickets().isAcceptingNewTickets(getRepositoryModel())) { + String newTicketUrl = getRequestCycle().urlFor(NewTicketPage.class, WicketUtils.newRepositoryParameter(repositoryName)).toString(); + addToolbarButton("newTicketLink", "fa fa-ticket", getString("gb.new"), newTicketUrl); + } else { + add(new Label("newTicketLink").setVisible(false)); + } + // (un)star link allows a user to star a repository if (user.isAuthenticated) { PageParameters starParams = DeepCopier.copy(getPageParameters()); diff --git a/src/main/java/com/gitblit/wicket/pages/TicketBasePage.java b/src/main/java/com/gitblit/wicket/pages/TicketBasePage.java new file mode 100644 index 00000000..3736cddf --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/TicketBasePage.java @@ -0,0 +1,124 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.wicket.pages; + +import org.apache.wicket.PageParameters; +import org.apache.wicket.markup.html.basic.Label; + +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.TicketModel.Type; +import com.gitblit.wicket.WicketUtils; + +public abstract class TicketBasePage extends RepositoryPage { + + public TicketBasePage(PageParameters params) { + super(params); + } + + protected Label getStateIcon(String wicketId, TicketModel ticket) { + return getStateIcon(wicketId, ticket.type, ticket.status); + } + + protected Label getStateIcon(String wicketId, Type type, Status state) { + Label label = new Label(wicketId); + if (type == null) { + type = Type.defaultType; + } + switch (type) { + case Proposal: + WicketUtils.setCssClass(label, "fa fa-code-fork"); + break; + case Bug: + WicketUtils.setCssClass(label, "fa fa-bug"); + break; + case Enhancement: + WicketUtils.setCssClass(label, "fa fa-magic"); + break; + case Question: + WicketUtils.setCssClass(label, "fa fa-question"); + break; + default: + // standard ticket + WicketUtils.setCssClass(label, "fa fa-ticket"); + } + WicketUtils.setHtmlTooltip(label, getTypeState(type, state)); + return label; + } + + protected String getTypeState(Type type, Status state) { + return state.toString() + " " + type.toString(); + } + + protected String getLozengeClass(Status status, boolean subtle) { + if (status == null) { + status = Status.New; + } + String css = ""; + switch (status) { + case Declined: + case Duplicate: + case Invalid: + case Wontfix: + css = "aui-lozenge-error"; + break; + case Fixed: + case Merged: + case Resolved: + css = "aui-lozenge-success"; + break; + case New: + css = "aui-lozenge-complete"; + break; + case On_Hold: + css = "aui-lozenge-current"; + break; + default: + css = ""; + break; + } + + return "aui-lozenge" + (subtle ? " aui-lozenge-subtle": "") + (css.isEmpty() ? "" : " ") + css; + } + + protected String getStatusClass(Status status) { + String css = ""; + switch (status) { + case Declined: + case Duplicate: + case Invalid: + case Wontfix: + css = "resolution-error"; + break; + case Fixed: + case Merged: + case Resolved: + css = "resolution-success"; + break; + case New: + css = "resolution-complete"; + break; + case On_Hold: + css = "resolution-current"; + break; + default: + css = ""; + break; + } + + return "resolution" + (css.isEmpty() ? "" : " ") + css; + } +} diff --git a/src/main/java/com/gitblit/wicket/pages/TicketPage.html b/src/main/java/com/gitblit/wicket/pages/TicketPage.html new file mode 100644 index 00000000..2e0288a5 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/TicketPage.html @@ -0,0 +1,577 @@ + + + + + + + +
+
[ticket title]
+ +
+
+
+ + + + + +
+ + +
+
+ + +
+
+ + [someone][created this ticket] [when created] +
+
+
[description]
+
+ + +
+ + + +
+
+ + +
+
+
[ticket status]
+
+
+ [label] +
+ +
+ +
+ + + + + + + + + +
[type]
[topic]
[responsible]
[milestone]
1 vote
1 watch
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+ + + +
+
+
+ +
+ + + + + + + + +
+
+
+
+
+ + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + + + +
+ [ticket type] +
+
[ticket status]
+
+ + + + +

+
+
+
+
+ + + +
+
+
+
+
+ +

+ +
+
+ [avatar] +
+
+
+
+
+
+
+ + + + +
+
+ [avatar] +
+
+ +
+ [author] [commented]

[comment date]

+
+
+
[comment text]
+
+
+
+
+ + + + +
+
+
+ [author] [changed status]

[comment date]

+
+
+
+
+ + + + +
+
+
+ + + + + +
+
+
+
+ + + + +
+
+ + [author] [merged patchset] + [commit] +

[change date]

+
+
+
+ + + + +
+
+
+
+

+
+

+

+

+
+

()

+
+
+
+
+
+
+ + + + +
+
+
+
+
+ +

+
+
+ + + + + + + + + + + + + + + + + + +
[avatar] [author][commit id][diff][title][commit date]
+
+
+
+ + + + +
+
+
+
+
+
+ [patch date] +
+ + +
+ +
+ + +
+ + + + +
+ +
+
+
+ [author] + +
+
+
+
+ +
+
+ +
+ +
+
+ + + + + + + +
[change type][commit path] + + + | + +
+
+
+
+
+
+
+
+

+

+ +

Git

+

+ : +

+

+	    

+ : +

+

+	    
+		
+

Barnum ()

+

+ +

+

+     
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
[avatar] [author] + [what happened] +
+
+ [revision type] + [R1] + + [patch date]
+
+ + + + + + + + + + +
+
+
+ +
+

+
+
+
+
+ + + + +
+ + +
+ +
+
+

+

+ +

Git

+

+ : +

+

+		

+ : +

+

+		

+ : +

+

+		
+		
+

Barnum ()

+

+ +

+

+	
+
+ + + + +
+
+

+
+
+
+ + + + +
+
+

+
+
+
+
+ + + + +
+
+

+ +
+
+
+ + + + +
+
+

+
+
+
+
+ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/TicketPage.java b/src/main/java/com/gitblit/wicket/pages/TicketPage.java new file mode 100644 index 00000000..0d60ec20 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/TicketPage.java @@ -0,0 +1,1527 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.wicket.pages; + +import java.text.DateFormat; +import java.text.MessageFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import java.util.TreeSet; + +import javax.servlet.http.HttpServletRequest; + +import org.apache.wicket.AttributeModifier; +import org.apache.wicket.Component; +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.PageParameters; +import org.apache.wicket.RestartResponseException; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.behavior.IBehavior; +import org.apache.wicket.behavior.SimpleAttributeModifier; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.image.ContextImage; +import org.apache.wicket.markup.html.link.BookmarkablePageLink; +import org.apache.wicket.markup.html.link.ExternalLink; +import org.apache.wicket.markup.html.panel.Fragment; +import org.apache.wicket.markup.repeater.Item; +import org.apache.wicket.markup.repeater.data.DataView; +import org.apache.wicket.markup.repeater.data.ListDataProvider; +import org.apache.wicket.model.Model; +import org.apache.wicket.protocol.http.WebRequest; +import org.eclipse.jgit.diff.DiffEntry.ChangeType; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.URIish; + +import com.gitblit.Constants; +import com.gitblit.Constants.AccessPermission; +import com.gitblit.Keys; +import com.gitblit.git.PatchsetCommand; +import com.gitblit.git.PatchsetReceivePack; +import com.gitblit.models.PathModel.PathChangeModel; +import com.gitblit.models.RegistrantAccessPermission; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.SubmoduleModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.CommentSource; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.PatchsetType; +import com.gitblit.models.TicketModel.Review; +import com.gitblit.models.TicketModel.Score; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.UserModel; +import com.gitblit.tickets.TicketIndexer.Lucene; +import com.gitblit.tickets.TicketLabel; +import com.gitblit.tickets.TicketMilestone; +import com.gitblit.tickets.TicketResponsible; +import com.gitblit.utils.JGitUtils; +import com.gitblit.utils.JGitUtils.MergeStatus; +import com.gitblit.utils.MarkdownUtils; +import com.gitblit.utils.StringUtils; +import com.gitblit.utils.TimeUtils; +import com.gitblit.wicket.GitBlitWebSession; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.panels.BasePanel.JavascriptTextPrompt; +import com.gitblit.wicket.panels.CommentPanel; +import com.gitblit.wicket.panels.DiffStatPanel; +import com.gitblit.wicket.panels.GravatarImage; +import com.gitblit.wicket.panels.IconAjaxLink; +import com.gitblit.wicket.panels.LinkPanel; +import com.gitblit.wicket.panels.ShockWaveComponent; +import com.gitblit.wicket.panels.SimpleAjaxLink; + +/** + * The ticket page handles viewing and updating a ticket. + * + * @author James Moger + * + */ +public class TicketPage extends TicketBasePage { + + static final String NIL = ""; + + static final String ESC_NIL = StringUtils.escapeForHtml(NIL, false); + + final int avatarWidth = 40; + + final TicketModel ticket; + + public TicketPage(PageParameters params) { + super(params); + + final UserModel user = GitBlitWebSession.get().getUser() == null ? UserModel.ANONYMOUS : GitBlitWebSession.get().getUser(); + final boolean isAuthenticated = !UserModel.ANONYMOUS.equals(user) && user.isAuthenticated; + final RepositoryModel repository = getRepositoryModel(); + final String id = WicketUtils.getObject(params); + long ticketId = Long.parseLong(id); + ticket = app().tickets().getTicket(repository, ticketId); + + if (ticket == null) { + // ticket not found + throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName)); + } + + final List revisions = new ArrayList(); + List comments = new ArrayList(); + List statusChanges = new ArrayList(); + List discussion = new ArrayList(); + for (Change change : ticket.changes) { + if (change.hasComment() || (change.isStatusChange() && (change.getStatus() != Status.New))) { + discussion.add(change); + } + if (change.hasComment()) { + comments.add(change); + } + if (change.hasPatchset()) { + revisions.add(change); + } + if (change.isStatusChange() && !change.hasPatchset()) { + statusChanges.add(change); + } + } + + final Change currentRevision = revisions.isEmpty() ? null : revisions.get(revisions.size() - 1); + final Patchset currentPatchset = ticket.getCurrentPatchset(); + + /* + * TICKET HEADER + */ + String href = urlFor(TicketsPage.class, params).toString(); + add(new ExternalLink("ticketNumber", href, "#" + ticket.number)); + Label headerStatus = new Label("headerStatus", ticket.status.toString()); + WicketUtils.setCssClass(headerStatus, getLozengeClass(ticket.status, false)); + add(headerStatus); + add(new Label("ticketTitle", ticket.title)); + if (currentPatchset == null) { + add(new Label("diffstat").setVisible(false)); + } else { + // calculate the current diffstat of the patchset + add(new DiffStatPanel("diffstat", ticket.insertions, ticket.deletions)); + } + + + /* + * TAB TITLES + */ + add(new Label("commentCount", "" + comments.size()).setVisible(!comments.isEmpty())); + add(new Label("commitCount", "" + (currentPatchset == null ? 0 : currentPatchset.commits)).setVisible(currentPatchset != null)); + + + /* + * TICKET AUTHOR and DATE (DISCUSSION TAB) + */ + UserModel createdBy = app().users().getUserModel(ticket.createdBy); + if (createdBy == null) { + add(new Label("whoCreated", ticket.createdBy)); + } else { + add(new LinkPanel("whoCreated", null, createdBy.getDisplayName(), + UserPage.class, WicketUtils.newUsernameParameter(createdBy.username))); + } + + if (ticket.isProposal()) { + // clearly indicate this is a change ticket + add(new Label("creationMessage", getString("gb.proposedThisChange"))); + } else { + // standard ticket + add(new Label("creationMessage", getString("gb.createdThisTicket"))); + } + + String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy"); + String timestampFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy"); + final TimeZone timezone = getTimeZone(); + final DateFormat df = new SimpleDateFormat(dateFormat); + df.setTimeZone(timezone); + final DateFormat tsf = new SimpleDateFormat(timestampFormat); + tsf.setTimeZone(timezone); + final Calendar cal = Calendar.getInstance(timezone); + + String fuzzydate; + TimeUtils tu = getTimeUtils(); + Date createdDate = ticket.created; + if (TimeUtils.isToday(createdDate, timezone)) { + fuzzydate = tu.today(); + } else if (TimeUtils.isYesterday(createdDate, timezone)) { + fuzzydate = tu.yesterday(); + } else { + // calculate a fuzzy time ago date + cal.setTime(createdDate); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + createdDate = cal.getTime(); + fuzzydate = getTimeUtils().timeAgo(createdDate); + } + Label when = new Label("whenCreated", fuzzydate + ", " + df.format(createdDate)); + WicketUtils.setHtmlTooltip(when, tsf.format(ticket.created)); + add(when); + + String exportHref = urlFor(ExportTicketPage.class, params).toString(); + add(new ExternalLink("exportJson", exportHref, "json")); + + + /* + * RESPONSIBLE (DISCUSSION TAB) + */ + if (StringUtils.isEmpty(ticket.responsible)) { + add(new Label("responsible")); + } else { + UserModel responsible = app().users().getUserModel(ticket.responsible); + if (responsible == null) { + add(new Label("responsible", ticket.responsible)); + } else { + add(new LinkPanel("responsible", null, responsible.getDisplayName(), + UserPage.class, WicketUtils.newUsernameParameter(responsible.username))); + } + } + + /* + * MILESTONE PROGRESS (DISCUSSION TAB) + */ + if (StringUtils.isEmpty(ticket.milestone)) { + add(new Label("milestone")); + } else { + // link to milestone query + TicketMilestone milestone = app().tickets().getMilestone(repository, ticket.milestone); + PageParameters milestoneParameters = new PageParameters(); + milestoneParameters.put("r", repositoryName); + milestoneParameters.put(Lucene.milestone.name(), ticket.milestone); + int progress = 0; + int open = 0; + int closed = 0; + if (milestone != null) { + progress = milestone.getProgress(); + open = milestone.getOpenTickets(); + closed = milestone.getClosedTickets(); + } + + Fragment milestoneProgress = new Fragment("milestone", "milestoneProgressFragment", this); + milestoneProgress.add(new LinkPanel("link", null, ticket.milestone, TicketsPage.class, milestoneParameters)); + Label label = new Label("progress"); + WicketUtils.setCssStyle(label, "width:" + progress + "%;"); + milestoneProgress.add(label); + WicketUtils.setHtmlTooltip(milestoneProgress, MessageFormat.format("{0} open, {1} closed", open, closed)); + add(milestoneProgress); + } + + + /* + * TICKET DESCRIPTION (DISCUSSION TAB) + */ + String desc; + if (StringUtils.isEmpty(ticket.body)) { + desc = getString("gb.noDescriptionGiven"); + } else { + desc = MarkdownUtils.transformGFM(app().settings(), ticket.body, ticket.repository); + } + add(new Label("ticketDescription", desc).setEscapeModelStrings(false)); + + + /* + * PARTICIPANTS (DISCUSSION TAB) + */ + if (app().settings().getBoolean(Keys.web.allowGravatar, true)) { + // gravatar allowed + List participants = ticket.getParticipants(); + add(new Label("participantsLabel", MessageFormat.format(getString(participants.size() > 1 ? "gb.nParticipants" : "gb.oneParticipant"), + "" + participants.size() + "")).setEscapeModelStrings(false)); + ListDataProvider participantsDp = new ListDataProvider(participants); + DataView participantsView = new DataView("participants", participantsDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + String username = item.getModelObject(); + UserModel user = app().users().getUserModel(username); + if (user == null) { + user = new UserModel(username); + } + item.add(new GravatarImage("participant", user.getDisplayName(), + user.emailAddress, null, 25, true)); + } + }; + add(participantsView); + } else { + // gravatar prohibited + add(new Label("participantsLabel").setVisible(false)); + add(new Label("participants").setVisible(false)); + } + + + /* + * LARGE STATUS INDICATOR WITH ICON (DISCUSSION TAB->SIDE BAR) + */ + Fragment ticketStatus = new Fragment("ticketStatus", "ticketStatusFragment", this); + Label ticketIcon = getStateIcon("ticketIcon", ticket); + ticketStatus.add(ticketIcon); + ticketStatus.add(new Label("ticketStatus", ticket.status.toString())); + WicketUtils.setCssClass(ticketStatus, getLozengeClass(ticket.status, false)); + add(ticketStatus); + + + /* + * UPDATE FORM (DISCUSSION TAB) + */ + if (isAuthenticated && app().tickets().isAcceptingTicketUpdates(repository)) { + Fragment controls = new Fragment("controls", "controlsFragment", this); + + + /* + * STATUS + */ + List choices = new ArrayList(); + if (ticket.isProposal()) { + choices.addAll(Arrays.asList(TicketModel.Status.proposalWorkflow)); + } else if (ticket.isBug()) { + choices.addAll(Arrays.asList(TicketModel.Status.bugWorkflow)); + } else { + choices.addAll(Arrays.asList(TicketModel.Status.requestWorkflow)); + } + choices.remove(ticket.status); + + ListDataProvider workflowDp = new ListDataProvider(choices); + DataView statusView = new DataView("newStatus", workflowDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + SimpleAjaxLink link = new SimpleAjaxLink("link", item.getModel()) { + + private static final long serialVersionUID = 1L; + + @Override + public void onClick(AjaxRequestTarget target) { + Status status = getModel().getObject(); + Change change = new Change(user.username); + change.setField(Field.status, status); + if (!ticket.isWatching(user.username)) { + change.watch(user.username); + } + TicketModel update = app().tickets().updateTicket(repository, ticket.number, change); + app().tickets().createNotifier().sendMailing(update); + setResponsePage(TicketsPage.class, getPageParameters()); + } + }; + String css = getStatusClass(item.getModel().getObject()); + WicketUtils.setCssClass(link, css); + item.add(link); + } + }; + controls.add(statusView); + + /* + * RESPONSIBLE LIST + */ + Set userlist = new TreeSet(ticket.getParticipants()); + for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) { + if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) { + userlist.add(rp.registrant); + } + } + List responsibles = new ArrayList(); + if (!StringUtils.isEmpty(ticket.responsible)) { + // exclude the current responsible + userlist.remove(ticket.responsible); + } + for (String username : userlist) { + UserModel u = app().users().getUserModel(username); + if (u != null) { + responsibles.add(new TicketResponsible(u)); + } + } + Collections.sort(responsibles); + responsibles.add(new TicketResponsible(ESC_NIL, "", "")); + ListDataProvider responsibleDp = new ListDataProvider(responsibles); + DataView responsibleView = new DataView("newResponsible", responsibleDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + SimpleAjaxLink link = new SimpleAjaxLink("link", item.getModel()) { + + private static final long serialVersionUID = 1L; + + @Override + public void onClick(AjaxRequestTarget target) { + TicketResponsible responsible = getModel().getObject(); + Change change = new Change(user.username); + change.setField(Field.responsible, responsible.username); + if (!StringUtils.isEmpty(responsible.username)) { + if (!ticket.isWatching(responsible.username)) { + change.watch(responsible.username); + } + } + if (!ticket.isWatching(user.username)) { + change.watch(user.username); + } + TicketModel update = app().tickets().updateTicket(repository, ticket.number, change); + app().tickets().createNotifier().sendMailing(update); + setResponsePage(TicketsPage.class, getPageParameters()); + } + }; + item.add(link); + } + }; + controls.add(responsibleView); + + /* + * MILESTONE LIST + */ + List milestones = app().tickets().getMilestones(repository, Status.Open); + if (!StringUtils.isEmpty(ticket.milestone)) { + for (TicketMilestone milestone : milestones) { + if (milestone.name.equals(ticket.milestone)) { + milestones.remove(milestone); + break; + } + } + } + milestones.add(new TicketMilestone(ESC_NIL)); + ListDataProvider milestoneDp = new ListDataProvider(milestones); + DataView milestoneView = new DataView("newMilestone", milestoneDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + SimpleAjaxLink link = new SimpleAjaxLink("link", item.getModel()) { + + private static final long serialVersionUID = 1L; + + @Override + public void onClick(AjaxRequestTarget target) { + TicketMilestone milestone = getModel().getObject(); + Change change = new Change(user.username); + if (NIL.equals(milestone.name)) { + change.setField(Field.milestone, ""); + } else { + change.setField(Field.milestone, milestone.name); + } + if (!ticket.isWatching(user.username)) { + change.watch(user.username); + } + TicketModel update = app().tickets().updateTicket(repository, ticket.number, change); + app().tickets().createNotifier().sendMailing(update); + setResponsePage(TicketsPage.class, getPageParameters()); + } + }; + item.add(link); + } + }; + controls.add(milestoneView); + + String editHref = urlFor(EditTicketPage.class, params).toString(); + controls.add(new ExternalLink("editLink", editHref, getString("gb.edit"))); + + add(controls); + } else { + add(new Label("controls").setVisible(false)); + } + + + /* + * TICKET METADATA + */ + add(new Label("ticketType", ticket.type.toString())); + if (StringUtils.isEmpty(ticket.topic)) { + add(new Label("ticketTopic").setVisible(false)); + } else { + // process the topic using the bugtraq config to link things + String topic = messageProcessor().processPlainCommitMessage(getRepository(), repositoryName, ticket.topic); + add(new Label("ticketTopic", topic).setEscapeModelStrings(false)); + } + + + /* + * VOTERS + */ + List voters = ticket.getVoters(); + Label votersCount = new Label("votes", "" + voters.size()); + if (voters.size() == 0) { + WicketUtils.setCssClass(votersCount, "badge"); + } else { + WicketUtils.setCssClass(votersCount, "badge badge-info"); + } + add(votersCount); + if (user.isAuthenticated) { + Model model; + if (ticket.isVoter(user.username)) { + model = Model.of(getString("gb.removeVote")); + } else { + model = Model.of(MessageFormat.format(getString("gb.vote"), ticket.type.toString())); + } + SimpleAjaxLink link = new SimpleAjaxLink("voteLink", model) { + + private static final long serialVersionUID = 1L; + + @Override + public void onClick(AjaxRequestTarget target) { + Change change = new Change(user.username); + if (ticket.isVoter(user.username)) { + change.unvote(user.username); + } else { + change.vote(user.username); + } + app().tickets().updateTicket(repository, ticket.number, change); + setResponsePage(TicketsPage.class, getPageParameters()); + } + }; + add(link); + } else { + add(new Label("voteLink").setVisible(false)); + } + + + /* + * WATCHERS + */ + List watchers = ticket.getWatchers(); + Label watchersCount = new Label("watchers", "" + watchers.size()); + if (watchers.size() == 0) { + WicketUtils.setCssClass(watchersCount, "badge"); + } else { + WicketUtils.setCssClass(watchersCount, "badge badge-info"); + } + add(watchersCount); + if (user.isAuthenticated) { + Model model; + if (ticket.isWatching(user.username)) { + model = Model.of(getString("gb.stopWatching")); + } else { + model = Model.of(MessageFormat.format(getString("gb.watch"), ticket.type.toString())); + } + SimpleAjaxLink link = new SimpleAjaxLink("watchLink", model) { + + private static final long serialVersionUID = 1L; + + @Override + public void onClick(AjaxRequestTarget target) { + Change change = new Change(user.username); + if (ticket.isWatching(user.username)) { + change.unwatch(user.username); + } else { + change.watch(user.username); + } + app().tickets().updateTicket(repository, ticket.number, change); + setResponsePage(TicketsPage.class, getPageParameters()); + } + }; + add(link); + } else { + add(new Label("watchLink").setVisible(false)); + } + + + /* + * TOPIC & LABELS (DISCUSSION TAB->SIDE BAR) + */ + ListDataProvider labelsDp = new ListDataProvider(ticket.getLabels()); + DataView labelsView = new DataView("labels", labelsDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + final String value = item.getModelObject(); + Label label = new Label("label", value); + TicketLabel tLabel = app().tickets().getLabel(repository, value); + String background = MessageFormat.format("background-color:{0};", tLabel.color); + label.add(new SimpleAttributeModifier("style", background)); + item.add(label); + } + }; + + add(labelsView); + + + /* + * COMMENTS & STATUS CHANGES (DISCUSSION TAB) + */ + if (comments.size() == 0) { + add(new Label("discussion").setVisible(false)); + } else { + Fragment discussionFragment = new Fragment("discussion", "discussionFragment", this); + ListDataProvider discussionDp = new ListDataProvider(discussion); + DataView discussionView = new DataView("discussion", discussionDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + final Change entry = item.getModelObject(); + if (entry.isMerge()) { + /* + * MERGE + */ + String resolvedBy = entry.getString(Field.mergeSha); + + // identify the merged patch, it is likely the last + Patchset mergedPatch = null; + for (Change c : revisions) { + if (c.patchset.tip.equals(resolvedBy)) { + mergedPatch = c.patchset; + break; + } + } + + String commitLink; + if (mergedPatch == null) { + // shouldn't happen, but just-in-case + int len = app().settings().getInteger(Keys.web.shortCommitIdLength, 6); + commitLink = resolvedBy.substring(0, len); + } else { + // expected result + commitLink = mergedPatch.toString(); + } + + Fragment mergeFragment = new Fragment("entry", "mergeFragment", this); + mergeFragment.add(new LinkPanel("commitLink", null, commitLink, + CommitPage.class, WicketUtils.newObjectParameter(repositoryName, resolvedBy))); + mergeFragment.add(new Label("toBranch", MessageFormat.format(getString("gb.toBranch"), + "" + ticket.mergeTo + "")).setEscapeModelStrings(false)); + addUserAttributions(mergeFragment, entry, 0); + addDateAttributions(mergeFragment, entry); + + item.add(mergeFragment); + } else if (entry.isStatusChange()) { + /* + * STATUS CHANGE + */ + Fragment frag = new Fragment("entry", "statusFragment", this); + Label status = new Label("statusChange", entry.getStatus().toString()); + String css = getLozengeClass(entry.getStatus(), false); + WicketUtils.setCssClass(status, css); + for (IBehavior b : status.getBehaviors()) { + if (b instanceof SimpleAttributeModifier) { + SimpleAttributeModifier sam = (SimpleAttributeModifier) b; + if ("class".equals(sam.getAttribute())) { + status.add(new SimpleAttributeModifier("class", "status-change " + sam.getValue())); + break; + } + } + } + frag.add(status); + addUserAttributions(frag, entry, avatarWidth); + addDateAttributions(frag, entry); + item.add(frag); + } else { + /* + * COMMENT + */ + String comment = MarkdownUtils.transformGFM(app().settings(), entry.comment.text, repositoryName); + Fragment frag = new Fragment("entry", "commentFragment", this); + Label commentIcon = new Label("commentIcon"); + if (entry.comment.src == CommentSource.Email) { + WicketUtils.setCssClass(commentIcon, "iconic-mail"); + } else { + WicketUtils.setCssClass(commentIcon, "iconic-comment-alt2-stroke"); + } + frag.add(commentIcon); + frag.add(new Label("comment", comment).setEscapeModelStrings(false)); + addUserAttributions(frag, entry, avatarWidth); + addDateAttributions(frag, entry); + item.add(frag); + } + } + }; + discussionFragment.add(discussionView); + add(discussionFragment); + } + + /* + * ADD COMMENT PANEL + */ + if (UserModel.ANONYMOUS.equals(user) + || !repository.isBare + || repository.isFrozen + || repository.isMirror) { + + // prohibit comments for anonymous users, local working copy repos, + // frozen repos, and mirrors + add(new Label("newComment").setVisible(false)); + } else { + // permit user to comment + Fragment newComment = new Fragment("newComment", "newCommentFragment", this); + GravatarImage img = new GravatarImage("newCommentAvatar", user.username, user.emailAddress, + "gravatar-round", avatarWidth, true); + newComment.add(img); + CommentPanel commentPanel = new CommentPanel("commentPanel", user, ticket, null, TicketsPage.class); + commentPanel.setRepository(repositoryName); + newComment.add(commentPanel); + add(newComment); + } + + + /* + * PATCHSET TAB + */ + if (currentPatchset == null) { + // no patchset yet, show propose fragment + String repoUrl = getRepositoryUrl(user, repository); + Fragment changeIdFrag = new Fragment("patchset", "proposeFragment", this); + changeIdFrag.add(new Label("proposeInstructions", MarkdownUtils.transformMarkdown(getString("gb.proposeInstructions"))).setEscapeModelStrings(false)); + changeIdFrag.add(new Label("ptWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Barnum"))); + changeIdFrag.add(new Label("ptWorkflowSteps", getProposeWorkflow("propose_pt.md", repoUrl, ticket.number)).setEscapeModelStrings(false)); + changeIdFrag.add(new Label("gitWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Git"))); + changeIdFrag.add(new Label("gitWorkflowSteps", getProposeWorkflow("propose_git.md", repoUrl, ticket.number)).setEscapeModelStrings(false)); + add(changeIdFrag); + } else { + // show current patchset + Fragment patchsetFrag = new Fragment("patchset", "patchsetFragment", this); + patchsetFrag.add(new Label("commitsInPatchset", MessageFormat.format(getString("gb.commitsInPatchsetN"), currentPatchset.number))); + + // current revision + MarkupContainer panel = createPatchsetPanel("panel", repository, user); + patchsetFrag.add(panel); + addUserAttributions(patchsetFrag, currentRevision, avatarWidth); + addUserAttributions(panel, currentRevision, 0); + addDateAttributions(panel, currentRevision); + + // commits + List commits = JGitUtils.getRevLog(getRepository(), currentPatchset.base, currentPatchset.tip); + ListDataProvider commitsDp = new ListDataProvider(commits); + DataView commitsView = new DataView("commit", commitsDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + RevCommit commit = item.getModelObject(); + PersonIdent author = commit.getAuthorIdent(); + item.add(new GravatarImage("authorAvatar", author.getName(), author.getEmailAddress(), null, 16, false)); + item.add(new Label("author", commit.getAuthorIdent().getName())); + item.add(new LinkPanel("commitId", null, getShortObjectId(commit.getName()), + CommitPage.class, WicketUtils.newObjectParameter(repositoryName, commit.getName()), true)); + item.add(new LinkPanel("diff", "link", getString("gb.diff"), CommitDiffPage.class, + WicketUtils.newObjectParameter(repositoryName, commit.getName()), true)); + item.add(new Label("title", StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG_REFS))); + item.add(WicketUtils.createDateLabel("commitDate", JGitUtils.getCommitDate(commit), GitBlitWebSession + .get().getTimezone(), getTimeUtils(), false)); + item.add(new DiffStatPanel("commitDiffStat", 0, 0, true)); + } + }; + patchsetFrag.add(commitsView); + add(patchsetFrag); + } + + + /* + * ACTIVITY TAB + */ + Fragment revisionHistory = new Fragment("activity", "activityFragment", this); + List events = new ArrayList(ticket.changes); + Collections.sort(events); + Collections.reverse(events); + ListDataProvider eventsDp = new ListDataProvider(events); + DataView eventsView = new DataView("event", eventsDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + Change event = item.getModelObject(); + + addUserAttributions(item, event, 16); + + if (event.hasPatchset()) { + // patchset + Patchset patchset = event.patchset; + String what; + if (event.isStatusChange() && (Status.New == event.getStatus())) { + what = getString("gb.proposedThisChange"); + } else if (patchset.rev == 1) { + what = MessageFormat.format(getString("gb.uploadedPatchsetN"), patchset.number); + } else { + if (patchset.added == 1) { + what = getString("gb.addedOneCommit"); + } else { + what = MessageFormat.format(getString("gb.addedNCommits"), patchset.added); + } + } + item.add(new Label("what", what)); + + LinkPanel psr = new LinkPanel("patchsetRevision", null, patchset.number + "-" + patchset.rev, + ComparePage.class, WicketUtils.newRangeParameter(repositoryName, patchset.parent == null ? patchset.base : patchset.parent, patchset.tip), true); + WicketUtils.setHtmlTooltip(psr, patchset.toString()); + item.add(psr); + String typeCss = getPatchsetTypeCss(patchset.type); + Label typeLabel = new Label("patchsetType", patchset.type.toString()); + if (typeCss == null) { + typeLabel.setVisible(false); + } else { + WicketUtils.setCssClass(typeLabel, typeCss); + } + item.add(typeLabel); + + // show commit diffstat + item.add(new DiffStatPanel("patchsetDiffStat", patchset.insertions, patchset.deletions, patchset.rev > 1)); + } else if (event.hasComment()) { + // comment + item.add(new Label("what", getString("gb.commented"))); + item.add(new Label("patchsetRevision").setVisible(false)); + item.add(new Label("patchsetType").setVisible(false)); + item.add(new Label("patchsetDiffStat").setVisible(false)); + } else if (event.hasReview()) { + // review + String score; + switch (event.review.score) { + case approved: + score = "" + getScoreDescription(event.review.score) + ""; + break; + case vetoed: + score = "" + getScoreDescription(event.review.score) + ""; + break; + default: + score = getScoreDescription(event.review.score); + } + item.add(new Label("what", MessageFormat.format(getString("gb.reviewedPatchsetRev"), + event.review.patchset, event.review.rev, score)) + .setEscapeModelStrings(false)); + item.add(new Label("patchsetRevision").setVisible(false)); + item.add(new Label("patchsetType").setVisible(false)); + item.add(new Label("patchsetDiffStat").setVisible(false)); + } else { + // field change + item.add(new Label("patchsetRevision").setVisible(false)); + item.add(new Label("patchsetType").setVisible(false)); + item.add(new Label("patchsetDiffStat").setVisible(false)); + + String what = ""; + if (event.isStatusChange()) { + switch (event.getStatus()) { + case New: + if (ticket.isProposal()) { + what = getString("gb.proposedThisChange"); + } else { + what = getString("gb.createdThisTicket"); + } + break; + default: + break; + } + } + item.add(new Label("what", what).setVisible(what.length() > 0)); + } + + addDateAttributions(item, event); + + if (event.hasFieldChanges()) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + for (Map.Entry entry : event.fields.entrySet()) { + String value; + switch (entry.getKey()) { + case body: + String body = entry.getValue(); + if (event.isStatusChange() && Status.New == event.getStatus() && StringUtils.isEmpty(body)) { + // ignore initial empty description + continue; + } + // trim body changes + if (StringUtils.isEmpty(body)) { + value = "" + ESC_NIL + ""; + } else { + value = StringUtils.trimString(body, Constants.LEN_SHORTLOG_REFS); + } + break; + case status: + // special handling for status + Status status = event.getStatus(); + String css = getLozengeClass(status, true); + value = String.format("%2$s", css, status.toString()); + break; + default: + value = StringUtils.isEmpty(entry.getValue()) ? ("" + ESC_NIL + "") : StringUtils.escapeForHtml(entry.getValue(), false); + break; + } + sb.append(""); + } + sb.append("
"); + sb.append(entry.getKey().name()); + sb.append(""); + sb.append(value); + sb.append("
"); + item.add(new Label("fields", sb.toString()).setEscapeModelStrings(false)); + } else { + item.add(new Label("fields").setVisible(false)); + } + } + }; + revisionHistory.add(eventsView); + add(revisionHistory); + } + + protected void addUserAttributions(MarkupContainer container, Change entry, int avatarSize) { + UserModel commenter = app().users().getUserModel(entry.author); + if (commenter == null) { + // unknown user + container.add(new GravatarImage("changeAvatar", entry.author, + entry.author, null, avatarSize, false).setVisible(avatarSize > 0)); + container.add(new Label("changeAuthor", entry.author.toLowerCase())); + } else { + // known user + container.add(new GravatarImage("changeAvatar", commenter.getDisplayName(), + commenter.emailAddress, avatarSize > 24 ? "gravatar-round" : null, + avatarSize, true).setVisible(avatarSize > 0)); + container.add(new LinkPanel("changeAuthor", null, commenter.getDisplayName(), + UserPage.class, WicketUtils.newUsernameParameter(commenter.username))); + } + } + + protected void addDateAttributions(MarkupContainer container, Change entry) { + container.add(WicketUtils.createDateLabel("changeDate", entry.date, GitBlitWebSession + .get().getTimezone(), getTimeUtils(), false)); + + // set the id attribute + if (entry.hasComment()) { + container.setOutputMarkupId(true); + container.add(new AttributeModifier("id", Model.of(entry.getId()))); + ExternalLink link = new ExternalLink("changeLink", "#" + entry.getId()); + container.add(link); + } else { + container.add(new Label("changeLink").setVisible(false)); + } + } + + protected String getProposeWorkflow(String resource, String url, long ticketId) { + String md = readResource(resource); + md = md.replace("${url}", url); + md = md.replace("${repo}", StringUtils.getLastPathElement(StringUtils.stripDotGit(repositoryName))); + md = md.replace("${ticketId}", "" + ticketId); + md = md.replace("${patchset}", "" + 1); + md = md.replace("${reviewBranch}", Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticketId))); + md = md.replace("${integrationBranch}", Repository.shortenRefName(getRepositoryModel().HEAD)); + return MarkdownUtils.transformMarkdown(md); + } + + protected Fragment createPatchsetPanel(String wicketId, RepositoryModel repository, UserModel user) { + final Patchset currentPatchset = ticket.getCurrentPatchset(); + List patchsets = new ArrayList(ticket.getPatchsetRevisions(currentPatchset.number)); + patchsets.remove(currentPatchset); + Collections.reverse(patchsets); + + Fragment panel = new Fragment(wicketId, "collapsiblePatchsetFragment", this); + + // patchset header + String ps = "" + currentPatchset.number + ""; + if (currentPatchset.rev == 1) { + panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetN"), ps)).setEscapeModelStrings(false)); + } else { + String rev = "" + currentPatchset.rev + ""; + panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetNRevisionN"), ps, rev)).setEscapeModelStrings(false)); + } + panel.add(new LinkPanel("patchId", null, "rev " + currentPatchset.rev, + CommitPage.class, WicketUtils.newObjectParameter(repositoryName, currentPatchset.tip), true)); + + // compare menu + panel.add(new LinkPanel("compareMergeBase", null, getString("gb.compareToMergeBase"), + ComparePage.class, WicketUtils.newRangeParameter(repositoryName, currentPatchset.base, currentPatchset.tip), true)); + + ListDataProvider compareMenuDp = new ListDataProvider(patchsets); + DataView compareMenu = new DataView("comparePatch", compareMenuDp) { + private static final long serialVersionUID = 1L; + @Override + public void populateItem(final Item item) { + Patchset patchset = item.getModelObject(); + LinkPanel link = new LinkPanel("compareLink", null, + MessageFormat.format(getString("gb.compareToN"), patchset.number + "-" + patchset.rev), + ComparePage.class, WicketUtils.newRangeParameter(getRepositoryModel().name, + patchset.tip, currentPatchset.tip), true); + item.add(link); + + } + }; + panel.add(compareMenu); + + + // reviews + List reviews = ticket.getReviews(currentPatchset); + ListDataProvider reviewsDp = new ListDataProvider(reviews); + DataView reviewsView = new DataView("reviews", reviewsDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + Change change = item.getModelObject(); + final String username = change.author; + UserModel user = app().users().getUserModel(username); + if (user == null) { + item.add(new Label("reviewer", username)); + } else { + item.add(new LinkPanel("reviewer", null, user.getDisplayName(), + UserPage.class, WicketUtils.newUsernameParameter(username))); + } + + // indicate review score + Review review = change.review; + Label scoreLabel = new Label("score"); + String scoreClass = getScoreClass(review.score); + String tooltip = getScoreDescription(review.score); + WicketUtils.setCssClass(scoreLabel, scoreClass); + if (!StringUtils.isEmpty(tooltip)) { + WicketUtils.setHtmlTooltip(scoreLabel, tooltip); + } + item.add(scoreLabel); + } + }; + panel.add(reviewsView); + + + if (ticket.isOpen() && user.canReviewPatchset(repository)) { + // can only review open tickets + Review myReview = null; + for (Change change : ticket.getReviews(currentPatchset)) { + if (change.author.equals(user.username)) { + myReview = change.review; + } + } + + // user can review, add review controls + Fragment reviewControls = new Fragment("reviewControls", "reviewControlsFragment", this); + + // show "approve" button if no review OR not current score + if (user.canApprovePatchset(repository) && (myReview == null || Score.approved != myReview.score)) { + reviewControls.add(createReviewLink("approveLink", Score.approved)); + } else { + reviewControls.add(new Label("approveLink").setVisible(false)); + } + + // show "looks good" button if no review OR not current score + if (myReview == null || Score.looks_good != myReview.score) { + reviewControls.add(createReviewLink("looksGoodLink", Score.looks_good)); + } else { + reviewControls.add(new Label("looksGoodLink").setVisible(false)); + } + + // show "needs improvement" button if no review OR not current score + if (myReview == null || Score.needs_improvement != myReview.score) { + reviewControls.add(createReviewLink("needsImprovementLink", Score.needs_improvement)); + } else { + reviewControls.add(new Label("needsImprovementLink").setVisible(false)); + } + + // show "veto" button if no review OR not current score + if (user.canVetoPatchset(repository) && (myReview == null || Score.vetoed != myReview.score)) { + reviewControls.add(createReviewLink("vetoLink", Score.vetoed)); + } else { + reviewControls.add(new Label("vetoLink").setVisible(false)); + } + panel.add(reviewControls); + } else { + // user can not review + panel.add(new Label("reviewControls").setVisible(false)); + } + + String insertions = MessageFormat.format("+{0}", ticket.insertions); + String deletions = MessageFormat.format("-{0}", ticket.deletions); + panel.add(new Label("patchsetStat", MessageFormat.format(StringUtils.escapeForHtml(getString("gb.diffStat"), false), + insertions, deletions)).setEscapeModelStrings(false)); + + // changed paths list + List paths = JGitUtils.getFilesInRange(getRepository(), currentPatchset.base, currentPatchset.tip); + ListDataProvider pathsDp = new ListDataProvider(paths); + DataView pathsView = new DataView("changedPath", pathsDp) { + private static final long serialVersionUID = 1L; + int counter; + + @Override + public void populateItem(final Item item) { + final PathChangeModel entry = item.getModelObject(); + Label changeType = new Label("changeType", ""); + WicketUtils.setChangeTypeCssClass(changeType, entry.changeType); + setChangeTypeTooltip(changeType, entry.changeType); + item.add(changeType); + item.add(new DiffStatPanel("diffStat", entry.insertions, entry.deletions, true)); + + boolean hasSubmodule = false; + String submodulePath = null; + if (entry.isTree()) { + // tree + item.add(new LinkPanel("pathName", null, entry.path, TreePage.class, + WicketUtils + .newPathParameter(repositoryName, currentPatchset.tip, entry.path), true)); + item.add(new Label("diffStat").setVisible(false)); + } else if (entry.isSubmodule()) { + // submodule + String submoduleId = entry.objectId; + SubmoduleModel submodule = getSubmodule(entry.path); + submodulePath = submodule.gitblitPath; + hasSubmodule = submodule.hasSubmodule; + + item.add(new LinkPanel("pathName", "list", entry.path + " @ " + + getShortObjectId(submoduleId), TreePage.class, + WicketUtils.newPathParameter(submodulePath, submoduleId, ""), true).setEnabled(hasSubmodule)); + item.add(new Label("diffStat").setVisible(false)); + } else { + // blob + String displayPath = entry.path; + String path = entry.path; + if (entry.isSymlink()) { + RevCommit commit = JGitUtils.getCommit(getRepository(), Constants.R_TICKETS_PATCHSETS + ticket.number); + path = JGitUtils.getStringContent(getRepository(), commit.getTree(), path); + displayPath = entry.path + " -> " + path; + } + + if (entry.changeType.equals(ChangeType.ADD)) { + // add show view + item.add(new LinkPanel("pathName", "list", displayPath, BlobPage.class, + WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true)); + } else if (entry.changeType.equals(ChangeType.DELETE)) { + // delete, show label + item.add(new Label("pathName", displayPath)); + } else { + // mod, show diff + item.add(new LinkPanel("pathName", "list", displayPath, BlobDiffPage.class, + WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true)); + } + } + + // quick links + if (entry.isSubmodule()) { + // submodule + item.add(setNewTarget(new BookmarkablePageLink("diff", BlobDiffPage.class, WicketUtils + .newPathParameter(repositoryName, entry.commitId, entry.path))) + .setEnabled(!entry.changeType.equals(ChangeType.ADD))); + item.add(new BookmarkablePageLink("view", CommitPage.class, WicketUtils + .newObjectParameter(submodulePath, entry.objectId)).setEnabled(hasSubmodule)); + } else { + // tree or blob + item.add(setNewTarget(new BookmarkablePageLink("diff", BlobDiffPage.class, WicketUtils + .newBlobDiffParameter(repositoryName, currentPatchset.base, currentPatchset.tip, entry.path))) + .setEnabled(!entry.changeType.equals(ChangeType.ADD) + && !entry.changeType.equals(ChangeType.DELETE))); + item.add(setNewTarget(new BookmarkablePageLink("view", BlobPage.class, WicketUtils + .newPathParameter(repositoryName, currentPatchset.tip, entry.path))) + .setEnabled(!entry.changeType.equals(ChangeType.DELETE))); + } + + WicketUtils.setAlternatingBackground(item, counter); + counter++; + } + }; + panel.add(pathsView); + + addPtReviewInstructions(user, repository, panel); + addGitReviewInstructions(user, repository, panel); + panel.add(createMergePanel(user, repository)); + + return panel; + } + + protected IconAjaxLink createReviewLink(String wicketId, final Score score) { + return new IconAjaxLink(wicketId, getScoreClass(score), Model.of(getScoreDescription(score))) { + + private static final long serialVersionUID = 1L; + + @Override + public void onClick(AjaxRequestTarget target) { + review(score); + } + }; + } + + protected String getScoreClass(Score score) { + switch (score) { + case vetoed: + return "fa fa-exclamation-circle"; + case needs_improvement: + return "fa fa-thumbs-o-down"; + case looks_good: + return "fa fa-thumbs-o-up"; + case approved: + return "fa fa-check-circle"; + case not_reviewed: + default: + return "fa fa-minus-circle"; + } + } + + protected String getScoreDescription(Score score) { + String description; + switch (score) { + case vetoed: + description = getString("gb.veto"); + break; + case needs_improvement: + description = getString("gb.needsImprovement"); + break; + case looks_good: + description = getString("gb.looksGood"); + break; + case approved: + description = getString("gb.approve"); + break; + case not_reviewed: + default: + description = getString("gb.hasNotReviewed"); + } + return String.format("%1$s (%2$+d)", description, score.getValue()); + } + + protected void review(Score score) { + UserModel user = GitBlitWebSession.get().getUser(); + Patchset ps = ticket.getCurrentPatchset(); + Change change = new Change(user.username); + change.review(ps, score, !ticket.isReviewer(user.username)); + if (!ticket.isWatching(user.username)) { + change.watch(user.username); + } + TicketModel updatedTicket = app().tickets().updateTicket(getRepositoryModel(), ticket.number, change); + app().tickets().createNotifier().sendMailing(updatedTicket); + setResponsePage(TicketsPage.class, getPageParameters()); + } + + protected X setNewTarget(X x) { + x.add(new SimpleAttributeModifier("target", "_blank")); + return x; + } + + protected void addGitReviewInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) { + String repoUrl = getRepositoryUrl(user, repository); + + panel.add(new Label("gitStep1", MessageFormat.format(getString("gb.stepN"), 1))); + panel.add(new Label("gitStep2", MessageFormat.format(getString("gb.stepN"), 2))); + + String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number)); + String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number); + + String step1 = MessageFormat.format("git fetch {0} {1}", repoUrl, ticketBranch); + String step2 = MessageFormat.format("git checkout -B {0} FETCH_HEAD", reviewBranch); + + panel.add(new Label("gitPreStep1", step1)); + panel.add(new Label("gitPreStep2", step2)); + + panel.add(createCopyFragment("gitCopyStep1", step1.replace("\n", " && "))); + panel.add(createCopyFragment("gitCopyStep2", step2.replace("\n", " && "))); + } + + protected void addPtReviewInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) { + String step1 = MessageFormat.format("pt checkout {0,number,0}", ticket.number); + panel.add(new Label("ptPreStep", step1)); + panel.add(createCopyFragment("ptCopyStep", step1)); + } + + /** + * Adds a merge panel for the patchset to the markup container. The panel + * may just a message if the patchset can not be merged. + * + * @param c + * @param user + * @param repository + */ + protected Component createMergePanel(UserModel user, RepositoryModel repository) { + Patchset patchset = ticket.getCurrentPatchset(); + if (patchset == null) { + // no patchset to merge + return new Label("mergePanel"); + } + + boolean allowMerge; + if (repository.requireApproval) { + // rpeository requires approval + allowMerge = ticket.isOpen() && ticket.isApproved(patchset); + } else { + // vetos are binding + allowMerge = ticket.isOpen() && !ticket.isVetoed(patchset); + } + + MergeStatus mergeStatus = JGitUtils.canMerge(getRepository(), patchset.tip, ticket.mergeTo); + if (allowMerge) { + if (MergeStatus.MERGEABLE == mergeStatus) { + // patchset can be cleanly merged to integration branch OR has already been merged + Fragment mergePanel = new Fragment("mergePanel", "mergeableFragment", this); + mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetMergeable"), ticket.mergeTo))); + if (user.canPush(repository)) { + // user can merge locally + SimpleAjaxLink mergeButton = new SimpleAjaxLink("mergeButton", Model.of(getString("gb.merge"))) { + + private static final long serialVersionUID = 1L; + + @Override + public void onClick(AjaxRequestTarget target) { + + // ensure the patchset is still current AND not vetoed + Patchset patchset = ticket.getCurrentPatchset(); + final TicketModel refreshedTicket = app().tickets().getTicket(getRepositoryModel(), ticket.number); + if (patchset.equals(refreshedTicket.getCurrentPatchset())) { + // patchset is current, check for recent veto + if (!refreshedTicket.isVetoed(patchset)) { + // patchset is not vetoed + + // execute the merge using the ticket service + app().tickets().exec(new Runnable() { + @Override + public void run() { + PatchsetReceivePack rp = new PatchsetReceivePack( + app().gitblit(), + getRepository(), + getRepositoryModel(), + GitBlitWebSession.get().getUser()); + MergeStatus result = rp.merge(refreshedTicket); + if (MergeStatus.MERGED == result) { + // notify participants and watchers + rp.sendAll(); + } else { + // merge failure + String msg = MessageFormat.format("Failed to merge ticket {0,number,0}: {1}", ticket.number, result.name()); + logger.error(msg); + GitBlitWebSession.get().cacheErrorMessage(msg); + } + } + }); + } else { + // vetoed patchset + String msg = MessageFormat.format("Can not merge ticket {0,number,0}, patchset {1,number,0} has been vetoed!", + ticket.number, patchset.number); + GitBlitWebSession.get().cacheErrorMessage(msg); + logger.error(msg); + } + } else { + // not current patchset + String msg = MessageFormat.format("Can not merge ticket {0,number,0}, the patchset has been updated!", ticket.number); + GitBlitWebSession.get().cacheErrorMessage(msg); + logger.error(msg); + } + + setResponsePage(TicketsPage.class, getPageParameters()); + } + }; + mergePanel.add(mergeButton); + Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetMergeableMore"); + mergePanel.add(instructions); + } else { + mergePanel.add(new Label("mergeButton").setVisible(false)); + mergePanel.add(new Label("mergeMore").setVisible(false)); + } + return mergePanel; + } else if (MergeStatus.ALREADY_MERGED == mergeStatus) { + // patchset already merged + Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this); + mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo))); + return mergePanel; + } else { + // patchset can not be cleanly merged + Fragment mergePanel = new Fragment("mergePanel", "notMergeableFragment", this); + mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo))); + if (user.canPush(repository)) { + // user can merge locally + Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetNotMergeableMore"); + mergePanel.add(instructions); + } else { + mergePanel.add(new Label("mergeMore").setVisible(false)); + } + return mergePanel; + } + } else { + // merge not allowed + if (MergeStatus.ALREADY_MERGED == mergeStatus) { + // patchset already merged + Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this); + mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo))); + return mergePanel; + } else if (ticket.isVetoed(patchset)) { + // patchset has been vetoed + Fragment mergePanel = new Fragment("mergePanel", "vetoedFragment", this); + mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo))); + return mergePanel; + } else if (repository.requireApproval) { + // patchset has been not been approved for merge + Fragment mergePanel = new Fragment("mergePanel", "notApprovedFragment", this); + mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotApproved"), ticket.mergeTo))); + mergePanel.add(new Label("mergeMore", MessageFormat.format(getString("gb.patchsetNotApprovedMore"), ticket.mergeTo))); + return mergePanel; + } else { + // other case + return new Label("mergePanel"); + } + } + } + + protected Component getMergeInstructions(UserModel user, RepositoryModel repository, String markupId, String infoKey) { + Fragment cmd = new Fragment(markupId, "commandlineMergeFragment", this); + cmd.add(new Label("instructions", MessageFormat.format(getString(infoKey), ticket.mergeTo))); + String repoUrl = getRepositoryUrl(user, repository); + + // git instructions + cmd.add(new Label("mergeStep1", MessageFormat.format(getString("gb.stepN"), 1))); + cmd.add(new Label("mergeStep2", MessageFormat.format(getString("gb.stepN"), 2))); + cmd.add(new Label("mergeStep3", MessageFormat.format(getString("gb.stepN"), 3))); + + String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number)); + String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number); + + String step1 = MessageFormat.format("git checkout -B {0} {1}", reviewBranch, ticket.mergeTo); + String step2 = MessageFormat.format("git pull {0} {1}", repoUrl, ticketBranch); + String step3 = MessageFormat.format("git checkout {0}\ngit merge {1}\ngit push origin {0}", ticket.mergeTo, reviewBranch); + + cmd.add(new Label("mergePreStep1", step1)); + cmd.add(new Label("mergePreStep2", step2)); + cmd.add(new Label("mergePreStep3", step3)); + + cmd.add(createCopyFragment("mergeCopyStep1", step1.replace("\n", " && "))); + cmd.add(createCopyFragment("mergeCopyStep2", step2.replace("\n", " && "))); + cmd.add(createCopyFragment("mergeCopyStep3", step3.replace("\n", " && "))); + + // pt instructions + String ptStep = MessageFormat.format("pt pull {0,number,0}", ticket.number); + cmd.add(new Label("ptMergeStep", ptStep)); + cmd.add(createCopyFragment("ptMergeCopyStep", step1.replace("\n", " && "))); + return cmd; + } + + /** + * Returns the primary repository url + * + * @param user + * @param repository + * @return the primary repository url + */ + protected String getRepositoryUrl(UserModel user, RepositoryModel repository) { + HttpServletRequest req = ((WebRequest) getRequest()).getHttpServletRequest(); + String primaryurl = app().gitblit().getRepositoryUrls(req, user, repository).get(0).url; + String url = primaryurl; + try { + url = new URIish(primaryurl).setUser(null).toString(); + } catch (Exception e) { + } + return url; + } + + /** + * Returns the ticket (if any) that this commit references. + * + * @param commit + * @return null or a ticket + */ + protected TicketModel getTicket(RevCommit commit) { + try { + Map refs = getRepository().getRefDatabase().getRefs(Constants.R_TICKETS_PATCHSETS); + for (Map.Entry entry : refs.entrySet()) { + if (entry.getValue().getObjectId().equals(commit.getId())) { + long id = PatchsetCommand.getTicketNumber(entry.getKey()); + TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), id); + return ticket; + } + } + } catch (Exception e) { + logger().error("failed to determine ticket from ref", e); + } + return null; + } + + protected String getPatchsetTypeCss(PatchsetType type) { + String typeCss; + switch (type) { + case Rebase: + case Rebase_Squash: + typeCss = getLozengeClass(Status.Declined, false); + break; + case Squash: + case Amend: + typeCss = getLozengeClass(Status.On_Hold, false); + break; + case Proposal: + typeCss = getLozengeClass(Status.New, false); + break; + case FastForward: + default: + typeCss = null; + break; + } + return typeCss; + } + + @Override + protected String getPageName() { + return getString("gb.ticket"); + } + + @Override + protected Class getRepoNavPageClass() { + return TicketsPage.class; + } + + @Override + protected String getPageTitle(String repositoryName) { + return "#" + ticket.number + " - " + ticket.title; + } + + protected Fragment createCopyFragment(String wicketId, String text) { + if (app().settings().getBoolean(Keys.web.allowFlashCopyToClipboard, true)) { + // clippy: flash-based copy & paste + Fragment copyFragment = new Fragment(wicketId, "clippyPanel", this); + String baseUrl = WicketUtils.getGitblitURL(getRequest()); + ShockWaveComponent clippy = new ShockWaveComponent("clippy", baseUrl + "/clippy.swf"); + clippy.setValue("flashVars", "text=" + StringUtils.encodeURL(text)); + copyFragment.add(clippy); + return copyFragment; + } else { + // javascript: manual copy & paste with modal browser prompt dialog + Fragment copyFragment = new Fragment(wicketId, "jsPanel", this); + ContextImage img = WicketUtils.newImage("copyIcon", "clippy.png"); + img.add(new JavascriptTextPrompt("onclick", "Copy to Clipboard (Ctrl+C, Enter)", text)); + copyFragment.add(img); + return copyFragment; + } + } +} diff --git a/src/main/java/com/gitblit/wicket/pages/TicketsPage.html b/src/main/java/com/gitblit/wicket/pages/TicketsPage.html new file mode 100644 index 00000000..90544908 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/TicketsPage.html @@ -0,0 +1,215 @@ + + + + + + + +
+ +
+ + +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ : + +
+ +
+ : + +
+ +
+ : + +
+ +
+ +
+
+ + + + + + + + + + + + + +
+ + + [title] +
+ + + [createdBy] [create date] + + +
+
+
+ + + + +
+
+
+ #[id] +
+
+
+ +
+ +
+
+
+
+
+
+
+

+ +
+
+
+
+ + + + + + + + + +
+
+ + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+ , + , + +
+
+
+ + +
+ + +
+
+ + +
+ +
+ + +
+ + [updatedBy] [update date] +
+
+ +
+ + \ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/TicketsPage.java b/src/main/java/com/gitblit/wicket/pages/TicketsPage.java new file mode 100644 index 00000000..525658c5 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/TicketsPage.java @@ -0,0 +1,878 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.wicket.pages; + +import java.io.Serializable; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import org.apache.wicket.Component; +import org.apache.wicket.PageParameters; +import org.apache.wicket.behavior.SimpleAttributeModifier; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.TextField; +import org.apache.wicket.markup.html.link.BookmarkablePageLink; +import org.apache.wicket.markup.html.panel.Fragment; +import org.apache.wicket.markup.repeater.Item; +import org.apache.wicket.markup.repeater.data.DataView; +import org.apache.wicket.markup.repeater.data.ListDataProvider; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.target.basic.RedirectRequestTarget; + +import com.gitblit.Constants; +import com.gitblit.Constants.AccessPermission; +import com.gitblit.Keys; +import com.gitblit.models.RegistrantAccessPermission; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.UserModel; +import com.gitblit.tickets.QueryBuilder; +import com.gitblit.tickets.QueryResult; +import com.gitblit.tickets.TicketIndexer.Lucene; +import com.gitblit.tickets.TicketLabel; +import com.gitblit.tickets.TicketMilestone; +import com.gitblit.tickets.TicketResponsible; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.StringUtils; +import com.gitblit.wicket.GitBlitWebSession; +import com.gitblit.wicket.SessionlessForm; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.panels.GravatarImage; +import com.gitblit.wicket.panels.LinkPanel; + +public class TicketsPage extends TicketBasePage { + + final TicketResponsible any; + + public static final String [] openStatii = new String [] { Status.New.name().toLowerCase(), Status.Open.name().toLowerCase() }; + + public static final String [] closedStatii = new String [] { "!" + Status.New.name().toLowerCase(), "!" + Status.Open.name().toLowerCase() }; + + public TicketsPage(PageParameters params) { + super(params); + + if (!app().tickets().isReady()) { + // tickets prohibited + setResponsePage(SummaryPage.class, WicketUtils.newRepositoryParameter(repositoryName)); + } else if (!app().tickets().hasTickets(getRepositoryModel())) { + // no tickets for this repository + setResponsePage(NoTicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName)); + } else { + String id = WicketUtils.getObject(params); + if (id != null) { + // view the ticket with the TicketPage + setResponsePage(TicketPage.class, params); + } + } + + // set stateless page preference + setStatelessHint(true); + + any = new TicketResponsible("any", "[* TO *]", null); + + UserModel user = GitBlitWebSession.get().getUser(); + boolean isAuthenticated = user != null && user.isAuthenticated; + + final String [] statiiParam = params.getStringArray(Lucene.status.name()); + final String assignedToParam = params.getString(Lucene.responsible.name(), null); + final String milestoneParam = params.getString(Lucene.milestone.name(), null); + final String queryParam = params.getString("q", null); + final String searchParam = params.getString("s", null); + final String sortBy = Lucene.fromString(params.getString("sort", Lucene.created.name())).name(); + final boolean desc = !"asc".equals(params.getString("direction", "desc")); + + + // add search form + TicketSearchForm searchForm = new TicketSearchForm("ticketSearchForm", repositoryName, searchParam); + add(searchForm); + searchForm.setTranslatedAttributes(); + + final String activeQuery; + if (!StringUtils.isEmpty(searchParam)) { + activeQuery = searchParam; + } else if (StringUtils.isEmpty(queryParam)) { + activeQuery = ""; + } else { + activeQuery = queryParam; + } + + // build Lucene query from defaults and request parameters + QueryBuilder qb = new QueryBuilder(queryParam); + if (!qb.containsField(Lucene.rid.name())) { + // specify the repository + qb.and(Lucene.rid.matches(getRepositoryModel().getRID())); + } + if (!qb.containsField(Lucene.responsible.name())) { + // specify the responsible + qb.and(Lucene.responsible.matches(assignedToParam)); + } + if (!qb.containsField(Lucene.milestone.name())) { + // specify the milestone + qb.and(Lucene.milestone.matches(milestoneParam)); + } + if (!qb.containsField(Lucene.status.name()) && !ArrayUtils.isEmpty(statiiParam)) { + // specify the states + boolean not = false; + QueryBuilder q = new QueryBuilder(); + for (String state : statiiParam) { + if (state.charAt(0) == '!') { + not = true; + q.and(Lucene.status.doesNotMatch(state.substring(1))); + } else { + q.or(Lucene.status.matches(state)); + } + } + if (not) { + qb.and(q.toString()); + } else { + qb.and(q.toSubquery().toString()); + } + } + final String luceneQuery = qb.build(); + + // open milestones + List milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open); + TicketMilestone currentMilestone = null; + if (!StringUtils.isEmpty(milestoneParam)) { + for (TicketMilestone tm : milestones) { + if (tm.name.equals(milestoneParam)) { + // get the milestone (queries the index) + currentMilestone = app().tickets().getMilestone(getRepositoryModel(), milestoneParam); + break; + } + } + + if (currentMilestone == null) { + // milestone not found, create a temporary one + currentMilestone = new TicketMilestone(milestoneParam); + } + } + + Fragment milestonePanel; + if (currentMilestone == null) { + milestonePanel = new Fragment("milestonePanel", "noMilestoneFragment", this); + add(milestonePanel); + } else { + milestonePanel = new Fragment("milestonePanel", "milestoneProgressFragment", this); + milestonePanel.add(new Label("currentMilestone", currentMilestone.name)); + if (currentMilestone.due == null) { + milestonePanel.add(new Label("currentDueDate", getString("gb.notSpecified"))); + } else { + milestonePanel.add(WicketUtils.createDateLabel("currentDueDate", currentMilestone.due, GitBlitWebSession + .get().getTimezone(), getTimeUtils(), false)); + } + Label label = new Label("progress"); + WicketUtils.setCssStyle(label, "width:" + currentMilestone.getProgress() + "%;"); + milestonePanel.add(label); + + milestonePanel.add(new LinkPanel("openTickets", null, + currentMilestone.getOpenTickets() + " open", + TicketsPage.class, + queryParameters(null, currentMilestone.name, openStatii, null, sortBy, desc, 1))); + + milestonePanel.add(new LinkPanel("closedTickets", null, + currentMilestone.getClosedTickets() + " closed", + TicketsPage.class, + queryParameters(null, currentMilestone.name, closedStatii, null, sortBy, desc, 1))); + + milestonePanel.add(new Label("totalTickets", currentMilestone.getTotalTickets() + " total")); + add(milestonePanel); + } + + Fragment milestoneDropdown = new Fragment("milestoneDropdown", "milestoneDropdownFragment", this); + PageParameters resetMilestone = queryParameters(queryParam, null, statiiParam, assignedToParam, sortBy, desc, 1); + milestoneDropdown.add(new BookmarkablePageLink("resetMilestone", TicketsPage.class, resetMilestone)); + + ListDataProvider milestonesDp = new ListDataProvider(milestones); + DataView milestonesMenu = new DataView("milestone", milestonesDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + final TicketMilestone tm = item.getModelObject(); + PageParameters params = queryParameters(queryParam, tm.name, statiiParam, assignedToParam, sortBy, desc, 1); + item.add(new LinkPanel("milestoneLink", null, tm.name, TicketsPage.class, params).setRenderBodyOnly(true)); + } + }; + milestoneDropdown.add(milestonesMenu); + milestonePanel.add(milestoneDropdown); + + // search or query tickets + int page = Math.max(1, WicketUtils.getPage(params)); + int pageSize = app().settings().getInteger(Keys.tickets.perPage, 25); + List results; + if (StringUtils.isEmpty(searchParam)) { + results = app().tickets().queryFor(luceneQuery, page, pageSize, sortBy, desc); + } else { + results = app().tickets().searchFor(getRepositoryModel(), searchParam, page, pageSize); + } + int totalResults = results.size() == 0 ? 0 : results.get(0).totalResults; + + // standard queries + add(new BookmarkablePageLink("changesQuery", TicketsPage.class, + queryParameters( + Lucene.type.matches(TicketModel.Type.Proposal.name()), + milestoneParam, + statiiParam, + assignedToParam, + sortBy, + desc, + 1))); + + add(new BookmarkablePageLink("bugsQuery", TicketsPage.class, + queryParameters( + Lucene.type.matches(TicketModel.Type.Bug.name()), + milestoneParam, + statiiParam, + assignedToParam, + sortBy, + desc, + 1))); + + add(new BookmarkablePageLink("enhancementsQuery", TicketsPage.class, + queryParameters( + Lucene.type.matches(TicketModel.Type.Enhancement.name()), + milestoneParam, + statiiParam, + assignedToParam, + sortBy, + desc, + 1))); + + add(new BookmarkablePageLink("tasksQuery", TicketsPage.class, + queryParameters( + Lucene.type.matches(TicketModel.Type.Task.name()), + milestoneParam, + statiiParam, + assignedToParam, + sortBy, + desc, + 1))); + + add(new BookmarkablePageLink("questionsQuery", TicketsPage.class, + queryParameters( + Lucene.type.matches(TicketModel.Type.Question.name()), + milestoneParam, + statiiParam, + assignedToParam, + sortBy, + desc, + 1))); + + add(new BookmarkablePageLink("resetQuery", TicketsPage.class, + queryParameters( + null, + milestoneParam, + openStatii, + null, + null, + true, + 1))); + + if (isAuthenticated) { + add(new Label("userDivider")); + add(new BookmarkablePageLink("createdQuery", TicketsPage.class, + queryParameters( + Lucene.createdby.matches(user.username), + milestoneParam, + statiiParam, + assignedToParam, + sortBy, + desc, + 1))); + + add(new BookmarkablePageLink("watchedQuery", TicketsPage.class, + queryParameters( + Lucene.watchedby.matches(user.username), + milestoneParam, + statiiParam, + assignedToParam, + sortBy, + desc, + 1))); + add(new BookmarkablePageLink("mentionsQuery", TicketsPage.class, + queryParameters( + Lucene.mentions.matches(user.username), + milestoneParam, + statiiParam, + assignedToParam, + sortBy, + desc, + 1))); + } else { + add(new Label("userDivider").setVisible(false)); + add(new Label("createdQuery").setVisible(false)); + add(new Label("watchedQuery").setVisible(false)); + add(new Label("mentionsQuery").setVisible(false)); + } + + Set dynamicQueries = new TreeSet(); + for (TicketLabel label : app().tickets().getLabels(getRepositoryModel())) { + String q = QueryBuilder.q(Lucene.labels.matches(label.name)).build(); + dynamicQueries.add(new TicketQuery(label.name, q).color(label.color)); + } + + for (QueryResult ticket : results) { + if (!StringUtils.isEmpty(ticket.topic)) { + String q = QueryBuilder.q(Lucene.topic.matches(ticket.topic)).build(); + dynamicQueries.add(new TicketQuery(ticket.topic, q)); + } + + if (!ArrayUtils.isEmpty(ticket.labels)) { + for (String label : ticket.labels) { + String q = QueryBuilder.q(Lucene.labels.matches(label)).build(); + dynamicQueries.add(new TicketQuery(label, q)); + } + } + } + + if (dynamicQueries.size() == 0) { + add(new Label("dynamicQueries").setVisible(false)); + } else { + Fragment fragment = new Fragment("dynamicQueries", "dynamicQueriesFragment", this); + ListDataProvider dynamicQueriesDp = new ListDataProvider(new ArrayList(dynamicQueries)); + DataView dynamicQueriesList = new DataView("dynamicQuery", dynamicQueriesDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + final TicketQuery tq = item.getModelObject(); + Component swatch = new Label("swatch", " ").setEscapeModelStrings(false); + if (StringUtils.isEmpty(tq.color)) { + // calculate a color + tq.color = StringUtils.getColor(tq.name); + } + String background = MessageFormat.format("background-color:{0};", tq.color); + swatch.add(new SimpleAttributeModifier("style", background)); + item.add(swatch); + if (activeQuery.contains(tq.query)) { + // selected + String q = QueryBuilder.q(activeQuery).remove(tq.query).build(); + PageParameters params = queryParameters(q, milestoneParam, statiiParam, assignedToParam, sortBy, desc, 1); + item.add(new LinkPanel("link", "active", tq.name, TicketsPage.class, params).setRenderBodyOnly(true)); + Label checked = new Label("checked"); + WicketUtils.setCssClass(checked, "iconic-o-x"); + item.add(checked); + item.add(new SimpleAttributeModifier("style", background)); + } else { + // unselected + String q = QueryBuilder.q(queryParam).toSubquery().and(tq.query).build(); + PageParameters params = queryParameters(q, milestoneParam, statiiParam, assignedToParam, sortBy, desc, 1); + item.add(new LinkPanel("link", null, tq.name, TicketsPage.class, params).setRenderBodyOnly(true)); + item.add(new Label("checked").setVisible(false)); + } + } + }; + fragment.add(dynamicQueriesList); + add(fragment); + } + + // states + if (ArrayUtils.isEmpty(statiiParam)) { + add(new Label("selectedStatii", getString("gb.all"))); + } else { + add(new Label("selectedStatii", StringUtils.flattenStrings(Arrays.asList(statiiParam), ","))); + } + add(new BookmarkablePageLink("openTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, openStatii, assignedToParam, sortBy, desc, 1))); + add(new BookmarkablePageLink("closedTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, closedStatii, assignedToParam, sortBy, desc, 1))); + add(new BookmarkablePageLink("allTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, null, assignedToParam, sortBy, desc, 1))); + + // by status + List statii = Arrays.asList(Status.values()); + ListDataProvider resolutionsDp = new ListDataProvider(statii); + DataView statiiLinks = new DataView("statii", resolutionsDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + final Status status = item.getModelObject(); + PageParameters p = queryParameters(queryParam, milestoneParam, new String [] { status.name().toLowerCase() }, assignedToParam, sortBy, desc, 1); + String css = getStatusClass(status); + item.add(new LinkPanel("statusLink", css, status.toString(), TicketsPage.class, p).setRenderBodyOnly(true)); + } + }; + add(statiiLinks); + + // responsible filter + List responsibles = new ArrayList(); + for (RegistrantAccessPermission perm : app().repositories().getUserAccessPermissions(getRepositoryModel())) { + if (perm.permission.atLeast(AccessPermission.PUSH)) { + UserModel u = app().users().getUserModel(perm.registrant); + responsibles.add(new TicketResponsible(u)); + } + } + Collections.sort(responsibles); + responsibles.add(0, any); + + TicketResponsible currentResponsible = null; + for (TicketResponsible u : responsibles) { + if (u.username.equals(assignedToParam)) { + currentResponsible = u; + break; + } + } + + add(new Label("currentResponsible", currentResponsible == null ? "" : currentResponsible.displayname)); + ListDataProvider responsibleDp = new ListDataProvider(responsibles); + DataView responsibleMenu = new DataView("responsible", responsibleDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + final TicketResponsible u = item.getModelObject(); + PageParameters params = queryParameters(queryParam, milestoneParam, statiiParam, u.username, sortBy, desc, 1); + item.add(new LinkPanel("responsibleLink", null, u.displayname, TicketsPage.class, params).setRenderBodyOnly(true)); + } + }; + add(responsibleMenu); + PageParameters resetResponsibleParams = queryParameters(queryParam, milestoneParam, statiiParam, null, sortBy, desc, 1); + add(new BookmarkablePageLink("resetResponsible", TicketsPage.class, resetResponsibleParams)); + + List sortChoices = new ArrayList(); + sortChoices.add(new TicketSort(getString("gb.sortNewest"), Lucene.created.name(), true)); + sortChoices.add(new TicketSort(getString("gb.sortOldest"), Lucene.created.name(), false)); + sortChoices.add(new TicketSort(getString("gb.sortMostRecentlyUpdated"), Lucene.updated.name(), true)); + sortChoices.add(new TicketSort(getString("gb.sortLeastRecentlyUpdated"), Lucene.updated.name(), false)); + sortChoices.add(new TicketSort(getString("gb.sortMostComments"), Lucene.comments.name(), true)); + sortChoices.add(new TicketSort(getString("gb.sortLeastComments"), Lucene.comments.name(), false)); + sortChoices.add(new TicketSort(getString("gb.sortMostPatchsetRevisions"), Lucene.patchsets.name(), true)); + sortChoices.add(new TicketSort(getString("gb.sortLeastPatchsetRevisions"), Lucene.patchsets.name(), false)); + sortChoices.add(new TicketSort(getString("gb.sortMostVotes"), Lucene.votes.name(), true)); + sortChoices.add(new TicketSort(getString("gb.sortLeastVotes"), Lucene.votes.name(), false)); + + TicketSort currentSort = sortChoices.get(0); + for (TicketSort ts : sortChoices) { + if (ts.sortBy.equals(sortBy) && desc == ts.desc) { + currentSort = ts; + break; + } + } + add(new Label("currentSort", currentSort.name)); + + ListDataProvider sortChoicesDp = new ListDataProvider(sortChoices); + DataView sortMenu = new DataView("sort", sortChoicesDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + final TicketSort ts = item.getModelObject(); + PageParameters params = queryParameters(queryParam, milestoneParam, statiiParam, assignedToParam, ts.sortBy, ts.desc, 1); + item.add(new LinkPanel("sortLink", null, ts.name, TicketsPage.class, params).setRenderBodyOnly(true)); + } + }; + add(sortMenu); + + + // paging links + buildPager(queryParam, milestoneParam, statiiParam, assignedToParam, sortBy, desc, page, pageSize, results.size(), totalResults); + + ListDataProvider resultsDataProvider = new ListDataProvider(results); + DataView ticketsView = new DataView("ticket", resultsDataProvider) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + final QueryResult ticket = item.getModelObject(); + item.add(getStateIcon("state", ticket.type, ticket.status)); + item.add(new Label("id", "" + ticket.number)); + UserModel creator = app().users().getUserModel(ticket.createdBy); + if (creator != null) { + item.add(new LinkPanel("createdBy", null, creator.getDisplayName(), + UserPage.class, WicketUtils.newUsernameParameter(ticket.createdBy))); + } else { + item.add(new Label("createdBy", ticket.createdBy)); + } + item.add(WicketUtils.createDateLabel("createDate", ticket.createdAt, GitBlitWebSession + .get().getTimezone(), getTimeUtils(), false)); + + if (ticket.updatedAt == null) { + item.add(new Label("updated").setVisible(false)); + } else { + Fragment updated = new Fragment("updated", "updatedFragment", this); + UserModel updater = app().users().getUserModel(ticket.updatedBy); + if (updater != null) { + updated.add(new LinkPanel("updatedBy", null, updater.getDisplayName(), + UserPage.class, WicketUtils.newUsernameParameter(ticket.updatedBy))); + } else { + updated.add(new Label("updatedBy", ticket.updatedBy)); + } + updated.add(WicketUtils.createDateLabel("updateDate", ticket.updatedAt, GitBlitWebSession + .get().getTimezone(), getTimeUtils(), false)); + item.add(updated); + } + + item.add(new LinkPanel("title", "list subject", StringUtils.trimString( + ticket.title, Constants.LEN_SHORTLOG), TicketsPage.class, newTicketParameter(ticket))); + + ListDataProvider labelsProvider = new ListDataProvider(ticket.getLabels()); + DataView labelsView = new DataView("labels", labelsProvider) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item labelItem) { + String content = messageProcessor().processPlainCommitMessage(getRepository(), repositoryName, labelItem.getModelObject()); + Label label = new Label("label", content); + label.setEscapeModelStrings(false); + TicketLabel tLabel = app().tickets().getLabel(getRepositoryModel(), labelItem.getModelObject()); + String background = MessageFormat.format("background-color:{0};", tLabel.color); + label.add(new SimpleAttributeModifier("style", background)); + labelItem.add(label); + } + }; + item.add(labelsView); + + if (StringUtils.isEmpty(ticket.responsible)) { + item.add(new Label("responsible").setVisible(false)); + } else { + UserModel responsible = app().users().getUserModel(ticket.responsible); + if (responsible == null) { + responsible = new UserModel(ticket.responsible); + } + GravatarImage avatar = new GravatarImage("responsible", responsible.getDisplayName(), + responsible.emailAddress, null, 16, true); + avatar.setTooltip(getString("gb.responsible") + ": " + responsible.getDisplayName()); + item.add(avatar); + } + + // votes indicator + Label v = new Label("votes", "" + ticket.votesCount); + WicketUtils.setHtmlTooltip(v, getString("gb.votes")); + item.add(v.setVisible(ticket.votesCount > 0)); + + // watching indicator + item.add(new Label("watching").setVisible(ticket.isWatching(GitBlitWebSession.get().getUsername()))); + + // status indicator + String css = getLozengeClass(ticket.status, true); + Label l = new Label("status", ticket.status.toString()); + WicketUtils.setCssClass(l, css); + item.add(l); + + // add the ticket indicators/icons + List indicators = new ArrayList(); + + // comments + if (ticket.commentsCount > 0) { + int count = ticket.commentsCount; + String pattern = "gb.nComments"; + if (count == 1) { + pattern = "gb.oneComment"; + } + indicators.add(new Indicator("fa fa-comment", count, pattern)); + } + + // participants + if (!ArrayUtils.isEmpty(ticket.participants)) { + int count = ticket.participants.size(); + if (count > 1) { + String pattern = "gb.nParticipants"; + indicators.add(new Indicator("fa fa-user", count, pattern)); + } + } + + // attachments + if (!ArrayUtils.isEmpty(ticket.attachments)) { + int count = ticket.attachments.size(); + String pattern = "gb.nAttachments"; + if (count == 1) { + pattern = "gb.oneAttachment"; + } + indicators.add(new Indicator("fa fa-file", count, pattern)); + } + + // patchset revisions + if (ticket.patchset != null) { + int count = ticket.patchset.commits; + String pattern = "gb.nCommits"; + if (count == 1) { + pattern = "gb.oneCommit"; + } + indicators.add(new Indicator("fa fa-code", count, pattern)); + } + + // milestone + if (!StringUtils.isEmpty(ticket.milestone)) { + indicators.add(new Indicator("fa fa-bullseye", ticket.milestone)); + } + + ListDataProvider indicatorsDp = new ListDataProvider(indicators); + DataView indicatorsView = new DataView("indicators", indicatorsDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + Indicator indicator = item.getModelObject(); + String tooltip = indicator.getTooltip(); + + Label icon = new Label("icon"); + WicketUtils.setCssClass(icon, indicator.css); + item.add(icon); + + if (indicator.count > 0) { + Label count = new Label("count", "" + indicator.count); + item.add(count.setVisible(!StringUtils.isEmpty(tooltip))); + } else { + item.add(new Label("count").setVisible(false)); + } + + WicketUtils.setHtmlTooltip(item, tooltip); + } + }; + item.add(indicatorsView); + } + }; + add(ticketsView); + + DataView milestonesList = new DataView("milestoneList", milestonesDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + final TicketMilestone tm = item.getModelObject(); + item.add(new Label("milestoneName", tm.name)); + item.add(new Label("milestoneState", tm.status.name())); + item.add(new Label("milestoneDue", tm.due == null ? getString("gb.notSpecified") : tm.due.toString())); + } + }; + add(milestonesList); + } + + protected PageParameters queryParameters( + String query, + String milestone, + String[] states, + String assignedTo, + String sort, + boolean descending, + int page) { + + PageParameters params = WicketUtils.newRepositoryParameter(repositoryName); + if (!StringUtils.isEmpty(query)) { + params.add("q", query); + } + if (!StringUtils.isEmpty(milestone)) { + params.add(Lucene.milestone.name(), milestone); + } + if (!ArrayUtils.isEmpty(states)) { + for (String state : states) { + params.add(Lucene.status.name(), state); + } + } + if (!StringUtils.isEmpty(assignedTo)) { + params.add(Lucene.responsible.name(), assignedTo); + } + if (!StringUtils.isEmpty(sort)) { + params.add("sort", sort); + } + if (!descending) { + params.add("direction", "asc"); + } + if (page > 1) { + params.add("pg", "" + page); + } + return params; + } + + protected PageParameters newTicketParameter(QueryResult ticket) { + return WicketUtils.newObjectParameter(repositoryName, "" + ticket.number); + } + + @Override + protected String getPageName() { + return getString("gb.tickets"); + } + + protected void buildPager( + final String query, + final String milestone, + final String [] states, + final String assignedTo, + final String sort, + final boolean desc, + final int page, + int pageSize, + int count, + int total) { + + boolean showNav = total > (2 * pageSize); + boolean allowPrev = page > 1; + boolean allowNext = (pageSize * (page - 1) + count) < total; + add(new BookmarkablePageLink("prevLink", TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, page - 1)).setEnabled(allowPrev).setVisible(showNav)); + add(new BookmarkablePageLink("nextLink", TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, page + 1)).setEnabled(allowNext).setVisible(showNav)); + + if (total <= pageSize) { + add(new Label("pageLink").setVisible(false)); + return; + } + + // determine page numbers to display + int pages = count == 0 ? 0 : ((total / pageSize) + (total % pageSize == 0 ? 0 : 1)); + // preferred number of pagelinks + int segments = 5; + if (pages < segments) { + // not enough data for preferred number of page links + segments = pages; + } + int minpage = Math.min(Math.max(1, page - 2), pages - (segments - 1)); + int maxpage = Math.min(pages, minpage + (segments - 1)); + List sequence = new ArrayList(); + for (int i = minpage; i <= maxpage; i++) { + sequence.add(i); + } + + ListDataProvider pagesDp = new ListDataProvider(sequence); + DataView pagesView = new DataView("pageLink", pagesDp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item item) { + final Integer i = item.getModelObject(); + LinkPanel link = new LinkPanel("page", null, "" + i, TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, i)); + link.setRenderBodyOnly(true); + if (i == page) { + WicketUtils.setCssClass(item, "active"); + } + item.add(link); + } + }; + add(pagesView); + } + + private class Indicator implements Serializable { + + private static final long serialVersionUID = 1L; + + final String css; + final int count; + final String tooltip; + + Indicator(String css, String tooltip) { + this.css = css; + this.tooltip = tooltip; + this.count = 0; + } + + Indicator(String css, int count, String pattern) { + this.css = css; + this.count = count; + this.tooltip = StringUtils.isEmpty(pattern) ? "" : MessageFormat.format(getString(pattern), count); + } + + String getTooltip() { + return tooltip; + } + } + + private class TicketQuery implements Serializable, Comparable { + + private static final long serialVersionUID = 1L; + + final String name; + final String query; + String color; + + TicketQuery(String name, String query) { + this.name = name; + this.query = query; + } + + TicketQuery color(String value) { + this.color = value; + return this; + } + + @Override + public boolean equals(Object o) { + if (o instanceof TicketQuery) { + return ((TicketQuery) o).query.equals(query); + } + return false; + } + + @Override + public int hashCode() { + return query.hashCode(); + } + + @Override + public int compareTo(TicketQuery o) { + return query.compareTo(o.query); + } + } + + private class TicketSort implements Serializable { + + private static final long serialVersionUID = 1L; + + final String name; + final String sortBy; + final boolean desc; + + TicketSort(String name, String sortBy, boolean desc) { + this.name = name; + this.sortBy = sortBy; + this.desc = desc; + } + } + + private class TicketSearchForm extends SessionlessForm implements Serializable { + private static final long serialVersionUID = 1L; + + private final String repositoryName; + + private final IModel searchBoxModel;; + + public TicketSearchForm(String id, String repositoryName, String text) { + super(id, TicketsPage.this.getClass(), TicketsPage.this.getPageParameters()); + + this.repositoryName = repositoryName; + this.searchBoxModel = new Model(text == null ? "" : text); + + TextField searchBox = new TextField("ticketSearchBox", searchBoxModel); + add(searchBox); + } + + void setTranslatedAttributes() { + WicketUtils.setHtmlTooltip(get("ticketSearchBox"), + MessageFormat.format(getString("gb.searchTicketsTooltip"), repositoryName)); + WicketUtils.setInputPlaceholder(get("ticketSearchBox"), getString("gb.searchTickets")); + } + + @Override + public void onSubmit() { + String searchString = searchBoxModel.getObject(); + if (StringUtils.isEmpty(searchString)) { + // redirect to self to avoid wicket page update bug + String absoluteUrl = getCanonicalUrl(); + getRequestCycle().setRequestTarget(new RedirectRequestTarget(absoluteUrl)); + return; + } + + // use an absolute url to workaround Wicket-Tomcat problems with + // mounted url parameters (issue-111) + PageParameters params = WicketUtils.newRepositoryParameter(repositoryName); + params.add("s", searchString); + String absoluteUrl = getCanonicalUrl(TicketsPage.class, params); + getRequestCycle().setRequestTarget(new RedirectRequestTarget(absoluteUrl)); + } + } +} diff --git a/src/main/java/com/gitblit/wicket/pages/propose_git.md b/src/main/java/com/gitblit/wicket/pages/propose_git.md new file mode 100644 index 00000000..1b4f429c --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/propose_git.md @@ -0,0 +1,6 @@ + git clone ${url} + cd ${repo} + git checkout -b ${reviewBranch} ${integrationBranch} + ... + git push origin HEAD:refs/for/${ticketId} + git branch --set-upstream-to=origin/${reviewBranch} diff --git a/src/main/java/com/gitblit/wicket/pages/propose_pt.md b/src/main/java/com/gitblit/wicket/pages/propose_pt.md new file mode 100644 index 00000000..949d2361 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/propose_pt.md @@ -0,0 +1,5 @@ + git clone ${url} + cd ${repo} + pt start ${ticketId} + ... + pt propose diff --git a/src/main/java/com/gitblit/wicket/panels/CommentPanel.html b/src/main/java/com/gitblit/wicket/panels/CommentPanel.html new file mode 100644 index 00000000..1fdfb168 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/panels/CommentPanel.html @@ -0,0 +1,29 @@ + + + +
+ + +
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+ \ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/panels/CommentPanel.java b/src/main/java/com/gitblit/wicket/panels/CommentPanel.java new file mode 100644 index 00000000..1d49ff0f --- /dev/null +++ b/src/main/java/com/gitblit/wicket/panels/CommentPanel.java @@ -0,0 +1,110 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.wicket.panels; + +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.markup.html.form.AjaxButton; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.Model; + +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.UserModel; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.pages.BasePage; + +public class CommentPanel extends BasePanel { + private static final long serialVersionUID = 1L; + + final UserModel user; + + final TicketModel ticket; + + final Change change; + + final Class pageClass; + + private MarkdownTextArea markdownEditor; + + private Label markdownPreview; + + private String repositoryName; + + public CommentPanel(String id, final UserModel user, final TicketModel ticket, + final Change change, final Class pageClass) { + super(id); + this.user = user; + this.ticket = ticket; + this.change = change; + this.pageClass = pageClass; + } + + @Override + protected void onInitialize() { + super.onInitialize(); + + Form form = new Form("editorForm"); + add(form); + + form.add(new AjaxButton("submit", new Model(getString("gb.comment")), form) { + private static final long serialVersionUID = 1L; + + @Override + public void onSubmit(AjaxRequestTarget target, Form form) { + String txt = markdownEditor.getText(); + if (change == null) { + // new comment + Change newComment = new Change(user.username); + newComment.comment(txt); + if (!ticket.isWatching(user.username)) { + newComment.watch(user.username); + } + RepositoryModel repository = app().repositories().getRepositoryModel(ticket.repository); + TicketModel updatedTicket = app().tickets().updateTicket(repository, ticket.number, newComment); + if (updatedTicket != null) { + app().tickets().createNotifier().sendMailing(updatedTicket); + setResponsePage(pageClass, WicketUtils.newObjectParameter(updatedTicket.repository, "" + ticket.number)); + } else { + error("Failed to add comment!"); + } + } else { + // TODO update comment + } + } + }.setVisible(ticket != null && ticket.number > 0)); + + final IModel markdownPreviewModel = new Model(); + markdownPreview = new Label("markdownPreview", markdownPreviewModel); + markdownPreview.setEscapeModelStrings(false); + markdownPreview.setOutputMarkupId(true); + add(markdownPreview); + + markdownEditor = new MarkdownTextArea("markdownEditor", markdownPreviewModel, markdownPreview); + markdownEditor.setRepository(repositoryName); + WicketUtils.setInputPlaceholder(markdownEditor, getString("gb.leaveComment")); + add(markdownEditor); + } + + public void setRepository(String repositoryName) { + this.repositoryName = repositoryName; + if (markdownEditor != null) { + markdownEditor.setRepository(repositoryName); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/panels/DigestsPanel.java b/src/main/java/com/gitblit/wicket/panels/DigestsPanel.java index de09aa95..decfda50 100644 --- a/src/main/java/com/gitblit/wicket/panels/DigestsPanel.java +++ b/src/main/java/com/gitblit/wicket/panels/DigestsPanel.java @@ -1,263 +1,276 @@ -/* - * Copyright 2013 gitblit.com. - * - * 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 com.gitblit.wicket.panels; - -import java.text.DateFormat; -import java.text.MessageFormat; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.TimeZone; - -import org.apache.wicket.markup.html.basic.Label; -import org.apache.wicket.markup.repeater.Item; -import org.apache.wicket.markup.repeater.data.DataView; -import org.apache.wicket.markup.repeater.data.ListDataProvider; -import org.eclipse.jgit.lib.PersonIdent; - -import com.gitblit.Constants; -import com.gitblit.Keys; -import com.gitblit.models.DailyLogEntry; -import com.gitblit.models.RepositoryCommit; -import com.gitblit.utils.StringUtils; -import com.gitblit.utils.TimeUtils; -import com.gitblit.wicket.WicketUtils; -import com.gitblit.wicket.pages.CommitPage; -import com.gitblit.wicket.pages.ComparePage; -import com.gitblit.wicket.pages.SummaryPage; -import com.gitblit.wicket.pages.TagPage; -import com.gitblit.wicket.pages.TreePage; - -public class DigestsPanel extends BasePanel { - - private static final long serialVersionUID = 1L; - - private final boolean hasChanges; - - private boolean hasMore; - - public DigestsPanel(String wicketId, List digests) { - super(wicketId); - hasChanges = digests.size() > 0; - - ListDataProvider dp = new ListDataProvider(digests); - DataView pushView = new DataView("change", dp) { - private static final long serialVersionUID = 1L; - - @Override - public void populateItem(final Item logItem) { - final DailyLogEntry change = logItem.getModelObject(); - - String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy"); - TimeZone timezone = getTimeZone(); - DateFormat df = new SimpleDateFormat(dateFormat); - df.setTimeZone(timezone); - - String fullRefName = change.getChangedRefs().get(0); - String shortRefName = fullRefName; - boolean isTag = false; - if (shortRefName.startsWith(Constants.R_HEADS)) { - shortRefName = shortRefName.substring(Constants.R_HEADS.length()); - } else if (shortRefName.startsWith(Constants.R_TAGS)) { - shortRefName = shortRefName.substring(Constants.R_TAGS.length()); - isTag = true; - } - - String fuzzydate; - TimeUtils tu = getTimeUtils(); - Date pushDate = change.date; - if (TimeUtils.isToday(pushDate, timezone)) { - fuzzydate = tu.today(); - } else if (TimeUtils.isYesterday(pushDate, timezone)) { - fuzzydate = tu.yesterday(); - } else { - fuzzydate = getTimeUtils().timeAgo(pushDate); - } - logItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(pushDate))); - - Label changeIcon = new Label("changeIcon"); - // use the repository hash color to differentiate the icon. - String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository)); - WicketUtils.setCssStyle(changeIcon, "color: " + color); - - if (isTag) { - WicketUtils.setCssClass(changeIcon, "iconic-tag"); - } else { - WicketUtils.setCssClass(changeIcon, "iconic-loop"); - } - logItem.add(changeIcon); - - if (isTag) { - // tags are special - PersonIdent ident = change.getCommits().get(0).getAuthorIdent(); - if (!StringUtils.isEmpty(ident.getName())) { - logItem.add(new Label("whoChanged", ident.getName())); - } else { - logItem.add(new Label("whoChanged", ident.getEmailAddress())); - } - } else { - logItem.add(new Label("whoChanged").setVisible(false)); - } - - String preposition = "gb.of"; - boolean isDelete = false; - String what; - String by = null; - switch(change.getChangeType(fullRefName)) { - case CREATE: - if (isTag) { - // new tag - what = getString("gb.createdNewTag"); - preposition = "gb.in"; - } else { - // new branch - what = getString("gb.createdNewBranch"); - preposition = "gb.in"; - } - break; - case DELETE: - isDelete = true; - if (isTag) { - what = getString("gb.deletedTag"); - } else { - what = getString("gb.deletedBranch"); - } - preposition = "gb.from"; - break; - default: - what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.commitsTo") : getString("gb.oneCommitTo"), change.getCommitCount()); - - if (change.getAuthorCount() == 1) { - by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName()); - } else { - by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount()); - } - break; - } - logItem.add(new Label("whatChanged", what)); - logItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by))); - - if (isDelete) { - // can't link to deleted ref - logItem.add(new Label("refChanged", shortRefName)); - } else if (isTag) { - // link to tag - logItem.add(new LinkPanel("refChanged", null, shortRefName, - TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); - } else { - // link to tree - logItem.add(new LinkPanel("refChanged", null, shortRefName, - TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); - } - - // to/from/etc - logItem.add(new Label("repoPreposition", getString(preposition))); - String repoName = StringUtils.stripDotGit(change.repository); - logItem.add(new LinkPanel("repoChanged", null, repoName, - SummaryPage.class, WicketUtils.newRepositoryParameter(change.repository))); - - int maxCommitCount = 5; - List commits = change.getCommits(); - if (commits.size() > maxCommitCount) { - commits = new ArrayList(commits.subList(0, maxCommitCount)); - } - - // compare link - String compareLinkText = null; - if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) { - compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size()); - } else if (change.getCommitCount() > maxCommitCount) { - int diff = change.getCommitCount() - maxCommitCount; - compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff); - } - if (StringUtils.isEmpty(compareLinkText)) { - logItem.add(new Label("compareLink").setVisible(false)); - } else { - String endRangeId = change.getNewId(fullRefName); - String startRangeId = change.getOldId(fullRefName); - logItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId))); - } - - final boolean showSwatch = app().settings().getBoolean(Keys.web.repositoryListSwatches, true); - - ListDataProvider cdp = new ListDataProvider(commits); - DataView commitsView = new DataView("commit", cdp) { - private static final long serialVersionUID = 1L; - - @Override - public void populateItem(final Item commitItem) { - final RepositoryCommit commit = commitItem.getModelObject(); - - // author gravatar - commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false)); - - // merge icon - if (commit.getParentCount() > 1) { - commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png")); - } else { - commitItem.add(WicketUtils.newBlankImage("commitIcon")); - } - - // short message - String shortMessage = commit.getShortMessage(); - String trimmedMessage = shortMessage; - if (commit.getRefs() != null && commit.getRefs().size() > 0) { - trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS); - } else { - trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG); - } - LinkPanel shortlog = new LinkPanel("commitShortMessage", "list", - trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter( - change.repository, commit.getName())); - if (!shortMessage.equals(trimmedMessage)) { - WicketUtils.setHtmlTooltip(shortlog, shortMessage); - } - commitItem.add(shortlog); - - // commit hash link - int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6); - LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen), - CommitPage.class, WicketUtils.newObjectParameter( - change.repository, commit.getName())); - WicketUtils.setCssClass(commitHash, "shortsha1"); - WicketUtils.setHtmlTooltip(commitHash, commit.getName()); - commitItem.add(commitHash); - - if (showSwatch) { - // set repository color - String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository)); - WicketUtils.setCssStyle(commitItem, MessageFormat.format("border-left: 2px solid {0};", color)); - } - } - }; - - logItem.add(commitsView); - } - }; - - add(pushView); - } - - public boolean hasMore() { - return hasMore; - } - - public boolean hideIfEmpty() { - setVisible(hasChanges); - return hasChanges; - } -} +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.wicket.panels; + +import java.text.DateFormat; +import java.text.MessageFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; + +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.repeater.Item; +import org.apache.wicket.markup.repeater.data.DataView; +import org.apache.wicket.markup.repeater.data.ListDataProvider; +import org.eclipse.jgit.lib.PersonIdent; + +import com.gitblit.Constants; +import com.gitblit.Keys; +import com.gitblit.models.DailyLogEntry; +import com.gitblit.models.RepositoryCommit; +import com.gitblit.utils.StringUtils; +import com.gitblit.utils.TimeUtils; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.pages.CommitPage; +import com.gitblit.wicket.pages.ComparePage; +import com.gitblit.wicket.pages.SummaryPage; +import com.gitblit.wicket.pages.TagPage; +import com.gitblit.wicket.pages.TicketsPage; +import com.gitblit.wicket.pages.TreePage; + +public class DigestsPanel extends BasePanel { + + private static final long serialVersionUID = 1L; + + private final boolean hasChanges; + + private boolean hasMore; + + public DigestsPanel(String wicketId, List digests) { + super(wicketId); + hasChanges = digests.size() > 0; + + ListDataProvider dp = new ListDataProvider(digests); + DataView pushView = new DataView("change", dp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item logItem) { + final DailyLogEntry change = logItem.getModelObject(); + + String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy"); + TimeZone timezone = getTimeZone(); + DateFormat df = new SimpleDateFormat(dateFormat); + df.setTimeZone(timezone); + + String fullRefName = change.getChangedRefs().get(0); + String shortRefName = fullRefName; + String ticketId = ""; + boolean isTag = false; + boolean isTicket = false; + if (shortRefName.startsWith(Constants.R_TICKET)) { + ticketId = shortRefName = shortRefName.substring(Constants.R_TICKET.length()); + shortRefName = MessageFormat.format(getString("gb.ticketN"), ticketId); + isTicket = true; + } else if (shortRefName.startsWith(Constants.R_HEADS)) { + shortRefName = shortRefName.substring(Constants.R_HEADS.length()); + } else if (shortRefName.startsWith(Constants.R_TAGS)) { + shortRefName = shortRefName.substring(Constants.R_TAGS.length()); + isTag = true; + } + + String fuzzydate; + TimeUtils tu = getTimeUtils(); + Date pushDate = change.date; + if (TimeUtils.isToday(pushDate, timezone)) { + fuzzydate = tu.today(); + } else if (TimeUtils.isYesterday(pushDate, timezone)) { + fuzzydate = tu.yesterday(); + } else { + fuzzydate = getTimeUtils().timeAgo(pushDate); + } + logItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(pushDate))); + + Label changeIcon = new Label("changeIcon"); + // use the repository hash color to differentiate the icon. + String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository)); + WicketUtils.setCssStyle(changeIcon, "color: " + color); + + if (isTag) { + WicketUtils.setCssClass(changeIcon, "iconic-tag"); + } else if (isTicket) { + WicketUtils.setCssClass(changeIcon, "fa fa-ticket"); + } else { + WicketUtils.setCssClass(changeIcon, "iconic-loop"); + } + logItem.add(changeIcon); + + if (isTag) { + // tags are special + PersonIdent ident = change.getCommits().get(0).getAuthorIdent(); + if (!StringUtils.isEmpty(ident.getName())) { + logItem.add(new Label("whoChanged", ident.getName())); + } else { + logItem.add(new Label("whoChanged", ident.getEmailAddress())); + } + } else { + logItem.add(new Label("whoChanged").setVisible(false)); + } + + String preposition = "gb.of"; + boolean isDelete = false; + String what; + String by = null; + switch(change.getChangeType(fullRefName)) { + case CREATE: + if (isTag) { + // new tag + what = getString("gb.createdNewTag"); + preposition = "gb.in"; + } else { + // new branch + what = getString("gb.createdNewBranch"); + preposition = "gb.in"; + } + break; + case DELETE: + isDelete = true; + if (isTag) { + what = getString("gb.deletedTag"); + } else { + what = getString("gb.deletedBranch"); + } + preposition = "gb.from"; + break; + default: + what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.commitsTo") : getString("gb.oneCommitTo"), change.getCommitCount()); + + if (change.getAuthorCount() == 1) { + by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName()); + } else { + by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount()); + } + break; + } + logItem.add(new Label("whatChanged", what)); + logItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by))); + + if (isDelete) { + // can't link to deleted ref + logItem.add(new Label("refChanged", shortRefName)); + } else if (isTag) { + // link to tag + logItem.add(new LinkPanel("refChanged", null, shortRefName, + TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); + } else if (isTicket) { + // link to ticket + logItem.add(new LinkPanel("refChanged", null, shortRefName, + TicketsPage.class, WicketUtils.newObjectParameter(change.repository, ticketId))); + } else { + // link to tree + logItem.add(new LinkPanel("refChanged", null, shortRefName, + TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); + } + + // to/from/etc + logItem.add(new Label("repoPreposition", getString(preposition))); + String repoName = StringUtils.stripDotGit(change.repository); + logItem.add(new LinkPanel("repoChanged", null, repoName, + SummaryPage.class, WicketUtils.newRepositoryParameter(change.repository))); + + int maxCommitCount = 5; + List commits = change.getCommits(); + if (commits.size() > maxCommitCount) { + commits = new ArrayList(commits.subList(0, maxCommitCount)); + } + + // compare link + String compareLinkText = null; + if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) { + compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size()); + } else if (change.getCommitCount() > maxCommitCount) { + int diff = change.getCommitCount() - maxCommitCount; + compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff); + } + if (StringUtils.isEmpty(compareLinkText)) { + logItem.add(new Label("compareLink").setVisible(false)); + } else { + String endRangeId = change.getNewId(fullRefName); + String startRangeId = change.getOldId(fullRefName); + logItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId))); + } + + final boolean showSwatch = app().settings().getBoolean(Keys.web.repositoryListSwatches, true); + + ListDataProvider cdp = new ListDataProvider(commits); + DataView commitsView = new DataView("commit", cdp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item commitItem) { + final RepositoryCommit commit = commitItem.getModelObject(); + + // author gravatar + commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false)); + + // merge icon + if (commit.getParentCount() > 1) { + commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png")); + } else { + commitItem.add(WicketUtils.newBlankImage("commitIcon")); + } + + // short message + String shortMessage = commit.getShortMessage(); + String trimmedMessage = shortMessage; + if (commit.getRefs() != null && commit.getRefs().size() > 0) { + trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS); + } else { + trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG); + } + LinkPanel shortlog = new LinkPanel("commitShortMessage", "list", + trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter( + change.repository, commit.getName())); + if (!shortMessage.equals(trimmedMessage)) { + WicketUtils.setHtmlTooltip(shortlog, shortMessage); + } + commitItem.add(shortlog); + + // commit hash link + int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6); + LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen), + CommitPage.class, WicketUtils.newObjectParameter( + change.repository, commit.getName())); + WicketUtils.setCssClass(commitHash, "shortsha1"); + WicketUtils.setHtmlTooltip(commitHash, commit.getName()); + commitItem.add(commitHash); + + if (showSwatch) { + // set repository color + String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository)); + WicketUtils.setCssStyle(commitItem, MessageFormat.format("border-left: 2px solid {0};", color)); + } + } + }; + + logItem.add(commitsView); + } + }; + + add(pushView); + } + + public boolean hasMore() { + return hasMore; + } + + public boolean hideIfEmpty() { + setVisible(hasChanges); + return hasChanges; + } +} diff --git a/src/main/java/com/gitblit/wicket/panels/GravatarImage.java b/src/main/java/com/gitblit/wicket/panels/GravatarImage.java index 9507a25e..e4157577 100644 --- a/src/main/java/com/gitblit/wicket/panels/GravatarImage.java +++ b/src/main/java/com/gitblit/wicket/panels/GravatarImage.java @@ -1,70 +1,74 @@ -/* - * Copyright 2011 gitblit.com. - * - * 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 com.gitblit.wicket.panels; - -import org.eclipse.jgit.lib.PersonIdent; - -import com.gitblit.Keys; -import com.gitblit.models.UserModel; -import com.gitblit.utils.ActivityUtils; -import com.gitblit.wicket.ExternalImage; -import com.gitblit.wicket.WicketUtils; - -/** - * Represents a Gravatar image. - * - * @author James Moger - * - */ -public class GravatarImage extends BasePanel { - - private static final long serialVersionUID = 1L; - - public GravatarImage(String id, PersonIdent person) { - this(id, person, 0); - } - - public GravatarImage(String id, PersonIdent person, int width) { - this(id, person.getName(), person.getEmailAddress(), "gravatar", width, true); - } - - public GravatarImage(String id, PersonIdent person, String cssClass, int width, boolean identicon) { - this(id, person.getName(), person.getEmailAddress(), cssClass, width, identicon); - } - - public GravatarImage(String id, UserModel user, String cssClass, int width, boolean identicon) { - this(id, user.getDisplayName(), user.emailAddress, cssClass, width, identicon); - } - - public GravatarImage(String id, String username, String emailaddress, String cssClass, int width, boolean identicon) { - super(id); - - String email = emailaddress == null ? username.toLowerCase() : emailaddress.toLowerCase(); - String url; - if (identicon) { - url = ActivityUtils.getGravatarIdenticonUrl(email, width); - } else { - url = ActivityUtils.getGravatarThumbnailUrl(email, width); - } - ExternalImage image = new ExternalImage("image", url); - if (cssClass != null) { - WicketUtils.setCssClass(image, cssClass); - } - add(image); - WicketUtils.setHtmlTooltip(image, username); - setVisible(app().settings().getBoolean(Keys.web.allowGravatar, true)); - } +/* + * Copyright 2011 gitblit.com. + * + * 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 com.gitblit.wicket.panels; + +import org.eclipse.jgit.lib.PersonIdent; + +import com.gitblit.Keys; +import com.gitblit.models.UserModel; +import com.gitblit.utils.ActivityUtils; +import com.gitblit.wicket.ExternalImage; +import com.gitblit.wicket.WicketUtils; + +/** + * Represents a Gravatar image. + * + * @author James Moger + * + */ +public class GravatarImage extends BasePanel { + + private static final long serialVersionUID = 1L; + + public GravatarImage(String id, PersonIdent person) { + this(id, person, 0); + } + + public GravatarImage(String id, PersonIdent person, int width) { + this(id, person.getName(), person.getEmailAddress(), "gravatar", width, true); + } + + public GravatarImage(String id, PersonIdent person, String cssClass, int width, boolean identicon) { + this(id, person.getName(), person.getEmailAddress(), cssClass, width, identicon); + } + + public GravatarImage(String id, UserModel user, String cssClass, int width, boolean identicon) { + this(id, user.getDisplayName(), user.emailAddress, cssClass, width, identicon); + } + + public GravatarImage(String id, String username, String emailaddress, String cssClass, int width, boolean identicon) { + super(id); + + String email = emailaddress == null ? username.toLowerCase() : emailaddress.toLowerCase(); + String url; + if (identicon) { + url = ActivityUtils.getGravatarIdenticonUrl(email, width); + } else { + url = ActivityUtils.getGravatarThumbnailUrl(email, width); + } + ExternalImage image = new ExternalImage("image", url); + if (cssClass != null) { + WicketUtils.setCssClass(image, cssClass); + } + add(image); + WicketUtils.setHtmlTooltip(image, username); + setVisible(app().settings().getBoolean(Keys.web.allowGravatar, true)); + } + + public void setTooltip(String tooltip) { + WicketUtils.setHtmlTooltip(get("image"), tooltip); + } } \ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java b/src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java new file mode 100644 index 00000000..fbce7892 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java @@ -0,0 +1,118 @@ +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.wicket.panels; + +import org.apache.wicket.ajax.AbstractAjaxTimerBehavior; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.form.AjaxFormComponentUpdatingBehavior; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.TextArea; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.PropertyModel; +import org.apache.wicket.util.time.Duration; + +import com.gitblit.utils.MarkdownUtils; +import com.gitblit.wicket.GitBlitWebApp; + +public class MarkdownTextArea extends TextArea { + + private static final long serialVersionUID = 1L; + + protected String repositoryName; + + protected String text = ""; + + public MarkdownTextArea(String id, final IModel previewModel, final Label previewLabel) { + super(id); + this.repositoryName = repositoryName; + setModel(new PropertyModel(this, "text")); + add(new AjaxFormComponentUpdatingBehavior("onblur") { + private static final long serialVersionUID = 1L; + + @Override + protected void onUpdate(AjaxRequestTarget target) { + renderPreview(previewModel); + if (target != null) { + target.addComponent(previewLabel); + } + } + }); + add(new AjaxFormComponentUpdatingBehavior("onchange") { + private static final long serialVersionUID = 1L; + + @Override + protected void onUpdate(AjaxRequestTarget target) { + renderPreview(previewModel); + if (target != null) { + target.addComponent(previewLabel); + } + } + }); + + add(new KeepAliveBehavior()); + setOutputMarkupId(true); + } + + protected void renderPreview(IModel previewModel) { + if (text == null) { + return; + } + String html = MarkdownUtils.transformGFM(GitBlitWebApp.get().settings(), text, repositoryName); + previewModel.setObject(html); + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public void setRepository(String repositoryName) { + this.repositoryName = repositoryName; + } + +// @Override +// protected void onBeforeRender() { +// super.onBeforeRender(); +// add(new RichTextSetActiveTextFieldAttributeModifier(this.getMarkupId())); +// } +// +// private class RichTextSetActiveTextFieldAttributeModifier extends AttributeModifier { +// +// private static final long serialVersionUID = 1L; +// +// public RichTextSetActiveTextFieldAttributeModifier(String markupId) { +// super("onClick", true, new Model("richTextSetActiveTextField('" + markupId + "');")); +// } +// } + + private class KeepAliveBehavior extends AbstractAjaxTimerBehavior { + + private static final long serialVersionUID = 1L; + + public KeepAliveBehavior() { + super(Duration.minutes(5)); + } + + @Override + protected void onTimer(AjaxRequestTarget target) { + // prevent wicket changing focus + target.focusComponent(null); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/panels/ReflogPanel.html b/src/main/java/com/gitblit/wicket/panels/ReflogPanel.html index 8df28494..3a0b0b8c 100644 --- a/src/main/java/com/gitblit/wicket/panels/ReflogPanel.html +++ b/src/main/java/com/gitblit/wicket/panels/ReflogPanel.html @@ -12,7 +12,7 @@
- [rewind] + [rewind]
[change author]
@@ -26,7 +26,7 @@ [hash link] - + [commit short message] diff --git a/src/main/java/com/gitblit/wicket/panels/ReflogPanel.java b/src/main/java/com/gitblit/wicket/panels/ReflogPanel.java index c1db726a..baefc6bd 100644 --- a/src/main/java/com/gitblit/wicket/panels/ReflogPanel.java +++ b/src/main/java/com/gitblit/wicket/panels/ReflogPanel.java @@ -1,313 +1,325 @@ -/* - * Copyright 2013 gitblit.com. - * - * 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 com.gitblit.wicket.panels; - -import java.text.DateFormat; -import java.text.MessageFormat; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Date; -import java.util.List; -import java.util.TimeZone; - -import org.apache.wicket.markup.html.basic.Label; -import org.apache.wicket.markup.repeater.Item; -import org.apache.wicket.markup.repeater.data.DataView; -import org.apache.wicket.markup.repeater.data.ListDataProvider; -import org.apache.wicket.model.StringResourceModel; -import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.transport.ReceiveCommand.Type; - -import com.gitblit.Constants; -import com.gitblit.Keys; -import com.gitblit.models.RefLogEntry; -import com.gitblit.models.RepositoryCommit; -import com.gitblit.models.RepositoryModel; -import com.gitblit.models.UserModel; -import com.gitblit.utils.RefLogUtils; -import com.gitblit.utils.StringUtils; -import com.gitblit.utils.TimeUtils; -import com.gitblit.wicket.WicketUtils; -import com.gitblit.wicket.pages.CommitPage; -import com.gitblit.wicket.pages.ComparePage; -import com.gitblit.wicket.pages.ReflogPage; -import com.gitblit.wicket.pages.TagPage; -import com.gitblit.wicket.pages.TreePage; -import com.gitblit.wicket.pages.UserPage; - -public class ReflogPanel extends BasePanel { - - private static final long serialVersionUID = 1L; - - private final boolean hasChanges; - - private boolean hasMore; - - public ReflogPanel(String wicketId, final RepositoryModel model, Repository r, int limit, int pageOffset) { - super(wicketId); - boolean pageResults = limit <= 0; - int changesPerPage = app().settings().getInteger(Keys.web.reflogChangesPerPage, 10); - if (changesPerPage <= 1) { - changesPerPage = 10; - } - - List changes; - if (pageResults) { - changes = RefLogUtils.getLogByRef(model.name, r, pageOffset * changesPerPage, changesPerPage); - } else { - changes = RefLogUtils.getLogByRef(model.name, r, limit); - } - - // inaccurate way to determine if there are more commits. - // works unless commits.size() represents the exact end. - hasMore = changes.size() >= changesPerPage; - hasChanges = changes.size() > 0; - - setup(changes); - - // determine to show pager, more, or neither - if (limit <= 0) { - // no display limit - add(new Label("moreChanges").setVisible(false)); - } else { - if (pageResults) { - // paging - add(new Label("moreChanges").setVisible(false)); - } else { - // more - if (changes.size() == limit) { - // show more - add(new LinkPanel("moreChanges", "link", new StringResourceModel("gb.moreChanges", - this, null), ReflogPage.class, - WicketUtils.newRepositoryParameter(model.name))); - } else { - // no more - add(new Label("moreChanges").setVisible(false)); - } - } - } - } - - public ReflogPanel(String wicketId, List changes) { - super(wicketId); - hasChanges = changes.size() > 0; - setup(changes); - add(new Label("moreChanges").setVisible(false)); - } - - protected void setup(List changes) { - - ListDataProvider dp = new ListDataProvider(changes); - DataView changeView = new DataView("change", dp) { - private static final long serialVersionUID = 1L; - - @Override - public void populateItem(final Item changeItem) { - final RefLogEntry change = changeItem.getModelObject(); - - String dateFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy HH:mm Z"); - TimeZone timezone = getTimeZone(); - DateFormat df = new SimpleDateFormat(dateFormat); - df.setTimeZone(timezone); - Calendar cal = Calendar.getInstance(timezone); - - String fullRefName = change.getChangedRefs().get(0); - String shortRefName = fullRefName; - boolean isTag = false; - if (shortRefName.startsWith(Constants.R_HEADS)) { - shortRefName = shortRefName.substring(Constants.R_HEADS.length()); - } else if (shortRefName.startsWith(Constants.R_TAGS)) { - shortRefName = shortRefName.substring(Constants.R_TAGS.length()); - isTag = true; - } - - String fuzzydate; - TimeUtils tu = getTimeUtils(); - Date changeDate = change.date; - if (TimeUtils.isToday(changeDate, timezone)) { - fuzzydate = tu.today(); - } else if (TimeUtils.isYesterday(changeDate, timezone)) { - fuzzydate = tu.yesterday(); - } else { - // calculate a fuzzy time ago date - cal.setTime(changeDate); - cal.set(Calendar.HOUR_OF_DAY, 0); - cal.set(Calendar.MINUTE, 0); - cal.set(Calendar.SECOND, 0); - cal.set(Calendar.MILLISECOND, 0); - Date date = cal.getTime(); - fuzzydate = getTimeUtils().timeAgo(date); - } - changeItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(changeDate))); - - Label changeIcon = new Label("changeIcon"); - if (Type.DELETE.equals(change.getChangeType(fullRefName))) { - WicketUtils.setCssClass(changeIcon, "iconic-trash-stroke"); - } else if (isTag) { - WicketUtils.setCssClass(changeIcon, "iconic-tag"); - } else { - WicketUtils.setCssClass(changeIcon, "iconic-upload"); - } - changeItem.add(changeIcon); - - if (change.user.username.equals(change.user.emailAddress) && change.user.emailAddress.indexOf('@') > -1) { - // username is an email address - 1.2.1 push log bug - changeItem.add(new Label("whoChanged", change.user.getDisplayName())); - } else if (change.user.username.equals(UserModel.ANONYMOUS.username)) { - // anonymous change - changeItem.add(new Label("whoChanged", getString("gb.anonymousUser"))); - } else { - // link to user account page - changeItem.add(new LinkPanel("whoChanged", null, change.user.getDisplayName(), - UserPage.class, WicketUtils.newUsernameParameter(change.user.username))); - } - - boolean isDelete = false; - boolean isRewind = false; - String what; - String by = null; - switch(change.getChangeType(fullRefName)) { - case CREATE: - if (isTag) { - // new tag - what = getString("gb.pushedNewTag"); - } else { - // new branch - what = getString("gb.pushedNewBranch"); - } - break; - case DELETE: - isDelete = true; - if (isTag) { - what = getString("gb.deletedTag"); - } else { - what = getString("gb.deletedBranch"); - } - break; - case UPDATE_NONFASTFORWARD: - isRewind = true; - default: - what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.pushedNCommitsTo") : getString("gb.pushedOneCommitTo") , change.getCommitCount()); - - if (change.getAuthorCount() == 1) { - by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName()); - } else { - by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount()); - } - break; - } - changeItem.add(new Label("whatChanged", what)); - changeItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by))); - - changeItem.add(new Label("refRewind", getString("gb.rewind")).setVisible(isRewind)); - - if (isDelete) { - // can't link to deleted ref - changeItem.add(new Label("refChanged", shortRefName)); - } else if (isTag) { - // link to tag - changeItem.add(new LinkPanel("refChanged", null, shortRefName, - TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); - } else { - // link to tree - changeItem.add(new LinkPanel("refChanged", null, shortRefName, - TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); - } - - int maxCommitCount = 5; - List commits = change.getCommits(); - if (commits.size() > maxCommitCount) { - commits = new ArrayList(commits.subList(0, maxCommitCount)); - } - - // compare link - String compareLinkText = null; - if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) { - compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size()); - } else if (change.getCommitCount() > maxCommitCount) { - int diff = change.getCommitCount() - maxCommitCount; - compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff); - } - if (StringUtils.isEmpty(compareLinkText)) { - changeItem.add(new Label("compareLink").setVisible(false)); - } else { - String endRangeId = change.getNewId(fullRefName); - String startRangeId = change.getOldId(fullRefName); - changeItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId))); - } - - ListDataProvider cdp = new ListDataProvider(commits); - DataView commitsView = new DataView("commit", cdp) { - private static final long serialVersionUID = 1L; - - @Override - public void populateItem(final Item commitItem) { - final RepositoryCommit commit = commitItem.getModelObject(); - - // author gravatar - commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false)); - - // merge icon - if (commit.getParentCount() > 1) { - commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png")); - } else { - commitItem.add(WicketUtils.newBlankImage("commitIcon")); - } - - // short message - String shortMessage = commit.getShortMessage(); - String trimmedMessage = shortMessage; - if (commit.getRefs() != null && commit.getRefs().size() > 0) { - trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS); - } else { - trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG); - } - LinkPanel shortlog = new LinkPanel("commitShortMessage", "list", - trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter( - change.repository, commit.getName())); - if (!shortMessage.equals(trimmedMessage)) { - WicketUtils.setHtmlTooltip(shortlog, shortMessage); - } - commitItem.add(shortlog); - - // commit hash link - int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6); - LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen), - CommitPage.class, WicketUtils.newObjectParameter( - change.repository, commit.getName())); - WicketUtils.setCssClass(commitHash, "shortsha1"); - WicketUtils.setHtmlTooltip(commitHash, commit.getName()); - commitItem.add(commitHash); - } - }; - - changeItem.add(commitsView); - } - }; - - add(changeView); - } - - public boolean hasMore() { - return hasMore; - } - - public boolean hideIfEmpty() { - setVisible(hasChanges); - return hasChanges; - } -} +/* + * Copyright 2013 gitblit.com. + * + * 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 com.gitblit.wicket.panels; + +import java.text.DateFormat; +import java.text.MessageFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; + +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.repeater.Item; +import org.apache.wicket.markup.repeater.data.DataView; +import org.apache.wicket.markup.repeater.data.ListDataProvider; +import org.apache.wicket.model.StringResourceModel; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.ReceiveCommand.Type; + +import com.gitblit.Constants; +import com.gitblit.Keys; +import com.gitblit.models.RefLogEntry; +import com.gitblit.models.RepositoryCommit; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.UserModel; +import com.gitblit.utils.RefLogUtils; +import com.gitblit.utils.StringUtils; +import com.gitblit.utils.TimeUtils; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.pages.CommitPage; +import com.gitblit.wicket.pages.ComparePage; +import com.gitblit.wicket.pages.ReflogPage; +import com.gitblit.wicket.pages.TagPage; +import com.gitblit.wicket.pages.TicketsPage; +import com.gitblit.wicket.pages.TreePage; +import com.gitblit.wicket.pages.UserPage; + +public class ReflogPanel extends BasePanel { + + private static final long serialVersionUID = 1L; + + private final boolean hasChanges; + + private boolean hasMore; + + public ReflogPanel(String wicketId, final RepositoryModel model, Repository r, int limit, int pageOffset) { + super(wicketId); + boolean pageResults = limit <= 0; + int changesPerPage = app().settings().getInteger(Keys.web.reflogChangesPerPage, 10); + if (changesPerPage <= 1) { + changesPerPage = 10; + } + + List changes; + if (pageResults) { + changes = RefLogUtils.getLogByRef(model.name, r, pageOffset * changesPerPage, changesPerPage); + } else { + changes = RefLogUtils.getLogByRef(model.name, r, limit); + } + + // inaccurate way to determine if there are more commits. + // works unless commits.size() represents the exact end. + hasMore = changes.size() >= changesPerPage; + hasChanges = changes.size() > 0; + + setup(changes); + + // determine to show pager, more, or neither + if (limit <= 0) { + // no display limit + add(new Label("moreChanges").setVisible(false)); + } else { + if (pageResults) { + // paging + add(new Label("moreChanges").setVisible(false)); + } else { + // more + if (changes.size() == limit) { + // show more + add(new LinkPanel("moreChanges", "link", new StringResourceModel("gb.moreChanges", + this, null), ReflogPage.class, + WicketUtils.newRepositoryParameter(model.name))); + } else { + // no more + add(new Label("moreChanges").setVisible(false)); + } + } + } + } + + public ReflogPanel(String wicketId, List changes) { + super(wicketId); + hasChanges = changes.size() > 0; + setup(changes); + add(new Label("moreChanges").setVisible(false)); + } + + protected void setup(List changes) { + + ListDataProvider dp = new ListDataProvider(changes); + DataView changeView = new DataView("change", dp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item changeItem) { + final RefLogEntry change = changeItem.getModelObject(); + + String dateFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy HH:mm Z"); + TimeZone timezone = getTimeZone(); + DateFormat df = new SimpleDateFormat(dateFormat); + df.setTimeZone(timezone); + Calendar cal = Calendar.getInstance(timezone); + + String fullRefName = change.getChangedRefs().get(0); + String shortRefName = fullRefName; + String ticketId = null; + boolean isTag = false; + boolean isTicket = false; + if (shortRefName.startsWith(Constants.R_TICKET)) { + ticketId = fullRefName.substring(Constants.R_TICKET.length()); + shortRefName = MessageFormat.format(getString("gb.ticketN"), ticketId); + isTicket = true; + } else if (shortRefName.startsWith(Constants.R_HEADS)) { + shortRefName = shortRefName.substring(Constants.R_HEADS.length()); + } else if (shortRefName.startsWith(Constants.R_TAGS)) { + shortRefName = shortRefName.substring(Constants.R_TAGS.length()); + isTag = true; + } + + String fuzzydate; + TimeUtils tu = getTimeUtils(); + Date changeDate = change.date; + if (TimeUtils.isToday(changeDate, timezone)) { + fuzzydate = tu.today(); + } else if (TimeUtils.isYesterday(changeDate, timezone)) { + fuzzydate = tu.yesterday(); + } else { + // calculate a fuzzy time ago date + cal.setTime(changeDate); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + Date date = cal.getTime(); + fuzzydate = getTimeUtils().timeAgo(date); + } + changeItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(changeDate))); + + Label changeIcon = new Label("changeIcon"); + if (Type.DELETE.equals(change.getChangeType(fullRefName))) { + WicketUtils.setCssClass(changeIcon, "iconic-trash-stroke"); + } else if (isTag) { + WicketUtils.setCssClass(changeIcon, "iconic-tag"); + } else if (isTicket) { + WicketUtils.setCssClass(changeIcon, "fa fa-ticket"); + } else { + WicketUtils.setCssClass(changeIcon, "iconic-upload"); + } + changeItem.add(changeIcon); + + if (change.user.username.equals(change.user.emailAddress) && change.user.emailAddress.indexOf('@') > -1) { + // username is an email address - 1.2.1 push log bug + changeItem.add(new Label("whoChanged", change.user.getDisplayName())); + } else if (change.user.username.equals(UserModel.ANONYMOUS.username)) { + // anonymous change + changeItem.add(new Label("whoChanged", getString("gb.anonymousUser"))); + } else { + // link to user account page + changeItem.add(new LinkPanel("whoChanged", null, change.user.getDisplayName(), + UserPage.class, WicketUtils.newUsernameParameter(change.user.username))); + } + + boolean isDelete = false; + boolean isRewind = false; + String what; + String by = null; + switch(change.getChangeType(fullRefName)) { + case CREATE: + if (isTag) { + // new tag + what = getString("gb.pushedNewTag"); + } else { + // new branch + what = getString("gb.pushedNewBranch"); + } + break; + case DELETE: + isDelete = true; + if (isTag) { + what = getString("gb.deletedTag"); + } else { + what = getString("gb.deletedBranch"); + } + break; + case UPDATE_NONFASTFORWARD: + isRewind = true; + default: + what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.pushedNCommitsTo") : getString("gb.pushedOneCommitTo"), change.getCommitCount()); + + if (change.getAuthorCount() == 1) { + by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName()); + } else { + by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount()); + } + break; + } + changeItem.add(new Label("whatChanged", what)); + changeItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by))); + changeItem.add(new Label("refRewind", getString("gb.rewind")).setVisible(isRewind)); + + if (isDelete) { + // can't link to deleted ref + changeItem.add(new Label("refChanged", shortRefName)); + } else if (isTag) { + // link to tag + changeItem.add(new LinkPanel("refChanged", null, shortRefName, + TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); + } else if (isTicket) { + // link to ticket + changeItem.add(new LinkPanel("refChanged", null, shortRefName, + TicketsPage.class, WicketUtils.newObjectParameter(change.repository, ticketId))); + } else { + // link to tree + changeItem.add(new LinkPanel("refChanged", null, shortRefName, + TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); + } + + int maxCommitCount = 5; + List commits = change.getCommits(); + if (commits.size() > maxCommitCount) { + commits = new ArrayList(commits.subList(0, maxCommitCount)); + } + + // compare link + String compareLinkText = null; + if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) { + compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size()); + } else if (change.getCommitCount() > maxCommitCount) { + int diff = change.getCommitCount() - maxCommitCount; + compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff); + } + if (StringUtils.isEmpty(compareLinkText)) { + changeItem.add(new Label("compareLink").setVisible(false)); + } else { + String endRangeId = change.getNewId(fullRefName); + String startRangeId = change.getOldId(fullRefName); + changeItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId))); + } + + ListDataProvider cdp = new ListDataProvider(commits); + DataView commitsView = new DataView("commit", cdp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item commitItem) { + final RepositoryCommit commit = commitItem.getModelObject(); + + // author gravatar + commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false)); + + // merge icon + if (commit.getParentCount() > 1) { + commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png")); + } else { + commitItem.add(WicketUtils.newBlankImage("commitIcon")); + } + + // short message + String shortMessage = commit.getShortMessage(); + String trimmedMessage = shortMessage; + if (commit.getRefs() != null && commit.getRefs().size() > 0) { + trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS); + } else { + trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG); + } + LinkPanel shortlog = new LinkPanel("commitShortMessage", "list", + trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter( + change.repository, commit.getName())); + if (!shortMessage.equals(trimmedMessage)) { + WicketUtils.setHtmlTooltip(shortlog, shortMessage); + } + commitItem.add(shortlog); + + // commit hash link + int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6); + LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen), + CommitPage.class, WicketUtils.newObjectParameter( + change.repository, commit.getName())); + WicketUtils.setCssClass(commitHash, "shortsha1"); + WicketUtils.setHtmlTooltip(commitHash, commit.getName()); + commitItem.add(commitHash); + } + }; + + changeItem.add(commitsView); + } + }; + + add(changeView); + } + + public boolean hasMore() { + return hasMore; + } + + public boolean hideIfEmpty() { + setVisible(hasChanges); + return hasChanges; + } +} diff --git a/src/main/java/com/gitblit/wicket/panels/RefsPanel.java b/src/main/java/com/gitblit/wicket/panels/RefsPanel.java index 7a16f4a2..6e9e866e 100644 --- a/src/main/java/com/gitblit/wicket/panels/RefsPanel.java +++ b/src/main/java/com/gitblit/wicket/panels/RefsPanel.java @@ -25,7 +25,6 @@ import java.util.Map; import org.apache.wicket.Component; import org.apache.wicket.markup.html.WebPage; import org.apache.wicket.markup.html.basic.Label; -import org.apache.wicket.markup.html.panel.Panel; import org.apache.wicket.markup.repeater.Item; import org.apache.wicket.markup.repeater.data.DataView; import org.apache.wicket.markup.repeater.data.ListDataProvider; @@ -34,13 +33,15 @@ import org.eclipse.jgit.revwalk.RevCommit; import com.gitblit.Constants; import com.gitblit.models.RefModel; +import com.gitblit.models.RepositoryModel; import com.gitblit.utils.StringUtils; import com.gitblit.wicket.WicketUtils; import com.gitblit.wicket.pages.CommitPage; import com.gitblit.wicket.pages.LogPage; import com.gitblit.wicket.pages.TagPage; +import com.gitblit.wicket.pages.TicketsPage; -public class RefsPanel extends Panel { +public class RefsPanel extends BasePanel { private static final long serialVersionUID = 1L; @@ -88,6 +89,8 @@ public class RefsPanel extends Panel { } } final boolean shouldBreak = remoteCount < refs.size(); + RepositoryModel repository = app().repositories().getRepositoryModel(repositoryName); + final boolean hasTickets = app().tickets().hasTickets(repository); ListDataProvider refsDp = new ListDataProvider(refs); DataView refsView = new DataView("ref", refsDp) { @@ -103,7 +106,13 @@ public class RefsPanel extends Panel { Class linkClass = CommitPage.class; String cssClass = ""; String tooltip = ""; - if (name.startsWith(Constants.R_HEADS)) { + if (name.startsWith(Constants.R_TICKET)) { + // Gitblit ticket ref + objectid = name.substring(Constants.R_TICKET.length()); + name = name.substring(Constants.R_HEADS.length()); + linkClass = TicketsPage.class; + cssClass = "localBranch"; + } else if (name.startsWith(Constants.R_HEADS)) { // local branch linkClass = LogPage.class; name = name.substring(Constants.R_HEADS.length()); @@ -113,13 +122,23 @@ public class RefsPanel extends Panel { linkClass = LogPage.class; cssClass = "headRef"; } else if (name.startsWith(Constants.R_CHANGES)) { - // Gerrit change ref + // Gitblit change ref name = name.substring(Constants.R_CHANGES.length()); // strip leading nn/ from nn/#####nn/ps = #####nn-ps name = name.substring(name.indexOf('/') + 1).replace('/', '-'); String [] values = name.split("-"); + // Gerrit change tooltip = MessageFormat.format(getString("gb.reviewPatchset"), values[0], values[1]); cssClass = "otherRef"; + } else if (name.startsWith(Constants.R_TICKETS_PATCHSETS)) { + // Gitblit patchset ref + name = name.substring(Constants.R_TICKETS_PATCHSETS.length()); + // strip leading nn/ from nn/#####nn/ps = #####nn-ps + name = name.substring(name.indexOf('/') + 1).replace('/', '-'); + String [] values = name.split("-"); + tooltip = MessageFormat.format(getString("gb.ticketPatchset"), values[0], values[1]); + linkClass = LogPage.class; + cssClass = "otherRef"; } else if (name.startsWith(Constants.R_PULL)) { // Pull Request ref String num = name.substring(Constants.R_PULL.length()); diff --git a/src/main/java/pt.cmd b/src/main/java/pt.cmd new file mode 100644 index 00000000..cec7e5f0 --- /dev/null +++ b/src/main/java/pt.cmd @@ -0,0 +1 @@ +@python %~dp0pt.py %1 %2 %3 %4 %5 %6 %7 %8 %9 diff --git a/src/main/java/pt.py b/src/main/java/pt.py new file mode 100644 index 00000000..f1fe27f8 --- /dev/null +++ b/src/main/java/pt.py @@ -0,0 +1,701 @@ +#!/usr/bin/env python3 +# +# Barnum, a Patchset Tool (pt) +# +# This Git wrapper script is designed to reduce the ceremony of working with Gitblit patchsets. +# +# Copyright 2014 gitblit.com. +# +# 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. +# +# +# Usage: +# +# pt fetch [-p,--patchset ] +# pt checkout [-p,--patchset ] [-f,--force] +# pt pull [-p,--patchset ] +# pt push [] [-i,--ignore] [-f,--force] [-m,--milestone ] [-t,--topic ] [-cc ] +# pt start | +# pt propose [new | | ] [-i,--ignore] [-m,--milestone ] [-t,--topic ] [-cc ] +# pt cleanup [] +# + +__author__ = 'James Moger' +__version__ = '1.0.5' + +import subprocess +import argparse +import errno +import sys + + +def fetch(args): + """ + fetch(args) + + Fetches the specified patchset for the ticket from the specified remote. + """ + + __resolve_remote(args) + + # fetch the patchset from the remote repository + + if args.patchset is None: + # fetch all current ticket patchsets + print("Fetching ticket patchsets from the '{}' repository".format(args.remote)) + if args.quiet: + __call(['git', 'fetch', args.remote, '--quiet']) + else: + __call(['git', 'fetch', args.remote]) + else: + # fetch specific patchset + __resolve_patchset(args) + print("Fetching ticket {} patchset {} from the '{}' repository".format(args.id, args.patchset, args.remote)) + patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(args.id % 100, args.id, args.patchset) + if args.quiet: + __call(['git', 'fetch', args.remote, patchset_ref, '--quiet']) + else: + __call(['git', 'fetch', args.remote, patchset_ref]) + + return + + +def checkout(args): + """ + checkout(args) + + Checkout the patchset on a named branch. + """ + + __resolve_uncommitted_changes_checkout(args) + fetch(args) + + # collect local branch names + branches = [] + for branch in __call(['git', 'branch']): + if branch[0] == '*': + branches.append(branch[1:].strip()) + else: + branches.append(branch.strip()) + + if args.patchset is None or args.patchset is 0: + branch = 'ticket/{:d}'.format(args.id) + illegals = set(branches) & {'ticket'} + else: + branch = 'patchset/{:d}/{:d}'.format(args.id, args.patchset) + illegals = set(branches) & {'patchset', 'patchset/{:d}'.format(args.id)} + + # ensure there are no local branch names that will interfere with branch creation + if len(illegals) > 0: + print('') + print('Sorry, can not complete the checkout for ticket {}.'.format(args.id)) + print("The following branches are blocking '{}' branch creation:".format(branch)) + for illegal in illegals: + print(' ' + illegal) + exit(errno.EINVAL) + + if args.patchset is None or args.patchset is 0: + # checkout the current ticket patchset + if args.force: + __call(['git', 'checkout', '-B', branch, '{}/{}'.format(args.remote, branch)]) + else: + __call(['git', 'checkout', branch]) + else: + # checkout a specific patchset + __checkout(args.remote, args.id, args.patchset, branch, args.force) + + return + + +def pull(args): + """ + pull(args) + + Pull (fetch & merge) a ticket patchset into the current branch. + """ + + __resolve_uncommitted_changes_checkout(args) + __resolve_remote(args) + + # reset the checkout before pulling + __call(['git', 'reset', '--hard']) + + # pull the patchset from the remote repository + if args.patchset is None or args.patchset is 0: + print("Pulling ticket {} from the '{}' repository".format(args.id, args.remote)) + patchset_ref = 'ticket/{:d}'.format(args.id) + else: + __resolve_patchset(args) + print("Pulling ticket {} patchset {} from the '{}' repository".format(args.id, args.patchset, args.remote)) + patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(args.id % 100, args.id, args.patchset) + + if args.squash: + __call(['git', 'pull', '--squash', '--no-log', '--no-rebase', args.remote, patchset_ref], echo=True) + else: + __call(['git', 'pull', '--commit', '--no-ff', '--no-log', '--no-rebase', args.remote, patchset_ref], echo=True) + + return + + +def push(args): + """ + push(args) + + Push your patchset update or a patchset rewrite. + """ + + if args.id is None: + # try to determine ticket and patchset from current branch name + for line in __call(['git', 'status', '-b', '-s']): + if line[0:2] == '##': + branch = line[2:].strip() + segments = branch.split('/') + if len(segments) >= 2: + if segments[0] == 'ticket' or segments[0] == 'patchset': + if '...' in segments[1]: + args.id = int(segments[1][:segments[1].index('...')]) + else: + args.id = int(segments[1]) + args.patchset = None + + if args.id is None: + print('Please specify a ticket id for the push command.') + exit(errno.EINVAL) + + __resolve_uncommitted_changes_push(args) + __resolve_remote(args) + + if args.force: + # rewrite a patchset for an existing ticket + push_ref = 'refs/for/' + str(args.id) + else: + # fast-forward update to an existing patchset + push_ref = 'refs/heads/ticket/{:d}'.format(args.id) + + ref_params = __get_pushref_params(args) + ref_spec = 'HEAD:' + push_ref + ref_params + + print("Pushing your patchset to the '{}' repository".format(args.remote)) + __call(['git', 'push', args.remote, ref_spec], echo=True) + + if args.force and args.patchset is not None and args.patchset is not 0: + # if we had to force the push then there is a new patchset + # revision on the server so checkout out the new patchset + args.patchset = None + args.force = False + args.quiet = True + checkout(args) + + return + + +def start(args): + """ + start(args) + + Start development of a topic on a new branch. + """ + + # collect local branch names + branches = [] + for branch in __call(['git', 'branch']): + if branch[0] == '*': + branches.append(branch[1:].strip()) + else: + branches.append(branch.strip()) + + branch = 'topic/' + args.topic + illegals = set(branches) & {'topic', branch} + + # ensure there are no local branch names that will interfere with branch creation + if len(illegals) > 0: + print('Sorry, can not complete the creation of the topic branch.') + print("The following branches are blocking '{}' branch creation:".format(branch)) + for illegal in illegals: + print(' ' + illegal) + exit(errno.EINVAL) + + __call(['git', 'checkout', '-b', branch]) + + return + + +def propose(args): + """ + propose_patchset(args) + + Push a patchset to create a new proposal ticket or to attach a proposal patchset to an existing ticket. + """ + + __resolve_uncommitted_changes_push(args) + __resolve_remote(args) + + curr_branch = None + push_ref = None + if args.target is None: + # see if the topic is a ticket id + # else default to new + for branch in __call(['git', 'branch']): + if branch[0] == '*': + curr_branch = branch[1:].strip() + if curr_branch.startswith('topic/'): + topic = curr_branch[6:].strip() + try: + int(topic) + push_ref = topic + except ValueError: + pass + if push_ref is None: + push_ref = 'new' + else: + push_ref = args.target + + try: + # check for current patchset and current branch + args.id = int(push_ref) + args.patchset = __get_current_patchset(args.remote, args.id) + if args.patchset > 0: + print('You can not propose a patchset for ticket {} because it already has one.'.format(args.id)) + + # check current branch for accidental propose instead of push + for line in __call(['git', 'status', '-b', '-s']): + if line[0:2] == '##': + branch = line[2:].strip() + segments = branch.split('/') + if len(segments) >= 2: + if segments[0] == 'ticket': + if '...' in segments[1]: + args.id = int(segments[1][:segments[1].index('...')]) + else: + args.id = int(segments[1]) + args.patchset = None + print("You are on the '{}' branch, perhaps you meant to push instead?".format(branch)) + elif segments[0] == 'patchset': + args.id = int(segments[1]) + args.patchset = int(segments[2]) + print("You are on the '{}' branch, perhaps you meant to push instead?".format(branch)) + exit(errno.EINVAL) + except ValueError: + pass + + ref_params = __get_pushref_params(args) + ref_spec = 'HEAD:refs/for/{}{}'.format(push_ref, ref_params) + + print("Pushing your proposal to the '{}' repository".format(args.remote)) + for line in __call(['git', 'push', args.remote, ref_spec, '-q'], echo=True, err=subprocess.STDOUT): + fields = line.split(':') + if fields[0] == 'remote' and fields[1].strip().startswith('--> #'): + # set the upstream branch configuration + args.id = int(fields[1].strip()[len('--> #'):]) + __call(['git', 'fetch', args.remote]) + __call(['git', 'branch', '--set-upstream-to={}/ticket/{:d}'.format(args.remote, args.id)]) + break + + return + + +def cleanup(args): + """ + cleanup(args) + + Removes local branches for the ticket. + """ + + if args.id is None: + branches = __call(['git', 'branch', '--list', 'ticket/*']) + branches += __call(['git', 'branch', '--list', 'patchset/*']) + else: + branches = __call(['git', 'branch', '--list', 'ticket/{:d}'.format(args.id)]) + branches += __call(['git', 'branch', '--list', 'patchset/{:d}/*'.format(args.id)]) + + if len(branches) == 0: + print("No local branches found for ticket {}, cleanup skipped.".format(args.id)) + return + + if not args.force: + print('Cleanup would remove the following local branches for ticket {}.'.format(args.id)) + for branch in branches: + if branch[0] == '*': + print(' ' + branch[1:].strip() + ' (skip)') + else: + print(' ' + branch) + print("To discard these local branches, repeat this command with '--force'.") + exit(errno.EINVAL) + + for branch in branches: + if branch[0] == '*': + print('Skipped {} because it is the current branch.'.format(branch[1:].strip())) + continue + __call(['git', 'branch', '-D', branch.strip()], echo=True) + + return + + +def __resolve_uncommitted_changes_checkout(args): + """ + __resolve_uncommitted_changes_checkout(args) + + Ensures the current checkout has no uncommitted changes that would be discarded by a checkout or pull. + """ + + status = __call(['git', 'status', '--porcelain']) + for line in status: + if not args.force and line[0] != '?': + print('Your local changes to the following files would be overwritten by {}:'.format(args.command)) + print('') + for state in status: + print(state) + print('') + print("To discard your local changes, repeat the {} with '--force'.".format(args.command)) + print('NOTE: forcing a {} will HARD RESET your working directory!'.format(args.command)) + exit(errno.EINVAL) + + +def __resolve_uncommitted_changes_push(args): + """ + __resolve_uncommitted_changes_push(args) + + Ensures the current checkout has no uncommitted changes that should be part of a propose or push. + """ + + status = __call(['git', 'status', '--porcelain']) + for line in status: + if not args.ignore and line[0] != '?': + print('You have local changes that have not been committed:') + print('') + for state in status: + print(state) + print('') + print("To ignore these uncommitted changes, repeat the {} with '--ignore'.".format(args.command)) + exit(errno.EINVAL) + + +def __resolve_remote(args): + """ + __resolve_remote(args) + + Identifies the git remote to use for fetching and pushing patchsets by parsing .git/config. + """ + + remotes = __call(['git', 'remote']) + + if len(remotes) == 0: + # no remotes defined + print("Please define a Git remote") + exit(errno.EINVAL) + elif len(remotes) == 1: + # only one remote, use it + args.remote = remotes[0] + return + else: + # multiple remotes, read .git/config + output = __call(['git', 'config', '--local', 'patchsets.remote'], fail=False) + preferred = output[0] if len(output) > 0 else '' + + if len(preferred) == 0: + print("You have multiple remote repositories and you have not configured 'patchsets.remote'.") + print("") + print("Available remote repositories:") + for remote in remotes: + print(' ' + remote) + print("") + print("Please set the remote repository to use for patchsets.") + print(" git config --local patchsets.remote ") + exit(errno.EINVAL) + else: + try: + remotes.index(preferred) + except ValueError: + print("The '{}' repository specified in 'patchsets.remote' is not configured!".format(preferred)) + print("") + print("Available remotes:") + for remote in remotes: + print(' ' + remote) + print("") + print("Please set the remote repository to use for patchsets.") + print(" git config --local patchsets.remote ") + exit(errno.EINVAL) + + args.remote = preferred + return + + +def __resolve_patchset(args): + """ + __resolve_patchset(args) + + Resolves the current patchset or validates the the specified patchset exists. + """ + if args.patchset is None: + # resolve current patchset + args.patchset = __get_current_patchset(args.remote, args.id) + + if args.patchset == 0: + # there are no patchsets for the ticket or the ticket does not exist + print("There are no patchsets for ticket {} in the '{}' repository".format(args.id, args.remote)) + exit(errno.EINVAL) + else: + # validate specified patchset + args.patchset = __validate_patchset(args.remote, args.id, args.patchset) + + if args.patchset == 0: + # there are no patchsets for the ticket or the ticket does not exist + print("Patchset {} for ticket {} can not be found in the '{}' repository".format(args.patchset, args.id, args.remote)) + exit(errno.EINVAL) + + return + + +def __validate_patchset(remote, ticket, patchset): + """ + __validate_patchset(remote, ticket, patchset) + + Validates that the specified ticket patchset exists. + """ + + nps = 0 + patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(ticket % 100, ticket, patchset) + for line in __call(['git', 'ls-remote', remote, patchset_ref]): + ps = int(line.split('/')[4]) + if ps > nps: + nps = ps + + if nps == patchset: + return patchset + return 0 + + +def __get_current_patchset(remote, ticket): + """ + __get_current_patchset(remote, ticket) + + Determines the most recent patchset for the ticket by listing the remote patchset refs + for the ticket and parsing the patchset numbers from the resulting set. + """ + + nps = 0 + patchset_refs = 'refs/tickets/{:02d}/{:d}/*'.format(ticket % 100, ticket) + for line in __call(['git', 'ls-remote', remote, patchset_refs]): + ps = int(line.split('/')[4]) + if ps > nps: + nps = ps + + return nps + + +def __checkout(remote, ticket, patchset, branch, force=False): + """ + __checkout(remote, ticket, patchset, branch) + __checkout(remote, ticket, patchset, branch, force) + + Checkout the patchset on a detached head or on a named branch. + """ + + has_branch = False + on_branch = False + + if branch is None or len(branch) == 0: + # checkout the patchset on a detached head + print('Checking out ticket {} patchset {} on a detached HEAD'.format(ticket, patchset)) + __call(['git', 'checkout', 'FETCH_HEAD'], echo=True) + return + else: + # checkout on named branch + + # determine if we are already on the target branch + for line in __call(['git', 'branch', '--list', branch]): + has_branch = True + if line[0] == '*': + # current branch (* name) + on_branch = True + + if not has_branch: + if force: + # force the checkout the patchset to the new named branch + # used when there are local changes to discard + print("Forcing checkout of ticket {} patchset {} on named branch '{}'".format(ticket, patchset, branch)) + __call(['git', 'checkout', '-b', branch, 'FETCH_HEAD', '--force'], echo=True) + else: + # checkout the patchset to the new named branch + __call(['git', 'checkout', '-b', branch, 'FETCH_HEAD'], echo=True) + return + + if not on_branch: + # switch to existing local branch + __call(['git', 'checkout', branch], echo=True) + + # + # now we are on the local branch for the patchset + # + + if force: + # reset HEAD to FETCH_HEAD, this drops any local changes + print("Forcing checkout of ticket {} patchset {} on named branch '{}'".format(ticket, patchset, branch)) + __call(['git', 'reset', '--hard', 'FETCH_HEAD'], echo=True) + return + else: + # try to merge the existing ref with the FETCH_HEAD + merge = __call(['git', 'merge', '--ff-only', branch, 'FETCH_HEAD'], echo=True, fail=False) + if len(merge) is 1: + up_to_date = merge[0].lower().index('up-to-date') > 0 + if up_to_date: + return + elif len(merge) is 0: + print('') + print("Your '{}' branch has diverged from patchset {} on the '{}' repository.".format(branch, patchset, remote)) + print('') + print("To discard your local changes, repeat the checkout with '--force'.") + print('NOTE: forcing a checkout will HARD RESET your working directory!') + exit(errno.EINVAL) + return + + +def __get_pushref_params(args): + """ + __get_pushref_params(args) + + Returns the push ref parameters for ticket field assignments. + """ + + params = [] + + if args.milestone is not None: + params.append('m=' + args.milestone) + + if args.topic is not None: + params.append('t=' + args.topic) + else: + for branch in __call(['git', 'branch']): + if branch[0] == '*': + b = branch[1:].strip() + if b.startswith('topic/'): + topic = b[len('topic/'):] + try: + # ignore ticket id topics + int(topic) + except: + # topic is a string + params.append('t=' + topic) + + if args.responsible is not None: + params.append('r=' + args.responsible) + + if args.cc is not None: + for cc in args.cc: + params.append('cc=' + cc) + + if len(params) > 0: + return '%' + ','.join(params) + + return '' + + +def __call(cmd_args, echo=False, fail=True, err=None): + """ + __call(cmd_args) + + Executes the specified command as a subprocess. The output is parsed and returned as a list + of strings. If the process returns a non-zero exit code, the script terminates with that + exit code. Std err of the subprocess is passed-through to the std err of the parent process. + """ + + p = subprocess.Popen(cmd_args, stdout=subprocess.PIPE, stderr=err, universal_newlines=True) + lines = [] + for line in iter(p.stdout.readline, b''): + line_str = str(line).strip() + if len(line_str) is 0: + break + lines.append(line_str) + if echo: + print(line_str) + p.wait() + if fail and p.returncode is not 0: + exit(p.returncode) + + return lines + +# +# define the acceptable arguments and their usage/descriptions +# + +# force argument +force_arg = argparse.ArgumentParser(add_help=False) +force_arg.add_argument('-f', '--force', default=False, help='force the command to complete', action='store_true') + +# quiet argument +quiet_arg = argparse.ArgumentParser(add_help=False) +quiet_arg.add_argument('-q', '--quiet', default=False, help='suppress git stderr output', action='store_true') + +# ticket & patchset arguments +ticket_args = argparse.ArgumentParser(add_help=False) +ticket_args.add_argument('id', help='the ticket id', type=int) +ticket_args.add_argument('-p', '--patchset', help='the patchset number', type=int) + +# push refspec arguments +push_args = argparse.ArgumentParser(add_help=False) +push_args.add_argument('-i', '--ignore', default=False, help='ignore uncommitted changes', action='store_true') +push_args.add_argument('-m', '--milestone', help='set the milestone') +push_args.add_argument('-r', '--responsible', help='set the responsible user') +push_args.add_argument('-t', '--topic', help='set the topic') +push_args.add_argument('-cc', nargs='+', help='specify accounts to add to the watch list') + +# the commands +parser = argparse.ArgumentParser(description='a Patchset Tool for Gitblit Tickets') +parser.add_argument('--version', action='version', version='%(prog)s {}'.format(__version__)) +commands = parser.add_subparsers(dest='command', title='commands') + +fetch_parser = commands.add_parser('fetch', help='fetch a patchset', parents=[ticket_args, quiet_arg]) +fetch_parser.set_defaults(func=fetch) + +checkout_parser = commands.add_parser('checkout', aliases=['co'], + help='fetch & checkout a patchset to a branch', + parents=[ticket_args, force_arg, quiet_arg]) +checkout_parser.set_defaults(func=checkout) + +pull_parser = commands.add_parser('pull', + help='fetch & merge a patchset into the current branch', + parents=[ticket_args, force_arg]) +pull_parser.add_argument('-s', '--squash', + help='squash the pulled patchset into your working directory', + default=False, + action='store_true') +pull_parser.set_defaults(func=pull) + +push_parser = commands.add_parser('push', aliases=['up'], + help='upload your patchset changes', + parents=[push_args, force_arg]) +push_parser.add_argument('id', help='the ticket id', nargs='?', type=int) +push_parser.set_defaults(func=push) + +propose_parser = commands.add_parser('propose', help='propose a new ticket or the first patchset', parents=[push_args]) +propose_parser.add_argument('target', help="the ticket id, 'new', or the integration branch", nargs='?') +propose_parser.set_defaults(func=propose) + +cleanup_parser = commands.add_parser('cleanup', aliases=['rm'], + help='remove local ticket branches', + parents=[force_arg]) +cleanup_parser.add_argument('id', help='the ticket id', nargs='?', type=int) +cleanup_parser.set_defaults(func=cleanup) + +start_parser = commands.add_parser('start', help='start a new branch for the topic or ticket') +start_parser.add_argument('topic', help="the topic or ticket id") +start_parser.set_defaults(func=start) + +if len(sys.argv) < 2: + parser.parse_args(['--help']) +else: + # parse the command-line arguments + script_args = parser.parse_args() + + # exec the specified command + script_args.func(script_args) diff --git a/src/main/java/pt.txt b/src/main/java/pt.txt new file mode 100644 index 00000000..34703f1a --- /dev/null +++ b/src/main/java/pt.txt @@ -0,0 +1,49 @@ +Barnum, a Patchset Tool (pt) + +This Git wrapper script is designed to reduce the ceremony of working with Gitblit patchsets. + +Copyright 2014 gitblit.com. + +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. + + +Linux + +1. This script should work out-of-the-box, assuming you have Python 3 and Git. +2. Put the pt script in a directory on your PATH + +Mac OS X + +1. Download and install Python 3, if you have not (http://www.python.org) +2. Put the pt script in a directory on your PATH + +Windows + +1. Download and install Python 3, if you have not (http://www.python.org) +2. Download and install Git for Windows, if you have not (http://git-scm.com) +3. Put the pt.cmd and pt.py file together in a directory on your PATH + + +Usage + + pt fetch [-p,--patchset ] + pt checkout [-p,--patchset ] [-f,--force] + pt push [] [-i,--ignore] [-f,--force] [-t,--topic ] + [-m,--milestone ] [-cc ] + pt pull + pt start | + pt propose [new | | ] [-i,--ignore] [-t,--topic ] + [-m,--milestone ] [-cc ] + pt cleanup [] + + \ No newline at end of file diff --git a/src/main/resources/barnum_32x32.png b/src/main/resources/barnum_32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..e364909de10565fc94ad1146cafc25c0ec02c07e GIT binary patch literal 1436 zcmV;N1!MY&P)Px#24YJ`L;wc>2LK1qNaRTX000SaNLh0L04^f{04^f|c%?sf00007bV*G`2i*z~ z69gG%x|)Ij00kULL_t(o!^M}+Yg|t* z=t4yVS1#NtZd4R?;lhnBq;3@T59mg)Dprt6DYhC+q!yzQ(}uRR#+tV2Br|i5i@ZC& zyv}4sTzKJd-`w|@@AsbbJ?Gv7|MQS57DNycVvMad@4YzZ9H8F&YgKhjRd+e(Zp*T4 zhxdL-P)yaLbrPYOS&UKY z_4*B~X&@082foWaV#UJLZ4|{=B9c<^#$E{2fp^N~xfp`gV!et6FteL`Az;7(;B?_P z?TD1uI{~L`F|!>2qobp%5&-xU__&u7tQ`MN+Q{`Vl%Bg0xD0#-)Y_erW#BS+nv)g^ zn%D*~F)`7V0KiYcSKY!;_CQ&HrEKUQ9UUF)rhzn07YIl%8t6d}eksOVdnG`AeEw26 zjM}DSr`VUhuw<60*~q^k@GUUW>BiJffbEKrnNb7oo1YH{jvd<#Ff=r@b$EFA+HM4z zz&F4`uL3MvBSvDQ!Aob(@b>JiJE8i-b=$Ys5cyd|rg!bywJevOWi_16AO>2oseGuWr~t%{lrCmmT1bRS7%_r`AB@9AtGsfj%?V09TuF4X{4{ z1DDT>Iq;kazj@E6{r&7)yO!V_)qmn8z`U8gK!=KVJ@D8@c>G1UeJyB~2QHbDLQRCrB3yKi^UhIIB@-c2#XD!JnmQ`H_fy{ccT{!X)YQ}|fRT|ACMPFX zc;J>II3VYMb9og^%8w$Duc-)51P4^iOqNqIpA0km!OY$R_BR@hlhf1F&AcEnIXQVn zUohVx*;{~?gOQhK2j*IDTi*Q3%=Q5vnAtHio1dAPX#i~9x)tv|$B!Rx+nf#nuLIAu zYgo%yv~)ogk*0|J=$!jfL_Sf~qu%?Ph&1Qs=9XhSIEbn;J3HIGI|g0>UTf!!YPC9V qW@pXp3lT9@{bXTb;j{nJp8pNuzr*U)s-!vq0000rAf literal 0 HcmV?d00001 diff --git a/src/main/resources/gitblit.css b/src/main/resources/gitblit.css index cd5c57b0..9c763783 100644 --- a/src/main/resources/gitblit.css +++ b/src/main/resources/gitblit.css @@ -38,11 +38,53 @@ a.bugtraq { font-weight: bold; } +.label a.bugtraq { + font-weight: normal; + color: white; +} + +.lwbadge { + color: #888; + font-size: 11px; + background-color: #e8e8e8; + padding: 1px 7px 2px; + -webkit-border-radius: 9px; + -moz-border-radius: 9px; + border-radius: 9px; + line-height: 14px; + white-space: nowrap; + vertical-align: baseline; +} + [class^="icon-"], [class*=" icon-"] i { /* override for a links that look like bootstrap buttons */ vertical-align: text-bottom; } +.pagination-small, .pagination-small ul { + margin: 0px !important; +} + +.pagination-small ul { + border-right: 1px solid #ddd; +} + +.pagination-small ul > li > a, +.pagination-small ul > li > span { + padding: 2px 8px; + font-size: 13px; + line-height: 22px; + border: 1px solid #ddd; + border-right: 0px; + border-radius: 0px !important; + float: left; +} + +.btn.disabled em, .pagination-small ul > li > span em { + font-style: normal; + color: #444; +} + hr { margin-top: 10px; margin-bottom: 10px; @@ -137,7 +179,8 @@ navbar div>ul .menu-dropdown li a:hover,.nav .menu-dropdown li a:hover,.navbar d color: #ffffff !important; } -.nav-pills > .active > a, .nav-pills > .active > a:hover { +.nav-pills > .active > a, .nav-pills > .active > a:hover, +.nav-list > .active > a, .nav-list > .active > a:hover { color: #fff; background-color: #002060; } @@ -520,6 +563,24 @@ th { text-align: left; } +table.tickets { + border-bottom: 1px solid #ccc; +} + +table.tickets td.indicators { + width: 75px; + text-align: right; + padding-right: 5px; + color: #888; +} + +.ticketLabel, +table.tickets .label { + color: white; + font-weight: normal; + margin: 0px 2px; +} + div.featureWelcome { padding: 15px; background-color: #fbfbfb; @@ -532,6 +593,30 @@ div.featureWelcome div.icon { font-size: 144px; } +li.dynamicQuery { + padding: 3px 0px; + margin: 1px 0px; + border-radius: 4px; +} + +li.dynamicQuery i { + color: rgba(255, 255, 255, 0.5); + padding-right: 5px; +} + +li.dynamicQuery a.active { + color: white; +} + +div.milestoneOverview { + color:#888; + border: 1px solid #ddd; + padding: 2px 5px; + text-align: center; + font-size: 11px; + background-color: #fbfbfb; +} + div.sourceview { overflow: hidden; } @@ -619,6 +704,7 @@ pre.prettyprint ol { border: 1px solid #ccc; color: #ccc; font-weight:bold; + display: inline-block; } .diffstat-inline { @@ -650,7 +736,207 @@ pre.prettyprint ol { .diffstat-delete { color: #B9583B; } - +.patch-group { + margin-bottom: 0px; + border: 1px solid #ccc; + background-color: #fbfbfb; +} + +.patch-group .accordion-inner { + padding: 0px; +} + +.ticket-meta-top { + padding: 0px 10px 10px 10px; +} + +.ticket-meta-middle { + border: 1px solid #ccc; + padding: 10px; + background-color: #fbfbfb; +} + +.ticket-meta-bottom { + border: 1px solid #ccc; + border-top: 0px; + padding: 10px; +} + +.ticket-title { + font-size: 20px; +} + +.ticket-number { + color: #ccc; + font-size: 20px; + font-weight: normal; +} + +.ticket-list-icon { + padding: 8px 0px 8px 8px !important; + width: 24px; + font-size: 24px; + vertical-align: middle !important; + color: #888; +} + +td.ticket-list-state { + vertical-align: middle; +} + +.ticket-list-details { + font-size: 11px; + color: #888; +} + +div.ticket-text { + max-width: 600px; +} + +.ticket-text-editor { + height:7em; + border:0px; + border-radius: 0px; + border-top:1px solid #ccc; + margin-bottom:0px; + padding:4px; + background-color:#ffffff; + box-shadow: none; +} + +.indicator-large-dark { + font-size: 20px; + color: #888; +} + +.indicator-large-light { + font-size: 20px; + color: #bbb; +} + +.indicator-huge-light { + font-size: 48px; + color: #bbb; +} + +.attribution-emphasize { + font-weight: bold; +} + +.attribution-text { + color: #888; +} + +.attribution-border { +} + +.attribution-header { + background-color: #fbfbfb; + padding: 8px; + border: 1px solid #ccc; +} + +.attribution-header-pullright { + float: right; + text-align: right; + padding-right: 1px; +} + +.attribution-patch-pullright { + float: right; + text-align: right; + margin: 5px 10px; +} + +.attribution-date { + color: #999; + font-size: smaller; +} + +.attribution-link { + color: #999; + padding-left: 5px; +} + +.attribution-pullright { + float: right; + text-align: right; + padding-right: 8px; +} + +.attribution-triangle { + position: absolute; + margin-left: -23px; + margin-top: 11px; + height: 0px; + width: 0px; + border-image: none; + border: 10px solid transparent; + border-right: 13px solid #ddd; +} + +.attribution-comment { + padding: 10px 10px 0px 10px; + /*border: 1px solid #ccc; + border-top: 0px;*/ +} + +.ticket-simple-event { + padding: 5px 0px; +} + +.status-display { + text-align: center; + font-weight: bold; +} + +.status-change { + font-size: 1.0em; + text-shadow: none; + padding: 5px 10px !important; + font-weight: bold; + display: inline-block; + text-align: center; + width: 50px; + margin-right: 5px !important; +} + +.submit-info { + margin-bottom: 0px; + border-radius: 0px; +} + +.merge-panel { + padding: 5px 7px; + background-color: #fbfbfb; + color: #444 +} + +.merge-panel p.step { + margin: 10px 0px 5px; +} + +.gitcommand { + margin-top: 5px; + border: 1px solid #ccc; + background-color: #333 !important; + color: #ccc; + border-radius: 3px; + padding: 5px; + margin-bottom: 5px; + text-shadow: none; +} + +a.commit { + border: 1px solid #ccc; + border-radius: 3px; + background-color: #fbfbfb; + padding: 2px 4px; + line-heihgt:99%; + font-size: 11px; + text-transform: lowercase; +} + h1 small, h2 small, h3 small, h4 small, h5 small, h6 small { color: #888; } @@ -727,6 +1013,12 @@ span.empty { color: #008000; } +span.highlight { + background-color: rgb(255, 255, 100); + color: black; + padding: 0px 2px; +} + span.link { color: #888; } @@ -775,11 +1067,17 @@ img.overview { img.gravatar { background-color: #ffffff; - border: 1px solid #ddd; + /*border: 1px solid #ddd;*/ border-radius: 5px; padding: 2px; } +img.gravatar-round { + background-color: #ffffff; + border: 1px solid #ccc; + border-radius: 100%; +} + img.navbarGravatar { border: 1px solid #fff; } @@ -1157,7 +1455,7 @@ div.references { text-align: right; } -table.plain, table.summary { +table.plain, table.summary, table.ticket { width: 0 !important; border: 0; } @@ -1168,11 +1466,16 @@ table.plain th, table.plain td, table.summary th, table.summary td { border: 0; } +table.ticket th, table.ticket td { + padding: 1px 3px; + border: 0; +} + table.summary { margin: 0px; } -table.summary th { +table.summary th, table.ticket th { color: #999; padding-right: 10px; text-align: right; @@ -1662,4 +1965,105 @@ div.markdown table.text th, div.markdown table.text td { vertical-align: top; border-top: 1px solid #ccc; padding:5px; +} +.resolution { + text-transform: uppercase; + font-weight: bold !important; + font-size: 11px; +} +.resolution-success, .resolution-success a { + color: #14892c !important; +} +.resolution-success a:hover { + color: white !important; +} +.resolution-error, .resolution-error a { + color: #d04437 !important; +} +.resolution-error a:hover { + color: white !important; +} +.resolution-complete, .resolution-complete a { + color: #4a6785 !important +} +.resolution-complete a:hover { + color: white !important; +} +.resolution-current, .resolution-current a { + color: #594300 !important; +} +.resolution-current, .resolution-current a:hover { + color: white; +} + +/*! AUI Lozenge */ +.aui-lozenge { + background: #ccc; + border: 1px solid #ccc; + border-radius: 3px; + color: #333; + display: inline-block; + font-size: 11px; + font-weight: bold; + line-height: 99%; /* cross-browser compromise to make the line-height match the font-size */ + margin: 0; + padding: 2px 5px; + text-align: center; + text-decoration: none; + text-transform: uppercase; +} +.aui-lozenge.aui-lozenge-subtle { + background-color: #fff; + border-color: #ccc; + color: #333; +} +.aui-lozenge-success { + background-color: #14892c; + border-color: #14892c; + color: #fff; +} +.aui-lozenge-success.aui-lozenge-subtle { + background-color: #fff; + border-color: #b2d8b9; + color: #14892c; +} +.aui-lozenge-error { + background-color: #d04437; + border-color: #d04437; + color: #fff; +} +.aui-lozenge-error.aui-lozenge-subtle { + background-color: #fff; + border-color: #f8d3d1; + color: #d04437; +} +.aui-lozenge-current { + background-color: #ffd351; + border-color: #ffd351; + color: #594300; +} +.aui-lozenge-current.aui-lozenge-subtle { + background-color: #fff; + border-color: #ffe28c; + color: #594300; +} +.aui-lozenge-complete { + background-color: #4a6785; + border-color: #4a6785; + color: #fff; +} +.aui-lozenge-complete.aui-lozenge-subtle { + background-color: #fff; + border-color: #e4e8ed; + color: #4a6785; +} +.aui-lozenge-moved { + background-color: #815b3a; + border-color: #815b3a; + color: #fff; +} +.aui-lozenge-moved.aui-lozenge-subtle { + background-color: #fff; + border-color: #ece7e2; + color: #815b3a; } \ No newline at end of file diff --git a/src/site/design.mkd b/src/site/design.mkd index 1f1635a4..9f77e30a 100644 --- a/src/site/design.mkd +++ b/src/site/design.mkd @@ -53,6 +53,7 @@ The following dependencies are automatically downloaded by Gitblit GO (or alread - [libpam4j](https://github.com/kohsuke/libpam4j) (MIT) - [commons-codec](http://commons.apache.org/proper/commons-codec) (Apache 2.0) - [pegdown](https://github.com/sirthias/pegdown) (Apache 2.0) +- [jedis](https://github.com/xetorthio/jedis) (MIT) ### Other Build Dependencies - [Fancybox image viewer](http://fancybox.net) (MIT and GPL dual-licensed) diff --git a/src/site/tickets_barnum.mkd b/src/site/tickets_barnum.mkd new file mode 100644 index 00000000..91f29b63 --- /dev/null +++ b/src/site/tickets_barnum.mkd @@ -0,0 +1,79 @@ +## Barnum + +*PREVIEW 1.4.0* + +Barnum is a command-line companion for Git. It's purpose is to simplify the syntax and ceremony for working with Gitblit Tickets and Patchsets. + +The current implementation is a Python script that wraps a native Git executable. It requires Python 3 and native Git. It works well on Windows, Linux, and Mac OS X. + +### Fetch + + pt fetch [-p,--patchset ] + +If *patchset* is specified, the **fetch** command will download the specified ticket patchset to the FETCH_HEAD ref. If *patchset* is **not*** specified, the configured remote will be fetched to download all ticket branch updates - this is the same as
git fetch {remote}
. + +### Checkout (co) + + pt checkout [-p,--patchset ] [--force] + +The **checkout** command fetches and checks-out the patchset to a predetermined branch. + +If *patchset* is not specified, the current patchset is checked-out to `ticket/{id}`. If *patchset* is specified, the patchset is checked-out to `patchset/{id}/{patchset}`. + +### Pull + + pt pull [-s,--squash] + +The **pull** command fetches and merges the ticket patchset into your current branch. + +You may specify the `--squash` flag to squash the pulled patchset into one commit. This will leave your working directory dirty and you must stage and commit the pending changes yourself. + +### Push (up) + + pt push [] [--force] [-r, --responsible ] [-m,--milestone ] [-t,--topic ] [-cc ] + +The **push** command allows you to upload a fast-forward update to an existing patchset or to upload a rewrite of an existing patchset (amend, rebase, or squash). + +You may set several ticket fields during the push such as *milestone*, *topic*, and *responsible*. Use the *cc* argument to add users to the watch list for the ticket. + +One thing to note about the *topic* field is that Gitblit will match the *topic* against the repository bugtraq configuration which allows you to link your ticket with an external issue tracker. + +### Start + + pt start + pt start + +The **start** command is used to start development of a topic branch that will eventually be pushed to a Ticket. + +You must specify what you are starting. If you specify a ticket id, the branch `topid/{id}` will be created. If you specify a topic string, the branch `topic/{topic}` will be created. The main difference will be how the **propose** command treats your branch name. + +### Propose + + pt propose [new | | ] [-r, --responsible ] [-m,--milestone ] [-t,--topic ] [-cc ] + +The **propose** command pushes an initial patchset to an existing ticket OR allows you to create a new ticket from your patchset on push. + +If you created your topic branch with the **start** command and you specified an existing ticket id as what you were starting, then Barnum will identify the id from the branch name and assume that is the target ticket for your patchset. + +If you created your topic branch with the **start** command and you specified a topic string as what you were starting, Barnum will identify that and specify that as the *topic* push ref parameter, but will still require a proposal target: *new*, *branch*, or *id*. + +#### Create Ticket on Push + +In order to create a ticket from your patchset, your patchset *must* contain only a *single* commit. The first line of the commit message will specify the ticket title. The remainder of the commit message will define the ticket description. + + Fix null pointer exception in StringUtils + + It is possible to throw a null pointer exception in the trim method. + This can be triggered by yada, yada, yada. + +After the ticket is created from the single commit, you can push as many additional commits as you want. It is just the first push with one commit that is special. + +One helpful tip to note about the *topic* field is that Gitblit will match the *topic* against the repository bugtraq configuration which allows you to link your ticket with an external issue tracker. + +### Cleanup (rm) + + pt cleanup [--force] + +The **cleanup** command is used to delete ticket branches from your local repository. + +The *force* argument is necessary for **cleanup** to actually remove the local branches. Running **cleanup** by itself will identify the branches that can be removed. diff --git a/src/site/tickets_overview.mkd b/src/site/tickets_overview.mkd new file mode 100644 index 00000000..f236e51f --- /dev/null +++ b/src/site/tickets_overview.mkd @@ -0,0 +1,145 @@ +## Tickets + +*PREVIEW 1.4.0* + +Gitblit's Tickets feature is analgous to GitHub/BitBucket Issues+Pull Requests. Gitblit does not make a hard distinction between what is an Issue and what is a Pull Request. In Gitblit, all tickets may have attached commits and there is no need to create a separate, new container to share & discuss these commits. Additionally, there is no need to create multiple Tickets for different versions of the same code - a common practice in other systems. + +You can view a screencast of Gitblit Tickets in action [here](https://vimeo.com/86164723). + +### Design + +The Tickets feature of Gitblit is designed around a few principles: + +1. Tickets should be simple enough to use quickly to track action items or user reports +2. Any ticket can contain commits shared by a contributor +3. The ticket should be the canonical source of commits related to the ticket (i.e. a fork repository should not be the canonical source of commits) +4. Additional contributors should be allowed to participate in developing the patchset for a ticket, not just the original patchset author. The ticket should be a container for collaborative branch development, not just for code-review/gating. +5. Contributors should be able to rewrite commits attached to a ticket without losing history. Contributors should be encouraged to polish, hone, and rewrite as needed to ensure that what eventually is merged is logical and concise. + +Gitblit takes inspiration from GitHub, BitBucket, and Gerrit. + +#### Ticket Model + +Gitblit stores each ticket as a journal (list of changes). A ticket journal is retrieved from the chosen persistence engine and an effective ticket is built by applying the ordered changes from the journal. These changes are usually additive, but in some cases a change may represent a deletion. Tickets are indexed by Lucene against which all ticket queries are executed. + +#### Collaboration Workflow + +Gitblit uses a 3-repository workflow. This means that Gitblit cuts the *fork* repository out of the collaboration workflow: patchsets are pushed directly to a special branch of the canonical repository, not to a fork. You may also push to fork, if you want, but all collaboration occurs in the canonical repository, not your fork. + +#### Persistence Choices + +Gitblit's ticket data is based on a ridiculously simple concept: a ticket is the end result of applying a sequence of changes to an empty ticket. Each change is serialized as JSON and stored in a journal. The journal may be a simple text file (`journal.json`) or it may be a Redis LIST or some future persistence type. + +All ticket services inherit from the same base class which handles most of the high level logic for ticket management including caching, milestones (stored in .git/config), indexing, queries, and searches. + +You can find descriptions of the available persistence services in the [setup][tickets_setup.mkd] page. + +#### Limitations + +- Ticket data is non-relational to user accounts. If *james* comments on a ticket, *james* is preserved forever in the ticket data. This is similar to git commits which are also non-relational. This could be overcome by writing a tool to deserialize all the journals and rewrite the authors, so it is not impossible to change, but following KISS - ticket data is non-relational to user accounts. +- The *Branch Ticket Service* does not currently permit ticket journal pushes from clones. This is an area of exploration and may be possible given that a ticket is constructed from an append-only journal of changes. +- Gitblit does not currently offer commit comments nor line comments, only overall ticket comments . + +#### How did GitHub influence the design of Tickets? + +**UI.** GitHub has a very efficient, and clean UI for their Issues. It offers the basics and give you labels to fill in the gaps. It is not overly complex. + +Gitblit's Ticket querying and discussion ui are modeled after GitHub's ui design. + +#### How did BitBucket influence the design of Tickets? + +**UI.** BitBucket has a more rigid issue tracker and a clean issue viewing ui. The rigidity makes it more like a traditional issue tracker with status, priority, kind, etc. + +Gitblit's Ticket page ui is partially inspired by BitBucket. Gitblit Tickets have state and types, which makes it a more rigid/traditional tracker. Atlassian has also gifted the community with the AUI, a webapp toolkit of CSS & JS. Gitblit has borrowed some of these Apache licensed CSS elements. + +**Branch Pull Requests.** BitBucket has a very cool feature of creating a pull request from a branch within the same repository. GitHub may also be able to do this. Gitblit does not currently allow you to create a ticket from an existing branch, but Gitblit tracks ticket commits using normal branches with the canonical repository. + +#### How did Gerrit influence the design of Tickets? + +**Patchsets.** Gerrit employs a clever patchset workflow that requires repeated use of `git commit --amend` to hone and polish a commit until it is ready for merging to the proposed integration branch. This technique is a much improved analog of patch revision. + +After working with this design for many months and dogfooding dozens of tickets with hundreds of amends, rebases, and squashes, I have concluded that this workflow doesn't work like I wanted it to for active, in-development code. It is best suited for it's original intention: code-review. It also introduces many, many refs. + +Gitblit has adopted Gerrit's three-repository workflow and *magic ref* design for pushes of new ticket patchsets or rewrites of existing ticket patchsets. + +### Nomenclature + +1. The organizational unit of the Gitblit Tickets feature is the *ticket*. +2. A *ticket* can be used to report a bug, request an enhancement, ask a question, etc. A ticket can also be used to collaborate on a *patchset* that addresses the request. +3. A *patchset* is a series of commits from a merge base that exists in the target branch of your repository to the tip of the patchset. A patchset may only contain a single commit, or it may contain dozens. This is similar to the commits in a *Pull Request*. One important distinction here is that in Gitblit, each *Patchset* is developed on a separate branch and can be completely rewritten without losing the previous patchsets (this creates a new patchset). +4. A *ticket* monitors the development of *patchsets* by tracking *revisions* to *patchsets*. The ticket alslo monitors rewritten patchsets. Each *patchset* is developed on it's own Git branch. + +Tracking *patchsets* is similar in concept to Gerrit, but there is a critical difference. In Gerrit, *every* commit in the *patchset* has it's own ticket **AND** Git branch. In Gerrit, *patchsets* can be easily rewritten and for each rewritten commit, a new branch ref is created. This leads to an explosion in refs for the repository over time. In Gitblit, only the tip of the *patchset* gets a branch ref and this branch ref is updated, like a regular branch, unless a rewrite is detected. + +If you prefer the Gerrit-style workflow, you can achieve a fair approximation by only pushing single commit patchsets and always amending them. You will not be able to chain tickets together, like you can chain reviews in Gerrit. + +### Types of Tickets + +Gitblit has two primary ticket types with a subtle distinction between them. + +1. *Proposal Ticket*. This ticket type is created when a contributor pushes a single commit to Gitblit using the **for** magic ref. The title and body of the commit message become the title and description of the ticket. If you want to adopt a Gerrit-style workflow then you may *--amend* this commit and push it again and again. Each *--amend* and push will update the Ticket's title and description from the commit message. However, if you push new commits that build on the initial commit then this title/description updating behavior will not apply. + +2. *Request Ticket*. This is a ticket that is manually created by a user using the web ui. These tickets have assignable types like *Bug*, *Enhancement*, *Task*, or *Question*. + +The only difference between these two ticket types is how they are created (on-push or through the ui) and the aforementioned special behavior of amending the initial commit. Otherwise, both types are identical. + +### Why not GitHub-style Pull/Merge Requests? + +GitHub-style Pull Requests require the following workflow: + +1. Fork RepoA -> MyRepoA +2. Clone MyRepoA +3. Create branch in MyRepoA clone and hack on contribution +4. Push new branch upstream to MyRepoA +5. Open Pull Request from MyRepoA -> RepoA +6. RepoA owner pulls from MyRepoA +7. RepoA owner pushes merge to RepoA + +Gitblit's flow looks like this: + +1. Clone RepoA +2. Create branch in RepoA clone and hack on contribution +3. Push to magic branch of RepoA +4. RepoA owner pulls from RepoA +5. RepoA owner pushes merge to RepoA + +The Gitblit workflow eliminates the 4-repository design of a GitHub pull request (canonical, canonical working copy, fork, & fork working copy) in favor of a 3-repository design (canonical, canonical working copy, clone working copy). + +You might wonder: is it a good idea to allow users to push into the canonical repository? And the answer is, it's no different than a GitHub pull request. When you open a GitHub pull request from MyRepoA to RepoA, your code is already being pushed to a private branch in RepoA (*refs/pull/{id}/head* and *refs/pull/{id}/merge*) so effectively you are already pushing into RepoA - you are just using an extra repository and the web ui to do it. By pushing directly to the canonical repository, you save server resources and eliminate the web ui step. + +Additionally, because the patchset is not linked to a user's personal fork it is possible to allow others to collaborate on development. + +## Status + +The Tickets feature is highly functional but there are several areas which need further refinements. + +#### What is working + +- Ticket creation and editing +- Ticket creation on patchset push +- Comments with Markdown syntax support +- Rich email notifications +- Fast-forward patchset updates and patchset rewrites +- Voting +- Watching +- Mentions +- Partial milestone support +- Querying +- Searching +- Is Mergeable test on view ticket page load +- Close-on-push of detected merge +- Multiple backend choices +- Server-side merge (testing) + +#### TODO + +- need a My Tickets page +- web ui for adding, editing, and deleting miletones +- continue cleanup of code and templates +- would be nice to have a collapsible ticket description (e.g. AUI expander) +- would be nice to edit a comment +- would be nice to delete a comment +- Groovy hook points major ticket changes (new, close, patchset change) +- REST API for tooling +- Might be nice to process Markdown previews client-side rather than round-tripping to Gitblit (another stateful example). Perhaps using AngularMarkdown? +- Would be nice to have a tool to import/export journals between services. All the journals use the same format so this should be very straight-forward to migrate/convert them between services. diff --git a/src/site/tickets_setup.mkd b/src/site/tickets_setup.mkd new file mode 100644 index 00000000..b1fa7758 --- /dev/null +++ b/src/site/tickets_setup.mkd @@ -0,0 +1,119 @@ +## Setting up Tickets + +*PREVIEW 1.4.0* + +By default, Gitblit is not configured for Tickets. There are several reasons for this, but the most important one is that you must choose the persistence backend that works best for you. + +### tickets.service + +*RESTART REQUIRED* + +The hardest part of setting up Gitblit Tickets is deciding which backend to use. Three implementations are provided, each with different strengths and weaknesses. + +#### File Ticket Service + + tickets.service = com.gitblit.tickets.FileTicketService + +Your ticket journals are persisted to `tickets/{shard}/{id}/journal.json`. These journals are stored on the filesystem within your .git directory. + +#### Branch Ticket Service + + tickets.service = com.gitblit.tickets.BranchTicketService + +Your ticket journals are persisted to `id/{shard}/{id}/journal.json`. These journals are stored on an orphan branch, `refs/gitblit/tickets`, within your repository. This allows you to easily clone your entire ticket history to client working copies or to mirrors. + +#### Redis Ticket Service + + tickets.service = com.gitblit.tickets.RedisTicketService + +Your ticket journals are persisted to a Redis data store. *Make sure you configure your Redis instance for durability!!* This particular service is highly-scalable and very fast. Plus you can use all of the power of Redis replication, should you want. + +The main drawback to this service is that Redis is primarily a Unix tool and works best on a Unix server. While there is a Windows port, sort-of maintained by Microsoft, it is not actively updated. + + tickets.redis.url = redis://(:{password}@){hostname}:{port}(/{databaseId}) + +**examples** + + tickets.redis.url = redis://localhost:6379 + tickets.redis.url = redis://:password@localhost:6379/2 + +### Other Settings + +You should also review the following settings before using Gitblit Tickets to understand what controls are available. + +#### web.canonicalUrl + + web.canonicalUrl = https://localhost:8443 + +The Tickets feature sends rich email notifications to those who are participating or watching a ticket. In order for the links in those emails to work properly, you really should set the canonical web url of your Gitblit install. This url should be your public url used to browse and navigate the website. + +#### tickets.acceptNewTickets + + tickets.acceptNewTickets = true + +This setting is used to globally disable manual creation of tickets through the web ui. You may still create proposal tickets by pushing patchsets. + +You may decide to disable creation of new tickets at the repository level in the *Edit Repository* page, however if this global setting is false, it will trump the repository setting. + +#### tickets.acceptNewPatchsets + + tickets.acceptNewPatchsets = true + +This setting is used to globally disable accepting new patchsets. If this set false, you can not create proposal tickets BUT you can still create tickets through the web ui, assuming *tickets.acceptNewTickets=true*. + +You may decide to disable accepting new patchsets at the repository level in the *Edit Repository* page, however if this global setting is false it will trump the repository setting. + +#### tickets.requireApproval + + tickets.requireApproval = false + +This setting is the default for requiring an approve review score (+2) before enabling the merge button in the web ui. This setting is not considered during the push process so an integrator may push a merged ticket disregarding this approval setting. The setting only affects the web ui and may be configured per-repository. + +#### tickets.indexFolder + +*RESTART REQUIRED* + + tickets.indexFolder = ${baseFolder}/tickets/lucene + +This is the destination for the unified Lucene ticket index. You probably won't need to change this, but it's configurable if the need arises. + +### Setting up a Repository + +#### Controlling Tickets + +Each repository can accept or reject tickets and/or patchsets by the repository settings. + +##### Issue-Tracker, no patchsets + + allow new tickets = true + accept patchsets = false + +##### Proposals only, no user-reported issues + + allow new tickets = false + accept patchsets = true + +##### Issue-tracker AND Proposals + + allow new tickets = true + accept patchsets = true + +##### No tickets whatsoever + + allow new tickets = false + accept patchsets = false + +#### Controlling Merges + +Gitblit has a simple review scoring mechanism designed to indicate overall impression of the patchset. You may optionally configure your repository to require an approval score for a patchset revision BEFORE the Merge button is displayed/enabled. This per-repository setting is not respected if an integrator pushes a merge. This setting is only used to control the web ui. + +#### Milestones + +Milestones are a way to group tickets together. Currently milestones are specified at the repository level and are stored in the repository git config file. Gitblit's internal architecture has all the methods necessary to maintain milestones, but this functionality is not yet exposed through the web ui. For now you will have to control milestones manually with a text editor. + + [milestone "v1.5.0"] + status = Open + due = 2014-06-01 + color = "#00f000" + +Please note the date format for the *due* field: yyyy-MM-dd. diff --git a/src/site/tickets_using.mkd b/src/site/tickets_using.mkd new file mode 100644 index 00000000..71a4ebec --- /dev/null +++ b/src/site/tickets_using.mkd @@ -0,0 +1,155 @@ +## Using Tickets + +*PREVIEW 1.4.0* + +### Creating Standard Tickets + +Standard tickets can be created using the web ui. These ticket types include *Bug*, *Enhancement*, *task*, and *Question*. + +### Creating a Proposal Ticket + +Proposal tickets are created by pushing a patchset to the magic ref. They can not be created from the web ui. + +*Why should I create a proposal ticket?* + +Because you are too lazy to create a ticket in the web ui first. The proposal ticket is a convenience mechanism. It allows you to propose changes using Git, not your browser. + +*Who can create a proposal ticket?* + +Any authenticated user who can clone your repository. + + git checkout -b mytopic + ...add a single commit... + git push origin HEAD:refs/for/new + git branch --set-upstream-to={remote}/ticket/{id} + +### Creating the first Patchset for an Existing Ticket + +If you have an existing ticket that does **not*** yet have a proposed patchset you can push using the magic ref. + +*Who can create the first patchset for an existing ticket?* + +Any authenticated user who can clone your repository. + + git checkout -b mytopic + ...add one or more commits... + git push origin HEAD:refs/for/{id} + git branch --set-upstream-to={remote}/ticket/{id} + +### Safely adding commits to a Patchset for an Existing Ticket + +*Who can add commits to an existing patchset?* + +1. The author of the ticket +2. The author of the initial patchset +3. The person set as *responsible* +4. Any user with write (RW) permissions to the repository + + + git checkout ticket/{id} + ...add one or more commits... + git push + +### Rewriting a Patchset (amend, rebase, squash) + +*Who can rewrite a patchset?* + +See the above rules for who can add commits to a patchset. You do **not** need rewind (RW+) to the repository to push a non-fast-forward patchset. Gitblit will detect the non-fast-forward update and create a new patchset ref. This preserves the previous patchset. + + git checkout ticket/{id} + ...amend, rebase, squash... + git push origin HEAD:refs/for/{id} + +### Ticket RefSpecs + +Gitblit supports two primary push ref specs: the magic ref and the patchset ref. + +#### to create a new proposal ticket + +| ref | description | +| :------------------- | :------------------------------------------- | +| refs/for/new | new proposal for the default branch | +| refs/for/default | new proposal for the default branch | +| refs/for/{branch} | new proposal for the specified branch | + +#### to add a proposal patchset (first patchset) to an existing ticket + +| ref | description | +| :------------------- | :------------------------------------------- | +| refs/for/{id} | add new patchset to an existing ticket | + +#### to add commits to an existing patchset + +| ref | description | +| :--------------------------- | :----------------------------------- | +| refs/heads/ticket/{id} | fast-forward an existing patchset | + + +#### to rewrite a patchset (amend, rebase, squash) + +| magic ref | description | +| :------------------- | :------------------------------------------- | +| refs/for/{id} | add new patchset to an existing ticket | + +### Ticket RefSpec Tricks + +Gitblit supports setting some ticket fields from the push refspec. + + refs/for/master%topic=bug/42,r=james,m=1.4.1,cc=dave,cc=mark + +| parameter | description | +| :-------- | :-------------------------------------------------------------- | +| t | assign a *topic* to the ticket (matched against bugtraq config) | +| r | set the *responsible* user | +| m | set the *milestone* for patchset integration | +| cc | add this account to the *watch* list (multiple ccs allowed) | + +#### examples + +Create a new patchset for ticket *12*, add *james* and *mark* to the watch list, and set the topic to *issue-123* which will be regex-matched against the repository bugtraq configuration. + + git push origin HEAD:refs/for/12%cc=james,cc=mark,t=issue-123 + +Add some commits to ticket *123* patchset *5*. Set the milestone to *1.4.1*. + + git push origin HEAD:refs/heads/ticket/123/5%m=1.4.1 + +### Merging Patchsets + +The Gitblit web ui offers a merge button which *should work* but is not fully tested. Gitblit does verify that you can cleanly merge a patchset to the integration branch. + +There are complicated merge scenarios for which it may be best to merge using your Git client. There are several ways to do this, here is a safe merge strategy which pulls into a new branch and then fast-forwards your integration branch, assuming you were happy with the pull (merge). + + git pull origin master + git checkout -b ticket-{id} master + git pull origin ticket/{id} + git checkout master + git merge ticket-{id} + git push origin master + +### Closing Tickets on Push with a Completely New Patchset + +Gitblit will look for patchset references on pushes to normal branches. If it finds a reference (like would be found in the previous merge instructions), the ticket is resolved as merged and everyone is notified. + +If you do not need to create a patchset for review, you can just push a commit to the integration branch that contains `fixes #1` or `closes #1` in the commit message. Gitblit will identify the ticket, create a new patchset with that commit as the tip, and resolve the ticket as merged. (And if the integration branch is not specified in the ticket - this is the case for a ticket without any existing patchsets - Gitblit will resolve the ticket as merged to the pushed branch). + +### Reopening Tickets with Patchsets + +Gitblit allows you to reopen a Ticket with a merged patchset. Since Gitblit allows patchset rewrites and versions patchsets, this seems like a logical capability. There is no need to create another ticket for a feature request or bug report if the merged commits did not actually resolve the ticket. + +This allows you to continue the discussion and create a new patchset that hopefully resolves the need. + +**NOTE:** There is one caveat to this feature. You can not push patchsets to a closed ticket; Gitblit will reject the push. You must first reopen the ticket through the web ui before you may push your patchset update or new patchset. + +### Reviews + +Gitblit includes a very simple review scoring mechanism. + +- +2, approved: patchset can be merged +- +1, looks good: someone else must approve for merge +- -1, needs improvement: please do not merge +- -2, vetoed: patchset may not be merged + +Only users with write (RW) permissions to the repository can give a +2 and -2 score. Any other user is free to score +/-1. + +If the patchset is updated or rewritten, all reviews are reset; reviews apply to specific revisions of patchsets - they are not blanket approvals/disapprovals. diff --git a/src/test/java/com/gitblit/tests/BranchTicketServiceTest.java b/src/test/java/com/gitblit/tests/BranchTicketServiceTest.java new file mode 100644 index 00000000..4bd74f51 --- /dev/null +++ b/src/test/java/com/gitblit/tests/BranchTicketServiceTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.tests; + +import com.gitblit.IStoredSettings; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.manager.NotificationManager; +import com.gitblit.manager.RepositoryManager; +import com.gitblit.manager.RuntimeManager; +import com.gitblit.manager.UserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.tickets.BranchTicketService; +import com.gitblit.tickets.ITicketService; + +/** + * Tests the branch ticket service. + * + * @author James Moger + * + */ +public class BranchTicketServiceTest extends TicketServiceTest { + + final RepositoryModel repo = new RepositoryModel("tickets/branch.git", null, null, null); + + @Override + protected RepositoryModel getRepository() { + return repo; + } + + + @Override + protected ITicketService getService(boolean deleteAll) throws Exception { + + IStoredSettings settings = getSettings(deleteAll); + + IRuntimeManager runtimeManager = new RuntimeManager(settings).start(); + INotificationManager notificationManager = new NotificationManager(settings).start(); + IUserManager userManager = new UserManager(runtimeManager).start(); + IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, userManager).start(); + + BranchTicketService service = new BranchTicketService( + runtimeManager, + notificationManager, + userManager, + repositoryManager).start(); + + if (deleteAll) { + service.deleteAll(getRepository()); + } + return service; + } +} diff --git a/src/test/java/com/gitblit/tests/FileTicketServiceTest.java b/src/test/java/com/gitblit/tests/FileTicketServiceTest.java new file mode 100644 index 00000000..3cc25218 --- /dev/null +++ b/src/test/java/com/gitblit/tests/FileTicketServiceTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.tests; + +import com.gitblit.IStoredSettings; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.manager.NotificationManager; +import com.gitblit.manager.RepositoryManager; +import com.gitblit.manager.RuntimeManager; +import com.gitblit.manager.UserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.tickets.FileTicketService; +import com.gitblit.tickets.ITicketService; + +/** + * Tests the file ticket service. + * + * @author James Moger + * + */ +public class FileTicketServiceTest extends TicketServiceTest { + + final RepositoryModel repo = new RepositoryModel("tickets/file.git", null, null, null); + + @Override + protected RepositoryModel getRepository() { + return repo; + } + + @Override + protected ITicketService getService(boolean deleteAll) throws Exception { + + IStoredSettings settings = getSettings(deleteAll); + + IRuntimeManager runtimeManager = new RuntimeManager(settings).start(); + INotificationManager notificationManager = new NotificationManager(settings).start(); + IUserManager userManager = new UserManager(runtimeManager).start(); + IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, userManager).start(); + + FileTicketService service = new FileTicketService( + runtimeManager, + notificationManager, + userManager, + repositoryManager).start(); + + if (deleteAll) { + service.deleteAll(getRepository()); + } + return service; + } +} diff --git a/src/test/java/com/gitblit/tests/GitBlitSuite.java b/src/test/java/com/gitblit/tests/GitBlitSuite.java index 9fe7312c..cba575d7 100644 --- a/src/test/java/com/gitblit/tests/GitBlitSuite.java +++ b/src/test/java/com/gitblit/tests/GitBlitSuite.java @@ -63,7 +63,8 @@ import com.gitblit.utils.JGitUtils; GitBlitTest.class, FederationTests.class, RpcTests.class, GitServletTest.class, GitDaemonTest.class, GroovyScriptTest.class, LuceneExecutorTest.class, RepositoryModelTest.class, FanoutServiceTest.class, Issue0259Test.class, Issue0271Test.class, HtpasswdAuthenticationTest.class, - ModelUtilsTest.class, JnaUtilsTest.class, LdapSyncServiceTest.class }) + ModelUtilsTest.class, JnaUtilsTest.class, LdapSyncServiceTest.class, FileTicketServiceTest.class, + BranchTicketServiceTest.class, RedisTicketServiceTest.class }) public class GitBlitSuite { public static final File BASEFOLDER = new File("data"); @@ -106,6 +107,11 @@ public class GitBlitSuite { return getRepository("test/gitective.git"); } + public static Repository getTicketsTestRepository() { + JGitUtils.createRepository(REPOSITORIES, "gb-tickets.git").close(); + return getRepository("gb-tickets.git"); + } + private static Repository getRepository(String name) { try { File gitDir = FileKey.resolve(new File(REPOSITORIES, name), FS.DETECTED); diff --git a/src/test/java/com/gitblit/tests/RedisTicketServiceTest.java b/src/test/java/com/gitblit/tests/RedisTicketServiceTest.java new file mode 100644 index 00000000..5a4bda73 --- /dev/null +++ b/src/test/java/com/gitblit/tests/RedisTicketServiceTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.tests; + +import com.gitblit.IStoredSettings; +import com.gitblit.Keys; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.manager.NotificationManager; +import com.gitblit.manager.RepositoryManager; +import com.gitblit.manager.RuntimeManager; +import com.gitblit.manager.UserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.tickets.ITicketService; +import com.gitblit.tickets.RedisTicketService; + +/** + * Tests the Redis ticket service. + * + * @author James Moger + * + */ +public class RedisTicketServiceTest extends TicketServiceTest { + + final RepositoryModel repo = new RepositoryModel("tickets/redis.git", null, null, null); + + @Override + protected RepositoryModel getRepository() { + return repo; + } + + @Override + protected IStoredSettings getSettings(boolean deleteAll) throws Exception { + IStoredSettings settings = super.getSettings(deleteAll); + settings.overrideSetting(Keys.tickets.redis.url, "redis://localhost:6379/10"); + return settings; + } + + @Override + protected ITicketService getService(boolean deleteAll) throws Exception { + + IStoredSettings settings = getSettings(deleteAll); + + IRuntimeManager runtimeManager = new RuntimeManager(settings).start(); + INotificationManager notificationManager = new NotificationManager(settings).start(); + IUserManager userManager = new UserManager(runtimeManager).start(); + IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, userManager).start(); + + RedisTicketService service = new RedisTicketService( + runtimeManager, + notificationManager, + userManager, + repositoryManager).start(); + + if (deleteAll) { + service.deleteAll(getRepository()); + } + return service; + } +} diff --git a/src/test/java/com/gitblit/tests/TicketServiceTest.java b/src/test/java/com/gitblit/tests/TicketServiceTest.java new file mode 100644 index 00000000..5f94a46e --- /dev/null +++ b/src/test/java/com/gitblit/tests/TicketServiceTest.java @@ -0,0 +1,351 @@ +/* + * Copyright 2014 gitblit.com. + * + * 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 com.gitblit.tests; + +import java.io.File; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.io.FileUtils; +import org.bouncycastle.util.Arrays; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import com.gitblit.IStoredSettings; +import com.gitblit.Keys; +import com.gitblit.models.Mailing; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.TicketModel.Type; +import com.gitblit.tests.mock.MemorySettings; +import com.gitblit.tickets.ITicketService; +import com.gitblit.tickets.ITicketService.TicketFilter; +import com.gitblit.tickets.QueryResult; +import com.gitblit.tickets.TicketIndexer.Lucene; +import com.gitblit.tickets.TicketLabel; +import com.gitblit.tickets.TicketMilestone; +import com.gitblit.tickets.TicketNotifier; +import com.gitblit.utils.JGitUtils; + +/** + * Tests the mechanics of Gitblit ticket management. + * + * @author James Moger + * + */ +public abstract class TicketServiceTest extends GitblitUnitTest { + + private ITicketService service; + + protected abstract RepositoryModel getRepository(); + + protected abstract ITicketService getService(boolean deleteAll) throws Exception; + + protected IStoredSettings getSettings(boolean deleteAll) throws Exception { + File dir = new File(GitBlitSuite.REPOSITORIES, getRepository().name); + if (deleteAll) { + FileUtils.deleteDirectory(dir); + JGitUtils.createRepository(GitBlitSuite.REPOSITORIES, getRepository().name).close(); + } + + File luceneDir = new File(dir, "tickets/lucene"); + luceneDir.mkdirs(); + + Map map = new HashMap(); + map.put(Keys.git.repositoriesFolder, GitBlitSuite.REPOSITORIES.getAbsolutePath()); + map.put(Keys.tickets.indexFolder, luceneDir.getAbsolutePath()); + + IStoredSettings settings = new MemorySettings(map); + return settings; + } + + @Before + public void setup() throws Exception { + service = getService(true); + } + + @After + public void cleanup() { + service.stop(); + } + + @Test + public void testLifecycle() throws Exception { + // create and insert a ticket + Change c1 = newChange("testCreation() " + Long.toHexString(System.currentTimeMillis())); + TicketModel ticket = service.createTicket(getRepository(), c1); + assertTrue(ticket.number > 0); + + // retrieve ticket and compare + TicketModel constructed = service.getTicket(getRepository(), ticket.number); + compare(ticket, constructed); + + assertEquals(1, constructed.changes.size()); + + // C1: create the ticket + int changeCount = 0; + c1 = newChange("testUpdates() " + Long.toHexString(System.currentTimeMillis())); + ticket = service.createTicket(getRepository(), c1); + assertTrue(ticket.number > 0); + changeCount++; + + constructed = service.getTicket(getRepository(), ticket.number); + compare(ticket, constructed); + assertEquals(1, constructed.changes.size()); + + // C2: set owner + Change c2 = new Change("C2"); + c2.comment("I'll fix this"); + c2.setField(Field.responsible, c2.author); + constructed = service.updateTicket(getRepository(), ticket.number, c2); + assertNotNull(constructed); + assertEquals(2, constructed.changes.size()); + assertEquals(c2.author, constructed.responsible); + changeCount++; + + // C3: add a note + Change c3 = new Change("C3"); + c3.comment("yeah, this is working"); + constructed = service.updateTicket(getRepository(), ticket.number, c3); + assertNotNull(constructed); + assertEquals(3, constructed.changes.size()); + changeCount++; + + if (service.supportsAttachments()) { + // C4: add attachment + Change c4 = new Change("C4"); + Attachment a = newAttachment(); + c4.addAttachment(a); + constructed = service.updateTicket(getRepository(), ticket.number, c4); + assertNotNull(constructed); + assertTrue(constructed.hasAttachments()); + Attachment a1 = service.getAttachment(getRepository(), ticket.number, a.name); + assertEquals(a.content.length, a1.content.length); + assertTrue(Arrays.areEqual(a.content, a1.content)); + changeCount++; + } + + // C5: close the issue + Change c5 = new Change("C5"); + c5.comment("closing issue"); + c5.setField(Field.status, Status.Resolved); + constructed = service.updateTicket(getRepository(), ticket.number, c5); + assertNotNull(constructed); + changeCount++; + assertTrue(constructed.isClosed()); + assertEquals(changeCount, constructed.changes.size()); + + List allTickets = service.getTickets(getRepository()); + List openTickets = service.getTickets(getRepository(), new TicketFilter() { + @Override + public boolean accept(TicketModel ticket) { + return ticket.isOpen(); + } + }); + List closedTickets = service.getTickets(getRepository(), new TicketFilter() { + @Override + public boolean accept(TicketModel ticket) { + return ticket.isClosed(); + } + }); + assertTrue(allTickets.size() > 0); + assertEquals(1, openTickets.size()); + assertEquals(1, closedTickets.size()); + + // build a new Lucene index + service.reindex(getRepository()); + List hits = service.searchFor(getRepository(), "working", 1, 10); + assertEquals(1, hits.size()); + + // reindex a ticket + ticket = allTickets.get(0); + Change change = new Change("reindex"); + change.comment("this is a test of reindexing a ticket"); + service.updateTicket(getRepository(), ticket.number, change); + ticket = service.getTicket(getRepository(), ticket.number); + + hits = service.searchFor(getRepository(), "reindexing", 1, 10); + assertEquals(1, hits.size()); + + service.stop(); + service = getService(false); + + // Lucene field query + List results = service.queryFor(Lucene.status.matches(Status.New.name()), 1, 10, Lucene.created.name(), true); + assertEquals(1, results.size()); + assertTrue(results.get(0).title.startsWith("testCreation")); + + // Lucene field query + results = service.queryFor(Lucene.status.matches(Status.Resolved.name()), 1, 10, Lucene.created.name(), true); + assertEquals(1, results.size()); + assertTrue(results.get(0).title.startsWith("testUpdates")); + + // delete all tickets + for (TicketModel aTicket : allTickets) { + assertTrue(service.deleteTicket(getRepository(), aTicket.number, "D")); + } + } + + @Test + public void testChangeComment() throws Exception { + // C1: create the ticket + Change c1 = newChange("testChangeComment() " + Long.toHexString(System.currentTimeMillis())); + TicketModel ticket = service.createTicket(getRepository(), c1); + assertTrue(ticket.number > 0); + assertTrue(ticket.changes.get(0).hasComment()); + + ticket = service.updateComment(ticket, c1.comment.id, "E1", "I changed the comment"); + assertNotNull(ticket); + assertTrue(ticket.changes.get(0).hasComment()); + assertEquals("I changed the comment", ticket.changes.get(0).comment.text); + + assertTrue(service.deleteTicket(getRepository(), ticket.number, "D")); + } + + @Test + public void testDeleteComment() throws Exception { + // C1: create the ticket + Change c1 = newChange("testDeleteComment() " + Long.toHexString(System.currentTimeMillis())); + TicketModel ticket = service.createTicket(getRepository(), c1); + assertTrue(ticket.number > 0); + assertTrue(ticket.changes.get(0).hasComment()); + + ticket = service.deleteComment(ticket, c1.comment.id, "D1"); + assertNotNull(ticket); + assertEquals(1, ticket.changes.size()); + assertFalse(ticket.changes.get(0).hasComment()); + + assertTrue(service.deleteTicket(getRepository(), ticket.number, "D")); + } + + @Test + public void testMilestones() throws Exception { + service.createMilestone(getRepository(), "M1", "james"); + service.createMilestone(getRepository(), "M2", "frank"); + service.createMilestone(getRepository(), "M3", "joe"); + + List milestones = service.getMilestones(getRepository(), Status.Open); + assertEquals("Unexpected open milestones count", 3, milestones.size()); + + for (TicketMilestone milestone : milestones) { + milestone.status = Status.Resolved; + milestone.due = new Date(); + assertTrue("failed to update milestone " + milestone.name, service.updateMilestone(getRepository(), milestone, "ted")); + } + + milestones = service.getMilestones(getRepository(), Status.Open); + assertEquals("Unexpected open milestones count", 0, milestones.size()); + + milestones = service.getMilestones(getRepository(), Status.Resolved); + assertEquals("Unexpected resolved milestones count", 3, milestones.size()); + + for (TicketMilestone milestone : milestones) { + assertTrue("failed to delete milestone " + milestone.name, service.deleteMilestone(getRepository(), milestone.name, "lucifer")); + } + } + + @Test + public void testLabels() throws Exception { + service.createLabel(getRepository(), "L1", "james"); + service.createLabel(getRepository(), "L2", "frank"); + service.createLabel(getRepository(), "L3", "joe"); + + List labels = service.getLabels(getRepository()); + assertEquals("Unexpected open labels count", 3, labels.size()); + + for (TicketLabel label : labels) { + label.color = "#ffff00"; + assertTrue("failed to update label " + label.name, service.updateLabel(getRepository(), label, "ted")); + } + + labels = service.getLabels(getRepository()); + assertEquals("Unexpected labels count", 3, labels.size()); + + for (TicketLabel label : labels) { + assertTrue("failed to delete label " + label.name, service.deleteLabel(getRepository(), label.name, "lucifer")); + } + } + + + + private Change newChange(String summary) { + Change change = new Change("C1"); + change.setField(Field.title, summary); + change.setField(Field.body, "this is my description"); + change.setField(Field.labels, "helpdesk"); + change.comment("my comment"); + return change; + } + + private Attachment newAttachment() { + Attachment attachment = new Attachment("test1.txt"); + attachment.content = new byte[] { 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, + 0x4a }; + return attachment; + } + + private void compare(TicketModel ticket, TicketModel constructed) { + assertEquals(ticket.number, constructed.number); + assertEquals(ticket.createdBy, constructed.createdBy); + assertEquals(ticket.responsible, constructed.responsible); + assertEquals(ticket.title, constructed.title); + assertEquals(ticket.body, constructed.body); + assertEquals(ticket.created, constructed.created); + + assertTrue(ticket.hasLabel("helpdesk")); + } + + @Test + public void testNotifier() throws Exception { + Change kernel = new Change("james"); + kernel.setField(Field.title, "Sample ticket"); + kernel.setField(Field.body, "this **is** my sample body\n\n- I hope\n- you really\n- *really* like it"); + kernel.setField(Field.status, Status.New); + kernel.setField(Field.type, Type.Proposal); + + kernel.comment("this is a sample comment on a kernel change"); + + Patchset patchset = new Patchset(); + patchset.insertions = 100; + patchset.deletions = 10; + patchset.number = 1; + patchset.rev = 25; + patchset.tip = "50f57913f816d04a16b7407134de5d8406421f37"; + kernel.patchset = patchset; + + TicketModel ticket = service.createTicket(getRepository(), 0L, kernel); + + Change merge = new Change("james"); + merge.setField(Field.mergeSha, patchset.tip); + merge.setField(Field.mergeTo, "master"); + merge.setField(Field.status, Status.Merged); + + ticket = service.updateTicket(getRepository(), ticket.number, merge); + ticket.repository = getRepository().name; + + TicketNotifier notifier = service.createNotifier(); + Mailing mailing = notifier.queueMailing(ticket); + assertNotNull(mailing); + } +} \ No newline at end of file -- 2.39.5