/* * * Copyright (c) 2010, Ryan Shelley * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), the rights * to use, copy, modify, merge, publish, distribute, and to permit persons to * whom the Software is furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package com.twelvegm.hudson.plugin.reviewboard; import java.util.ArrayList; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; import net.sf.json.JSONException; import net.sf.json.JSONObject; import org.apache.commons.httpclient.HttpClient; import org.apache.commons.httpclient.HttpMethod; import org.apache.commons.httpclient.NameValuePair; import org.apache.commons.httpclient.URI; import org.apache.commons.httpclient.URIException; import org.apache.commons.httpclient.UsernamePasswordCredentials; import org.apache.commons.httpclient.auth.AuthScope; import org.apache.commons.httpclient.methods.GetMethod; import org.apache.commons.httpclient.methods.PostMethod; import org.apache.commons.httpclient.params.HttpClientParams; import org.apache.commons.httpclient.params.HttpMethodParams; /** * Creates an instance of the Reviewboard API. Calls are currently executed against * the RESTful HTTP endpoints exposed by Reviewboard. Authentication is done by * Basic Authentication with supplied username and password during instantiation. * * Assumptions are that you have a configured version of Reviewboard associated with * a SCM. Currently, this has only been tested with Perforce. * * Currently supports a limited selection of HTTP endpoints pre-1.5 beta 2. * * TODO: Add better/more error handling and logging * * What this DOES currently do: * 1) Update an existing review with changes uploaded to the SCM (post-commit). * 2) Add a change description to a new diff (see #1). * 3) Set default reviewers on a review request. * 4) Set bugs on a review request. * 5) Set default review groups on a review request. * 6) Publish a review that is in draft. * 7) Get reviewboard users matching a query. * 8) Get reviewboard groups matching a query. * 9) Supports Perforce SCM * * What this DOESN'T currently do: * 1) Create a new review request. This API currently assumes a review request already exists, * having been created through the web interface or post-review (for pre/post-commits). * 2) Set the branch field of a review request (haven't had a need for it yet). * 3) Get a complete review request. * 4) Password encryption/decryption. It's currently clear-text. * 5) Add a review to a review request. * 6) Add a test description to a review request. * 7) Add a screenshot to a review request. * 8) Star a review request. * 9) Close a review request. * 10) Become sentient and destroy all humans. * 11) Support CVS, SVN, Git, or any SCMs other than Perforce * * Known issues / To Dos: * 1) The API URLs are known to be changing in the release of 1.5, so these will * need to be updated accordingly. For backward compatibility, the constants * for these APIs should probably have versions included in their name so * the 1.5 version of the URLs don't conflict when added. * 2) Some strings need to be made into constants, such as the JSON keys for * retrieval of groups and users. String literals embedded in source code * are bad, mmmm-kay? * * @author Ryan Shelley * @version 1.0-beta */ public class ReviewboardHttpAPI { // Base Reviewboard URL private final String baseUrl; private final URI baseUri; // Reviewboard authentication params private final String username; private final String password; private static final String RB_AUTH_REALM = "Web API"; // API URL to publish an existing review request. Appended to base URL. private static final String RB_PUBLISH_REST_PATH = "/api/json/reviewrequests/%REVIEW_ID%/publish/"; // API URL for setting review request reviewers. Appended to base URL. // Param names used for setting reviewers. private static final String RB_SET_REVIEWERS_PATH = "/api/json/reviewrequests/%REVIEW_ID%/draft/set/target_people/"; private static final String RB_SET_REVIEWERS_FORM_PARAM = "value"; // API URL for setting related bugs on a review request. Appended to base URL. // Param names used for setting bugs. private static final String RB_SET_BUGS_PATH = "/api/json/reviewrequests/%REVIEW_ID%/draft/set/bugs_closed/"; private static final String RB_SET_BUGS_FORM_PARAM = "value"; // API URL for setting review request groups. Appended to base URL. // Param names used for setting review request groups private static final String RB_SET_GROUPS_PATH = "/api/json/reviewrequests/%REVIEW_ID%/draft/set/target_groups/"; private static final String RB_SET_GROUP_FORM_PARAM = "value"; // API URL for getting groups. Appended to base URL. // Param names used for getting groups. private static final String RB_GET_GROUP_PATH = "/api/json/groups/"; private static final String RB_GET_GROUP_QUERY_PARAM = "q"; private static final String RB_GET_GROUP_LIMIT_PARAM = "limit"; private static final String RB_GET_GROUP_TIMESTAMP_PARAM = "timestamp"; private static final String RB_GET_GROUP_DISPLAYNAME_PARAM = "displayname"; // API URL for setting the change description of a pending unpublished review request. Appended to base URL. // Param names used for setting the change description. private static final String RB_SET_CHANGE_DESCR_PATH = "/api/json/reviewrequests/%REVIEW_ID%/draft/set/changedescription/"; private static final String RB_SET_CHANGE_DESCR_FORM_PARAM = "value"; // API URL for getting reviewboard users to set change request reviewers. Append to base URL. // Param names used for getting reviewboard users. private static final String RB_GET_REVIEWERS_PATH = "/api/json/users/"; private static final String RB_GET_REVIEWERS_QUERY_PARAM = "q"; private static final String RB_GET_REVIEWERS_LIMIT_PARAM = "limit"; private static final String RB_GET_REVIEWERS_TIMESTAMP_PARAM = "timestamp"; private static final String RB_GET_REVIEWERS_FULLNAME_PARAM = "fullname"; // String that maps to the key in a Reviewboard JSON response to obtain the status of the request. private static final String RB_JSON_STATUS_KEY = "stat"; // Client used to execute HTTP methods against. This is shared by all API calls for this instance of // the API. Another instance of the API may have different authentication credentials, so it can't // be static. private final HttpClient client; // Status codes returned from Reviewboard in the JSON response body in the "stat" field. private static enum ReviewboardStatusCode{ OK // TODO: Add the rest of the error codes. }; /** * Creates a new ReviewboardHttpAPI object used to connect to Reviewboard and * execute commands. Tested with v1.5 beta 2. All parameters are required. * * User must have the following permissions in Reviewboard: * Can add default reviewer * Can change status * Can edit review request * Can submit as another user * Can change review request * Can change review request draft * * @param username Username of account that has access rights to Reviewboard * @param password Password of Username * @param baseUrl Base URL at which Reviewboard is running * @throws NullPointerException * @throws URIException */ public ReviewboardHttpAPI(final String username, final String password, final String baseUrl) throws URIException, NullPointerException { this.username = username; this.password = password; this.baseUrl = baseUrl; this.baseUri = new URI(baseUrl, false); this.client = new HttpClient(); HttpClientParams clientParams = new HttpClientParams(); clientParams.setSoTimeout(300000); // 5 minutes this.client.setParams(clientParams); this.client.getState().setCredentials( new AuthScope(baseUri.getHost(), baseUri.getPort(), RB_AUTH_REALM), new UsernamePasswordCredentials(this.username, this.password) ); } /** * Trims off a trailing "/" from a URL String * * @param URL to trim * @return trimmed URL */ private String trimUrl(String url){ url = url.trim(); if(url.endsWith("/")) url = url.substring(0, url.length()-1); return url; } /** * Creates a new URI based upon the parameters supplied, replacing the supplied string parameter * within the path with the replacement provided. Param and replacement can be null, path is * required. Param and replacement are often a review request ID. * * @param path URL path to convert to URI. Can contain one arbitrary parameter to be replaced * @param param String within the path to replace with the replacement argument * @param replacement String to replace the param argument with in the path * @return URI if a valid URL is generated */ private URI createUri(String path, String param, String replacement){ URI uri = null; if(param == null) param = ""; if(replacement == null) replacement = ""; try { uri = new URI(trimUrl(this.baseUrl) + path.replace(param, replacement), false); } catch (URIException e) { e.printStackTrace(); } catch (NullPointerException e) { e.printStackTrace(); } return uri; } /** * Executes a POST method against Reviewboard. This method is used for anything * that changes a review request in Reviewboard. * * @param uri URI to execute the POST against * @param params Params to pass in the POST * @return true if successful, false if otherwise * @throws URIException */ private boolean executePostApiCall(final URI uri, final NameValuePair[] params) throws URIException{ boolean status = false; PostMethod post = new PostMethod(uri.getURI()); post.addParameters(params); JSONObject response = executeApiCall(JSONObject.class, post); if(response != null){ ReviewboardStatusCode code = null; code = resolveResponseStatus(response); status = (response != null && ReviewboardStatusCode.OK.equals(code)); } return status; } /** * Executes a GET method against Reviewboard. This method is used for anything * that queries information from Reviewboard. * * @param uri URI to execute the GET against * @param params Params to pass in the querystring * @return JSONObject containing the parsed response body of a valid response, null otherwise. * @throws URIException */ private JSONObject executeGetApiCall(final URI uri, final NameValuePair[] params) throws URIException{ JSONObject response = null; GetMethod get = new GetMethod(uri.getURI()); HttpMethodParams methodParams = new HttpMethodParams(); get.setQueryString(params); get.setParams(methodParams); response = executeApiCall(JSONObject.class, get); if(response != null){ ReviewboardStatusCode status = null; status = resolveResponseStatus(response); if(response == null || !ReviewboardStatusCode.OK.equals(status)){ response = null; } } return response; } /** * Executes an arbitrary HTTP method. HTTP Method should be pre-configured with * the URI and parameters. * * @param <T> Type of return value. * @param returnType Type of return value. Currently only Boolean and String are supported. * @param method HTTP method to execute. * @return if returnType is Boolean and status code is 2XX/3XX, returns true otherwise false; if returnType is String and status code is 2XX, returns response body otherwise null */ @SuppressWarnings("unchecked") private <T> T executeApiCall(final Class<T> returnType, final HttpMethod method){ if( !(returnType == Boolean.class) && !(returnType == String.class) && !(returnType == JSONObject.class) ) throw new UnsupportedOperationException("This method only supports return types of Boolean, String or JSONObject, currently."); T returnValue = null; try { method.setDoAuthentication( true ); int statusCode = client.executeMethod(method); if(statusCode >= 200 && statusCode < 400){ JSONObject jsonResponse = parseStringToJSONObject(method.getResponseBodyAsString()); ReviewboardStatusCode status = null; if(jsonResponse != null) status = resolveResponseStatus(jsonResponse); if(jsonResponse != null && ReviewboardStatusCode.OK.equals(status)){ if(returnType == String.class) returnValue = (T)method.getResponseBodyAsString(); else if(returnType == Boolean.class) returnValue = (T)Boolean.TRUE; else if(returnType == JSONObject.class) returnValue = (T)jsonResponse; } } } catch (Exception e) { e.printStackTrace(); } finally { method.releaseConnection(); } return returnValue; } /** * Converts a JSON string into a JSONObject. * * @param str String to convert to JSONObject * @return JSONObject if string was converted properly, null otherwise */ private static JSONObject parseStringToJSONObject(final String str){ JSONObject o = null; if(str != null && !str.trim().isEmpty()){ try{ o = JSONObject.fromObject(str); }catch(JSONException e){ e.printStackTrace(); } } return o; } /** * Resolves the status of a Reviewboard response from a JSONObject into it's * associated Enumeration. * * @param response JSON response from Reviewboard, parsed into a JSONObject * @return ReviewboardStatusCode matching the status in the "stat" param. * @see #parseStringToJSONObject(String) */ private static ReviewboardStatusCode resolveResponseStatus(final JSONObject response){ if(response == null) throw new IllegalArgumentException("JSON Response argument cannot be null."); ReviewboardStatusCode code = null; try{ String status = response.getString(RB_JSON_STATUS_KEY); if(status != null && !status.isEmpty()) code = ReviewboardStatusCode.valueOf(status.trim().toUpperCase()); }catch(IllegalArgumentException e){ e.printStackTrace(); }catch(JSONException e){ e.printStackTrace(); } return code; } /** * Retrieves a list of Reviewboard Groups that match the query argument. Group names are case-sensitive. * * @param query part of string to search Reviewboard to match group names against. * @return Set of matching groups, or empty list if none were found. Group names are case-sensitive. */ public Set<String> getGroups(final String query){ Set<String> groups = new HashSet<String>(); URI uri = this.createUri(RB_GET_GROUP_PATH, null, null); NameValuePair[] params = new NameValuePair[]{ new NameValuePair(RB_GET_GROUP_QUERY_PARAM, query), new NameValuePair(RB_GET_GROUP_LIMIT_PARAM, "150"), new NameValuePair(RB_GET_GROUP_TIMESTAMP_PARAM, String.valueOf((new Date()).getTime())), new NameValuePair(RB_GET_GROUP_DISPLAYNAME_PARAM, "0") }; try { JSONObject response = this.executeGetApiCall(uri, params); if(response != null){ List<Object> jsonGrps = response.getJSONArray("groups"); for(Object jsonGrp : jsonGrps){ if(jsonGrp instanceof JSONObject) groups.add( ((JSONObject)jsonGrp).getString("name") ); } }else{ System.out.println("Error response from Reviewboard Group query: " + response); } } catch (URIException e) { e.printStackTrace(); } return groups; } /** * Retrieves a list of Reviewboard users who match the query argument. Usernames are case-sensitive. * * @param query part of usernames to match against Reviewboard users. * @return Set of matching usernames or empty list if none were found. Usernames are case-sensitive. */ public Set<String> getReviewers(final String query){ Set<String> users = new HashSet<String>(); URI uri = this.createUri(RB_GET_REVIEWERS_PATH, null, null); NameValuePair[] params = new NameValuePair[]{ new NameValuePair(RB_GET_REVIEWERS_QUERY_PARAM, query), new NameValuePair(RB_GET_REVIEWERS_LIMIT_PARAM, "150"), new NameValuePair(RB_GET_REVIEWERS_TIMESTAMP_PARAM, String.valueOf((new Date()).getTime())), new NameValuePair(RB_GET_REVIEWERS_FULLNAME_PARAM, "0") }; try { JSONObject response = this.executeGetApiCall(uri, params); if(response != null){ List<Object> jsonUsers = response.getJSONArray("users"); for(Object jsonUser : jsonUsers){ if(jsonUser instanceof JSONObject) users.add( ((JSONObject)jsonUser).getString("username") ); } }else{ System.out.println("Error response from Reviewboard User query: " + response); } } catch (URIException e) { e.printStackTrace(); } return users; } /** * Sets one or more groups as default review groups on a review request. The groups * argument should be a comma-delimited string of valid Reviewboard groups. Use * {@link #getGroups(String)} to validate input before passing to this method. The * review request being modified should be in draft and not yet submit. This method * will overwrite any existing groups on the review request. * * @param review Review to modify set the review groups on * @param groups String of comma-separated groups to set * @return true if successful, false otherwise */ public boolean setGroups(final ReviewRequest review, final String groups){ boolean status = false; URI uri = this.createUri(RB_SET_GROUPS_PATH, "%REVIEW_ID%", review.getReviewBoardID().toString()); NameValuePair[] params = new NameValuePair[]{ new NameValuePair(RB_SET_GROUP_FORM_PARAM, groups) }; try { status = this.executePostApiCall(uri, params); } catch (URIException e) { e.printStackTrace(); } return status; } /** * Sets one or more users as default reviewers on a review request. The reviewers * argument should be a comma-delimited string of valid Reviewboard users. Use * {@link #getReviewers(String)} to validate input before passing to this method. The * review request being modified should be in draft and not yet submit. This method * will overwrite any existing reviewers on the review request. * * @param review Review to set the reviewers on * @param reviewers String of comma-separated Reviewboard users to set * @return true if successful, false otherwise */ public boolean setReviewers(final ReviewRequest review, final String reviewers){ boolean status = false; URI uri = this.createUri(RB_SET_REVIEWERS_PATH, "%REVIEW_ID%", review.getReviewBoardID().toString()); NameValuePair[] params = new NameValuePair[]{ new NameValuePair(RB_SET_REVIEWERS_FORM_PARAM, reviewers) }; try { status = this.executePostApiCall(uri, params); } catch (URIException e) { e.printStackTrace(); } return status; } /** * Sets the change description on a review that is being updated and in draft. * This method is used to add a description to an updated diff of an existing * review request. * * @param review Review to add the description to * @param description Description to add to the pending diff * @return true if successful, false otherwise */ public boolean setChangeDescription(final ReviewRequest review, final String description){ boolean status = false; URI uri = this.createUri(RB_SET_CHANGE_DESCR_PATH, "%REVIEW_ID%", review.getReviewBoardID().toString()); NameValuePair[] params = new NameValuePair[]{ new NameValuePair(RB_SET_CHANGE_DESCR_FORM_PARAM, description) }; try { status = this.executePostApiCall(uri, params); } catch (URIException e) { e.printStackTrace(); } return status; } /** * Sets the related bugs of a pending review request. This is usually the * JIRA/Bugzilla/etc ID(s) associated with the review request and often the * same as the External ID. * * @param review Review to add the bug(s) to * @param bugs String of comma-separated bug IDs * @return */ public boolean setBugs(final ReviewRequest review, final String bugs){ boolean status = false; URI uri = this.createUri(RB_SET_BUGS_PATH, "%REVIEW_ID%", review.getReviewBoardID().toString()); NameValuePair[] params = new NameValuePair[]{ new NameValuePair(RB_SET_BUGS_FORM_PARAM, bugs) }; try { status = this.executePostApiCall(uri, params); } catch (URIException e) { e.printStackTrace(); } return status; } /** * Publishes a review request in draft. If the review request is configured to * notify reviewers and review groups, they will be sent emails. * * @param review Review to publish * @return true if successful, false otherwise */ public boolean publishReview(final ReviewRequest review){ boolean status = false; URI uri = this.createUri(RB_PUBLISH_REST_PATH, "%REVIEW_ID%", review.getReviewBoardID().toString()); NameValuePair[] params = new NameValuePair[]{}; try { status = this.executePostApiCall(uri, params); } catch (URIException e) { e.printStackTrace(); } return status; } }