Java tutorial
/******************************************************************************* * Copyright (c) 2008,2011 Peter Stibrany * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * Peter Stibrany (pstibrany@gmail.com) - initial API and implementation *******************************************************************************/ package com.foglyn.fogbugz; import java.io.OutputStream; import java.math.BigDecimal; import java.net.URI; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import nu.xom.Document; import nu.xom.Element; import org.apache.commons.httpclient.HttpClient; import org.apache.commons.httpclient.HttpStatus; import org.apache.commons.httpclient.methods.multipart.FilePart; import org.apache.commons.httpclient.methods.multipart.Part; import org.apache.commons.httpclient.methods.multipart.StringPart; import org.apache.commons.logging.Log; import org.eclipse.core.runtime.Assert; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.NullProgressMonitor; import org.eclipse.mylyn.commons.net.AbstractWebLocation; import com.foglyn.fogbugz.FogBugzArea.AreaID; import com.foglyn.fogbugz.FogBugzCase.CaseID; import com.foglyn.fogbugz.FogBugzCategory.CategoryID; import com.foglyn.fogbugz.FogBugzData.FogBugzDataBuilder; import com.foglyn.fogbugz.FogBugzEvent.EventID; import com.foglyn.fogbugz.FogBugzFilter.FilterID; import com.foglyn.fogbugz.FogBugzFilter.FilterType; import com.foglyn.fogbugz.FogBugzFixFor.FixForID; import com.foglyn.fogbugz.FogBugzPerson.PersonID; import com.foglyn.fogbugz.FogBugzPriority.PriorityID; import com.foglyn.fogbugz.FogBugzProject.ProjectID; import com.foglyn.fogbugz.FogBugzStatus.StatusID; /** * This class uses FogBugz API to obtain information from remote FogBugz server. */ public class FogBugzClient { private static final String CASE_COLUMNS_NO_EVENTS = "ixBug,ixBugEventLatest,sTitle,fOpen,dtOpened," + "dtClosed,dtResolved,ixProject,ixPersonAssignedTo,ixPersonOpenedBy,ixPersonResolvedBy," + "ixArea,ixStatus,ixCategory,ixPriority,ixFixFor,dtLastUpdated," + "hrsOrigEst,hrsCurrEst,hrsElapsed,dtDue,ixRelatedBugs"; private static final String EVENTS_COLUMN = ",events"; private static final String FOGBUGZ7_COLUMNS = ",ixBugParent,ixBugChildren,tags"; // Version of API this class uses. If remote FogBugz server returns minVersion higher than this, we are out of luck. private static final int MIN_VERSION = 5; private final Request request; /** * Version of remote API. */ private final int apiVersion; // Base Fogbugz API URL, usually ends with "api.asp?" or "api.php?". Suffix is returned from /api.xml call. private final String fogbugzBaseApiURL; private final Object tokenLock; // Guarded by tokenLock private String token; // token obtained after logon // Guarded by tokenLock /** FogBugz API URL with appended token and ampersand (https://.../api.asp?token=23752947dbakjsgdf&) */ private String apiURL; private final AtomicReference<FogBugzData> data; private final Log log; public static FogBugzClient createFogBugzClient(AbstractWebLocation repositoryLocation, HttpClient httpClient, IProgressMonitor monitor) throws FogBugzException { Assert.isNotNull(repositoryLocation); Assert.isNotNull(httpClient); Assert.isNotNull(monitor); monitor.subTask("Contacting FogBugz server"); String baseURL = getBaseURL(repositoryLocation); Request request = new Request(httpClient, repositoryLocation); Document doc = request.requestAPI(baseURL, monitor); monitor.worked(1); int apiVersion = Integer.parseInt(XOMUtils.xpathValueOf(doc, "/response/version")); int minVersion = Integer.parseInt(XOMUtils.xpathValueOf(doc, "/response/minversion")); if (apiVersion < MIN_VERSION || minVersion > MIN_VERSION) { throw new FogBugzException( "Remote server doesn't support required version of API. Server is at version " + apiVersion + ", minimum supported version is " + minVersion + ". Client implements version " + MIN_VERSION); } String apiUrl = URI.create(baseURL).resolve(XOMUtils.xpathValueOf(doc, "/response/url")).toASCIIString(); return new FogBugzClient(repositoryLocation, httpClient, apiUrl, apiVersion); } private static String getBaseURL(AbstractWebLocation repositoryLocation) { String base = repositoryLocation.getUrl(); if (!base.endsWith("/")) { base = base + "/"; } base = base + "api.xml"; return base; } /** * * @param repositoryLocation * @param httpClient * @param fogbugzApiURL * URL where FogBugz API is accessible, e.g. * <code>https://.../api.asp?</code>. This must end with ? or * & because more query parameters are appended to this * string when performing commands. * @param apiVersion version of remote API * @param minVersion minimum compatible version of API */ FogBugzClient(AbstractWebLocation repositoryLocation, HttpClient httpClient, String fogbugzApiURL, int apiVersion) { this.log = Logging.getLogger("client.url" + "@" + repositoryLocation.getUrl()); this.tokenLock = new Object(); this.request = new Request(httpClient, repositoryLocation); this.fogbugzBaseApiURL = fogbugzApiURL; this.apiVersion = apiVersion; setToken(null); this.data = new AtomicReference<FogBugzData>(); this.data.set(new FogBugzData.FogBugzDataBuilder().build()); } private void setToken(String token) { synchronized (tokenLock) { if (token == null) { this.token = null; this.apiURL = fogbugzBaseApiURL; } else { this.token = token; this.apiURL = fogbugzBaseApiURL + "token=" + Utils.urlEncode(token) + "&"; } } } private String getToken() { synchronized (tokenLock) { return this.token; } } private String getApiURL() { synchronized (tokenLock) { return apiURL; } } private String getPostURL() { // remove trailing ? from URL return fogbugzBaseApiURL.substring(0, fogbugzBaseApiURL.length() - 1); } private String getCaseURL(CaseID caseID) { String r = Utils.replaceSuffix(fogbugzBaseApiURL, "api.asp?", "default.asp?", "api.php?", "default.php?"); if (r != fogbugzBaseApiURL) { return r + caseID; } return null; } /** * Generates full URL of command, incl. token and all parameters. This can * be used with {@link Request#requestAPI(String, IProgressMonitor)}, or * other methods from {@link Request} class. * * @param cmd * @param params * @return */ private String command(String cmd, String... params) { StringBuilder sb = new StringBuilder(); sb.append(getApiURL()); sb.append(Utils.urlEncode("cmd")); sb.append("="); sb.append(Utils.urlEncode(cmd)); for (int i = 0; i < (params.length / 2); i++) { String paramName = params[2 * i]; String paramValue = params[2 * i + 1]; Assert.isNotNull(paramName, "paramenter name " + i); Assert.isNotNull(paramName, "paramenter value " + i); sb.append("&"); sb.append(Utils.urlEncode(paramName)); sb.append("="); sb.append(Utils.urlEncode(paramValue)); } return sb.toString(); } /** * Performs login into the server. When login succeeds, login token is * stored and used for subsequent uses of this client, until * {@link #logout(IProgressMonitor)} method is called. * * @throws FogBugzResponseIncorrectPasswordOrUsername if username or password is incorrect (returned by FogBugz server) */ public void login(String username, String password, IProgressMonitor monitor) throws FogBugzException { Assert.isNotNull(username, "username"); Assert.isNotNull(password, "password"); monitor.subTask("Logging into server"); // Use POST for logging in. FogBugz enforces HTTPS for POST requests, and this will check that // supplied URL is correct. // Furthermore, POST request doesn't show username/password in Apache HTTP Client Log. List<Part> parts = new ArrayList<Part>(); parts.add(stringPart("cmd", "logon")); parts.add(stringPart("email", username)); parts.add(stringPart("password", password)); String postURL = getPostURL(); Document logon = null; try { logon = request.post(postURL, parts, monitor); } catch (FogBugzHttpException e) { checkUseDifferentRepositoryProblem(e, postURL); throw e; } monitor.worked(1); String token = XOMUtils.xpathValueOf(logon, "/response/token"); setToken(token); } /** * Will throw exception if FogBugz wants to use different repository */ private void checkUseDifferentRepositoryProblem(FogBugzHttpException e, String usedURL) throws FogBugzException { if (e.getHttpCode() != HttpStatus.SC_MOVED_TEMPORARILY) { return; } String location = e.getResponseHeaders().get("location"); if (location == null) { return; } String orig = usedURL.toLowerCase(); String received = location.toLowerCase(); String locationForUser = Utils.replaceSuffix(location, "api.asp", "", "api.php", ""); // if requested location is same as supplied, but with HTTPS insteada of HTTP, we send simplified error to user if (orig.startsWith("http:") && received.startsWith("https:") && orig.substring("http:".length()).equals(received.substring("https:".length()))) { throw new FogBugzException("This server requires HTTPS protocol (i.e., " + locationForUser + ")"); } throw new FogBugzException("Please use '" + locationForUser + "' as server repository"); } public void logout(IProgressMonitor monitor) throws FogBugzException { monitor.subTask("Logging off"); try { request.requestAPI(command("logoff"), monitor); } catch (FogBugzResponseNogLoggedOnException e) { // ok, expected. FogBugz replies with this error on logoff. } monitor.worked(1); setToken(null); } public List<FogBugzFilter> listFilters(IProgressMonitor monitor) throws FogBugzException { monitor.subTask("Getting list of filters"); Document response = request.requestAPI(command("listFilters"), monitor); monitor.worked(1); List<Element> filters = XOMUtils.xpathElements(response, "/response/filters/filter"); List<FogBugzFilter> result = new ArrayList<FogBugzFilter>(); for (Element e : filters) { String type = e.getAttributeValue("type"); String id = e.getAttributeValue("sFilter"); String desc = e.getValue(); FilterType filterType = FilterType.UNKNOWN; if ("builtin".equals(type)) { filterType = FilterType.BUILTIN; } else if ("saved".equals(type)) { filterType = FilterType.SAVED; } else if ("shared".equals(type)) { filterType = FilterType.SHARED; } String status = e.getAttributeValue("status"); FogBugzFilter filter = new FogBugzFilter(filterType, FilterID.valueOf(id), desc, "current".equals(status)); result.add(filter); } return result; } // private FogBugzFilter getCurrentFilter(IProgressMonitor monitor) throws CoreException { // List<FogBugzFilter> filters = listFilters(monitor); // // for (FogBugzFilter f: filters) { // if (f.isCurrent()) { // return f; // } // } // // // FIXME: builtin filter is never returned as 'current' // return null; // } private void setCurrentFilter(FilterID filterID, IProgressMonitor monitor) throws FogBugzException { Document response = request.requestAPI(command("saveFilter", "sFilter", filterID.toString()), monitor); Assert.isNotNull(response); // no exception indicates success } public List<FogBugzCase> listTasksForFilter(FilterID filterID, IProgressMonitor monitor, boolean loadEvents) throws FogBugzException { Assert.isNotNull(filterID, "filterID"); monitor.subTask("Fetching cases from filter"); // FogBugzFilter currentFilter = getCurrentFilter(monitor); setCurrentFilter(filterID, monitor); monitor.worked(1); List<FogBugzCase> result = fetchList(request, command("search", "cols", getColumns(loadEvents)), "/response/cases/case", monitor, new CaseMapper(monitor, loadEvents)); monitor.worked(1); // if (currentFilter != null) { // try { // setCurrentFilter(currentFilter.getFilterID(), monitor); // } catch (CoreException e) { // // can't set filter back ... ignore :-( // } // } return result; } private String getColumns(boolean loadEvents) { StringBuilder cols = new StringBuilder( CASE_COLUMNS_NO_EVENTS.length() + FOGBUGZ7_COLUMNS.length() + EVENTS_COLUMN.length()); cols.append(CASE_COLUMNS_NO_EVENTS); if (isFogBugz7Repository()) { cols.append(FOGBUGZ7_COLUMNS); } if (loadEvents) { cols.append(EVENTS_COLUMN); } return cols.toString(); } private FogBugzCase createCase(Element e, IProgressMonitor monitor, boolean loadEvents) throws FogBugzException { FogBugzCase fbCase = new FogBugzCase(); if ("".equals(XOMUtils.xpathValueOf(e, "ixBug").trim()) || "".equals(XOMUtils.xpathValueOf(e, "ixBugEventLatest").trim()) || "".equals(XOMUtils.xpathValueOf(e, "ixProject").trim()) || "".equals(XOMUtils.xpathValueOf(e, "ixPersonAssignedTo").trim()) || "".equals(XOMUtils.xpathValueOf(e, "ixPersonOpenedBy").trim()) || "".equals(XOMUtils.xpathValueOf(e, "ixPersonResolvedBy").trim()) || "".equals(XOMUtils.xpathValueOf(e, "ixArea").trim()) || "".equals(XOMUtils.xpathValueOf(e, "ixStatus").trim()) || "".equals(XOMUtils.xpathValueOf(e, "ixCategory").trim()) || "".equals(XOMUtils.xpathValueOf(e, "ixPriority").trim()) || "".equals(XOMUtils.xpathValueOf(e, "ixFixFor").trim())) { log.debug("Some ix element is empty or missing, full case element: " + e.toXML()); } CaseID caseID = CaseID.valueOf(XOMUtils.xpathValueOf(e, "ixBug")); fbCase.setCaseID(caseID); fbCase.setLatestEvent(EventID.valueOf(XOMUtils.xpathValueOf(e, "ixBugEventLatest"))); fbCase.setTitle(XOMUtils.xpathValueOf(e, "sTitle")); fbCase.setTaskURL(getCaseURL(fbCase.getCaseID())); fbCase.setOpen(Boolean.parseBoolean(XOMUtils.xpathValueOf(e, "fOpen"))); fbCase.setProject(ProjectID.valueOf(XOMUtils.xpathValueOf(e, "ixProject"))); fbCase.setOpenedDate(Parsers.parseDate(XOMUtils.xpathValueOf(e, "dtOpened"))); fbCase.setResolvedDate(Parsers.parseDate(XOMUtils.xpathValueOf(e, "dtResolved"))); fbCase.setClosedDate(Parsers.parseDate(XOMUtils.xpathValueOf(e, "dtClosed"))); fbCase.setAssignedTo(PersonID.valueOf(XOMUtils.xpathValueOf(e, "ixPersonAssignedTo"))); fbCase.setOpenedBy(PersonID.valueOf(XOMUtils.xpathValueOf(e, "ixPersonOpenedBy"))); String resolvedBy = XOMUtils.xpathValueOf(e, "ixPersonResolvedBy"); if (resolvedBy.trim().length() == 0) { fbCase.setResolvedBy(null); } else { fbCase.setResolvedBy(PersonID.valueOf(resolvedBy)); } fbCase.setArea(AreaID.valueOf(XOMUtils.xpathValueOf(e, "ixArea"))); fbCase.setStatus(StatusID.valueOf(XOMUtils.xpathValueOf(e, "ixStatus"))); fbCase.setCategory(CategoryID.valueOf(XOMUtils.xpathValueOf(e, "ixCategory"))); fbCase.setPriority(PriorityID.valueOf(XOMUtils.xpathValueOf(e, "ixPriority"))); fbCase.setFixFor(FixForID.valueOf(XOMUtils.xpathValueOf(e, "ixFixFor"))); fbCase.setDueDate(Parsers.parseDate(XOMUtils.xpathValueOf(e, "dtDue"))); fbCase.setRelatedBugs(Parsers.parseBugList(XOMUtils.xpathValueOf(e, "ixRelatedBugs"))); fbCase.setChildrenCases(Parsers.parseBugList(XOMUtils.xpathValueOf(e, "ixBugChildren"))); fbCase.setParentCase(Parsers.parseCaseID(XOMUtils.xpathValueOf(e, "ixBugParent"))); fbCase.setTags(Parsers.parseTags(XOMUtils.xpathElement(e, "tags"))); fbCase.setOriginalEstimateInHours(Parsers.parseHours(XOMUtils.xpathValueOf(e, "hrsOrigEst"), false)); boolean hasEstimate = fbCase.getOriginalEstimateInHours() != null; fbCase.setCurrentEstimateInHours(Parsers.parseHours(XOMUtils.xpathValueOf(e, "hrsCurrEst"), hasEstimate)); fbCase.setElapsedTimeInHours(Parsers.parseHours(XOMUtils.xpathValueOf(e, "hrsElapsed"), hasEstimate)); fbCase.setRemainingTimeInHours( computeRemainingTime(fbCase.getCurrentEstimateInHours(), fbCase.getElapsedTimeInHours())); fbCase.setConvertedOriginalEstimate(convertToDaysHoursMinutes(fbCase.getOriginalEstimateInHours())); fbCase.setConvertedCurrentEstimate(convertToDaysHoursMinutes(fbCase.getCurrentEstimateInHours())); fbCase.setConvertedElapsedTime(convertToDaysHoursMinutes(fbCase.getElapsedTimeInHours())); fbCase.setConvertedRemainingTime(convertToDaysHoursMinutes(fbCase.getRemainingTimeInHours())); fbCase.setActions(Parsers.parseActions(XOMUtils.xpathValueOf(e, "@operations"))); // FIXME: set closed by? fbCase.setLastUpdated(Parsers.parseDate(XOMUtils.xpathValueOf(e, "dtLastUpdated"))); if (loadEvents) { monitor.subTask("Fetching details about case " + caseID.toString()); addEvents(e, fbCase, monitor); monitor.worked(1); } return fbCase; } /** * Computes remaining time from current estimate and elapsed time. * @param current current estimate in hours, can be null * @param elapsed elapsed time in hours, can be null */ private BigDecimal computeRemainingTime(BigDecimal current, BigDecimal elapsed) { BigDecimal remaining = current; // possibly null if (current != null && elapsed != null) { remaining = current.subtract(elapsed); if (remaining.compareTo(BigDecimal.ZERO) < 0) { remaining = BigDecimal.ZERO; } } return remaining; } /** * @param hours * @return Converts given time in hours to (days/hours/minutes) */ private DaysHoursMinutes convertToDaysHoursMinutes(BigDecimal hours) { if (hours == null) { return null; } WorkingSchedule ws = getSiteWorkingSchedule(); if (ws == null) { return null; } BigDecimal workingHours = ws.getWorkingHoursPerDay(); return DaysHoursMinutes.convertToDaysHoursMinutes(hours, workingHours); } private void addEvents(Element caseElement, FogBugzCase fbCase, IProgressMonitor monitor) throws FogBugzException { Element eventsElement = XOMUtils.xpathElement(caseElement, "events"); if (eventsElement != null) { List<Element> events = XOMUtils.xpathElements(caseElement, "events/event"); List<FogBugzEvent> caseEvents = new ArrayList<FogBugzEvent>(); for (Element ee : events) { FogBugzEvent ev = new FogBugzEvent(); ev.setEventID(EventID.valueOf(XOMUtils.xpathValueOf(ee, "ixBugEvent"))); ev.setVerb(Utils.transformNumericAndCommonEntities(XOMUtils.xpathValueOf(ee, "sVerb"))); ev.setEventDescription( Utils.transformNumericAndCommonEntities(XOMUtils.xpathValueOf(ee, "evtDescription"))); ev.setInitiator(PersonID.valueOf(XOMUtils.xpathValueOf(ee, "ixPerson"))); ev.setDate(Parsers.parseDate(XOMUtils.xpathValueOf(ee, "dt"))); ev.setText(XOMUtils.xpathValueOf(ee, "s")); ev.setChanges(XOMUtils.xpathValueOf(ee, "sChanges")); String bEmail = XOMUtils.xpathValueOf(ee, "bEmail"); if ("".equals(bEmail)) { bEmail = XOMUtils.xpathValueOf(ee, "fEmail"); // FogBugz 7 } ev.setEmail(Boolean.parseBoolean(bEmail)); if (ev.isEmail()) { ev.setEmailFrom(XOMUtils.xpathValueOf(ee, "sFrom")); } List<Element> attachmentElements = XOMUtils.xpathElements(ee, "rgAttachments/attachment"); List<FogBugzAttachment> attachments = new ArrayList<FogBugzAttachment>(); for (Element ae : attachmentElements) { String filename = XOMUtils.xpathValueOf(ae, "sFileName"); String url = XOMUtils.xpathValueOf(ae, "sURL"); url = Utils.transformCommonEntities(url); FogBugzAttachment attach = new FogBugzAttachment(filename, url); fetchAdditionalAttachmentDetails(attach, monitor); attachments.add(attach); } if (!attachments.isEmpty()) { ev.setAttachments(attachments); } caseEvents.add(ev); } fbCase.setEvents(caseEvents); } } private void fetchAdditionalAttachmentDetails(FogBugzAttachment attach, IProgressMonitor monitor) throws FogBugzException { attach.setUrlWithHost(getAttachmentUrlWithHost(attach.getUrlComponent())); String urlWithHostAndToken = getFullAttachmentUrlWithHostAndToken(attach.getUrlComponent()); Map<String, String> headers = request.getHeaders(urlWithHostAndToken, monitor); // keys are in lower-case if (headers.containsKey("content-length")) { try { long length = Long.parseLong(headers.get("content-length")); if (length > 0) { attach.setLength(length); } } catch (NumberFormatException e) { // ignore } } attach.setMimetype(headers.get("content-type")); } private String getAttachmentUrlWithHost(String urlComponent) { return URI.create(this.fogbugzBaseApiURL).resolve(urlComponent).toASCIIString(); } private String getFullAttachmentUrlWithHostAndToken(String urlComponent) { String urlWithHost = getAttachmentUrlWithHost(urlComponent); String urlWithHostAndToken = urlWithHost + "&token=" + Utils.urlEncode(getToken()); return urlWithHostAndToken; } public FogBugzCase getCase(String taskID, IProgressMonitor monitor) throws FogBugzException { Assert.isNotNull(taskID, "taskID"); Document response = request.requestAPI(command("search", "q", taskID, "cols", getColumns(true)), monitor); List<Element> casesElements = XOMUtils.xpathElements(response, "/response/cases/case"); if (casesElements.isEmpty()) { return null; } return createCase(casesElements.get(0), monitor, true); } public List<FogBugzCase> search(String searchString, IProgressMonitor monitor, boolean loadEvents) throws FogBugzException { log.debug("Searching: " + searchString + ", loadEvents: " + loadEvents); monitor.subTask("Searching for cases"); List<FogBugzCase> result = fetchList(request, command("search", "q", searchString, "cols", getColumns(loadEvents)), "/response/cases/case", monitor, new CaseMapper(monitor, loadEvents)); monitor.worked(1); log.debug("Found " + result.size() + " cases"); return result; } private <T> List<T> fetch(IProgressMonitor monitor, String listCommand, String xpath, Mapper<T> mapper, String... parameters) throws FogBugzException { return fetchList(request, command(listCommand, parameters), xpath, monitor, mapper); } private <T> List<T> fetchList(Request request, String url, String xpath, IProgressMonitor monitor, Mapper<T> mapper) throws FogBugzException { log.debug("Fetching from " + url); Document response = request.requestAPI(url, monitor); log.debug("Parsing response"); List<Element> elements = XOMUtils.xpathElements(response, xpath); List<T> result = new ArrayList<T>(); for (Element e : elements) { result.add(mapper.mapElement(e)); } log.debug("Fetched " + result.size() + " objects"); return result; } private <T> T fetchSingle(Request request, String url, String xpath, IProgressMonitor monitor, Mapper<T> mapper) throws FogBugzException { log.debug("Fetching single from " + url); Document response = request.requestAPI(url, monitor); log.debug("Parsing response"); Element element = XOMUtils.xpathElement(response, xpath); if (element == null) { log.debug("No object"); return null; } T result = mapper.mapElement(element); log.debug("Fetched single object"); return result; } public FogBugzCategory getCategory(CategoryID categoryID) { return data.get().getCategory(categoryID); } public Collection<FogBugzCategory> getAllCategories() { return data.get().getAllCategories(); } public FogBugzPriority getPriority(PriorityID priorityID) { return data.get().getPriority(priorityID); } public Collection<FogBugzPriority> getAllPriorities() { return data.get().getAllPriorities(); } public FogBugzStatus getStatus(StatusID statusID) { return data.get().getStatus(statusID); } public Collection<FogBugzStatus> getAllStatuses() { return data.get().getAllStatuses(); } public FogBugzPerson getPerson(PersonID personID) { FogBugzData fbData = data.get(); FogBugzPerson result = fbData.getPerson(personID); if (result != null) { return result; } if (fbData.isNonExistant(personID)) { return null; } FogBugzPerson p = null; try { p = fetchSingle(request, command("viewPerson", "ixPerson", personID.toString()), "/response/person", new NullProgressMonitor(), new PersonMapper()); } catch (FogBugzException e) { // ignore this problem ... even some legitimate problems are ignored // here, in assumption that they will arise again, if they persist return null; } FogBugzDataBuilder b = new FogBugzDataBuilder(fbData); if (p == null) { b.addNonExistantPersonID(personID); } else { if (p.isInactive()) { b.addInactivePerson(p); } else { // new person? b.addPerson(p); } } data.set(b.build()); return p; } /** * @return collection of active people (normal or virtual). Inactive people * are not returned, even if they were already fetched from server. */ public Collection<FogBugzPerson> getAllPeople() { return data.get().getAllPeople(); } public FogBugzProject getProject(ProjectID projectID) { return data.get().getProject(projectID); } public Collection<FogBugzProject> getAllProjects() { return data.get().getAllProjects(); } public FogBugzArea getArea(AreaID areaID) { return data.get().getArea(areaID); } public Collection<FogBugzArea> getAllAreas() { return data.get().getAllAreas(); } public FogBugzFixFor getFixFor(FixForID fixFor) { return data.get().getFixFor(fixFor); } public Collection<FogBugzFixFor> getAllFixFors() { return data.get().getAllFixFors(); } /** * @return working schedule for logged-in user (may be null, if caches has not yet been loaded) */ public WorkingSchedule getWorkingSchedule() { return data.get().getWorkingSchedule(); } public WorkingSchedule getSiteWorkingSchedule() { return data.get().getSiteWorkingSchedule(); } /** * Activates time tracking for given case. * @param bugID * @throws FogBugzResponseTimeTrackingException if it isn't possible to starting time tracking for given case * @throws FogBugzException in case of other problems */ public void startWork(CaseID bugID, IProgressMonitor monitor) throws FogBugzResponseTimeTrackingException, FogBugzException { request.requestAPI(command("startWork", "ixBug", bugID.toString()), monitor); } /** * Deactivates time tracking for current case. * @param monitor */ public void stopWork(IProgressMonitor monitor) throws FogBugzException { request.requestAPI(command("stopWork"), monitor); } public PersonID getOwner(ProjectID projectID, AreaID areaID) { return this.data.get().getOwner(projectID, areaID); } public int getApiVersion() { return apiVersion; } /** * @param urlComponent URL as returned by Fogbugz (i.e. usually "default.asp?..." without host and token). * @throws FogBugzCommunicationException */ public void getAttachmentContent(String urlComponent, OutputStream output, IProgressMonitor monitor) throws FogBugzException { String url = getFullAttachmentUrlWithHostAndToken(urlComponent); request.download(url, output, monitor); } public void postNewAttachment(CaseID caseID, String comment, AttachmentData attachment, IProgressMonitor monitor) throws FogBugzException { ChangeEventData ced = new ChangeEventData(); ced.setNewComment(comment); ced.setCaseID(caseID); ced.addAttachment(attachment); performCaseAction(CaseAction.EDIT, ced, monitor, false); } /** * @param action to be performed on case * @param data what to modify in case * @param loadEvents should new/updated case have all events information? * @return new or updated case with all details (with/without events as specified by loadEvents parameter) */ public FogBugzCase performCaseAction(CaseAction action, ChangeEventData data, IProgressMonitor monitor, boolean loadEvents) throws FogBugzException { List<Part> parts = new ArrayList<Part>(); parts.add(stringPart("cmd", action.getCommand())); parts.add(stringPart("token", getToken())); parts.add(stringPart("cols", getColumns(loadEvents))); // send back all details about case addOptionalPart(parts, "ixBug", data.getCaseID()); addOptionalPart(parts, "ixBugEvent", data.getEventID()); addOptionalPart(parts, "sTitle", data.getNewTitle()); addOptionalPart(parts, "ixProject", data.getNewProjectID()); addOptionalPart(parts, "ixArea", data.getNewAreaID()); addOptionalPart(parts, "ixFixFor", data.getNewFixForID()); addOptionalPart(parts, "ixCategory", data.getNewCategoryID()); addOptionalPart(parts, "ixPersonAssignedTo", data.getNewAssignedTo()); addOptionalPart(parts, "ixPriority", data.getNewPriorityID()); addOptionalDatePart(parts, "dtDue", data.getNewDueDate()); addOptionalPart(parts, "sVersion", data.getNewVersion()); addOptionalPart(parts, "sComputer", data.getNewComputer()); // sCustomerEmail, ixMailbox, sScoutDescription, sScoutMessage, fScoutStopReporting addOptionalPart(parts, "sEvent", data.getNewComment()); addOptionalPart(parts, "ixStatus", data.getNewStatus()); addOptionalPart(parts, "hrsCurrEst", convertDaysHoursMinutesToString(data.getNewCurrentHoursEstimate())); if (isFogBugz7Repository()) { addOptionalPart(parts, "ixBugParent", data.getParentCaseID()); addOptionalPart(parts, "sTags", convertToTags(data.getTags())); } // hrsElapsed cannot be modified :-( // addOptionalPart(parts, "hrsElapsed", convertDaysHoursMinutesToString(data.getNewElapsedTime())); if (!data.getAttachments().isEmpty()) { int count = data.getAttachments().size(); parts.add(stringPart("nFileCount", Integer.toString(count))); int fileIndex = 1; for (AttachmentData ad : data.getAttachments()) { parts.add(filePart("File" + fileIndex, ad)); fileIndex++; } } Document doc = request.post(getPostURL(), parts, monitor); Element caseElement = XOMUtils.xpathElement(doc, "/response/case"); if (caseElement == null) { return null; } return createCase(caseElement, monitor, loadEvents); } private String convertToTags(List<String> tags) { if (tags == null) { return null; } if (tags.isEmpty()) { // Single space clears tags. See http://our.fogbugz.com/default.asp?fogbugz.4.84558.0 for details. return " "; } StringBuilder result = new StringBuilder(); String delim = ""; for (String t : tags) { result.append(delim); result.append(t); delim = ","; } return result.toString(); } private String convertDaysHoursMinutesToString(DaysHoursMinutes e) { if (e == null) { return null; } e = e.normalize(getSiteWorkingSchedule().getWorkingHoursPerDay()); StringBuilder sb = new StringBuilder(); if (e.days.compareTo(BigDecimal.ZERO) > 0) { sb.append(e.days.toPlainString()); sb.append("d"); } if (e.hours.compareTo(BigDecimal.ZERO) > 0) { if (sb.length() > 0) { sb.append(", "); } sb.append(e.hours.toPlainString()); sb.append("h"); } if (e.minutes.compareTo(BigDecimal.ZERO) > 0) { if (sb.length() > 0) { sb.append(", "); } sb.append(e.minutes.toPlainString()); sb.append("m"); } if (sb.length() == 0) { sb.append("0"); } return sb.toString(); } private void addOptionalDatePart(List<Part> parts, String name, Date value) { if (value == null) { return; } String formatted = Parsers.formatDate(value); parts.add(stringPart(name, String.valueOf(formatted))); } private void addOptionalPart(List<Part> parts, String name, Object value) { if (value != null) { parts.add(stringPart(name, String.valueOf(value))); } } private FilePart filePart(String name, AttachmentData attachment) { FilePart p = new FilePart(name, attachment.getPartSource(), attachment.getContentType(), null); p.setTransferEncoding(null); return p; } private StringPart stringPart(String name, String value) { // FogBugz doesn't like when we set Content-Type or Transfer-Enconding headers for part // It also always expects text encoded in UTF-8 StringPart p = new StringPart(name, value, "UTF-8"); p.setContentType(null); p.setTransferEncoding(null); return p; } public void loadCaches(IProgressMonitor monitor) throws FogBugzException { loadCaches(EnumSet.allOf(CacheType.class), monitor); } public void loadCaches(Set<CacheType> types, IProgressMonitor monitor) throws FogBugzException { Utils.checkCancellation(monitor); FogBugzDataBuilder builder = new FogBugzDataBuilder(data.get()); if (types.contains(CacheType.CATEGORY)) { monitor.subTask("Loading categories"); builder.addCategories( fetch(monitor, "listCategories", "/response/categories/category", new CategoryMapper())); monitor.worked(1); } Utils.checkCancellation(monitor); if (types.contains(CacheType.PRIORITY)) { monitor.subTask("Loading priorities"); builder.addPriorities( fetch(monitor, "listPriorities", "/response/priorities/priority", new PriorityMapper())); monitor.worked(1); } Utils.checkCancellation(monitor); if (types.contains(CacheType.STATUS)) { monitor.subTask("Loading statuses"); List<FogBugzStatus> resolved = fetch(monitor, "listStatuses", "/response/statuses/status", new StatusMapper(), "fResolved", "1"); Set<StatusID> resolvedStatuses = new HashSet<StatusID>(); for (FogBugzStatus fbs : resolved) { resolvedStatuses.add(fbs.getID()); } builder.addStatuses(fetch(monitor, "listStatuses", "/response/statuses/status", new StatusMapper(resolvedStatuses))); monitor.worked(1); } Utils.checkCancellation(monitor); if (types.contains(CacheType.PERSON)) { monitor.subTask("Loading people"); List<FogBugzPerson> normalPeople = fetch(monitor, "listPeople", "/response/people/person", new PersonMapper()); List<FogBugzPerson> virtualPeople = fetch(monitor, "listPeople", "/response/people/person", new PersonMapper(), "fIncludeVirtual", "1"); List<FogBugzPerson> allPeople = new ArrayList<FogBugzPerson>(); allPeople.addAll(normalPeople); allPeople.addAll(virtualPeople); builder.addPeople(allPeople); // get current user FogBugzPerson currentUser = fetchSingle(request, command("viewPerson"), "/response/person", monitor, new PersonMapper()); if (currentUser != null) { builder.setCurrentUser(currentUser.getID()); } monitor.worked(1); } Utils.checkCancellation(monitor); if (types.contains(CacheType.AREA)) { monitor.subTask("Loading areas"); builder.addAreas(fetch(monitor, "listAreas", "/response/areas/area", new AreaMapper())); monitor.worked(1); } Utils.checkCancellation(monitor); if (types.contains(CacheType.PROJECT)) { monitor.subTask("Loading projects"); builder.addProjects(fetch(monitor, "listProjects", "/response/projects/project", new ProjectMapper())); monitor.worked(1); } Utils.checkCancellation(monitor); if (types.contains(CacheType.FIX_FOX)) { monitor.subTask("Loading releases"); builder.addFixFors(fetch(monitor, "listFixFors", "/response/fixfors/fixfor", new FixForMapper(), "fIncludeDeleted", "1")); monitor.worked(1); } Utils.checkCancellation(monitor); if (types.contains(CacheType.WORKING_SCHEDULE)) { monitor.subTask("Loading working schedule"); // There was bug in older fogbugz version, which prevented fogbugz from returning workingSchedule in some cases. builder.setWorkingSchedule(fetchSingle(request, command("listWorkingSchedule"), "/response/workingSchedule", monitor, new WorkingScheduleMapper())); builder.setSiteWorkingSchedule(fetchSingle(request, command("listWorkingSchedule", "ixPerson", "1"), "/response/workingSchedule", monitor, new WorkingScheduleMapper())); monitor.worked(1); } builder.addLoadCacheTypes(types); FogBugzData data = builder.build(); this.data.set(data); } private static final class CategoryMapper implements Mapper<FogBugzCategory> { public FogBugzCategory mapElement(Element e) throws FogBugzException { CategoryID id = CategoryID.valueOf(XOMUtils.xpathValueOf(e, "ixCategory")); String name = XOMUtils.xpathValueOf(e, "sCategory"); String namePlural = XOMUtils.xpathValueOf(e, "sPlural"); boolean scheduleItem = Boolean.parseBoolean(XOMUtils.xpathValueOf(e, "fIsScheduleItem")); StatusID defaultStatus = StatusID.valueOf(XOMUtils.xpathValueOf(e, "ixStatusDefault")); StatusID defaultActiveStatus = null; String defaultActiveStatusValue = XOMUtils.xpathValueOf(e, "ixStatusDefaultActive"); if (defaultActiveStatusValue.trim().length() > 0) { defaultActiveStatus = StatusID.valueOf(defaultActiveStatusValue); } return new FogBugzCategory(id, name, namePlural, scheduleItem, defaultStatus, defaultActiveStatus); } } private static final class PriorityMapper implements Mapper<FogBugzPriority> { public FogBugzPriority mapElement(Element e) throws FogBugzException { PriorityID pid = PriorityID.valueOf(XOMUtils.xpathValueOf(e, "ixPriority")); String name = XOMUtils.xpathValueOf(e, "sPriority"); boolean isDefault = Boolean.parseBoolean(XOMUtils.xpathValueOf(e, "fDefault")); return new FogBugzPriority(pid, name, isDefault); } } private static final class StatusMapper implements Mapper<FogBugzStatus> { private final Set<StatusID> resolvedStatuses; StatusMapper(Set<StatusID> resolvedStatuses) { this.resolvedStatuses = resolvedStatuses; } StatusMapper() { this(Collections.<StatusID>emptySet()); } public FogBugzStatus mapElement(Element e) throws FogBugzException { StatusID id = StatusID.valueOf(XOMUtils.xpathValueOf(e, "ixStatus")); String name = XOMUtils.xpathValueOf(e, "sStatus"); CategoryID cat = CategoryID.valueOf(XOMUtils.xpathValueOf(e, "ixCategory")); boolean resolved = resolvedStatuses.contains(id); int order = -1; String orderValue = XOMUtils.xpathValueOf(e, "iOrder"); try { order = Integer.parseInt(orderValue); } catch (NumberFormatException ex) { // ignore } return new FogBugzStatus(id, name, cat, resolved, order); } } private static final class PersonMapper implements Mapper<FogBugzPerson> { public FogBugzPerson mapElement(Element e) { PersonID personID = PersonID.valueOf(XOMUtils.xpathValueOf(e, "ixPerson")); String fullName = XOMUtils.xpathValueOf(e, "sFullName"); String email = XOMUtils.xpathValueOf(e, "sEmail"); boolean inactive = Boolean.parseBoolean(XOMUtils.xpathValueOf(e, "fDeleted")); boolean virtual = Boolean.parseBoolean(XOMUtils.xpathValueOf(e, "fVirtual")); return new FogBugzPerson(personID, fullName, email, virtual, inactive); } } private static class ProjectMapper implements Mapper<FogBugzProject> { public FogBugzProject mapElement(Element e) { ProjectID projectID = ProjectID.valueOf(XOMUtils.xpathValueOf(e, "ixProject")); String name = XOMUtils.xpathValueOf(e, "sProject"); boolean inbox = Boolean.parseBoolean(XOMUtils.xpathValueOf(e, "fInbox")); PersonID ownerID = PersonID.valueOf(XOMUtils.xpathValueOf(e, "ixPersonOwner")); return new FogBugzProject(projectID, name, inbox, ownerID); } } private static class AreaMapper implements Mapper<FogBugzArea> { public FogBugzArea mapElement(Element e) { AreaID areaID = AreaID.valueOf(XOMUtils.xpathValueOf(e, "ixArea")); String name = XOMUtils.xpathValueOf(e, "sArea"); ProjectID projectID = ProjectID.valueOf(XOMUtils.xpathValueOf(e, "ixProject")); PersonID ownerID = null; String owner = XOMUtils.xpathValueOf(e, "ixPersonOwner"); if (owner.trim().length() > 0) { ownerID = PersonID.valueOf(owner); } return new FogBugzArea(areaID, name, projectID, ownerID); } } private class CaseMapper implements Mapper<FogBugzCase> { private final IProgressMonitor monitor; private final boolean loadEvents; CaseMapper(IProgressMonitor monitor, boolean fullDetails) { this.monitor = monitor; this.loadEvents = fullDetails; } public FogBugzCase mapElement(Element e) throws FogBugzException { return createCase(e, monitor, loadEvents); } } private static class FixForMapper implements Mapper<FogBugzFixFor> { public FogBugzFixFor mapElement(Element e) throws FogBugzException { FixForID id = FixForID.valueOf(XOMUtils.xpathValueOf(e, "ixFixFor")); String name = XOMUtils.xpathValueOf(e, "sFixFor"); boolean deleted = Boolean.parseBoolean(XOMUtils.xpathValueOf(e, "fDeleted")); Date date = Parsers.parseDate(XOMUtils.xpathValueOf(e, "dt")); ProjectID projectID = null; String pid = XOMUtils.xpathValueOf(e, "ixProject"); if (pid.trim().length() > 0) { projectID = ProjectID.valueOf(pid); } return new FogBugzFixFor(id, name, deleted, date, projectID); } } private static class WorkingScheduleMapper implements Mapper<WorkingSchedule> { public WorkingSchedule mapElement(Element e) throws FogBugzException { WorkingSchedule ws = new WorkingSchedule(); ws.setWorkdayStart(new BigDecimal(XOMUtils.xpathValueOf(e, "nWorkdayStarts"))); ws.setWorkdayEnd(new BigDecimal(XOMUtils.xpathValueOf(e, "nWorkdayEnds"))); ws.setHasLunch(Boolean.parseBoolean(XOMUtils.xpathValueOf(e, "fHasLunch"))); ws.setLunchStart(Parsers.parseHours(XOMUtils.xpathValueOf(e, "nLunchStarts"), true)); ws.setLunchLenghtHours(Parsers.parseHours(XOMUtils.xpathValueOf(e, "hrsLunchLength"), true)); return ws; } } /** * FogBugz 7 introduced subcases and tags feature. */ public boolean isFogBugz7Repository() { return apiVersion >= 7; } public PersonID getCurrentUser() { return data.get().getCurrentUser(); } }