public class Issue implements Identifiable, FluentStyle {

    private final PropertyStorage storage = new PropertyStorage();

    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");
    public final static Property<String> SUBJECT = new Property<>(String.class, "subject");
    public final static Property<Date> START_DATE = new Property<>(Date.class, "startDate");
    public final static Property<Date> DUE_DATE = new Property<>(Date.class, "dueDate");
    public final static Property<Date> CREATED_ON = new Property<>(Date.class, "createdOn");
    public final static Property<Date> UPDATED_ON = new Property<>(Date.class, "updatedOn");
    public final static Property<Integer> DONE_RATIO = new Property<>(Integer.class, "doneRatio");
    public final static Property<Integer> PARENT_ID = new Property<>(Integer.class, "parentId");
    public final static Property<Integer> PRIORITY_ID = new Property<>(Integer.class, "priorityId");
    public final static Property<Float> ESTIMATED_HOURS = new Property<>(Float.class, "estimatedHours");
    public final static Property<Float> SPENT_HOURS = new Property<>(Float.class, "spentHours");
    public final static Property<Integer> ASSIGNEE_ID = new Property<>(Integer.class, "assigneeId");
    public final static Property<String> ASSIGNEE_NAME = new Property<>(String.class, "assigneeName");

    /**
     * Some comment describing an issue update.
     */
    public final static Property<String> NOTES = new Property<String>(String.class, "notes");
    public final static Property<Boolean> PRIVATE_NOTES = new Property<>(Boolean.class, "notes");
    public final static Property<String> PRIORITY_TEXT = new Property<>(String.class, "priorityText");
    public final static Property<Integer> PROJECT_ID = new Property<>(Integer.class, "projectId");
    public final static Property<String> PROJECT_NAME = new Property<>(String.class, "projectName");
    public final static Property<Integer> AUTHOR_ID = new Property<>(Integer.class, "authorId");
    public final static Property<String> AUTHOR_NAME = new Property<>(String.class, "authorName");
    public final static Property<Tracker> TRACKER = new Property<>(Tracker.class, "tracker");
    public final static Property<String> DESCRIPTION = new Property<>(String.class, "description");
    public final static Property<Date> CLOSED_ON = new Property<>(Date.class, "closedOn");
    public final static Property<Integer> STATUS_ID = new Property<>(Integer.class, "statusId");
    public final static Property<String> STATUS_NAME = new Property<>(String.class, "statusName");
    public final static Property<Version> TARGET_VERSION = new Property<>(Version.class, "targetVersion");
    public final static Property<IssueCategory> ISSUE_CATEGORY = new Property<>(IssueCategory.class, "issueCategory");
    public final static Property<Boolean> PRIVATE_ISSUE = new Property<>(Boolean.class, "privateIssue");

    /**
     * can't have two custom fields with the same ID in the collection, that's why it is declared
     * as a Set, not a List.
     */
    public final static Property<Set<CustomField>> CUSTOM_FIELDS = (Property<Set<CustomField>>) new Property(Set.class, "customFields");
    public final static Property<Set<Journal>> JOURNALS = (Property<Set<Journal>>) new Property(Set.class, "journals");
    public final static Property<Set<IssueRelation>> RELATIONS = (Property<Set<IssueRelation>>) new Property(Set.class, "relations");
    public final static Property<Set<Attachment>> ATTACHMENTS = (Property<Set<Attachment>>) new Property(Set.class, "attachments");
    public final static Property<Set<Changeset>> CHANGESETS = (Property<Set<Changeset>>) new Property(Set.class, "changesets");
    public final static Property<Set<Watcher>> WATCHERS = (Property<Set<Watcher>>) new Property(Set.class, "watchers");
    public final static Property<Set<Issue>> CHILDREN = (Property<Set<Issue>>) new Property(Set.class, "children");

    private Transport transport;

    public Issue() {
        initCollections(storage);
    }

    public Issue(Transport transport) {
        this();
        setTransport(transport);
    }

    /**
     * Each Issue object must have project Id set in order for Redmine 3.x to accept it via REST API.
     */
    public Issue(Transport transport, int projectId) {
        this();
        this.transport = transport;
        setProjectId(projectId);
    }

    /**
     * @param projectId Each Issue object must have project Id set in order for Redmine 3.x to accept it via REST API.
     */
    public Issue(Transport transport, int projectId, String subject) {
        this();
        this.transport = transport;
        setSubject(subject);
        setProjectId(projectId);
    }

    private void initCollections(PropertyStorage storage) {
        storage.set(CUSTOM_FIELDS, new HashSet<>());
        storage.set(CHILDREN, new HashSet<>());
        storage.set(WATCHERS, new HashSet<>());
        storage.set(CHANGESETS, new HashSet<>());
        storage.set(ATTACHMENTS, new HashSet<>());
        storage.set(RELATIONS, new HashSet<>());
        storage.set(JOURNALS, new HashSet<>());
    }

    public Integer getProjectId() {
        return storage.get(PROJECT_ID);
    }

    public Issue setProjectId(Integer projectId) {
        storage.set(PROJECT_ID, projectId);
        return this;
    }

    public String getProjectName() {
        return storage.get(PROJECT_NAME);
    }


    public Issue setProjectName(String name) {
        storage.set(PROJECT_NAME, name);
        return this;
    }

    /**
     * @param id database ID.
     */
    public Issue setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    public Integer getDoneRatio() {
        return storage.get(DONE_RATIO);
    }

    public Issue setDoneRatio(Integer doneRatio) {
        storage.set(DONE_RATIO, doneRatio);
        return this;
    }

    public String getPriorityText() {
        return storage.get(PRIORITY_TEXT);
    }

    /**
     * @deprecated This method has no effect when creating issues on Redmine Server, so we might as well just delete it
     * in the future releases.
     */
    public void setPriorityText(String priority) {
        storage.set(PRIORITY_TEXT, priority);
    }

    /**
     * Redmine can be configured to allow group assignments for issues:
     * Configuration option: Settings -> Issue Tracking -> Allow issue assignment to groups
     *
     * <p>An assignee can be a user or a group</p>
     */
    public Integer getAssigneeId() {
        return storage.get(ASSIGNEE_ID);
    }

    public Issue setAssigneeId(Integer assigneeId) {
        storage.set(ASSIGNEE_ID, assigneeId);
        return this;
    }

    public String getAssigneeName() {
        return storage.get(ASSIGNEE_NAME);
    }

    public Issue setAssigneeName(String assigneeName) {
        storage.set(ASSIGNEE_NAME, assigneeName);
        return this;
    }

    public Float getEstimatedHours() {
        return storage.get(ESTIMATED_HOURS);
    }

    public Issue setEstimatedHours(Float estimatedTime) {
        storage.set(ESTIMATED_HOURS, estimatedTime);
        return this;
    }

    public Float getSpentHours() {
        return storage.get(SPENT_HOURS);
    }

    public Issue setSpentHours(Float spentHours) {
        storage.set(SPENT_HOURS, spentHours);
        return this;
    }

  /**
     * Parent Issue ID, or NULL for issues without a parent.
     *
     * @return NULL, if there's no parent
     */
    public Integer getParentId() {
        return storage.get(PARENT_ID);
    }

    public Issue setParentId(Integer parentId) {
        storage.set(PARENT_ID, parentId);
        return this;
    }

    /**
     * @return database id for this object. can be NULL for Issues not added to Redmine yet
     */
    @Override
    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    public String getSubject() {
        return storage.get(SUBJECT);
    }

    public Issue setSubject(String subject) {
        storage.set(SUBJECT, subject);
        return this;
    }

    public Date getStartDate() {
        return storage.get(START_DATE);
    }

    public Issue setStartDate(Date startDate) {
        storage.set(START_DATE, startDate);
        return this;
    }

    public Date getDueDate() {
        return storage.get(DUE_DATE);
    }

    public Issue setDueDate(Date dueDate) {
        storage.set(DUE_DATE, dueDate);
        return this;
    }

    public Integer getAuthorId() {
        return storage.get(AUTHOR_ID);
    }

    /**
     * Marking as "deprecated": according to Redmine REST API docs
     * https://www.redmine.org/projects/redmine/wiki/Rest_Issues#Creating-an-issue , this parameter is not used
     * when creating issues (January 2020).
     */
    @Deprecated
    public Issue setAuthorId(Integer id) {
        storage.set(AUTHOR_ID, id);
        return this;
    }

    public String getAuthorName() {
        return storage.get(AUTHOR_NAME);
    }

    /**
     * Marking as "deprecated": according to Redmine REST API docs
     * https://www.redmine.org/projects/redmine/wiki/Rest_Issues#Creating-an-issue , this parameter is not used
     * when creating issues (January 2020).
     */
    @Deprecated
    public Issue setAuthorName(String name) {
        storage.set(AUTHOR_NAME, name);
        return this;
    }

    public Tracker getTracker() {
        return storage.get(TRACKER);
    }

    public Issue setTracker(Tracker tracker) {
        storage.set(TRACKER, tracker);
        return this;
    }

    public String getDescription() {
        return storage.get(DESCRIPTION);
    }

    public Issue setDescription(String description) {
        storage.set(DESCRIPTION, description);
        return this;
    }

    public Date getCreatedOn() {
        return storage.get(CREATED_ON);
    }

    public Issue setCreatedOn(Date createdOn) {
        storage.set(CREATED_ON, createdOn);
        return this;
    }

    public Date getUpdatedOn() {
        return storage.get(UPDATED_ON);
    }

    public Issue setUpdatedOn(Date updatedOn) {
        storage.set(UPDATED_ON, updatedOn);
        return this;
    }

    public Date getClosedOn() {
        return storage.get(CLOSED_ON);
    }

    public Issue setClosedOn(Date closedOn) {
        storage.set(CLOSED_ON, closedOn);
        return this;
    }

    public Integer getStatusId() {
        return storage.get(STATUS_ID);
    }

    public Issue setStatusId(Integer statusId) {
        storage.set(STATUS_ID, statusId);
        return this;
    }

    public String getStatusName() {
        return storage.get(STATUS_NAME);
    }

    public Issue setStatusName(String statusName) {
        storage.set(STATUS_NAME, statusName);
        return this;
    }

    /**
     * @return unmodifiable collection of Custom Field objects. the collection may be empty, but it is never NULL.
     */
    public Collection<CustomField> getCustomFields() {
        return Collections.unmodifiableCollection(storage.get(CUSTOM_FIELDS));
    }

    public Issue clearCustomFields() {
        storage.set(CUSTOM_FIELDS, new HashSet<>());
        return this;
    }

    /**
     * NOTE: The custom field(s) <strong>must have correct database ID set</strong> to be saved to Redmine.
     * This is Redmine REST API's requirement.
     */
    public Issue addCustomFields(Collection<CustomField> customFields) {
        storage.get(CUSTOM_FIELDS).addAll(customFields);
        return this;
    }

    /**
     * If there is a custom field with the same ID already present in the Issue,
     * the new field replaces the old one.
     *
     * @param customField the field to add to the issue.
     */
    public Issue addCustomField(CustomField customField) {
        storage.get(CUSTOM_FIELDS).add(customField);
        return this;
    }

    @Deprecated
    /**
     * This method should not be used by clients. "notes" only makes sense when creating/updating an issue - that is the
     * string value added along with the update.
     * <p>
     * use {@link #getJournals()} if you want to access previously saved notes. feel free to submit an enhancement
     * request to Redmine developers if you think this "notes - journals" separation looks weird...
     */
    public String getNotes() {
        return storage.get(NOTES);
    }

    /**
     * @param notes Some comment describing the issue update
     */
    public Issue setNotes(String notes) {
        storage.set(NOTES, notes);
        return this;
    }

    public boolean isPrivateNotes() {
        return storage.get(PRIVATE_NOTES);
    }

    /**
     * @param privateNotes mark note as private
     */
    public Issue setPrivateNotes(boolean privateNotes) {
        storage.set(PRIVATE_NOTES, privateNotes);
        return this;
    }

    /**
     * Don't forget to use Include.journals flag when loading issue from Redmine server:
     * <pre>
     *     Issue issue = issueManager.getIssueById(3205, Include.journals);
     * </pre>
     * @return unmodifiable collection of Journal entries or empty collection if no objects found. Never NULL.
     * @see com.taskadapter.redmineapi.Include#journals
     */
    public Collection<Journal> getJournals() {
        return Collections.unmodifiableCollection(storage.get(JOURNALS));
    }

    /**
     * Issue journals are created automatically when you update existing issues.
     * journal entries are essentially log records for changes you make.
     * you cannot just add log records without making actual changes.
     * this API method is misleading and it should only be used internally by Redmine Json parser
     * when parsing response from server. we should hide it from public.
     *
     * TODO hide this method. https://github.com/taskadapter/redmine-java-api/issues/199
     */
    public void addJournals(Collection<Journal> journals) {
        storage.get(JOURNALS).addAll(journals);
    }

    /**
     * Don't forget to use Include.changesets flag when loading issue from Redmine server:
     * <pre>
     *     Issue issue = issueManager.getIssueById(3205, Include.changesets);
     * </pre>
     * @return unmodifiable collection of entries or empty collection if no objects found.
     * @see com.taskadapter.redmineapi.Include#changesets
     */
    public Collection<Changeset> getChangesets() {
        return Collections.unmodifiableCollection(storage.get(CHANGESETS));
    }

    public Issue addChangesets(Collection<Changeset> changesets) {
        storage.get(CHANGESETS).addAll(changesets);
        return this;
    }

    /**
     * Don't forget to use Include.watchers flag when loading issue from Redmine server:
     * <pre>
     *     Issue issue = issueManager.getIssueById(3205, Include.watchers);
     * </pre>
     * @return unmodifiable collection of entries or empty collection if no objects found.
     * @see com.taskadapter.redmineapi.Include#watchers
     */
    public Collection<Watcher> getWatchers() {
        return Collections.unmodifiableCollection(storage.get(WATCHERS));
    }

    public Issue addWatchers(Collection<Watcher> watchers) {
        storage.get(WATCHERS).addAll(watchers);
        return this;
    }

    /**
      * Don't forget to use Include.children flag when loading issue from Redmine server:
      * <pre>
      *     Issue issue = issueManager.getIssueById(3205, Include.children);
      * </pre>
      * @return Collection of entries or empty collection if no objects found.
      * @see com.taskadapter.redmineapi.Include#children
      */
    public Collection<Issue> getChildren() {
        return Collections.unmodifiableCollection(storage.get(CHILDREN));
    }

    public Issue addChildren(Collection<Issue> children) {
        storage.get(CHILDREN).addAll(children);
        return this;
    }

    /**
     * Issues are considered equal if their IDs are equal. what about two issues with null ids?
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Issue issue = (Issue) o;

        if (getId() != null ? !getId().equals(issue.getId()) : issue.getId() != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    /**
     * @return the custom field with given Id or NULL if the field is not found
     */
    public CustomField getCustomFieldById(int customFieldId) {
        for (CustomField customField : storage.get(CUSTOM_FIELDS)) {
            if (customFieldId == customField.getId()) {
                return customField;
            }
        }
        return null;
    }

    /**
     * @return the custom field with given name or NULL if the field is not found
     */
    public CustomField getCustomFieldByName(String customFieldName) {
        for (CustomField customField : storage.get(CUSTOM_FIELDS)) {
            if (customFieldName.equals(customField.getName())) {
                return customField;
            }
        }
        return null;
    }


    @Override
    public String toString() {
        return "Issue [id=" + getId() + ", subject=" + getSubject() + "]";
    }

    /**
     * Relations are only loaded if you include Include.relations when loading the Issue.
     * <pre>
     *     Issue issue = issueManager.getIssueById(3205, Include.relations);
     * </pre>
     * <p>Since the returned collection is not modifiable, you need to use addRelations() method
     * if you want to add elements, e.g.:
     * <pre>
     *     issue.addRelations(Collections.singletonList(relation));
     * </pre>
     * @return unmodifiable collection of Relations or EMPTY collection if none found. Never returns NULL.
     * @see com.taskadapter.redmineapi.Include#relations
     */
    public Collection<IssueRelation> getRelations() {
        return Collections.unmodifiableCollection(storage.get(RELATIONS));
    }

    public Issue addRelations(Collection<IssueRelation> collection) {
        storage.get(RELATIONS).addAll(collection);
        return this;
    }

    public Integer getPriorityId() {
        return storage.get(PRIORITY_ID);
    }

    public Issue setPriorityId(Integer priorityId) {
        storage.set(PRIORITY_ID, priorityId);
        return this;
    }

    public Version getTargetVersion() {
        return storage.get(TARGET_VERSION);
    }

    /**
     * Don't forget to use <i>Include.attachments</i> flag when loading issue from Redmine server:
     * <pre>
     *     Issue issue = issueManager.getIssueById(3205, Include.attachments);
     * </pre>
     * @return unmodifiable collection of entries or empty collection if no objects found.
     * @see com.taskadapter.redmineapi.Include#attachments
     */
    public Collection<Attachment> getAttachments() {
        return Collections.unmodifiableCollection(storage.get(ATTACHMENTS));
    }

    public Issue addAttachments(Collection<Attachment> collection) {
        storage.get(ATTACHMENTS).addAll(collection);
        return this;
    }

    public Issue addAttachment(Attachment attachment) {
        storage.get(ATTACHMENTS).add(attachment);
        return this;
    }

    public Issue setTargetVersion(Version version) {
        storage.set(TARGET_VERSION, version);
        return this;
    }

    public IssueCategory getCategory() {
        return storage.get(ISSUE_CATEGORY);
    }

    public Issue setCategory(IssueCategory category) {
        storage.set(ISSUE_CATEGORY, category);
        return this;
    }

    /**
     * Default value is not determines. it's up to the server what it thinks the default value is if not set.
     */
    public boolean isPrivateIssue() {
        return storage.get(PRIVATE_ISSUE);
    }

    public Issue setPrivateIssue(boolean privateIssue) {
        storage.set(PRIVATE_ISSUE, privateIssue);
        return this;
    }

    public PropertyStorage getStorage() {
        return storage;
    }

    /**
     * @return the newly created Issue.
     *
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws NotFoundException       the required project is not found
     * @throws RedmineException
     */
    public Issue create(RequestParam... params) throws RedmineException {
        RequestParam[] enrichParams = Arrays.copyOf(params, params.length + 1);
        enrichParams[params.length] = new RequestParam("include",
                Include.attachments.toString());
        return transport.addObject(this, enrichParams);
    }

    public void update(RequestParam... params) throws RedmineException {
        transport.updateObject(this, params);
    }

    public void delete() throws RedmineException {
        transport.deleteObject(Issue.class, Integer.toString(this.getId()));
    }

    public void addWatcher(int watcherId) throws RedmineException {
        transport.addWatcherToIssue(watcherId, getId());
    }

    public void deleteWatcher(int watcherId) throws RedmineException {
        transport.deleteChildId(Issue.class, Integer.toString(getId()), new Watcher(), watcherId);
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
        PropertyStorageUtil.updateCollections(storage, transport);
    }
} public class IssueCategory implements Identifiable, Serializable, FluentStyle {
	private static final long serialVersionUID = -109010410391968475L;

    private final PropertyStorage storage = new PropertyStorage();

    /**
     * database numeric ID.
     */
    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");
    public final static Property<String> NAME = new Property<>(String.class, "name");
    public final static Property<Integer> PROJECT_ID = new Property<>(Integer.class, "projectId");
    public final static Property<Integer> ASSIGNEE_ID = new Property<>(Integer.class, "assigneeId");
    public final static Property<String> ASSIGNEE_NAME = new Property<>(String.class, "assigneeName");
    private Transport transport;

    public IssueCategory(Transport transport) {
        setTransport(transport);
    }

    public IssueCategory(Transport transport, Integer projectId, String name) {
        setTransport(transport);
        setProjectId(projectId);
        setName(name);
    }

    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    public String getName() {
        return storage.get(NAME);
    }

    public IssueCategory setName(String name) {
        storage.set(NAME, name);
        return this;
    }

    /**
     * @param id database Id of the issue category.
     */
    public IssueCategory setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    public Integer getProjectId() {
        return storage.get(PROJECT_ID);
    }

    public IssueCategory setProjectId(Integer projectId) {
        storage.set(PROJECT_ID, projectId);
        return this;
    }

    /**
     * Redmine can be configured to allow group assignments for issues:
     * Configuration option: Settings -> Issue Tracking -> Allow issue assignment to groups
     *
     * <p>An assignee can be a user or a group</p>
     */
    public Integer getAssigneeId() {
        return storage.get(ASSIGNEE_ID);
    }

    public IssueCategory setAssigneeId(Integer assigneeId) {
        storage.set(ASSIGNEE_ID, assigneeId);
        return this;
    }

    public String getAssigneeName() {
        return storage.get(ASSIGNEE_NAME);
    }

    public void setAssigneeName(String assigneeName) {
        storage.set(ASSIGNEE_NAME, assigneeName);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        IssueCategory that = (IssueCategory) o;

        if (getId() != null ? !getId().equals(that.getId()) : that.getId() != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    @Override
    public String toString() {
        return "IssueCategory{" +
                "id=" + getId() +
                ", name='" + getName() + '\'' +
                ", projectId=" + getProjectId() +
                ", assigneeId=" + getAssigneeId() +
                ", assigneeName='" + getAssigneeName() + '\'' +
                '}';
    }

    public PropertyStorage getStorage() {
        return storage;
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
    }

    /**
     * creates a new {@link IssueCategory} for the {@link Project} contained. <br>
     * Pre-condition: the attribute {@link Project} for the {@link IssueCategory} must
     * not be null!
     *
     * @return the new {@link IssueCategory} created by Redmine
     * @throws IllegalArgumentException       thrown in case the category does not contain a project.
     * @throws RedmineAuthenticationException thrown in case something went wrong while trying to login
     * @throws RedmineException               thrown in case something went wrong in Redmine
     * @throws NotFoundException              thrown in case an object can not be found
     */
    public IssueCategory create() throws RedmineException {
        if (getProjectId() == null) {
            throw new IllegalArgumentException(
                    "IssueCategory must contain projectId");
        }

        return transport.addChildEntry(Project.class, getProjectId().toString(), this);
    }

    public void delete() throws RedmineException {
        transport.deleteObject(IssueCategory.class, Integer.toString(this.getId()));
    }
} public class IssuePriority {
    /**
     * database ID.
     */
    private Integer id;
    private String name;
    private boolean isDefault;

    /**
     * @param id database ID.
     */
    public IssuePriority setId(Integer id) {
        this.id = id;
        return this;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public IssuePriority setName(String name) {
        this.name = name;
        return this;
    }

    public boolean isDefault() {
        return isDefault;
    }

    public IssuePriority setDefault(boolean isDefault) {
        this.isDefault = isDefault;
        return this;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        IssuePriority that = (IssuePriority) o;

        if (id != null ? !id.equals(that.id) : that.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }

    @Override
    public String toString() {
        return "Priority [id=" + id + ", name=" + name + ", isDefault="
                + isDefault + "]";
    }

} public class IssueRelation implements Identifiable, FluentStyle {
    private Transport transport;

    public enum TYPE {
        precedes
    }

    /*
     GET /relations/1819.xml
     Response:

     <?xml version="1.0" encoding="UTF-8"?>
     <relation>
       <id>1819</id>
       <issue_id>8470</issue_id>
       <issue_to_id>8469</issue_to_id>
       <relation_type>relates</relation_type>
       <delay/>
     </relation>
     */

    private final PropertyStorage storage = new PropertyStorage();

    /**
     * database numeric Id
     */
    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");
    public final static Property<Integer> ISSUE_ID = new Property<>(Integer.class, "issueId");
    public final static Property<List<Integer>> ISSUE_TO_ID = new Property(List.class, "issueToId");

    public final static Property<String> RELATION_TYPE = new Property<>(String.class, "relationType");
    public final static Property<Integer> DELAY = new Property<>(Integer.class, "delay");

    private IssueRelation() {
        storage.set(ISSUE_TO_ID, new ArrayList<>());
    }

    public IssueRelation(Transport transport) {
        this();
        setTransport(transport);
    }

    public IssueRelation(Transport transport, Integer issueId, Integer issueToId, String type) {
        this();
        setTransport(transport);
        setIssueId(issueId);
        addIssueToId(issueToId);
        setType(type);
    }

    public IssueRelation setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    @Override
    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    public Integer getIssueId() {
        return storage.get(ISSUE_ID);
    }

    public IssueRelation setIssueId(Integer issueId) {
        storage.set(ISSUE_ID, issueId);
        return this;
    }

    public Integer getIssueToId() {
        return storage.get(ISSUE_TO_ID)
                .stream()
                .findFirst().orElse(null);
    }

    public IssueRelation addIssueToId(Integer issueToId) {
        storage.get(ISSUE_TO_ID).add(issueToId);
        return this;
    }

    public Integer getDelay() {
        return storage.get(DELAY);
    }

    public IssueRelation setDelay(Integer delay) {
        storage.set(DELAY, delay);
        return this;
    }

    public String getType() {
        return storage.get(RELATION_TYPE);
    }

    public IssueRelation setType(String type) {
        storage.set(RELATION_TYPE, type);
        return this;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        IssueRelation that = (IssueRelation) o;

        if (getId() != null ? !getId().equals(that.getId()) : that.getId() != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    @Override
    public String toString() {
        return "IssueRelation [getId()=" + getId() + ", issueId=" + getIssueId()
                + ", issueToId=" + getIssueToId() + ", type=" + getType() + ", delay="
                + getDelay() + "]";
    }

    public PropertyStorage getStorage() {
        return storage;
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
    }

    /**
     * Each relation must have issueId, issueToId and type set.
     */
    public IssueRelation create() throws RedmineException {
        return transport.addChildEntry(Issue.class, getIssueId().toString(), this);
    }

    public void delete() throws RedmineException {
        transport.deleteObject(IssueRelation.class, Integer.toString(getId()));
    }

} public class IssueStatus implements Identifiable, Serializable {
	private static final long serialVersionUID = -2221390098554222099L;

	private Integer id;
    private String name;
    private boolean defaultStatus = false;
    private boolean closed = false;

    public IssueStatus setId(Integer id) {
        this.id = id;
        return this;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public IssueStatus setName(String name) {
        this.name = name;
        return this;
    }

    public boolean isDefaultStatus() {
        return defaultStatus;
    }

    public void setDefaultStatus(boolean defaultStatus) {
        this.defaultStatus = defaultStatus;
    }

    public boolean isClosed() {
        return closed;
    }

    public void setClosed(boolean closed) {
        this.closed = closed;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        IssueStatus that = (IssueStatus) o;

        if (id != null ? !id.equals(that.id) : that.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }

    @Override
    public String toString() {
        return "Status [id=" + id + ", name=" + name + ", isDefault=" + defaultStatus + ", closed=" + closed + "]";
    }

} public class Project implements Identifiable, Serializable, FluentStyle {
	private static final long serialVersionUID = 4529305420978716446L;

    private final PropertyStorage storage = new PropertyStorage();

    /**
     * database ID
     */
    public final static Property<Integer> DATABASE_ID = new Property<>(Integer.class, "id");

    /**
     * String "identifier" (human-readable name without spaces and other extra stuff)
     */
    public final static Property<String> STRING_IDENTIFIER = new Property<>(String.class, "identifier");

    /**
     * Can contain any symbols
     */
    public final static Property<String> NAME = new Property<>(String.class, "name");
    public final static Property<String> DESCRIPTION = new Property<>(String.class, "description");
    public final static Property<String> HOMEPAGE = new Property<>(String.class, "homepage");
    public final static Property<Date> CREATED_ON = new Property<>(Date.class, "createdOn");
    public final static Property<Date> UPDATED_ON = new Property<>(Date.class, "updatedOn");

    /**
     * This is the *database ID*, not a String-based key.
     */
    public final static Property<Integer> PARENT_DATABASE_ID = new Property<>(Integer.class, "parentId");
    public final static Property<Integer> STATUS = new Property<>(Integer.class, "status");
    public final static Property<Boolean> PUBLIC = new Property<>(Boolean.class, "public");
    public final static Property<Boolean> INHERIT_MEMBERS = new Property<>(Boolean.class, "inheritMembers");
    public final static Property<Set<CustomField>> CUSTOM_FIELDS = (Property<Set<CustomField>>) new Property(Set.class, "customFields");

    /**
     * Trackers available for this project
     */
    public final static Property<Set<Tracker>> TRACKERS = (Property<Set<Tracker>>) new Property(Set.class, "trackers");
    private Transport transport;

    public Project(Transport transport) {
        this.transport = transport;
        storage.set(CUSTOM_FIELDS, new HashSet<>());
    }

    public Project(Transport transport, String name, String key) {
        this(transport);
        setName(name);
        setIdentifier(key);
    }

    public Project setId(Integer id) {
        storage.set(DATABASE_ID, id);
        return this;
    }

    public String getHomepage() {
        return storage.get(HOMEPAGE);
    }

    public Project setHomepage(String homepage) {
        storage.set(HOMEPAGE, homepage);
        return this;
    }

    /**
     * @return project's string "key" (not a numeric database id!). Example: "project_ABC"
     */
    public String getIdentifier() {
        return storage.get(STRING_IDENTIFIER);
    }

    public Project setIdentifier(String identifier) {
        storage.set(STRING_IDENTIFIER, identifier);
        return this;
    }

    /**
     * @return numeric database ID
     */
    @Override
    public Integer getId() {
        return storage.get(DATABASE_ID);
    }

    /**
     * @return project name
     */
    public String getName() {
        return storage.get(NAME);
    }

    /**
     * @param name the project name
     */
    public Project setName(String name) {
        storage.set(NAME, name);
        return this;
    }

    /**
     * @return Trackers allowed in this project (e.g.: Bug, Feature, Support, Task, ...)
     */
    public Collection<Tracker> getTrackers() {
    	if (!storage.isPropertySet(TRACKERS)) //checks because trackers storage is not created for new projects
    		return Collections.unmodifiableCollection(new HashSet<Tracker>());
        return Collections.unmodifiableCollection(storage.get(TRACKERS));
    }

    /**
     * Adds the specified trackers to this project.
     * If this project is created or updated on the redmine server, 
     * each tracker id must be a valid tracker on the server.
     */
    public Project addTrackers(Collection<Tracker> trackers) {
    	if (!storage.isPropertySet(TRACKERS)) //checks because trackers storage is not created for new projects
    		storage.set(TRACKERS, new HashSet<>());
        storage.get(TRACKERS).addAll(trackers);
        return this;
    }

    /**
     * Removes all of the trackers from this project.
     */
    public Project clearTrackers() {
    	storage.set(TRACKERS, new HashSet<>());
    	return this;
    }

    public Tracker getTrackerByName(String trackerName) {
        for (Tracker t : getTrackers()) {
            if (t.getName().equals(trackerName)) return t;
        }
        return null;
    }

    @Override
    public String toString() {
        return "Project{" +
                "id=" + getId() +
                ", identifier='" + getIdentifier() + '\'' +
                ", name='" + getName() + '\'' +
                '}';
    }

    public String getDescription() {
        return storage.get(DESCRIPTION);
    }

    public Project setDescription(String description) {
        storage.set(DESCRIPTION, description);
        return this;
    }

    public Date getCreatedOn() {
        return storage.get(CREATED_ON);
    }

    public Project setCreatedOn(Date createdOn) {
        storage.set(CREATED_ON, createdOn);
        return this;
    }

    public Date getUpdatedOn() {
        return storage.get(UPDATED_ON);
    }

    public Project setUpdatedOn(Date updatedOn) {
        storage.set(UPDATED_ON, updatedOn);
        return this;
    }

    /**
     * Redmine's REST API "get project" operation does NOT return the 
     * parent project ID in redmine 1.1.2 (and maybe earlier). Which means 
     * calling getParentId() of the project loaded from Redmine server will
     * return <strong>NULL</strong> with that redmine. This bug was fixed in redmine 1.2.1.
     * See bug http://www.redmine.org/issues/8229
     * 
     *
     * @return the parent project Id if it was set programmatically or NULL (!!!) if the project was loaded from the server.
     */
    public Integer getParentId() {
        return storage.get(PARENT_DATABASE_ID);
    }

    public Project setParentId(Integer parentId) {
        storage.set(PARENT_DATABASE_ID, parentId);
        return this;
    }

    /**
     * Returns the project status. This number can theoretically be different for different Redmine versions,
     * But the **current Redmine version in 2018** defines these numbers as:
     * <ul>
     *   <li>1: status active</li>
     *   <li>5: status closed</li>
     *   <li>9: status archived</li>
     * </ul>
     * 
     * <p>see http://www.redmine.org/projects/redmine/repository/entry/trunk/app/models/project.rb
     *
     * @return possibly Redmine-version-specific number that represents project status (active/closed/archived)
     * @since Redmine REST API 2.5.0
     */
    public Integer getStatus() {
    	return storage.get(STATUS);
    }
    /**
     * Sets the project status (Note that this will not take effect when updating a project as
     * the **current Redmine version in 2018** does not allow reopen, close or archive projects,
     * see https://www.redmine.org/issues/13725)
     */
    public Project setStatus(Integer status) {
    	storage.set(STATUS, status);
    	return this;
    }

    /**
     *
     * @return true if the project is public, false if the project is private. 
     * Returns <code>null</code> if the project visibility was not specified or if the project was just retrieved from server.
     *
     * @since Redmine 2.6.0. see http://www.redmine.org/issues/17628 . this property is for writing only before Redmine 2.6.0.
     * The value is not returned by older Redmine versions.
     */
    @Deprecated
    public Boolean getProjectPublic() {
        return storage.get(PUBLIC);
    }
    
    public Project setInheritMembers(Boolean inheritMembers) {
        storage.set(INHERIT_MEMBERS, inheritMembers);
        return this;
    }

    public Boolean getInheritMembers() {
        return storage.get(INHERIT_MEMBERS);
    }

    public Project setProjectPublic(Boolean projectPublic) {
        storage.set(PUBLIC, projectPublic);
        return this;
    }

    public Collection<CustomField> getCustomFields() {
        return storage.get(CUSTOM_FIELDS);
    }

    public Project addCustomFields(Collection<CustomField> customFields) {
        storage.get(CUSTOM_FIELDS).addAll(customFields);
        return this;
    }

    public CustomField getCustomFieldById(int customFieldId) {
        for (CustomField customField : getCustomFields()) {
            if (customFieldId == customField.getId()) {
                return customField;
            }
        }
        return null;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Project project = (Project) o;

        if (getId() != null ? !getId().equals(project.getId()) : project.getId() != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return getId() != null ? getId().hashCode() : 0;
    }

    public PropertyStorage getStorage() {
        return storage;
    }

    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
        PropertyStorageUtil.updateCollections(storage, transport);
    }

    /**
     * Sample usage:
     *
     * <pre>
     * {@code
     * 	Project project = new Project(transport);
     * 	Long timeStamp = Calendar.getInstance().getTimeInMillis();
     * 	String key = "projkey" + timeStamp;
     * 	String name = &quot;project number &quot; + timeStamp;
     * 	String description = &quot;some description for the project&quot;;
     * 	project.setIdentifier(key)
     * 	  .setName(name)
     * 	  .setDescription(description)
     * 	  .create();
     * }
     * </pre>
     * <p> 
     * Note: if {@code project} trackers have not been set with {@link Project#addTrackers} 
     * and if they have been cleared with {@link Project#clearTrackers},
     * the created project will get the server default trackers (if any).
     * Otherwise, the {@code Project} trackers will override the server default settings. 
     * </p>
     *
     * @return the newly created Project object.
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws RedmineException
     */
    public Project create() throws RedmineException {
        return transport.addObject(this, new RequestParam("include",
                "trackers"));
    }

    public void update() throws RedmineException {
        transport.updateObject(this);
    }

    /**
     * The project object must have projectKey set (String-based identifier like "project-ABC",
     * NOT a database numeric ID!)
     *
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws NotFoundException       if the project with this key is not found
     */
    public void delete() throws RedmineException {
        transport.deleteObject(Project.class, getIdentifier());
    }
} public class SavedQuery {
    private Integer id;

    private String name;
    private boolean publicQuery;
    private Integer projectId;

    public SavedQuery setId(Integer id) {
        this.id = id;
        return this;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        SavedQuery that = (SavedQuery) o;

        if (id != null ? !id.equals(that.id) : that.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public SavedQuery setName(String name) {
        this.name = name;
        return this;
    }

    public boolean isPublicQuery() {
        return publicQuery;
    }

    public SavedQuery setPublicQuery(boolean isPublic) {
        this.publicQuery = isPublic;
        return this;
    }

    public Integer getProjectId() {
        return projectId;
    }

    public SavedQuery setProjectId(Integer value) {
        this.projectId = value;
        return this;
    }

    @Override
    public String toString() {
        return "SavedQuery [id=" + id + ", name=" + name + ", publicQuery="
                + publicQuery + ", projectId=" + projectId + "]";
    }

} public class Tracker implements Identifiable, Serializable {

    /**
     * database ID.
     */
    private Integer id;
    private String name;

    /**
     * @param id database ID.
     */
    public Tracker setId(Integer id) {
        this.id = id;
        return this;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Tracker setName(String name) {
        this.name = name;
        return this;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Tracker tracker = (Tracker) o;

        if (id != null ? !id.equals(tracker.id) : tracker.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }
} public class Watcher {

    private Integer id;
    private String name;

    /**
     * @param id database Id
     */
    public Watcher setId(Integer id) {
        this.id = id;
        return this;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Watcher watcher = (Watcher) o;

        if (id != null ? !id.equals(watcher.id) : watcher.id != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }

    @Override
    public String toString() {
        return "Watcher{" +
                "id=" + id +
                ", name='" + name + '\'' +
                '}';
    }

} public final class DirectObjectsSearcher {

    public static <T> ResultsWrapper<T> getObjectsListNoPaging(Transport transport, Map<String, String> map, Class<T> classRef) throws RedmineException {
        final Set<RequestParam> set = map.entrySet()
                .stream()
                .map(param -> new RequestParam(param.getKey(), param.getValue()))
                .collect(Collectors.toSet());
        return transport.getObjectsListNoPaging(classRef, set);
    }
} public class Joiner {
    // TODO add unit tests
    public static String join(String delimToUse, Include... include) {
        String delim = "";
        StringBuilder sb = new StringBuilder();
        for (Include i : include) {
            sb.append(delim).append(i);
            delim = delimToUse;
        }
        return sb.toString();
    }

} public class RequestParam {

    private final String name;
    private final String value;

    public RequestParam(final String name, final String value) {
        this.name = Objects.requireNonNull(name, "Name may not be null");
        this.value = Objects.requireNonNull(value, "Value may not be null");
    }

    public String getName() {
        return name;
    }

    public String getValue() {
        return value;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        RequestParam that = (RequestParam) o;
        return name.equals(that.name) &&
                value.equals(that.value);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, value);
    }

    @Override
    public String toString() {
        return "RequestParam{" +
                "name='" + name + '\'' +
                ", value='" + value + '\'' +
                '}';
    }
} public class Transport {
	private static final Map<Class<?>, EntityConfig<?>> OBJECT_CONFIGS = new HashMap<>();
	private static final String CONTENT_TYPE = "application/json; charset=utf-8";
	private static final int DEFAULT_OBJECTS_PER_PAGE = 25;
	private static final String KEY_TOTAL_COUNT = "total_count";
	private static final String KEY_LIMIT = "limit";
	private static final String KEY_OFFSET = "offset";

	private final Logger logger = LoggerFactory.getLogger(RedmineManager.class);
	private SimpleCommunicator<String> communicator;
	private Communicator<BasicHttpResponse> errorCheckingCommunicator;
	private Communicator<HttpResponse> authenticator;

    private String onBehalfOfUser = null;

    static {
		OBJECT_CONFIGS.put(
				Project.class,
				config("project", "projects",
						RedmineJSONBuilder::writeProject,
						RedmineJSONParser::parseProject));
		OBJECT_CONFIGS.put(
				Issue.class,
				config("issue", "issues", RedmineJSONBuilder::writeIssue,
						RedmineJSONParser::parseIssue));
		OBJECT_CONFIGS.put(
				User.class,
				config("user", "users", RedmineJSONBuilder::writeUser,
						RedmineJSONParser::parseUser));
		OBJECT_CONFIGS.put(
				Group.class,
				config("group", "groups", RedmineJSONBuilder::writeGroup,
						RedmineJSONParser::parseGroup));
		OBJECT_CONFIGS.put(
				IssueCategory.class,
				config("issue_category", "issue_categories",
						RedmineJSONBuilder::writeCategory,
						RedmineJSONParser::parseCategory));
		OBJECT_CONFIGS.put(
				Version.class,
				config("version", "versions",
						RedmineJSONBuilder::writeVersion,
						RedmineJSONParser::parseVersion));
		OBJECT_CONFIGS.put(
				TimeEntry.class,
				config("time_entry", "time_entries",
						RedmineJSONBuilder::writeTimeEntry,
						RedmineJSONParser::parseTimeEntry));
		OBJECT_CONFIGS.put(News.class,
				config("news", "news", null, RedmineJSONParser::parseNews));
		OBJECT_CONFIGS.put(
				IssueRelation.class,
				config("relation", "relations",
						RedmineJSONBuilder::writeRelation,
						RedmineJSONParser::parseRelation));
		OBJECT_CONFIGS.put(
				Tracker.class,
				config("tracker", "trackers", null,
						RedmineJSONParser::parseTracker));
		OBJECT_CONFIGS.put(
				IssueStatus.class,
				config("status", "issue_statuses", null,
						RedmineJSONParser::parseStatus));
		OBJECT_CONFIGS
				.put(SavedQuery.class,
						config("query", "queries", null,
								RedmineJSONParser::parseSavedQuery));
		OBJECT_CONFIGS.put(Role.class,
				config("role", "roles", null, RedmineJSONParser::parseRole));
		OBJECT_CONFIGS.put(
				Membership.class,
				config("membership", "memberships",
						RedmineJSONBuilder::writeMembership,
						RedmineJSONParser::parseMembership));
        OBJECT_CONFIGS.put(
                IssuePriority.class,
                config("issue_priority", "issue_priorities", null,
                        RedmineJSONParser::parseIssuePriority));
        OBJECT_CONFIGS.put(
                TimeEntryActivity.class,
                config("time_entry_activity", "time_entry_activities", null,
                        RedmineJSONParser::parseTimeEntryActivity));

        OBJECT_CONFIGS.put(
                Watcher.class,
                config("watcher", "watchers", null,
                        RedmineJSONParser::parseWatcher));

        OBJECT_CONFIGS.put(
                WikiPage.class,
                config("wiki_page", "wiki_pages", null, RedmineJSONParser::parseWikiPage)
        );

        OBJECT_CONFIGS.put(
                WikiPageDetail.class,
                config("wiki_page", null, RedmineJSONBuilder::writeWikiPageDetail, RedmineJSONParser::parseWikiPageDetail)
        );
        OBJECT_CONFIGS.put(
                CustomFieldDefinition.class,
                config("custom_field", "custom_fields", null,
                        RedmineJSONParser::parseCustomFieldDefinition));
		OBJECT_CONFIGS.put(
				Attachment.class,
				config("attachment", "attachments", null,
						RedmineJSONParser::parseAttachments));
		OBJECT_CONFIGS.put(
				File.class,
				config("file", "files", null, RedmineJSONParser::parseFiles));
    }

	private URIConfigurator configurator;
	private int objectsPerPage = DEFAULT_OBJECTS_PER_PAGE;
	private static final String CHARSET = "UTF-8";

	public Transport(URIConfigurator configurator, HttpClient client) {
		var baseCommunicator = new BaseCommunicator(client);
		var redmineAuthenticator = new RedmineAuthenticator<>(baseCommunicator, CHARSET);
		configure(configurator, redmineAuthenticator);
	}

	public Transport(URIConfigurator configurator, Communicator communicator) {
		configure(configurator, communicator);
	}

	private void configure(URIConfigurator configurator, Communicator communicator) {
		this.configurator = configurator;
		this.authenticator = communicator;
		final ContentHandler<BasicHttpResponse, BasicHttpResponse> errorProcessor = new RedmineErrorHandler();
		errorCheckingCommunicator = Communicators.fmap(
				authenticator,
				Communicators.compose(errorProcessor,
						Communicators.transportDecoder()));
		Communicator<String> coreCommunicator = Communicators.fmap(errorCheckingCommunicator,
				Communicators.contentReader());
		this.communicator = Communicators.simplify(coreCommunicator,
				Communicators.<String>identityHandler());
	}

	public User getCurrentUser(RequestParam... params) throws RedmineException {
		URI uri = getURIConfigurator().createURI("users/current.json", params);
		HttpGet http = new HttpGet(uri);
		String response = send(http);
		return parseResponse(response, "user", RedmineJSONParser::parseUser);
	}

	/**
	 * Performs an "add object" request.
	 * 
	 * @param object
	 *            object to use.
	 * @param params
	 *            name params.
	 * @return object to use.
	 * @throws RedmineException
	 *             if something goes wrong.
	 */
	public <T> T addObject(T object, RequestParam... params)
			throws RedmineException {
		final EntityConfig<T> config = getConfig(object.getClass());
        if (config.writer == null) {
            throw new RuntimeException("can't create object: writer is not implemented or is not registered in RedmineJSONBuilder for object " + object);
        }
		URI uri = getURIConfigurator().getObjectsURI(object.getClass(), params);
		HttpPost httpPost = new HttpPost(uri);
		String body = RedmineJSONBuilder.toSimpleJSON(config.singleObjectName, object, config.writer);
		setEntity(httpPost, body);
		String response = send(httpPost);
		logger.debug(response);
		return parseResponse(response, config.singleObjectName, config.parser);
	}

	/**
	 * Performs an "add child object" request.
	 * 
	 * @param parentClass
	 *            parent object id.
	 * @param object
	 *            object to use.
	 * @param params
	 *            name params.
	 * @return object to use.
	 * @throws RedmineException
	 *             if something goes wrong.
	 */
	public <T> T addChildEntry(Class<?> parentClass, String parentId, T object,
			RequestParam... params) throws RedmineException {
		final EntityConfig<T> config = getConfig(object.getClass());
		URI uri = getURIConfigurator().getChildObjectsURI(parentClass,
				parentId, object.getClass(), params);
		HttpPost httpPost = new HttpPost(uri);
		String body = RedmineJSONBuilder.toSimpleJSON(config.singleObjectName,
				object, config.writer);
		setEntity(httpPost, body);
		String response = send(httpPost);
		logger.debug(response);
		return parseResponse(response, config.singleObjectName, config.parser);
	}

	/*
	 * note: This method cannot return the updated object from Redmine because
	 * the server does not provide any XML in response.
	 * 
	 * @since 1.8.0
	 */
	public <T extends Identifiable> void updateObject(T obj,
			RequestParam... params) throws RedmineException {
		final EntityConfig<T> config = getConfig(obj.getClass());
		final Integer id = obj.getId();
		if (id == null) {
			throw new RuntimeException("'id' field cannot be NULL in the given object:" +
					" it is required to identify the object in the target system");
		}
		final URI uri = getURIConfigurator().getObjectURI(obj.getClass(),
				Integer.toString(id), params);
		final HttpPut http = new HttpPut(uri);
		final String body = RedmineJSONBuilder.toSimpleJSON(
				config.singleObjectName, obj, config.writer);
		setEntity(http, body);
		send(http);
	}

	/*
	 * note: This method cannot return the updated object from Redmine because
	 * the server does not provide anything in response.
	 */
	public <T> void updateChildEntry(Class<?> parentClass, String parentId,
			T obj, String objId, RequestParam... params) throws RedmineException {
		final EntityConfig<T> config = getConfig(obj.getClass());
		URI uri = getURIConfigurator().getChildIdURI(parentClass, parentId, obj.getClass(), objId, params);
		final HttpPut http = new HttpPut(uri);
		final String body = RedmineJSONBuilder.toSimpleJSON(config.singleObjectName, obj, config.writer);
		setEntity(http, body);
		send(http);
	}

	/**
	 * Performs "delete child Id" request.
	 * 
	 * @param parentClass
	 *            parent object id.
	 * @param object
	 *            object to use.
	 * @param value
	 *            child object id.
	 * @throws RedmineException
	 *             if something goes wrong.
	 */
    public <T> void deleteChildId(Class<?> parentClass, String parentId, T object, Integer value) throws RedmineException {
        URI uri = getURIConfigurator().getChildIdURI(parentClass, parentId, object.getClass(), value);
        HttpDelete httpDelete = new HttpDelete(uri);
        String response = send(httpDelete);
        logger.debug(response);
    }

	/**
	 * Deletes an object.
	 * 
	 * @param classs
	 *            object class.
	 * @param id
	 *            object id.
	 * @throws RedmineException
	 *             if something goes wrong.
	 */
	public <T extends Identifiable> void deleteObject(Class<T> classs, String id)
			throws RedmineException {
		final URI uri = getURIConfigurator().getObjectURI(classs, id);
		final HttpDelete http = new HttpDelete(uri);
		send(http);
	}

	/**
	 * @param classs
	 *            target class
	 * @param key
	 *            item key
	 * @param params
	 *            extra arguments.
	 * @throws RedmineAuthenticationException
	 *             invalid or no API access key is used with the server, which
	 *             requires authorization. Check the constructor arguments.
	 * @throws NotFoundException
	 *             the object with the given key is not found
	 * @throws RedmineException
	 */
	public <T> T getObject(Class<T> classs, String key, RequestParam... params)
			throws RedmineException {
		final EntityConfig<T> config = getConfig(classs);
		final URI uri = getURIConfigurator().getObjectURI(classs, key, params);
		final HttpGet http = new HttpGet(uri);
		String response = send(http);
		logger.debug(response);
		return parseResponse(response, config.singleObjectName, config.parser);
	}

	/**
	 * Downloads redmine content.
	 * 
	 * @param uri
	 *            target uri.
	 * @param handler
	 *            content handler.
	 * @return handler result.
	 * @throws RedmineException
	 *             if something goes wrong.
	 */
	public <R> R download(String uri,
			ContentHandler<BasicHttpResponse, R> handler)
			throws RedmineException {
		final HttpGet request = new HttpGet(uri);
        if (onBehalfOfUser != null) {
            request.addHeader("X-Redmine-Switch-User", onBehalfOfUser);
        }
        return errorCheckingCommunicator.sendRequest(request, handler);
    }

	/**
	 * Deprecated because Redmine server can return invalid string depending on its configuration.
	 * See https://github.com/taskadapter/redmine-java-api/issues/78 .
	 * <p>Use {@link #upload(InputStream, long)} instead.
	 *
	 * <p>
	 * Uploads content on a server. This method calls {@link #upload(InputStream, long)} with -1 as content length.
	 * 
	 * @param content the content stream.
	 * @return uploaded item token.
	 * @throws RedmineException if something goes wrong.
	 */
	@Deprecated
	public String upload(InputStream content) throws RedmineException {
		return upload(content, -1);
	}

	/**
	 * @param content the content
	 * @param contentLength the length of the content in bytes. you can provide -1 but be aware that some
	 *                      users reported Redmine configuration problems that prevent it from processing -1 correctly.
	 *                      See https://github.com/taskadapter/redmine-java-api/issues/78 for details.
	 * @return the string token of the uploaded item. see {@link Attachment#getToken()}
	 */
	public String upload(InputStream content, long contentLength) throws RedmineException {
		final URI uploadURI = getURIConfigurator().getUploadURI();
		final HttpPost request = new HttpPost(uploadURI);
		final AbstractHttpEntity entity = new InputStreamEntity(content, contentLength);
		/* Content type required by a Redmine */
		entity.setContentType("application/octet-stream");
		request.setEntity(entity);

		final String result = send(request);
		return parseResponse(result, "upload", input -> JsonInput.getStringNotNull(input, "token"));
	}

	/**
	 * @param classs
	 *            target class
	 * @param key
	 *            item key
	 * @param params
	 *            extra arguments.
	 * @throws RedmineAuthenticationException
	 *             invalid or no API access key is used with the server, which
	 *             requires authorization. Check the constructor arguments.
	 * @throws NotFoundException
	 *             the object with the given key is not found
	 * @throws RedmineException
	 */
	public <T> T getObject(Class<T> classs, Integer key, RequestParam... params) throws RedmineException {
		return getObject(classs, key.toString(), params);
	}

	public <T> List<T> getObjectsList(Class<T> objectClass, RequestParam... params) throws RedmineException {
		return getObjectsList(objectClass, Arrays.asList(params));
	}

	/**
	 * Returns all objects found using the provided parameters.
	 * This method IGNORES "limit" and "offset" parameters and handles paging AUTOMATICALLY for you.
	 * Please use getObjectsListNoPaging() method if you want to control paging yourself with "limit" and "offset" parameters.
	 * 
	 * @return objects list, never NULL
	 *
	 * @see #getObjectsListNoPaging(Class, Collection)
	 */
	public <T> List<T> getObjectsList(Class<T> objectClass,
									  Collection<? extends RequestParam> params) throws RedmineException {
		final List<T> result = new ArrayList<>();
		int offset = 0;

		Integer totalObjectsFoundOnServer;
		do {
			final List<RequestParam> newParams = new ArrayList<>(params);
			newParams.add(new RequestParam("limit", String.valueOf(objectsPerPage)));
			newParams.add(new RequestParam("offset", String.valueOf(offset)));

			final ResultsWrapper<T> wrapper = getObjectsListNoPaging(objectClass, newParams);
			result.addAll(wrapper.getResults());

			totalObjectsFoundOnServer = wrapper.getTotalFoundOnServer();
			// Necessary for trackers.
			// TODO Alexey: is this still necessary for Redmine 2.x?
			if (totalObjectsFoundOnServer == null) {
				break;
			}
			if (!wrapper.hasSomeResults()) {
				break;
			}
			offset += wrapper.getResultsNumber();
		} while (offset < totalObjectsFoundOnServer);
		return result;
	}

	/**
	 * Returns an object list. Provide your own "limit" and "offset" parameters if you need those, otherwise
	 * this method will return the first page of some default size only (this default is controlled by
	 * your Redmine configuration).
	 *
	 * @return objects list, never NULL
	 */
	public <T> ResultsWrapper<T> getObjectsListNoPaging(Class<T> objectClass,
											  Collection<? extends RequestParam> params) throws RedmineException {
		final EntityConfig<T> config = getConfig(objectClass);
		try {
			final JSONObject responseObject = getJsonResponseFromGet(objectClass, params);
			List<T> results = JsonInput.getListOrNull(responseObject, config.multiObjectName, config.parser);
			Integer totalFoundOnServer = JsonInput.getIntOrNull(responseObject, KEY_TOTAL_COUNT);
			Integer limitOnServer = JsonInput.getIntOrNull(responseObject, KEY_LIMIT);
			Integer offsetOnServer = JsonInput.getIntOrNull(responseObject, KEY_OFFSET);
			return new ResultsWrapper<>(totalFoundOnServer, limitOnServer, offsetOnServer, results);
		} catch (JSONException e) {
			throw new RedmineFormatException(e);
		}
	}

	/**
	 * Use this method if you need direct access to Json results.
	 <pre>
	 Params params = new Params()
	   .add(...)
     getJsonResponseFromGet(Issue.class, params);
	 </pre>
	 */
	public <T> JSONObject getJsonResponseFromGet(Class<T> objectClass,
												 Collection<? extends RequestParam> params) throws RedmineException, JSONException {
		final List<RequestParam> newParams = new ArrayList<>(params);
		List<RequestParam> paramsList = new ArrayList<>(newParams);
		final URI uri = getURIConfigurator().getObjectsURI(objectClass, paramsList);
		final HttpGet http = new HttpGet(uri);
		final String response = send(http);
		return RedmineJSONParser.getResponse(response);
	}

	public <T> List<T> getChildEntries(Class<?> parentClass, int parentId, Class<T> classs) throws RedmineException {
		return getChildEntries(parentClass, parentId + "", classs);
	}

	/**
	 * Delivers a list of a child entries.
	 * 
	 * @param classs
	 *            target class.
	 */
	public <T> List<T> getChildEntries(Class<?> parentClass, String parentKey, Class<T> classs) throws RedmineException {
		final EntityConfig<T> config = getConfig(classs);
		final URI uri = getURIConfigurator().getChildObjectsURI(parentClass,
				parentKey, classs, new RequestParam("limit", String.valueOf(objectsPerPage)));

		HttpGet http = new HttpGet(uri);
		String response = send(http);
		final JSONObject responseObject;
		try {
			responseObject = RedmineJSONParser.getResponse(response);
			return JsonInput.getListNotNull(responseObject, config.multiObjectName, config.parser);
		} catch (JSONException e) {
			throw new RedmineFormatException("Bad categories response " + response, e);
		}
	}

    /**
     * Delivers a single child entry by its identifier.
     */
    public <T> T getChildEntry(Class<?> parentClass, String parentId,
                               Class<T> classs, String childId, RequestParam... params) throws RedmineException {
        final EntityConfig<T> config = getConfig(classs);
		final URI uri = getURIConfigurator().getChildIdURI(parentClass, parentId, classs, childId, params);
        HttpGet http = new HttpGet(uri);
        String response = send(http);

        return parseResponse(response, config.singleObjectName, config.parser);
    }

    /**
	 * This number of objects (tasks, projects, users) will be requested from
	 * Redmine server in 1 request.
	 */
	public void setObjectsPerPage(int pageSize) {
		if (pageSize <= 0) {
			throw new IllegalArgumentException("Page size must be >= 0. You provided: " + pageSize);
		}
		this.objectsPerPage = pageSize;
	}
	
	public void addUserToGroup(int userId, int groupId) throws RedmineException {
		logger.debug("adding user " + userId + " to group " + groupId + "...");
		URI uri = getURIConfigurator().getChildObjectsURI(Group.class, Integer.toString(groupId), User.class);
		HttpPost httpPost = new HttpPost(uri);
		final StringWriter writer = new StringWriter();
		final JSONWriter jsonWriter = new JSONWriter(writer);
		try {
			jsonWriter.object().key("user_id").value(userId).endObject();
		} catch (JSONException e) {
			throw new RedmineInternalError("Unexpected exception", e);
		}
		String body = writer.toString();
		setEntity(httpPost, body);
		String response = send(httpPost);
		logger.debug(response);
	}

	public void addWatcherToIssue(int watcherId, int issueId) throws RedmineException {
		logger.debug("adding watcher " + watcherId + " to issue " + issueId + "...");
		URI uri = getURIConfigurator().getChildObjectsURI(Issue.class, Integer.toString(issueId), Watcher.class);
		HttpPost httpPost = new HttpPost(uri);
		final StringWriter writer = new StringWriter();
		final JSONWriter jsonWriter = new JSONWriter(writer);
		try {
			jsonWriter.object().key("user_id").value(watcherId).endObject();
		} catch (JSONException e) {
			throw new RedmineInternalError("Unexpected exception", e);
		}
		String body = writer.toString();
		setEntity(httpPost, body);
		String response = send(httpPost);
		logger.debug(response);
	}

    private String send(HttpRequestBase http) throws RedmineException {
        if (onBehalfOfUser != null) {
            http.addHeader("X-Redmine-Switch-User", onBehalfOfUser);
        }
        return communicator.sendRequest(http);
    }

	private <T> T parseResponse(String response, String tag,
                                     JsonObjectParser<T> parser) throws RedmineFormatException {
		try {
			T parse = parser.parse(RedmineJSONParser.getResponseSingleObject(response, tag));
			if (parse instanceof FluentStyle) {
				((FluentStyle) parse).setTransport(this);
			}
			return parse;
		} catch (JSONException e) {
			throw new RedmineFormatException(e);
		}
	}

	private static void setEntity(HttpEntityEnclosingRequest request, String body) {
		setEntity(request, body, CONTENT_TYPE);
	}

	private static void setEntity(HttpEntityEnclosingRequest request, String body, String contentType) {
		StringEntity entity;
		try {
			entity = new StringEntity(body, CHARSET);
		} catch (UnsupportedCharsetException e) {
			throw new RedmineInternalError("Required charset " + CHARSET
					+ " is not supported", e);
		}
		entity.setContentType(contentType);
		request.setEntity(entity);
	}

	@SuppressWarnings("unchecked")
	private <T> EntityConfig<T> getConfig(Class<?> class1) {
		final EntityConfig<?> guess = OBJECT_CONFIGS.get(class1);
		if (guess == null)
			throw new RedmineInternalError("Unsupported class " + class1);
		return (EntityConfig<T>) guess;
	}

	private URIConfigurator getURIConfigurator() {
		return configurator;
	}

	private static <T> EntityConfig<T> config(String objectField,
			String urlPrefix, JsonObjectWriter<T> writer,
			JsonObjectParser<T> parser) {
		return new EntityConfig<>(objectField, urlPrefix, writer, parser);
	}

    /**
     * This works only when the main authentication has led to Redmine Admin level user.
     * The given user name will be sent to the server in "X-Redmine-Switch-User" HTTP Header
     * to indicate that the action (create issue, delete issue, etc) must be done
     * on behalf of the given user name.
     *
     * @param loginName Redmine user login name to provide to the server
     *
     * @see <a href="http://www.redmine.org/issues/11755">Redmine issue 11755</a>
     */
    public void setOnBehalfOfUser(String loginName) {
        this.onBehalfOfUser = loginName;
    }

	/**
	 * Entity config.
	 */
	static class EntityConfig<T> {
		final String singleObjectName;
		final String multiObjectName;
		final JsonObjectWriter<T> writer;
		final JsonObjectParser<T> parser;

		public EntityConfig(String objectField, String urlPrefix,
				JsonObjectWriter<T> writer, JsonObjectParser<T> parser) {
			super();
			this.singleObjectName = objectField;
			this.multiObjectName = urlPrefix;
			this.writer = writer;
			this.parser = parser;
		}
	}

}
import com.taskadapter.redmineapi.bean.Issue;
import com.taskadapter.redmineapi.bean.IssueCategory;
import com.taskadapter.redmineapi.bean.IssuePriority;
import com.taskadapter.redmineapi.bean.IssueRelation;
import com.taskadapter.redmineapi.bean.IssueStatus;
import com.taskadapter.redmineapi.bean.Project;
import com.taskadapter.redmineapi.bean.SavedQuery;
import com.taskadapter.redmineapi.bean.Tracker;
import com.taskadapter.redmineapi.bean.Watcher;
import com.taskadapter.redmineapi.internal.DirectObjectsSearcher;
import com.taskadapter.redmineapi.internal.Joiner;
import com.taskadapter.redmineapi.internal.RequestParam;
import com.taskadapter.redmineapi.internal.ResultsWrapper;
import com.taskadapter.redmineapi.internal.Transport;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
package com.taskadapter.redmineapi;



/**
 * Works with Issues, Time Entries, Issue Statuses, Issue Relations.
 * <p>Obtain it via RedmineManager:
 * <pre>
 RedmineManager redmineManager = RedmineManagerFactory.createWithUserAuth(redmineURI, login, password);
 IssueManager issueManager = redmineManager.getIssueManager();
 * </pre>
 *
 * <p>Sample usage:
 * <pre>
 Issue issue = issueManager.getIssueById(3205, Include.journals, Include.relations, Include.attachments);
 System.out.println(issue.getJournals());
 * </pre>
 *
 * @see RedmineManager#getIssueManager()
 */
public class IssueManager {
    private final Transport transport;

    IssueManager(Transport transport) {
        this.transport = transport;
    }

    /**
     * There could be several issues with the same summary, so the method returns List.
     *
     * @return empty list if not issues with this summary field exist, never NULL
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws NotFoundException
     * @throws RedmineException
     */
    public List<Issue> getIssuesBySummary(String projectKey, String summaryField) throws RedmineException {
        if ((projectKey != null) && (projectKey.length() > 0)) {
            return transport.getObjectsList(Issue.class,
                    new RequestParam("subject", summaryField),
                    new RequestParam("project_id", projectKey));
        } else {
            return transport.getObjectsList(Issue.class,
                    new RequestParam("subject", summaryField));
        }
    }

    /**
     * Direct method to search for issues using any Redmine REST API parameters you want.
     * <p>Unlike other getXXXObjects() methods in this library, this one does NOT handle paging for you so
     * you have to provide "offset" and "limit" parameters if you want to control paging.
     *
     * <p>Sample usage:
     <pre>
     final Map<String, String> params = new HashMap<String, String>();
     params.put("project_id", projectId);
     params.put("subject", "~free_form_search");
     final List<Issue> issues = issueManager.getIssues(params);
     </pre>

     * @param parameters the http parameters key/value pairs to append to the rest api request
     * @return resultsWrapper with raw response from Redmine REST API
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws RedmineException
     */
    public ResultsWrapper<Issue> getIssues(Map<String, String> parameters) throws RedmineException {
        return DirectObjectsSearcher.getObjectsListNoPaging(transport, parameters, Issue.class);
    }

    /**
     * Free-form search that does not do any paging for you. Btw, where is Redmine free-form search documentation??
     * <p>
     * Sample usage:
     * <pre>

     Params params = new Params()
     .add("set_filter", "1")
     .add("f[]", "summary")
     .add("op[summary]", "~")
     .add("v[summary]", "another")
     .add("f[]", "description")
     .add("op[description]", "~")
     .add("v[description][]", "abc");

     list = issueManager.getIssues(params);

     * </pre>
     * @param parameters
     */
    public ResultsWrapper<Issue> getIssues(Params parameters) throws RedmineException {
        return transport.getObjectsListNoPaging(Issue.class, parameters.getList());
    }

    /**
     * @param id      Redmine issue Id
     * @param include list of "includes". e.g. "relations", "journals", ...
     * @return Issue object. never Null: an exception is thrown if the issue is not found (see Throws section).
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws NotFoundException       the issue with the given id is not found on the server
     * @throws RedmineException
     */
    public Issue getIssueById(Integer id, Include... include) throws RedmineException {
        String value = Joiner.join(",", include);
        return transport.getObject(Issue.class, id, new RequestParam("include", value));
    }

    /**
     * DEPRECATED. use issue.addWatcher(..)
     */
    @Deprecated
    public void addWatcherToIssue(Watcher watcher, Issue issue) throws RedmineException {
        transport.addWatcherToIssue(watcher.getId(), issue.getId());
    }

    /**
     * DEPRECATED. use issue.deleteWatcher(..)
     */
    @Deprecated
    public void deleteWatcherFromIssue(Watcher watcher, Issue issue) throws RedmineException {
        transport.deleteChildId(Issue.class, Integer.toString(issue.getId()), watcher, watcher.getId());
    }

    /**
     * Deprecated. Use the new fluent-style API: new Issue(...).create();
     *
     * @param issue      the Issue object to create on the server.
     * @return the newly created Issue.
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws NotFoundException       the project is not found
     * @throws RedmineException
     */
    @Deprecated
    public Issue createIssue(Issue issue) throws RedmineException {
        return issue.create();
    }

    /**
     * Deprecated. use issue.delete() instead.
     *
     * @throws RedmineException
     */
    @Deprecated
    public void deleteIssue(Integer id) throws RedmineException {
        transport.deleteObject(Issue.class, Integer.toString(id));
    }

    /**
     * @param projectKey ignored if NULL
     * @param queryId    id of the saved query in Redmine. the query must be accessible to the user
     *                   represented by the API access key (if the Redmine project requires authorization).
     *                   This parameter is <strong>optional</strong>, NULL can be provided to get all available issues.
     * @return list of Issue objects
     * @throws RedmineAuthenticationException invalid or no API access key is used with the server, which
     *                                 requires authorization. Check the constructor arguments.
     * @throws RedmineException
     * @see Issue
     */
    public List<Issue> getIssues(String projectKey, Integer queryId, Include... include) throws RedmineException {
        List<RequestParam> params = new ArrayList<>();
        if (queryId != null) {
            params.add(new RequestParam("query_id", String.valueOf(queryId)));
        }

        if ((projectKey != null) && (projectKey.length() > 0)) {
            params.add(new RequestParam("project_id", projectKey));
        }
        String includeStr = Joiner.join(",", include);
        params.add(new RequestParam("include", includeStr));

        return transport.getObjectsList(Issue.class, params);
    }

    /**
     * DEPRECATED. use relation.create()
     */
    @Deprecated
    public IssueRelation createRelation(Integer issueId, Integer issueToId, String type) throws RedmineException {
        return new IssueRelation(transport, issueId, issueToId, type)
                .create();
    }

    /**
     * DEPRECATED. use relation.delete()
     */
    @Deprecated
    public void deleteRelation(Integer id) throws RedmineException {
        new IssueRelation(transport).setId(id).delete();
    }

    /**
     * DEPRECATED. use relation.delete()
     */
    @Deprecated
    public void deleteIssueRelations(Issue redmineIssue) throws RedmineException {
        for (IssueRelation relation : redmineIssue.getRelations()) {
            deleteRelation(relation.getId());
        }
    }

    /**
     * Delete relations for the given issue ID.
     *
     * @param issueId issue ID
     */
    public void deleteIssueRelationsByIssueId(Integer issueId) throws RedmineException {
        Issue issue = getIssueById(issueId, Include.relations);
        deleteIssueRelations(issue);
    }

    public List<IssuePriority> getIssuePriorities() throws RedmineException {
        return transport.getObjectsList(IssuePriority.class);
    }

    /**
     * Deprecated. use issue.update() instead.
     */
    @Deprecated
    public void update(Issue obj) throws RedmineException {
        obj.update();
    }

    /**
     * delivers a list of {@link com.taskadapter.redmineapi.bean.IssueCategory}s of a {@link Project}
     *
     * @param projectID the ID of the {@link Project}
     * @return the list of {@link com.taskadapter.redmineapi.bean.IssueCategory}s of the {@link Project}
     * @throws RedmineAuthenticationException thrown in case something went wrong while trying to login
     * @throws RedmineException        thrown in case something went wrong in Redmine
     * @throws NotFoundException       thrown in case an object can not be found
     */
    public List<IssueCategory> getCategories(int projectID) throws RedmineException {
        return transport.getChildEntries(Project.class,
                Integer.toString(projectID), IssueCategory.class);
    }

    /**
     * DEPRECATED. use category.create() instead.
     *
     * creates a new {@link IssueCategory} for the {@link Project} contained. <br>
     * Pre-condition: the attribute {@link Project} for the {@link IssueCategory} must
     * not be null!
     *
     * @param category the {@link IssueCategory}. Must contain a {@link Project}.
     * @return the new {@link IssueCategory} created by Redmine
     * @throws IllegalArgumentException thrown in case the category does not contain a project.
     * @throws RedmineAuthenticationException  thrown in case something went wrong while trying to login
     * @throws RedmineException         thrown in case something went wrong in Redmine
     * @throws NotFoundException        thrown in case an object can not be found
     */
    @Deprecated
    public IssueCategory createCategory(IssueCategory category) throws RedmineException {
        if (category.getProjectId() == null) {
            throw new IllegalArgumentException(
                    "IssueCategory must contain projectId");
        }

        return transport.addChildEntry(Project.class, category.getProjectId().toString(), category);
    }

    /**
     * DEPRECATED. use category.delete() instead
     *
     * deletes an {@link IssueCategory}. <br>
     *
     * @param category the {@link IssueCategory}.
     * @throws RedmineAuthenticationException thrown in case something went wrong while trying to login
     * @throws RedmineException        thrown in case something went wrong in Redmine
     * @throws NotFoundException       thrown in case an object can not be found
     */
    @Deprecated
    public void deleteCategory(IssueCategory category) throws RedmineException {
        transport.deleteObject(IssueCategory.class,
                Integer.toString(category.getId()));
    }

    /**
     * Delivers a list of existing {@link com.taskadapter.redmineapi.bean.IssueStatus}es.
     *
     * @return a list of existing {@link com.taskadapter.redmineapi.bean.IssueStatus}es.
     * @throws RedmineAuthenticationException thrown in case something went wrong while trying to login
     * @throws RedmineException        thrown in case something went wrong in Redmine
     * @throws NotFoundException       thrown in case an object can not be found
     */
    public List<IssueStatus> getStatuses() throws RedmineException {
        return transport.getObjectsList(IssueStatus.class);
    }

    /**
     * @return a list of all {@link com.taskadapter.redmineapi.bean.Tracker}s available (like "Bug", "Task", "Feature")
     * @throws RedmineAuthenticationException thrown in case something went wrong while trying to login
     * @throws RedmineException        thrown in case something went wrong in Redmine
     * @throws NotFoundException       thrown in case an object can not be found
     */
    public List<Tracker> getTrackers() throws RedmineException {
        return transport.getObjectsList(Tracker.class);
    }

    /**
     * Get "saved queries" for the given project available to the current user.
     *
     * <p>This REST API feature was added in Redmine 1.3.0. See http://www.redmine.org/issues/5737</p>
     */
    public List<SavedQuery> getSavedQueries(String projectKey) throws RedmineException {