blob: 283a499fbd9022a1180c107063f7e3fbfd256d9e [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2006 - 2006 Mylar eclipse.org project and others.
* 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:
* Mylar project committers - initial API and implementation
*******************************************************************************/
package org.eclipse.mylar.internal.trac.core;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.StreamTokenizer;
import java.io.StringReader;
import java.net.HttpURLConnection;
import java.net.Proxy;
import java.net.URL;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;
import javax.security.auth.login.LoginException;
import org.apache.commons.httpclient.Credentials;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.UsernamePasswordCredentials;
import org.apache.commons.httpclient.auth.AuthScope;
import org.apache.commons.httpclient.cookie.CookiePolicy;
import org.apache.commons.httpclient.methods.GetMethod;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.mylar.core.MylarStatusHandler;
import org.eclipse.mylar.core.net.HtmlStreamTokenizer;
import org.eclipse.mylar.core.net.HtmlTag;
import org.eclipse.mylar.core.net.WebClientUtil;
import org.eclipse.mylar.core.net.HtmlStreamTokenizer.Token;
import org.eclipse.mylar.internal.trac.core.model.TracComponent;
import org.eclipse.mylar.internal.trac.core.model.TracMilestone;
import org.eclipse.mylar.internal.trac.core.model.TracPriority;
import org.eclipse.mylar.internal.trac.core.model.TracSearch;
import org.eclipse.mylar.internal.trac.core.model.TracSearchFilter;
import org.eclipse.mylar.internal.trac.core.model.TracSeverity;
import org.eclipse.mylar.internal.trac.core.model.TracTicket;
import org.eclipse.mylar.internal.trac.core.model.TracTicketResolution;
import org.eclipse.mylar.internal.trac.core.model.TracTicketStatus;
import org.eclipse.mylar.internal.trac.core.model.TracTicketType;
import org.eclipse.mylar.internal.trac.core.model.TracVersion;
import org.eclipse.mylar.internal.trac.core.model.TracSearchFilter.CompareOperator;
import org.eclipse.mylar.internal.trac.core.model.TracTicket.Key;
import org.eclipse.mylar.internal.trac.core.util.TracUtils;
import org.eclipse.mylar.internal.trac.core.util.TracHttpClientTransportFactory.TracHttpException;
/**
* Represents a Trac repository that is accessed through the Trac's query script
* and web interface.
*
* @author Steffen Pingel
*/
public class TracWebClient extends AbstractTracClient {
private HttpClient httpClient = new HttpClient();
private boolean authenticated;
public TracWebClient(URL url, Version version, String username, String password, Proxy proxy) {
super(url, version, username, password, proxy);
}
private synchronized GetMethod connect(String serverURL) throws TracException {
try {
return connectInternal(serverURL);
} catch (TracException e) {
throw e;
} catch (Exception e) {
throw new TracException(e);
}
}
private GetMethod connectInternal(String serverURL) throws TracLoginException, IOException, TracHttpException {
WebClientUtil.setupHttpClient(httpClient, proxy, serverURL, null, null);
for (int attempt = 0; attempt < 2; attempt++) {
// force authentication
if (!authenticated && hasAuthenticationCredentials()) {
authenticate();
}
GetMethod method = new GetMethod(WebClientUtil.getRequestPath(serverURL));
method.getParams().setCookiePolicy(CookiePolicy.BROWSER_COMPATIBILITY);
int code;
try {
code = httpClient.executeMethod(method);
} catch (IOException e) {
method.releaseConnection();
throw e;
}
if (code == HttpURLConnection.HTTP_OK) {
return method;
} else if (code == HttpURLConnection.HTTP_UNAUTHORIZED || code == HttpURLConnection.HTTP_FORBIDDEN) {
// login or re-authenticate due to an expired session
method.releaseConnection();
authenticated = false;
authenticate();
} else {
throw new TracHttpException(code);
}
}
throw new TracLoginException();
}
private void authenticate() throws TracLoginException, IOException {
if (!hasAuthenticationCredentials()) {
throw new TracLoginException();
}
// try standard basic/digest authentication first
Credentials credentials = new UsernamePasswordCredentials(username, password);
httpClient.getState().setCredentials(
new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT, AuthScope.ANY_REALM), credentials);
GetMethod method = new GetMethod(WebClientUtil.getRequestPath(repositoryUrl + LOGIN_URL));
method.setFollowRedirects(false);
int code;
try {
httpClient.getParams().setAuthenticationPreemptive(true);
code = httpClient.executeMethod(method);
if (code == HttpURLConnection.HTTP_UNAUTHORIZED || code == HttpURLConnection.HTTP_FORBIDDEN) {
throw new TracLoginException();
}
} finally {
method.releaseConnection();
httpClient.getParams().setAuthenticationPreemptive(false);
}
// the expected return code is a redirect, anything else is suspicious
if (code == HttpURLConnection.HTTP_OK) {
// try form-based authentication via AccountManagerPlugin as a fall-back
authenticateAccountManager(httpClient);
}
validateAuthenticationState(httpClient);
// success since no exception was thrown
authenticated = true;
}
/**
* Fetches the web site of a single ticket and returns the Trac ticket.
*
* @param id
* Trac id of ticket
* @throws LoginException
*/
public TracTicket getTicket(int id) throws TracException {
GetMethod method = connect(repositoryUrl + ITracClient.TICKET_URL + id);
try {
TracTicket ticket = new TracTicket(id);
BufferedReader reader = new BufferedReader(new InputStreamReader(method.getResponseBodyAsStream(),
ITracClient.CHARSET));
HtmlStreamTokenizer tokenizer = new HtmlStreamTokenizer(reader, null);
for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
if (token.getType() == Token.TAG) {
HtmlTag tag = (HtmlTag) token.getValue();
if (tag.getTagType() == HtmlTag.Type.TD) {
String headers = tag.getAttribute("headers");
if ("h_component".equals(headers)) {
ticket.putBuiltinValue(Key.COMPONENT, getText(tokenizer));
} else if ("h_milestone".equals(headers)) {
ticket.putBuiltinValue(Key.MILESTONE, getText(tokenizer));
} else if ("h_priority".equals(headers)) {
ticket.putBuiltinValue(Key.PRIORITY, getText(tokenizer));
} else if ("h_severity".equals(headers)) {
ticket.putBuiltinValue(Key.SEVERITY, getText(tokenizer));
} else if ("h_version".equals(headers)) {
ticket.putBuiltinValue(Key.VERSION, getText(tokenizer));
} else if ("h_keywords".equals(headers)) {
ticket.putBuiltinValue(Key.KEYWORDS, getText(tokenizer));
} else if ("h_cc".equals(headers)) {
ticket.putBuiltinValue(Key.CC, getText(tokenizer));
} else if ("h_owner".equals(headers)) {
ticket.putBuiltinValue(Key.OWNER, getText(tokenizer));
} else if ("h_reporter".equals(headers)) {
ticket.putBuiltinValue(Key.REPORTER, getText(tokenizer));
}
// TODO handle custom fields
} else if (tag.getTagType() == HtmlTag.Type.H2 && "summary".equals(tag.getAttribute("class"))) {
ticket.putBuiltinValue(Key.SUMMARY, getText(tokenizer));
} else if (tag.getTagType() == HtmlTag.Type.H3 && "status".equals(tag.getAttribute("class"))) {
String text = getStrongText(tokenizer);
if (text.length() > 0) {
int i = text.indexOf(" (");
if (i != -1) {
// status contains resolution as well
ticket.putBuiltinValue(Key.STATUS, text.substring(0, i));
ticket.putBuiltinValue(Key.RESOLUTION, text.substring(i, text.length() - 1));
} else {
ticket.putBuiltinValue(Key.STATUS, text);
}
}
}
// TODO parse description
}
}
if (ticket.isValid() && ticket.getValue(Key.SUMMARY) != null) {
return ticket;
}
throw new InvalidTicketException();
} catch (IOException e) {
throw new TracException(e);
} catch (ParseException e) {
throw new TracException(e);
} finally {
method.releaseConnection();
}
}
public void search(TracSearch query, List<TracTicket> tickets) throws TracException {
GetMethod method = connect(repositoryUrl + ITracClient.QUERY_URL + query.toUrl());
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(method.getResponseBodyAsStream(),
ITracClient.CHARSET));
String line;
Map<String, String> constantValues = getExactMatchValues(query);
// first line contains names of returned ticket fields
line = reader.readLine();
if (line == null) {
throw new InvalidTicketException();
}
StringTokenizer t = new StringTokenizer(line, "\t");
Key[] fields = new Key[t.countTokens()];
for (int i = 0; i < fields.length; i++) {
fields[i] = Key.fromKey(t.nextToken());
}
// create a ticket for each following line of output
while ((line = reader.readLine()) != null) {
t = new StringTokenizer(line, "\t");
TracTicket ticket = new TracTicket();
for (int i = 0; i < fields.length && t.hasMoreTokens(); i++) {
if (fields[i] != null) {
try {
if (fields[i] == Key.ID) {
ticket.setId(Integer.parseInt(t.nextToken()));
} else if (fields[i] == Key.TIME) {
ticket.setCreated(TracUtils.parseDate(Integer.parseInt(t.nextToken())));
} else if (fields[i] == Key.CHANGE_TIME) {
ticket.setLastChanged(TracUtils.parseDate(Integer.parseInt(t.nextToken())));
} else {
ticket.putBuiltinValue(fields[i], parseTicketValue(t.nextToken()));
}
} catch (NumberFormatException e) {
MylarStatusHandler.log(e, "Error parsing response: " + line);
}
}
}
if (ticket.isValid()) {
for (String key : constantValues.keySet()) {
ticket.putValue(key, parseTicketValue(constantValues.get(key)));
}
tickets.add(ticket);
}
}
} catch (IOException e) {
throw new TracException(e);
} finally {
method.releaseConnection();
}
}
/**
* Trac has sepcial encoding rules for the returned output: None is
* represented by "--".
*/
private String parseTicketValue(String value) {
if ("--".equals(value)) {
return "";
}
return value;
}
/**
* Extracts constant values from <code>query</code>. The Trac query
* script does not return fields that matched exactly againt a single value.
*/
private Map<String, String> getExactMatchValues(TracSearch query) {
Map<String, String> values = new HashMap<String, String>();
List<TracSearchFilter> filters = query.getFilters();
for (TracSearchFilter filter : filters) {
if (filter.getOperator() == CompareOperator.IS && filter.getValues().size() == 1) {
values.put(filter.getFieldName(), filter.getValues().get(0));
}
}
return values;
}
public void validate() throws TracException {
GetMethod method = connect(repositoryUrl + "/");
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(method.getResponseBodyAsStream(),
ITracClient.CHARSET));
boolean inFooter = false;
boolean valid = false;
String version = null;
HtmlStreamTokenizer tokenizer = new HtmlStreamTokenizer(reader, null);
for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
if (token.getType() == Token.TAG) {
HtmlTag tag = (HtmlTag) token.getValue();
if (tag.getTagType() == HtmlTag.Type.DIV) {
String id = tag.getAttribute("id");
inFooter = !tag.isEndTag() && "footer".equals(id);
} else if (tag.getTagType() == HtmlTag.Type.STRONG && inFooter) {
version = getText(tokenizer);
} else if (tag.getTagType() == HtmlTag.Type.A) {
String id = tag.getAttribute("id");
if ("tracpowered".equals(id)) {
valid = true;
}
}
}
}
if (version != null && !(version.startsWith("Trac 0.9") || version.startsWith("Trac 0.10"))) {
throw new TracException("The Trac version " + version + " is unsupported. Please use version 0.9.x or 0.10.x.");
}
if (!valid) {
throw new TracException("Not a valid Trac repository");
}
} catch (IOException e) {
throw new TracException(e);
} catch (ParseException e) {
throw new TracException(e);
} finally {
method.releaseConnection();
}
}
@Override
public void updateAttributes(IProgressMonitor monitor) throws TracException {
monitor.beginTask("Updating attributes", IProgressMonitor.UNKNOWN);
GetMethod method = connect(repositoryUrl + ITracClient.CUSTOM_QUERY_URL);
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(method.getResponseBodyAsStream(),
ITracClient.CHARSET));
HtmlStreamTokenizer tokenizer = new HtmlStreamTokenizer(reader, null);
for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
if (monitor.isCanceled()) {
throw new OperationCanceledException();
}
if (token.getType() == Token.TAG) {
HtmlTag tag = (HtmlTag) token.getValue();
if (tag.getTagType() == HtmlTag.Type.SCRIPT) {
String text = getText(tokenizer).trim();
if (text.startsWith("var properties=")) {
parseAttributes(text);
}
}
}
}
addResolutionAndStatus();
} catch (IOException e) {
throw new TracException(e);
} catch (ParseException e) {
throw new TracException(e);
} finally {
method.releaseConnection();
}
}
enum AttributeState { INIT, IN_LIST, IN_ATTRIBUTE_KEY, IN_ATTRIBUTE_VALUE, IN_ATTRIBUTE_VALUE_LIST };
/**
* Parses the JavaScript code from the query page to extract repository configuration.
*/
private void parseAttributes(String text) throws IOException {
StreamTokenizer t = new StreamTokenizer(new StringReader(text));
t.quoteChar('"');
AttributeFactory attributeFactory = null;
String attributeType = null;
AttributeState state = AttributeState.INIT;
int tokenType;
while ((tokenType = t.nextToken()) != StreamTokenizer.TT_EOF) {
switch (tokenType) {
case StreamTokenizer.TT_WORD:
if (state == AttributeState.IN_LIST) {
if ("component".equals(t.sval)) {
data.components = new ArrayList<TracComponent>();
attributeFactory = new AttributeFactory() {
public void addAttribute(String value) {
data.components.add(new TracComponent(value));
}
};
} else if ("milestone".equals(t.sval)) {
data.milestones = new ArrayList<TracMilestone>();
attributeFactory = new AttributeFactory() {
public void addAttribute(String value) {
data.milestones.add(new TracMilestone(value));
}
};
} else if ("priority".equals(t.sval)) {
data.priorities = new ArrayList<TracPriority>();
attributeFactory = new AttributeFactory() {
public void addAttribute(String value) {
data.priorities.add(new TracPriority(value, data.priorities.size() + 1));
}
};
} else if ("resolution".equals(t.sval)) {
data.ticketResolutions = new ArrayList<TracTicketResolution>();
attributeFactory = new AttributeFactory() {
public void addAttribute(String value) {
data.ticketResolutions.add(new TracTicketResolution(value, data.ticketResolutions.size() + 1));
}
};
} else if ("severity".equals(t.sval)) {
data.severities = new ArrayList<TracSeverity>();
attributeFactory = new AttributeFactory() {
public void addAttribute(String value) {
data.severities.add(new TracSeverity(value, data.severities.size() + 1));
}
};
} else if ("status".equals(t.sval)) {
data.ticketStatus = new ArrayList<TracTicketStatus>();
attributeFactory = new AttributeFactory() {
public void addAttribute(String value) {
data.ticketStatus.add(new TracTicketStatus(value, data.ticketStatus.size() + 1));
}
};
} else if ("type".equals(t.sval)) {
data.ticketTypes = new ArrayList<TracTicketType>();
attributeFactory = new AttributeFactory() {
public void addAttribute(String value) {
data.ticketTypes.add(new TracTicketType(value, data.ticketTypes.size() + 1));
}
};
} else if ("version".equals(t.sval)) {
data.versions = new ArrayList<TracVersion>();
attributeFactory = new AttributeFactory() {
public void addAttribute(String value) {
data.versions.add(new TracVersion(value));
}
};
} else {
attributeFactory = null;
}
} else if (state == AttributeState.IN_ATTRIBUTE_KEY) {
attributeType = t.sval;
}
break;
case '"':
if (state == AttributeState.IN_ATTRIBUTE_VALUE_LIST && "options".equals(attributeType)) {
if (attributeFactory != null) {
attributeFactory.addAttribute(t.sval);
}
}
break;
case ':':
if (state == AttributeState.IN_ATTRIBUTE_KEY) {
state = AttributeState.IN_ATTRIBUTE_VALUE;
}
break;
case ',':
if (state == AttributeState.IN_ATTRIBUTE_VALUE) {
state = AttributeState.IN_ATTRIBUTE_KEY;
}
break;
case '[':
if (state == AttributeState.IN_ATTRIBUTE_VALUE) {
state = AttributeState.IN_ATTRIBUTE_VALUE_LIST;
}
break;
case ']':
if (state == AttributeState.IN_ATTRIBUTE_VALUE_LIST) {
state = AttributeState.IN_ATTRIBUTE_VALUE;
}
break;
case '{':
if (state == AttributeState.INIT) {
state = AttributeState.IN_LIST;
} else if (state == AttributeState.IN_LIST) {
state = AttributeState.IN_ATTRIBUTE_KEY;
} else {
throw new IOException("Error parsing attributes: unexpected token '{'");
}
break;
case '}':
if (state == AttributeState.IN_ATTRIBUTE_KEY || state == AttributeState.IN_ATTRIBUTE_VALUE) {
state = AttributeState.IN_LIST;
} else if (state == AttributeState.IN_LIST) {
state = AttributeState.INIT;
} else {
throw new IOException("Error parsing attributes: unexpected token '}'");
}
break;
}
}
}
public void updateAttributesNewTicketPage(IProgressMonitor monitor) throws TracException {
monitor.beginTask("Updating attributes", IProgressMonitor.UNKNOWN);
GetMethod method = connect(repositoryUrl + ITracClient.NEW_TICKET_URL);
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(method.getResponseBodyAsStream(),
ITracClient.CHARSET));
HtmlStreamTokenizer tokenizer = new HtmlStreamTokenizer(reader, null);
for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
if (monitor.isCanceled()) {
throw new OperationCanceledException();
}
if (token.getType() == Token.TAG) {
HtmlTag tag = (HtmlTag) token.getValue();
if (tag.getTagType() == HtmlTag.Type.SELECT) {
String name = tag.getAttribute("id");
if ("component".equals(name)) {
List<String> values = getOptionValues(tokenizer);
data.components = new ArrayList<TracComponent>(values.size());
for (String value : values) {
data.components.add(new TracComponent(value));
}
} else if ("milestone".equals(name)) {
List<String> values = getOptionValues(tokenizer);
data.milestones = new ArrayList<TracMilestone>(values.size());
for (String value : values) {
data.milestones.add(new TracMilestone(value));
}
} else if ("priority".equals(name)) {
List<String> values = getOptionValues(tokenizer);
data.priorities = new ArrayList<TracPriority>(values.size());
for (int i = 0; i < values.size(); i++) {
data.priorities.add(new TracPriority(values.get(i), i + 1));
}
} else if ("severity".equals(name)) {
List<String> values = getOptionValues(tokenizer);
data.severities = new ArrayList<TracSeverity>(values.size());
for (int i = 0; i < values.size(); i++) {
data.severities.add(new TracSeverity(values.get(i), i + 1));
}
} else if ("type".equals(name)) {
List<String> values = getOptionValues(tokenizer);
data.ticketTypes = new ArrayList<TracTicketType>(values.size());
for (int i = 0; i < values.size(); i++) {
data.ticketTypes.add(new TracTicketType(values.get(i), i + 1));
}
} else if ("version".equals(name)) {
List<String> values = getOptionValues(tokenizer);
data.versions = new ArrayList<TracVersion>(values.size());
for (String value : values) {
data.versions.add(new TracVersion(value));
}
}
}
}
}
addResolutionAndStatus();
} catch (IOException e) {
throw new TracException(e);
} catch (ParseException e) {
throw new TracException(e);
} finally {
method.releaseConnection();
}
}
private void addResolutionAndStatus() {
data.ticketResolutions = new ArrayList<TracTicketResolution>(5);
data.ticketResolutions.add(new TracTicketResolution("fixed", 1));
data.ticketResolutions.add(new TracTicketResolution("invalid", 2));
data.ticketResolutions.add(new TracTicketResolution("wontfix", 3));
data.ticketResolutions.add(new TracTicketResolution("duplicate", 4));
data.ticketResolutions.add(new TracTicketResolution("worksforme", 5));
data.ticketStatus = new ArrayList<TracTicketStatus>(4);
data.ticketStatus.add(new TracTicketStatus("new", 1));
data.ticketStatus.add(new TracTicketStatus("assigned", 2));
data.ticketStatus.add(new TracTicketStatus("reopened", 3));
data.ticketStatus.add(new TracTicketStatus("closed", 4));
}
private List<String> getOptionValues(HtmlStreamTokenizer tokenizer) throws IOException, ParseException {
List<String> values = new ArrayList<String>();
for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
if (token.getType() == Token.TAG) {
HtmlTag tag = (HtmlTag) token.getValue();
if (tag.getTagType() == HtmlTag.Type.OPTION && !tag.isEndTag()) {
String value = getText(tokenizer).trim();
if (value.length() > 0) {
values.add(value);
}
} else {
return values;
}
}
}
return values;
}
private String getText(HtmlStreamTokenizer tokenizer) throws IOException, ParseException {
StringBuffer sb = new StringBuffer();
for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
if (token.getType() == Token.TEXT) {
sb.append(token.toString());
} else if (token.getType() == Token.COMMENT) {
// ignore
} else {
break;
}
}
return HtmlStreamTokenizer.unescape(sb).toString();
}
/**
* Looks for a <code>strong</code> tag and returns the text enclosed by
* the tag.
*/
private String getStrongText(HtmlStreamTokenizer tokenizer) throws IOException, ParseException {
for (Token token = tokenizer.nextToken(); token.getType() != Token.EOF; token = tokenizer.nextToken()) {
if (token.getType() == Token.TAG && ((HtmlTag) token.getValue()).getTagType() == HtmlTag.Type.STRONG) {
return getText(tokenizer);
} else if (token.getType() == Token.COMMENT) {
// ignore
} else if (token.getType() == Token.TEXT) {
// ignore
} else {
break;
}
}
return "";
}
public byte[] getAttachmentData(int id, String filename) throws TracException {
throw new TracException("Unsupported operation");
}
public void putAttachmentData(int id, String name, String description, byte[] data) throws TracException {
throw new TracException("Unsupported operation");
}
public void deleteAttachment(int ticketId, String filename) throws TracException {
throw new TracException("Unsupported operation");
}
public int createTicket(TracTicket ticket) throws TracException {
throw new TracException("Unsupported operation");
}
public void updateTicket(TracTicket ticket, String comment) throws TracException {
throw new TracException("Unsupported operation");
}
public Set<Integer> getChangedTickets(Date since) throws TracException {
return null;
}
private interface AttributeFactory {
void addAttribute(String value);
}
}