1. Atlassian Tutorials
  2. Untitled project
  3. fecru-review-creator

Commits

Mary Anthony  committed 46d9721

Moving from SVN to BB

  • Participants
  • Branches master

Comments (0)

Files changed (17)

File LICENSE.TXT

View file
+DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+                    Version 2, December 2004
+
+ Copyright (C) 2004 Sam Hocevar
+  14 rue de Plaisance, 75014 Paris, France
+ Everyone is permitted to copy and distribute verbatim or modified
+ copies of this license document, and changing it is allowed as long
+ as the name is changed.
+
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. You just DO WHAT THE FUCK YOU WANT TO.

File README.txt

View file
+  INTRODUCTION
+
+This plugin facilitates automatic review creation for each commit made on a
+repository. Review creation can be enable/disabled on a per-project and
+per-user basis.
+
+
+  REQUIREMENTS
+
+- FishEye _with_ Crucible (will not work with Crucible standalone)
+- Release 2.1.0 or higher
+- When running from the Plugin SDK, provide sufficient heap space:
+  $ export ATLAS_OPTS=-Xmx512m
+  $ atlas-run

File pom.xml

View file
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>com.atlassian.plugins</groupId>
+    <artifactId>reviewcreator</artifactId>
+    <version>1.5-SNAPSHOT</version>
+
+    <organization>
+        <name>Atlassian</name>
+        <url>http://www.atlassian.com/</url>
+    </organization>
+
+    <name>reviewcreator</name>
+    <description>This is the com.atlassian.example:reviewcreator plugin for Atlassian FishEye/Crucible.</description>
+    <packaging>atlassian-plugin</packaging>
+
+    <scm>
+        <connection>scm:svn:https://svn.atlassian.com/svn/public/atlassian/crucible/plugins/review-creator/trunk</connection>
+        <developerConnection>scm:svn:https://svn.atlassian.com/svn/public/atlassian/crucible/plugins/review-creator/trunk</developerConnection>
+        <url>http://svn.atlassian.com/fisheye/browse/public/atlassian/crucible/plugins/review-creator/trunk</url>
+    </scm>
+
+    <dependencies>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.6</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.atlassian.fisheye</groupId>
+            <artifactId>atlassian-fisheye-api</artifactId>
+            <version>${fecru.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.google.collections</groupId>
+            <artifactId>google-collections</artifactId>
+            <version>1.0-rc2</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>com.atlassian.maven.plugins</groupId>
+                <artifactId>maven-fecru-plugin</artifactId>
+                <version>3.1.2</version>
+                <extensions>true</extensions>
+                <configuration>
+                    <skipManifestValidation>true</skipManifestValidation>
+                    <productVersion>${fecru.version}</productVersion>
+                    <productDataVersion>${fecru.data.version}</productDataVersion>
+                </configuration>
+            </plugin>
+            <plugin>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <configuration>
+                    <source>1.5</source>
+                    <target>1.5</target>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+
+    <distributionManagement>
+        <repository>
+            <id>atlassian-m2-repository</id>
+            <name>Atlassian Public Repository</name>
+            <url>davs://maven.atlassian.com/public</url>
+        </repository>
+        <snapshotRepository>
+            <id>atlassian-m2-snapshot-repository</id>
+            <name>Atlassian Public Snapshot Repository</name>
+            <url>davs://maven.atlassian.com/public-snapshot</url>
+        </snapshotRepository>
+    </distributionManagement>
+
+    <properties>
+        <fecru.version>2.3.0-480</fecru.version>
+        <fecru.data.version>2.3.0-480</fecru.data.version>
+    </properties>
+
+</project>

File src/main/java/com/atlassian/example/reviewcreator/AdminServlet.java

View file
+package com.atlassian.example.reviewcreator;
+
+import com.atlassian.crucible.spi.data.ProjectData;
+import com.atlassian.crucible.spi.services.ImpersonationService;
+import com.atlassian.crucible.spi.services.NotFoundException;
+import com.atlassian.crucible.spi.services.Operation;
+import com.atlassian.crucible.spi.services.ProjectService;
+import com.atlassian.crucible.spi.services.ServerException;
+import com.atlassian.crucible.spi.services.UserService;
+import com.atlassian.fisheye.plugin.web.helpers.VelocityHelper;
+import com.google.common.base.Predicate;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.Lists;
+import org.apache.commons.lang.StringUtils;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+public class AdminServlet extends HttpServlet {
+
+    private final ProjectService projectService;
+    private final ImpersonationService impersonator;
+    private final UserService userService;
+    private final VelocityHelper velocity;
+    private final ConfigurationManager config;
+
+    public AdminServlet(
+            ConfigurationManager config,
+            ProjectService projectService,
+            ImpersonationService impersonator,
+            UserService userService,
+            VelocityHelper velocity) {
+        
+        this.projectService = projectService;
+        this.impersonator = impersonator;
+        this.userService = userService;
+        this.velocity = velocity;
+        this.config = config;
+    }
+
+    public void doGet(HttpServletRequest request, HttpServletResponse response)
+            throws IOException {
+
+        final Map<String, Object> params = new HashMap<String, Object>();
+
+        final String username = config.loadRunAsUser();
+        if (!StringUtils.isEmpty(username)) {
+            params.put("username", username);
+
+            impersonator.doAsUser(null, username, new Operation<Void, RuntimeException>() {
+                public Void perform() throws RuntimeException {
+                    params.put("projects", loadProjects());
+                    return null;
+                }
+            });
+
+            params.put("contextPath", request.getContextPath());
+            params.put("createMode", config.loadCreateMode().name());
+            params.put("committerNames", config.loadCrucibleUserNames());
+            params.put("groupNames", config.loadCrucibleGroups());
+            params.put("iterative", config.loadIterative());
+            params.put("stringUtils", new StringUtils());
+        }
+
+        response.setContentType("text/html");
+        velocity.renderVelocityTemplate("templates/admin.vm", params, response.getWriter());
+    }
+
+    @Override
+    protected void doPost(final HttpServletRequest req, final HttpServletResponse resp)
+            throws ServletException, IOException {
+
+        final String username = req.getParameter("username");
+        config.storeRunAsUser(username);
+
+        final List<String> enabled = req.getParameterValues("enabled") == null ?
+                Collections.<String>emptyList() : Arrays.asList(req.getParameterValues("enabled"));
+
+        impersonator.doAsUser(null, username, new Operation<Void, RuntimeException>() {
+            public Void perform() throws RuntimeException {
+                final Set<Project> projects = new HashSet<Project>();
+                // TODO: use a google collections transformer
+                for (Project p : loadProjects()) {
+                    projects.add(new Project(p.getId(), p.getKey(), p.getName(), p.getModerator(), enabled.contains(p.getKey())));
+                }
+                storeProjects(projects);
+
+                config.storeCreateMode(CreateMode.valueOf(
+                        Utils.defaultIfNull(req.getParameter("createMode"), CreateMode.ALWAYS.name())));
+
+                final String[] committerNames = StringUtils.split(req.getParameter("committerNames"), ",    \n\r");
+                config.storeCrucibleUserNames(committerNames == null ? Collections.<String>emptyList() :
+                        getValidatedUsernames(Lists.newArrayList(committerNames)));
+
+                final String[] groupNames = StringUtils.split(req.getParameter("groupNames"), ",    \n\r");
+                config.storeCrucibleGroups(groupNames == null ? Collections.<String>emptyList() :
+                        Lists.newArrayList(groupNames));
+
+                config.storeIterative(req.getParameter("iterative") != null);
+                return null;
+            }
+        });
+
+        resp.sendRedirect("./reviewcreatoradmin");
+    }
+
+    /**
+     * @param crucibleUsernames
+     * @return  the (sub)set of usernames that exist in the system. The names
+     * present in the specified list that are not present in the returned list
+     * represent invalid usernames.
+     */
+    private Collection<String> getValidatedUsernames(Collection<String> crucibleUsernames) {
+
+        return Collections2.filter(crucibleUsernames, new Predicate<String>() {
+            public boolean apply(String username) {
+                try {
+                    userService.getUser(username);
+                    return true;
+                } catch (NotFoundException nfe) {
+                    // Not very good practice to use exceptions for flow
+                    // control, but it's the only way to detect the existence
+                    // of a Crucible user.
+                    return false;
+                } catch (ServerException se) {
+                    throw new RuntimeException(String.format(
+                            "Error validating Crucible user \"%s\": %s", username, se.getMessage()), se);
+                }
+            }
+        });
+    }
+
+    /**
+     * Returns a set of all projects.
+     * Note: this method must be run as a valid Crucible user.
+     *
+     * @return
+     */
+    private Set<Project> loadProjects() {
+
+        final List<String> enabledKeys = config.loadEnabledProjects();
+
+        final Set<Project> projects = new TreeSet<Project>(new Comparator<Project>() {
+            public int compare(Project p1, Project p2) {
+                return p1.getKey().compareTo(p2.getKey());
+            }
+        });
+        for (ProjectData p : projectService.getAllProjects()) {
+            projects.add(new Project(p.getId(), p.getKey(), p.getName(), p.getDefaultModerator(), enabledKeys.contains(p.getKey())));
+        }
+        return projects;
+    }
+
+    /**
+     * Stores the projects for which auto review creation is enabled.
+     *
+     * @param projects
+     */
+    private void storeProjects(Set<Project> projects) {
+
+        final List<String> enabled = new ArrayList<String>();
+        for (Project p : projects) {
+            if (p.isEnabled()) {
+                enabled.add(p.getKey());
+            }
+        }
+        config.storeEnabledProjects(enabled);
+    }
+}

File src/main/java/com/atlassian/example/reviewcreator/CommitListener.java

View file
+package com.atlassian.example.reviewcreator;
+
+import com.atlassian.crucible.spi.data.*;
+import com.atlassian.crucible.spi.PermId;
+import com.atlassian.crucible.spi.services.*;
+import com.atlassian.event.Event;
+import com.atlassian.event.EventListener;
+import com.atlassian.fisheye.event.CommitEvent;
+import com.atlassian.fisheye.spi.data.ChangesetDataFE;
+import com.atlassian.fisheye.spi.services.RevisionDataService;
+import com.atlassian.sal.api.user.UserManager;
+import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import org.apache.commons.lang.StringUtils;
+import org.apache.log4j.Logger;
+
+import java.util.*;
+
+/**
+ * <p>
+ * Event listener that subscribes to commit events and creates a review for
+ * each commit.
+ * </p>
+ * <p>
+ * Auto review creation can be enabled/disabled by an administrator on a
+ * per-project basis. Enabled projects must be bound to a FishEye repository
+ * and must have a default Moderator configured in the admin section.
+ * </p>
+ * <p>
+ * When auto review creation is enabled for a Crucible project, this
+ * {@link com.atlassian.event.EventListener} will intercept all commits for
+ * the project's repository and create a review for it. The review's author
+ * role is set to the committer of the changeset and the review's moderator is
+ * set to the project's default moderator.
+ * </p>
+ * <p>
+ * When the project has default reviewers configured, these will be added to
+ * the review.
+ * </p>
+ *
+ * @author  Erik van Zijst
+ */
+public class CommitListener implements EventListener {
+
+    private final Logger logger = Logger.getLogger(getClass().getName());
+
+    private final RevisionDataService revisionService;          // provided by FishEye
+    private final ReviewService reviewService;                  // provided by Crucible
+    private final ProjectService projectService;                // provided by Crucible
+    private final UserService userService;                      // provided by Crucible
+    private final UserManager userManager;                      // provided by SAL
+    private final ImpersonationService impersonator;            // provided by Crucible
+    private final ConfigurationManager config;                  // provided by our plugin
+
+    private static final ThreadLocal<Map<String, UserData>> committerToCrucibleUser =
+            new ThreadLocal();
+
+    public CommitListener(ConfigurationManager config,
+            ReviewService reviewService,
+            ProjectService projectService,
+            RevisionDataService revisionService,
+            UserService userService,
+            UserManager userManager,
+            ImpersonationService impersonator) {
+
+        this.reviewService = reviewService;
+        this.revisionService = revisionService;
+        this.projectService = projectService;
+        this.userService = userService;
+        this.userManager = userManager;
+        this.impersonator = impersonator;
+        this.config = config;
+    }
+
+    public Class[] getHandledEventClasses() {
+        return new Class[] {CommitEvent.class};
+    }
+
+    public void handleEvent(Event event) {
+
+        final CommitEvent commit = (CommitEvent) event;
+
+        if (isPluginEnabled()) {
+            try {
+                // switch to admin user so we can access all projects and API services:
+                impersonator.doAsUser(null, config.loadRunAsUser(),
+                    new Operation<Void, ServerException>() {
+                        public Void perform() throws ServerException {
+
+                            final ChangesetDataFE cs = revisionService.getChangeset(
+                                    commit.getRepositoryName(), commit.getChangeSetId());
+                            final ProjectData project = getEnabledProjectForRepository(
+                                    commit.getRepositoryName());
+
+                            if (project != null) {
+                                committerToCrucibleUser.set(loadCommitterMappings(project.getDefaultRepositoryName()));
+                                if (project.getDefaultModerator() != null) {
+                                    if (isUnderScrutiny(cs.getAuthor())) {
+                                        if (!config.loadIterative() || !appendToReview(commit.getRepositoryName(), cs, project)) {
+                                            // create a new review:
+                                            createReview(commit.getRepositoryName(), cs, project);
+                                        }
+                                    } else {
+                                        logger.info(String.format("Not creating a review for changeset %s.",
+                                                commit.getChangeSetId()));
+                                    }
+                                } else {
+                                    logger.error(String.format("Unable to auto-create review for changeset %s. " +
+                                            "No default moderator configured for project %s.",
+                                            commit.getChangeSetId(), project.getKey()));
+                                }
+                            } else {
+                                logger.error(String.format("Unable to auto-create review for changeset %s. " +
+                                        "No projects found that bind to repository %s.",
+                                        commit.getChangeSetId(), commit.getRepositoryName()));
+                            }
+                            return null;
+                        }
+                    });
+            } catch (Exception e) {
+                logger.error(String.format("Unable to auto-create " +
+                        "review for changeset %s: %s.",
+                        commit.getChangeSetId(), e.getMessage()), e);
+            }
+        }
+    }
+
+    /**
+     * Determines whether or not the user that made the commit is exempt from
+     * automatic reviews, or whether the user is on the list of always having
+     * its commits automatically reviewed.
+     *
+     * @param committer the username that made the commit (the system will use
+     * the committer mapping information to find the associated Crucible user)
+     * @return
+     */
+    protected boolean isUnderScrutiny(String committer) {
+
+        final UserData crucibleUser = committerToCrucibleUser.get().get(committer);
+        final boolean userInList = crucibleUser != null &&
+                config.loadCrucibleUserNames().contains(crucibleUser.getUserName());
+        final boolean userInGroups = crucibleUser != null &&
+                Iterables.any(config.loadCrucibleGroups(), new Predicate<String>() {
+                    public boolean apply(String group) {
+                        return userManager.isUserInGroup(crucibleUser.getUserName(), group);
+                    }
+                });
+
+        switch (config.loadCreateMode()) {
+            case ALWAYS:
+                return !(userInList || userInGroups);
+            case NEVER:
+                return userInList || userInGroups;
+            default:
+                throw new AssertionError("Unsupported create mode");
+        }
+    }
+
+    /**
+     * Attempts to add the change set to an existing open review by scanning
+     * the commit message for review IDs in the current project. When multiple
+     * IDs are found, the first non-closed review is used.
+     *
+     * @param repoKey
+     * @param cs
+     * @param project
+     * @return  {@code true} if the change set was successfully added to an
+     * existing review, {@code false} otherwise.
+     */
+    private boolean appendToReview(final String repoKey, final ChangesetDataFE cs, final ProjectData project) {
+
+        final ReviewData review = getFirstOpenReview(Utils.extractReviewIds(cs.getComment(), project.getKey()));
+
+        if (review != null) {
+
+            // impersonate the review's moderator (or creator if there is no moderator set):
+            return impersonator.doAsUser(null,
+                    Utils.defaultIfNull(review.getModerator(), review.getCreator()).getUserName(),
+                    new Operation<Boolean, RuntimeException>() {
+
+                public Boolean perform() throws RuntimeException {
+
+                    try {
+                        reviewService.addChangesetsToReview(review.getPermaId(), repoKey, Collections.singletonList(new ChangesetData(cs.getCsid())));
+                        addComment(review, String.format(
+                                "The Automatic Review Creator Plugin added changeset {cs:id=%s|rep=%s} to this review.",
+                                cs.getCsid(), repoKey));
+                        return true;
+                    } catch (Exception e) {
+                        logger.warn(String.format("Error appending changeset %s to review %s: %s",
+                                cs.getCsid(), review.getPermaId().getId(), e.getMessage()), e);
+                    }
+                    return false;
+                }
+            });
+        }
+        return false;
+    }
+
+    /**
+     * Note that this check is broken in Crucible older than 2.2. In 2.1, the
+     * review state gets stale and won't always show the current state.
+     * See: http://jira.atlassian.com/browse/CRUC-2912
+     *
+     * @param reviewIds
+     * @return
+     */
+    private ReviewData getFirstOpenReview(Iterable<String> reviewIds) {
+
+        final Collection<ReviewData.State> acceptableStates = ImmutableSet.of(
+                ReviewData.State.Draft,
+                ReviewData.State.Approval,
+                ReviewData.State.Review);
+
+        for (String reviewId : reviewIds) {
+            try {
+                final ReviewData review = reviewService.getReview(new PermId<ReviewData>(reviewId), false);
+                if (acceptableStates.contains(review.getState())) {
+                    return review;
+                }
+            } catch (NotFoundException nfe) {
+                /* Exceptions for flow control is bad practice, but the API
+                 * has no exists() method.
+                 */
+            }
+        }
+        return null;
+    }
+
+    private void createReview(final String repoKey, final ChangesetDataFE cs,
+                              final ProjectData project)
+            throws ServerException {
+
+        final ReviewData template = buildReviewTemplate(cs, project);
+
+        // switch to user moderator:
+        impersonator.doAsUser(null, project.getDefaultModerator(), new Operation<Void, ServerException>() {
+            public Void perform() throws ServerException {
+
+                // create a new review:
+                final ReviewData review = reviewService.createReviewFromChangeSets(
+                        template,
+                        repoKey,
+                        Collections.singletonList(new ChangesetData(cs.getCsid())));
+
+                // add the project's default reviewers:
+                addReviewers(review, project);
+
+                // start the review, so everyone is notified:
+                reviewService.changeState(review.getPermaId(), "action:approveReview");
+                addComment(review, "This review was created by the Automatic Review Creator Plugin.");
+
+                logger.info(String.format("Auto-created review %s for " +
+                        "commit %s:%s with moderator %s.",
+                        review.getPermaId(), repoKey,
+                        cs.getCsid(), review.getModerator().getUserName()));
+                return null;
+            }
+        });
+    }
+
+    /**
+     * Must be called within the context of a user.
+     *
+     * @param review
+     * @param message
+     */
+    private void addComment(final ReviewData review, final String message) {
+
+        final GeneralCommentData comment = new GeneralCommentData();
+        comment.setCreateDate(new Date());
+        comment.setDraft(false);
+        comment.setDeleted(false);
+        comment.setMessage(message);
+
+        try {
+            reviewService.addGeneralComment(review.getPermaId(), comment);
+        } catch (Exception e) {
+            logger.error(String.format("Unable to add a general comment to review %s: %s",
+                    review.getPermaId().getId(), e.getMessage()), e);
+        }
+    }
+
+    private void addReviewers(ReviewData review, ProjectData project) {
+        final List<String> reviewers = project.getDefaultReviewerUsers();
+        if (reviewers != null && !reviewers.isEmpty()) {
+            reviewService.addReviewers(review.getPermaId(),
+                    reviewers.toArray(new String[reviewers.size()]));
+        }
+    }
+
+    /**
+     * <p>
+     * This method must be invoked with admin permissions.
+     * </p>
+     *
+     * @param cs
+     * @param project
+     * @return
+     * @throws ServerException
+     */
+    private ReviewData buildReviewTemplate(ChangesetDataFE cs, ProjectData project)
+            throws ServerException {
+
+        final UserData creator = committerToCrucibleUser.get().get(cs.getAuthor()) == null ?
+                userService.getUser(project.getDefaultModerator()) :
+                committerToCrucibleUser.get().get(cs.getAuthor());
+        final Date dueDate = project.getDefaultDuration() == null ? null :
+                DateHelper.addWorkingDays(new Date(), project.getDefaultDuration());
+
+        return new ReviewDataBuilder()
+                .setProjectKey(project.getKey())
+                .setName(Utils.firstNonEmptyLine(cs.getComment()))
+                .setDescription(StringUtils.defaultIfEmpty(project.getDefaultObjectives(), cs.getComment()))
+                .setAuthor(creator)
+                .setModerator(userService.getUser(project.getDefaultModerator()))
+                .setCreator(creator)
+                .setAllowReviewersToJoin(project.isAllowReviewersToJoin())
+                .setDueDate(dueDate)
+                .build();
+    }
+
+    /**
+     * <p>
+     * Given a FishEye repository key, returns the Crucible project that has
+     * this repository configured as its default.
+     * </p>
+     * <p>
+     * When no project is bound to the specified repository, or not enabled
+     * for automatic review creation, <code>null</code> is returned.
+     * </p>
+     * <p>
+     * This method must be invoked with admin permissions.
+     * </p>
+     *
+     * TODO: What to do when there are multiple projects?
+     *
+     * @param repoKey   a FishEye repository key (e.g. "CR").
+     * @return  the Crucible project that has the specified repository
+     *  configured as its default repo and has been enabled for automatic
+     *  review creation.
+     */
+    private ProjectData getEnabledProjectForRepository(String repoKey) {
+
+        final List<ProjectData> projects = projectService.getAllProjects();
+        final List<String> enabled = config.loadEnabledProjects();
+        for (ProjectData project : projects) {
+            if (repoKey.equals(project.getDefaultRepositoryName()) &&
+                    enabled.contains(project.getKey())) {
+                return project;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns a map containing all committer names that are mapped to Crucible
+     * user accounts.
+     * This is an expensive operation that will be redundant when the fecru SPI
+     * gets a <code>CommitterMapperService</code>.
+     * <p>
+     * This method must be invoked with admin permissions.
+     * </p>
+     *
+     * @param   repoKey
+     * @return
+     */
+    private Map<String, UserData> loadCommitterMappings(final String repoKey)
+            throws ServerException {
+
+        final Map<String, UserData> committerToUser = new HashMap<String, UserData>();
+        for (UserData ud : userService.getAllUsers()) {
+            final UserProfileData profile = userService.getUserProfile(ud.getUserName());
+            final List<String> committers = profile.getMappedCommitters().get(repoKey);
+            if (committers != null) {
+                for (String committer : committers) {
+                    committerToUser.put(committer, ud);
+                }
+            }
+        }
+        return committerToUser;
+    }
+    
+    private boolean isPluginEnabled() {
+        return !StringUtils.isEmpty(config.loadRunAsUser());
+    }
+}

File src/main/java/com/atlassian/example/reviewcreator/ConfigurationManager.java

View file
+package com.atlassian.example.reviewcreator;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Manages plugin settings serialization and persistence.
+ * This service is accessed by both the admin servlet and the event listener.
+ */
+public interface ConfigurationManager {
+
+    String loadRunAsUser();
+
+    void storeRunAsUser(String username);
+
+    List<String> loadEnabledProjects();
+
+    void storeEnabledProjects(List<String> projectKeys);
+
+    /**
+     * @since   v1.2
+     */
+    Collection<String> loadCrucibleUserNames();
+
+    /**
+     * @since   v1.2
+     */
+    void storeCrucibleUserNames(Collection<String> usernames);
+
+    /**
+     * @since   v1.3
+     */
+    Collection<String> loadCrucibleGroups();
+
+    /**
+     * @since   v1.3
+     */
+    void storeCrucibleGroups(Collection<String> groupnames);
+
+    CreateMode loadCreateMode();
+
+    void storeCreateMode(CreateMode mode);
+
+    /**
+     * @since   v1.4.1
+     */
+    boolean loadIterative();
+
+    /**
+     * @since   v1.4.1
+     */
+    void storeIterative(boolean iterative);
+}

File src/main/java/com/atlassian/example/reviewcreator/ConfigurationManagerImpl.java

View file
+package com.atlassian.example.reviewcreator;
+
+import com.atlassian.plugin.util.Assertions;
+import com.atlassian.sal.api.pluginsettings.PluginSettings;
+import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory;
+import org.apache.commons.lang.StringUtils;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+public class ConfigurationManagerImpl implements ConfigurationManager {
+
+    // TODO: write an UpgradeTask to change these constant names to the plugin key
+    private final String RUNAS_CFG          = "com.example.reviewcreator.runAs";
+    private final String PROJECTS_CFG       = "com.example.reviewcreator.projects";
+    private final String COMMITTER_CFG      = "com.example.reviewcreator.crucibleUsers";
+    private final String GROUP_CFG          = "com.example.reviewcreator.crucibleGroups";
+    private final String CREATE_MODE_CFG    = "com.example.reviewcreator.createMode";
+    private final String ITERATIVE_CFG      = "com.example.reviewcreator.iterative";
+    private final PluginSettings store;
+
+    public ConfigurationManagerImpl(PluginSettingsFactory settingsFactory) {
+        this(settingsFactory.createGlobalSettings());
+    }
+
+    ConfigurationManagerImpl(PluginSettings store) {
+        this.store = store;
+    }
+
+    public String loadRunAsUser() {
+        final Object value = store.get(RUNAS_CFG);
+        return value == null ? null : value.toString();
+    }
+
+    public void storeRunAsUser(String username) {
+        store.put(RUNAS_CFG, username);
+    }
+
+    public CreateMode loadCreateMode() {
+        final Object value = store.get(CREATE_MODE_CFG);
+        try {
+            return value == null ? CreateMode.ALWAYS : CreateMode.valueOf(value.toString());
+        } catch(IllegalArgumentException e) {
+            return CreateMode.ALWAYS;
+        }
+    }
+
+    public void storeCreateMode(CreateMode mode) {
+        store.put(CREATE_MODE_CFG, mode.name());
+    }
+
+    public List<String> loadEnabledProjects() {
+        return loadStringList(PROJECTS_CFG);
+    }
+
+    public void storeEnabledProjects(List<String> projectKeys) {
+        storeStringList(PROJECTS_CFG, projectKeys);
+    }
+
+    public Collection<String> loadCrucibleUserNames() {
+        return loadStringList(COMMITTER_CFG);
+    }
+
+    public void storeCrucibleUserNames(Collection<String> usernames) {
+        storeStringList(COMMITTER_CFG, usernames);
+    }
+
+    public Collection<String> loadCrucibleGroups() {
+        return loadStringList(GROUP_CFG);
+    }
+
+    public void storeCrucibleGroups(Collection<String> groupnames) {
+        storeStringList(GROUP_CFG, groupnames);
+    }
+
+    private void storeStringList(String key, Iterable<String> strings) {
+        store.put(Assertions.notNull("PluginSettings key", key),
+                StringUtils.join(strings.iterator(), ';'));
+    }
+
+    private List<String> loadStringList(String key) {
+        final Object value = store.get(Assertions.notNull("PluginSettings key", key));
+        return value == null ?
+                Collections.<String>emptyList() :
+                Arrays.asList(StringUtils.split(value.toString(), ';'));
+    }
+
+    public boolean loadIterative()
+    {
+        final Object value = store.get(ITERATIVE_CFG);
+        return value == null ? false : Boolean.parseBoolean(value.toString());
+    }
+
+    public void storeIterative(boolean iterative)
+    {
+        store.put(ITERATIVE_CFG, Boolean.toString(iterative));
+    }
+}

File src/main/java/com/atlassian/example/reviewcreator/CreateMode.java

View file
+package com.atlassian.example.reviewcreator;
+
+/**
+ * @since   v1.2
+ * @author  Erik van Zijst
+ */
+public enum CreateMode {
+    ALWAYS,
+    NEVER
+}

File src/main/java/com/atlassian/example/reviewcreator/DateHelper.java

View file
+package com.atlassian.example.reviewcreator;
+
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.Calendar;
+
+public class DateHelper {
+
+    /**
+     * Takes a start date and adds the specified number of working days to it.
+     * E.g. when <code>start</code> is Friday and <code>workingDays</code> is
+     * 2, the returned date will be the Tuesday following <code>start</code>.
+     *
+     * @param start
+     * @param workingDays
+     * @return
+     */
+    public static Date addWorkingDays(Date start, int workingDays) {
+
+        /*
+         * IMPLEMENTATION NOTE
+         *
+         * When your addition of working days carries over a Daylight Savings
+         * change, expect the outcome in local time to be one hour off.
+         *
+         * For instance, when you do 2009-03-18T13:15:30Z + 5 working days on a
+         * US server and interpret the result as local time, it will be next
+         * Wednesday, but one hour earlier than the starting timestamp.
+         *
+         * This is because we're actually adding 24 * 7 hours to the starting
+         * time and the weekend is one hour longer than normal.
+         */
+
+        if (workingDays < 0) {
+            throw new IllegalArgumentException("Subtracting of working days is currently not supported.");
+        } else if (workingDays == 0) {
+            return start;
+
+        } else {
+            final GregorianCalendar cal = new GregorianCalendar();
+            cal.setTime(start);
+            final int startDay = cal.get(Calendar.DAY_OF_WEEK);
+
+            int weekendDays = Math.max(((workingDays / 5) - 1), 0) * 2;
+            if (startDay == Calendar.SATURDAY) {
+                weekendDays += 2;
+            } else if (startDay == Calendar.SUNDAY) {
+                weekendDays += 1;
+            } else {
+                weekendDays += (Calendar.FRIDAY - startDay) < workingDays ? 2 : 0;
+            }
+
+            cal.add(Calendar.DAY_OF_WEEK, workingDays + weekendDays);
+            return cal.getTime();
+        }
+    }
+}

File src/main/java/com/atlassian/example/reviewcreator/Project.java

View file
+package com.atlassian.example.reviewcreator;
+
+public class Project {
+
+    private final int id;
+    private final String key;
+    private final String name;
+    private final boolean enabled;
+    private final String moderator;
+
+    Project(int id, String key, String name, String moderator, boolean enabled) {
+        this.id = id;
+        this.key = key;
+        this.name = name;
+        this.moderator = moderator;
+        this.enabled = enabled;
+    }
+
+    /**
+     * @since   v1.3
+     */
+    public int getId() {
+        return id;
+    }
+
+    public String getKey() {
+        return key;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * @since   v1.3
+     * @return  the username of this project's default moderator or
+     * <code>null</code> if not set.
+     */
+    public String getModerator() {
+        return moderator;
+    }
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        Project project = (Project) o;
+
+        if (id != project.id) return false;
+
+        return true;
+    }
+
+    @Override
+    public int hashCode() {
+        return id;
+    }
+}

File src/main/java/com/atlassian/example/reviewcreator/ReviewDataBuilder.java

View file
+package com.atlassian.example.reviewcreator;
+
+import com.atlassian.crucible.spi.PermId;
+import com.atlassian.crucible.spi.data.ReviewData;
+import com.atlassian.crucible.spi.data.UserData;
+
+import java.util.Date;
+
+/**
+ *
+ * @since v1.4.2
+ */
+public class ReviewDataBuilder
+{
+    private final ReviewData reviewData = new ReviewData();
+
+    public ReviewData build() {
+        return new ReviewData(reviewData);
+    }
+
+    public ReviewDataBuilder setAllowReviewersToJoin(boolean allowReviewersToJoin)
+    {
+        reviewData.setAllowReviewersToJoin(allowReviewersToJoin);
+        return this;
+    }
+
+    public ReviewDataBuilder setAuthor(UserData author)
+    {
+        reviewData.setAuthor(author);
+        return this;
+    }
+
+    public ReviewDataBuilder setCloseDate(Date closeDate)
+    {
+        reviewData.setCloseDate(closeDate);
+        return this;
+    }
+
+    public ReviewDataBuilder setCreateDate(Date createDate)
+    {
+        reviewData.setCreateDate(createDate);
+        return this;
+    }
+
+    public ReviewDataBuilder setCreator(UserData creator)
+    {
+        reviewData.setCreator(creator);
+        return this;
+    }
+
+    public ReviewDataBuilder setDescription(String description)
+    {
+        reviewData.setDescription(description);
+        return this;
+    }
+
+    public ReviewDataBuilder setDueDate(Date dueDate)
+    {
+        reviewData.setDueDate(dueDate);
+        return this;
+    }
+
+    public ReviewDataBuilder setJiraIssueKey(String jiraIssueKey)
+    {
+        reviewData.setJiraIssueKey(jiraIssueKey);
+        return this;
+    }
+
+    public ReviewDataBuilder setMetricsVersion(int metricsVersion)
+    {
+        reviewData.setMetricsVersion(metricsVersion);
+        return this;
+    }
+
+    public ReviewDataBuilder setModerator(UserData moderator)
+    {
+        reviewData.setModerator(moderator);
+        return this;
+    }
+
+    public ReviewDataBuilder setName(String name)
+    {
+        reviewData.setName(name);
+        return this;
+    }
+
+    public ReviewDataBuilder setParentReview(PermId<ReviewData> parentReview)
+    {
+        reviewData.setParentReview(parentReview);
+        return this;
+    }
+
+    public ReviewDataBuilder setPermaId(String permaId)
+    {
+        reviewData.setPermaIdAsString(permaId);
+        return this;
+    }
+
+    public ReviewDataBuilder setProjectKey(String projectKey)
+    {
+        reviewData.setProjectKey(projectKey);
+        return this;
+    }
+
+    public ReviewDataBuilder setState(ReviewData.State state)
+    {
+        reviewData.setState(state);
+        return this;
+    }
+
+    public ReviewDataBuilder setSummary(String summary)
+    {
+        reviewData.setSummary(summary);
+        return this;
+    }
+}

File src/main/java/com/atlassian/example/reviewcreator/Utils.java

View file
+package com.atlassian.example.reviewcreator;
+
+import org.apache.commons.lang.StringUtils;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class Utils {
+
+    public static <T> T defaultIfNull(T object, T defaultObject) {
+        return object == null ? defaultObject : object;
+    }
+
+    public static String firstNonEmptyLine(String message) {
+
+        final String[] lines = StringUtils.split(message, "\r\n");
+        if (lines == null) {
+            return null;
+        } else {
+            for (String line : lines) {
+                if (StringUtils.isNotBlank(line)) {
+                    return line;
+                }
+            }
+            return "";
+        }
+    }
+
+    /**
+     * Returns a distinct set of review ids, extracted from the specified commit
+     * message.
+     *
+     * @since   v1.4.1
+     * @param commitMsg
+     * @param projectKey   e.g. "CR-FE"
+     * @return
+     */
+    public static Set<String> extractReviewIds(String commitMsg, String projectKey) {
+
+        if (StringUtils.isEmpty(projectKey) || StringUtils.isEmpty(commitMsg)) {
+            return Collections.EMPTY_SET;
+
+        } else {
+            final Set<String> ids = new HashSet<String>();
+
+            final Matcher matcher = Pattern.compile("(" + projectKey + "-\\d+)").matcher(commitMsg);
+            matcher.useTransparentBounds(true);
+            matcher.useAnchoringBounds(false);
+
+            while (matcher.find()) {
+                ids.add(matcher.group(1));
+            }
+            return ids;
+        }
+    }
+}

File src/main/resources/META-INF/spring/dummy.txt

Empty file added.

File src/main/resources/atlassian-plugin.xml

View file
+<atlassian-plugin key="${project.groupId}.${project.artifactId}"
+                  name="${project.artifactId}" plugins-version="2">
+    <plugin-info>
+        <description>${project.description}</description>
+        <version>${project.version}</version>
+        <application-version min="2.1"/>
+        <vendor name="${project.organization.name}" url="${project.organization.url}" />
+    </plugin-info>
+
+    <!-- our crucible event listener -->
+    <listener key="commit-listener" class="com.atlassian.example.reviewcreator.CommitListener"/>
+
+    <!-- persistence and group management provided by SAL -->
+    <component-import key="pluginSettingsFactory"
+                      interface="com.atlassian.sal.api.pluginsettings.PluginSettingsFactory" />
+    <component-import key="userManager"
+                      interface="com.atlassian.sal.api.user.UserManager"/>
+
+    <!-- our Spring bean to manage config settings -->
+    <component key="configurationManager"
+               class="com.atlassian.example.reviewcreator.ConfigurationManagerImpl"
+               public="false">
+        <description>Manages plugin settings serialization and persistence.</description>
+        <interface>com.atlassian.example.reviewcreator.ConfigurationManager</interface>
+    </component>
+
+    <!-- the new menu entry in the admin screen -->
+    <web-item key="reviewcreatorwebitem" section="system.admin/system">
+        <link>/plugins/servlet/reviewcreatoradmin</link>
+        <label key="Auto Review Creation"/>
+    </web-item>
+
+    <!-- the plugin's admin page -->
+    <servlet name="${project.artifactId}"
+             class="com.atlassian.example.reviewcreator.AdminServlet"
+             key="reviewcreator" adminLevel="system">
+        <description>Configuration for Automatic Review Creation</description>
+        <url-pattern>/reviewcreatoradmin</url-pattern>
+    </servlet>
+</atlassian-plugin>

File src/main/resources/templates/admin.vm

View file
+<html>
+<head>
+    <title>Automatic Review Creation for New Commits</title>
+    <meta name="decorator" content="atl.admin"/>
+    <meta name="admin.sectionName" content="Automatic Review Creation"/>
+</head>
+<body>
+<form method="post">
+    <table class="adminTable">
+        <tbody>
+            <tr>
+                <td>
+                    <p>
+                    This plugin subscribes to commit events and creates a review for
+                    each commit.
+                    </p>
+                    <p>
+                    Auto review creation can be enabled/disabled on a
+                    per-project basis. Enabled projects must be bound to a FishEye repository
+                    and must have a default Moderator configured in the admin section.
+                    </p>
+                    <p>
+                        Run plugin as user (not used as review moderator):
+                        <input type="text" size="15" name="username" value="#if ($username)$username#end"/>
+                    </p>
+                </td>
+            </tr>
+        </tbody>
+    </table>
+    #if ($username)
+    <table class="adminTable sortable">
+        <thead>
+        <th>Enabled</th>
+        <th>Project</th>
+        <th>Project Name</th>
+        <th>Review Moderator</th>
+        </thead>
+        <tbody>
+        #foreach ($project in $projects)
+        <tr>
+            <td>
+                <input type="checkbox" name="enabled" value="$project.Key"#if ($project.Enabled) checked="true"#end/>
+            </td>
+            <td>
+                $project.Key
+            </td>
+            <td>
+                $project.Name
+            </td>
+            <td>
+            #if ($stringUtils.isEmpty($project.moderator))
+                <span title="Automatic review creation for this project will not work without a moderator." style="color: DarkRed">
+                    No moderator set. <a href="${contextPath}/admin/editProject.do?id=$project.id">[Specify one now]</a></span>
+            #else
+                $project.moderator
+            #end
+            </td>
+        </tr>
+        #end
+        </tbody>
+    </table>
+
+    <table class="adminTable">
+        <thead>
+            <th colspan="2">Configuration</th>
+        </thead>
+        <tbody>
+            <tr>
+                <td colspan="2">
+                    <select name="createMode">
+                        ## these strings MUST represent the CreateMode enum's string values:
+                        <option value="ALWAYS"#if ($createMode == "ALWAYS") selected="selected"#end>Always</option>
+                        <option value="NEVER"#if ($createMode == "NEVER") selected="selected"#end>Never</option>
+                    </select>
+                    create reviews automatically, except for:
+                </td>
+            </tr>
+            <tr>
+                <td>
+                    commits made by users:
+                </td>
+                <td>
+                    <textarea title="Invalid usernames are dropped. Use commas, whitespace and/or newlines as separators."
+                              name="committerNames">$stringUtils.join($committerNames, ", ")</textarea><br>
+                    (comma-separated list of <em>Crucible</em> usernames)
+                </td>
+            </tr>
+            <tr>
+                <td>
+                    or users in groups:
+                </td>
+                <td>
+                    <textarea title="Group names are not validated. Use commas, whitespace and/or newlines as separators."
+                              name="groupNames">$stringUtils.join($groupNames, ", ")</textarea><br>
+                    (comma-separated list of groups)
+                </td>
+            </tr>
+            <tr>
+                <td colspan="2">
+                    <hr>
+                </td>
+            </tr>
+            <tr>
+                <td>
+                    <input type="checkbox" id="iterative" name="iterative"#if($iterative) checked="true"#end>
+                    <label for="iterative" title="Automatically add commits to the review that is mentioned in the commit message (instead of always creating a new review).">
+                        Enable iterative reviews</label>
+                </td>
+            </tr>
+        </tbody>
+    </table>
+    #else
+    <p style="color: DarkRed">Specify a valid (admin) user for this plugin to run as.</p>
+    #end
+    <p>
+        <input type="submit" value="save"/>
+    </p>
+</form>
+</body>
+</html>

File src/test/java/com/atlassian/example/reviewcreator/ConfigurationManagerImplTest.java

View file
+package com.atlassian.example.reviewcreator;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+import org.junit.Before;
+import com.atlassian.sal.api.pluginsettings.PluginSettings;
+
+import java.util.*;
+
+public class ConfigurationManagerImplTest {
+
+    private SettingsMock store;
+
+    @Before
+    public void setup() {
+        store = new SettingsMock();
+    }
+
+    @Test
+    public void testConfigurationManager() {
+
+        final String user = "foo";
+        final List<String> expected = Arrays.asList("CR", "RC");
+        final ConfigurationManagerImpl config = new ConfigurationManagerImpl(store);
+        assertTrue(config.loadEnabledProjects().isEmpty());
+        assertNull(config.loadRunAsUser());
+
+        config.storeRunAsUser(user);
+        assertEquals(user, config.loadRunAsUser());
+
+        config.storeEnabledProjects(expected);
+        assertEquals(expected.size(), config.loadEnabledProjects().size());
+        List<String> actual = new ArrayList<String>(config.loadEnabledProjects());
+        actual.removeAll(expected);
+        assertTrue(actual.isEmpty());
+    }
+
+    private static class SettingsMock implements PluginSettings {
+
+        private final Map<String, Object> store = new HashMap<String, Object>();
+
+        public Object get(String s) {
+            return store.get(s);
+        }
+
+        public Object put(String s, Object o) {
+            return store.put(s, o);
+        }
+
+        public Object remove(String s) {
+            return store.remove(s);
+        }
+    }
+}

File src/test/java/com/atlassian/example/reviewcreator/UtilsTest.java

View file
+package com.atlassian.example.reviewcreator;
+
+import com.google.common.collect.ImmutableSet;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class UtilsTest {
+
+    @Test
+    public void testGetFirstNonEmptyLine() throws Exception {
+
+        assertNull(Utils.firstNonEmptyLine(null));
+        assertEquals("", Utils.firstNonEmptyLine(""));
+        assertEquals("", Utils.firstNonEmptyLine("\r\n\n\r"));
+        assertEquals(" line2 ", Utils.firstNonEmptyLine(" \n line2 "));
+        assertEquals("line1", Utils.firstNonEmptyLine("\nline1\nline2"));
+        assertEquals("line3", Utils.firstNonEmptyLine(" \n \nline3"));
+    }
+
+    @Test
+    public void testExtractReviewIds() {
+
+        assertTrue(Utils.extractReviewIds(null, "Foo").isEmpty());
+        assertTrue(Utils.extractReviewIds("", "Foo").isEmpty());
+        assertTrue(Utils.extractReviewIds("Foo", "Foo").isEmpty());
+        assertTrue(Utils.extractReviewIds("This is not a review id: Foo-", "Foo").isEmpty());
+        assertTrue(Utils.extractReviewIds("This is a review in a different project: CR-FE-1", "CR").isEmpty());
+
+        assertEquals(ImmutableSet.of("FOO-1"), Utils.extractReviewIds("FOO-1", "FOO"));
+        assertEquals(ImmutableSet.of("FOO-1", "FOO-3456"), Utils.extractReviewIds("There's 2 reviews in here: FOO-1, FOOBAR-4, FOO-3456", "FOO"));
+    }
+}