diff --git a/src/YouTrackSharp/Agiles/Agile.cs b/src/YouTrackSharp/Agiles/Agile.cs new file mode 100644 index 00000000..536af0ee --- /dev/null +++ b/src/YouTrackSharp/Agiles/Agile.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using YouTrackSharp.Json; +using YouTrackSharp.Projects; +using YouTrackSharp.SerializationAttributes; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents an agile board configuration. + /// + public class Agile + { + /// + /// Id of the Agile. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// The name of the agile board. Can be null. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// Owner of the agile board. Can be null. + /// + [JsonProperty("owner")] + public User Owner { get; set; } + + /// + /// The user group that can view this board. Can be null. + /// + [Verbose] + [JsonProperty("visibleFor")] + public UserGroup VisibleFor { get; set; } + + /// + /// When true, the board is visible to everyone who can view all projects that are associated with the board. + /// + [JsonProperty("visibleForProjectBased")] + public bool VisibleForProjectBased { get; set; } + + /// + /// Group of users who can update board settings. Can be null. + /// + [Verbose] + [JsonProperty("updateableBy")] + public UserGroup UpdateableBy { get; set; } + + /// + /// When true, anyone who can update the associated projects can update the board. + /// + [JsonProperty("updateableByProjectBased")] + public bool UpdateableByProjectBased { get; set; } + + /// + /// When true, the orphan swimlane is placed at the top of the board. Otherwise, the orphans swimlane is located + /// below all other swimlanes. + /// + [JsonProperty("orphansAtTheTop")] + public bool OrphansAtTheTop { get; set; } + + /// + /// When true, the orphans swimlane is not displayed on the board. + /// + [JsonProperty("hideOrphansSwimlane")] + public bool HideOrphansSwimlane { get; set; } + + /// + /// A custom field that is used as the estimation field for the board. Can be null. + /// + [Verbose] + [JsonProperty("estimationField")] + public CustomField EstimationField { get; set; } + + /// + /// A custom field that is used as the original estimation field for the board. Can be null. + /// + [Verbose] + [JsonProperty("originalEstimationField")] + public CustomField OriginalEstimationField { get; set; } + + /// + /// A collection of projects associated with the board. + /// + [Verbose] + [JsonProperty("projects")] + public List Projects { get; set; } + + /// + /// The set of sprints that are associated with the board. + /// + [Verbose] + [JsonProperty("sprints")] + public List Sprints { get; set; } + + /// + /// A sprint that is actual for the current date. Read-only. Can be null. + /// + [Verbose] + [JsonProperty("currentSprint")] + public Sprint CurrentSprint { get; set; } + + /// + /// Column settings of the board. Read-only. + /// + [Verbose] + [JsonProperty("columnSettings")] + public ColumnSettings ColumnSettings { get; set; } + + /// + /// Settings of the board swimlanes. Can be null. + /// + [Verbose] + [JsonProperty("swimlaneSettings")] + [JsonConverter(typeof(KnownTypeConverter))] + public SwimlaneSettings SwimlaneSettings { get; set; } + + /// + /// Settings of the board sprints. Read-only. + /// + [Verbose] + [JsonProperty("sprintsSettings")] + public SprintsSettings SprintsSettings { get; set; } + + /// + /// Color coding settings for the board. Can be null. + /// + [Verbose] + [JsonProperty("colorCoding")] + [JsonConverter(typeof(KnownTypeConverter))] + public ColorCoding ColorCoding { get; set; } + + /// + /// Status of the board. Read-only. + /// + [Verbose] + [JsonProperty("status")] + public AgileStatus Status { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/AgileColumn.cs b/src/YouTrackSharp/Agiles/AgileColumn.cs new file mode 100644 index 00000000..8f2a90e6 --- /dev/null +++ b/src/YouTrackSharp/Agiles/AgileColumn.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents settings for a single board column + /// + public class AgileColumn + { + /// + /// Id of the AgileColumn. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// Text presentation of values stored in a column. Read-only. Can be null. + /// + [JsonProperty("presentation")] + public string Presentation { get; set; } + + /// + /// true if a column represents resolved state of an issue. Can be updated only for newly created value. Read-only. + /// + [JsonProperty("isResolved")] + public bool IsResolved { get; set; } + + /// + /// Order of this column on board, counting from left to right. + /// + [JsonProperty("ordinal")] + public int Ordinal { get; set; } + + /// + /// WIP limit for this column. Can be null. + /// + [JsonProperty("wipLimit")] + public WIPLimit WipLimit { get; set; } + + /// + /// Field values represented by this column. + /// + [JsonProperty("fieldValues")] + public List FieldValues { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/AgileColumnFieldValue.cs b/src/YouTrackSharp/Agiles/AgileColumnFieldValue.cs new file mode 100644 index 00000000..30b240e3 --- /dev/null +++ b/src/YouTrackSharp/Agiles/AgileColumnFieldValue.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents a field value or values, parameterizing agile column. + /// + public class AgileColumnFieldValue : DatabaseAttributeValue + { + /// + /// Presentation of a field value or values. Can be null. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// True, if field has type State and teh value is resolved or all values are resolved. Read-only. + /// + [JsonProperty("isResolved")] + public bool IsResolved { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/AgileService.cs b/src/YouTrackSharp/Agiles/AgileService.cs new file mode 100644 index 00000000..89293de3 --- /dev/null +++ b/src/YouTrackSharp/Agiles/AgileService.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using YouTrackSharp.Internal; + +namespace YouTrackSharp.Agiles +{ + /// + /// Service offering Agile-related operations. + /// + public class AgileService : IAgileService + { + private readonly Connection _connection; + + private readonly FieldSyntaxEncoder _fieldSyntaxEncoder; + + /// + /// Creates an instance of the class. + /// + /// + /// A instance that provides a connection to the remote YouTrack server instance. + /// + /// + /// An instance that allows to encode types into Youtrack request URL format for fields + /// + public AgileService(Connection connection, FieldSyntaxEncoder fieldSyntaxEncoder) + { + _connection = connection; + _fieldSyntaxEncoder = fieldSyntaxEncoder; + } + + /// + public async Task> GetAgileBoards(bool verbose = false) + { + HttpClient client = await _connection.GetAuthenticatedHttpClient(); + + const int batchSize = 10; + List agileBoards = new List(); + List currentBatch; + + do + { + string fields = _fieldSyntaxEncoder.Encode(typeof(Agile), verbose); + + HttpResponseMessage message = await client.GetAsync($"api/agiles?fields={fields}&$top={batchSize}&$skip={agileBoards.Count}"); + + string response = await message.Content.ReadAsStringAsync(); + + currentBatch = JsonConvert.DeserializeObject>(response); + + agileBoards.AddRange(currentBatch); + } while (currentBatch.Count == batchSize); + + return agileBoards; + } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/AgileStatus.cs b/src/YouTrackSharp/Agiles/AgileStatus.cs new file mode 100644 index 00000000..1958fad3 --- /dev/null +++ b/src/YouTrackSharp/Agiles/AgileStatus.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Shows if the board has any configuration problems. + /// + public class AgileStatus + { + /// + /// Id of the AgileStatus. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// true if the board is in valid state and can be used. Read-only. + /// + [JsonProperty("valid")] + public bool Valid { get; set; } + + /// + /// If `true`, then a background job is currently being executed for the board. In this case, while a background + /// job is running, the board cannot be updated. Read-only. + /// + [JsonProperty("hasJobs")] + public bool HasJobs { get; set; } + + /// + /// List of configuration errors found for this board. Read-only. + /// + [JsonProperty("errors")] + public List Errors { get; set; } + + /// + /// List of configuration-related warnings found for this board. Read-only. + /// + [JsonProperty("warnings")] + public List Warnings { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/AttributeBasedSwimlaneSettings.cs b/src/YouTrackSharp/Agiles/AttributeBasedSwimlaneSettings.cs new file mode 100644 index 00000000..e14a3854 --- /dev/null +++ b/src/YouTrackSharp/Agiles/AttributeBasedSwimlaneSettings.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using YouTrackSharp.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Settings of swimlanes that are identified by the set of values in the selected field. For example, you can set + /// swimlanes to represent issues for each Assignee. + /// + public class AttributeBasedSwimlaneSettings : SwimlaneSettings + { + /// + /// CustomField which values are used to identify swimlane. + /// + [JsonProperty("field")] + [JsonConverter(typeof(KnownTypeConverter))] + public FilterField Field { get; set; } + + /// + /// Swimlanes that are visible on the Board. + /// + [JsonProperty("values")] + public List Values { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/ColorCoding.cs b/src/YouTrackSharp/Agiles/ColorCoding.cs new file mode 100644 index 00000000..e158fba9 --- /dev/null +++ b/src/YouTrackSharp/Agiles/ColorCoding.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using YouTrackSharp.SerializationAttributes; + +namespace YouTrackSharp.Agiles +{ + /// + /// Describe rules according to which different colors are used for cards on agile board. + /// + [KnownType(typeof(FieldBasedColorCoding))] + [KnownType(typeof(ProjectBasedColorCoding))] + public class ColorCoding + { + /// + /// Id of the ColorCoding. + /// + [JsonProperty("id")] + public string Id { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/ColumnSettings.cs b/src/YouTrackSharp/Agiles/ColumnSettings.cs new file mode 100644 index 00000000..10e91946 --- /dev/null +++ b/src/YouTrackSharp/Agiles/ColumnSettings.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using YouTrackSharp.Projects; + +namespace YouTrackSharp.Agiles +{ + /// + /// Agile board columns settings. + /// + public class ColumnSettings + { + /// + /// Id of the ColumnSettings. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// Custom field, which values are used for columns. Can be null. + /// + [JsonProperty("field")] + public CustomField Field { get; set; } + + /// + /// Columns that are shown on the board. + /// + [JsonProperty("columns")] + public List Columns { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/CustomFilterField.cs b/src/YouTrackSharp/Agiles/CustomFilterField.cs new file mode 100644 index 00000000..22d66106 --- /dev/null +++ b/src/YouTrackSharp/Agiles/CustomFilterField.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using YouTrackSharp.Projects; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents a custom field of the issue. + /// + public class CustomFilterField : FilterField + { + /// + /// Reference to settings of the custom field. Read-only. + /// + [JsonProperty("customField")] + public CustomField CustomField { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/DatabaseAttributeValue.cs b/src/YouTrackSharp/Agiles/DatabaseAttributeValue.cs new file mode 100644 index 00000000..51e6ed51 --- /dev/null +++ b/src/YouTrackSharp/Agiles/DatabaseAttributeValue.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using YouTrackSharp.SerializationAttributes; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents string reference to the value. + /// + [KnownType(typeof(SwimlaneEntityAttributeValue))] + [KnownType(typeof(AgileColumnFieldValue))] + public class DatabaseAttributeValue + { + /// + /// Id of the DatabaseAttributeValue. + /// + [JsonProperty("id")] + public string Id { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/FieldBasedColorCoding.cs b/src/YouTrackSharp/Agiles/FieldBasedColorCoding.cs new file mode 100644 index 00000000..f8526fe0 --- /dev/null +++ b/src/YouTrackSharp/Agiles/FieldBasedColorCoding.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using YouTrackSharp.Projects; + +namespace YouTrackSharp.Agiles +{ + /// + /// Allows to set card's color based on a value of some custom field. + /// + public class FieldBasedColorCoding : ColorCoding + { + /// + /// Sets card color based on this custom field. Can be null. + /// + [JsonProperty("prototype")] + public CustomField Prototype { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/FieldStyle.cs b/src/YouTrackSharp/Agiles/FieldStyle.cs new file mode 100644 index 00000000..c7f4a6e0 --- /dev/null +++ b/src/YouTrackSharp/Agiles/FieldStyle.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents the style settings of the field in YouTrack. + /// + public class FieldStyle + { + /// + /// Id of the FieldStyle. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// Background color. Read-only. Can be null. + /// + [JsonProperty("background")] + public string Background { get; set; } + + /// + /// Foreground color. Read-only. Can be null. + /// + [JsonProperty("foreground")] + public string Foreground { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/FilterField.cs b/src/YouTrackSharp/Agiles/FilterField.cs new file mode 100644 index 00000000..1760ff4f --- /dev/null +++ b/src/YouTrackSharp/Agiles/FilterField.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; +using YouTrackSharp.SerializationAttributes; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents an issue property, which can be a predefined field, a custom field, a link, and so on. + /// + [KnownType(typeof(PredefinedFilterField))] + [KnownType(typeof(CustomFilterField))] + public class FilterField + { + /// + /// Id of the FilterField. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// Presentation of the field. Read-only. + /// + [JsonProperty("presentation")] + public string Presentation { get; set; } + + /// + /// The name of the field. Read-only. + /// + [JsonProperty("name")] + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/IAgileService.cs b/src/YouTrackSharp/Agiles/IAgileService.cs new file mode 100644 index 00000000..9b4393a5 --- /dev/null +++ b/src/YouTrackSharp/Agiles/IAgileService.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace YouTrackSharp.Agiles +{ + public interface IAgileService + { + /// + /// Retrieves the available agile boards from the server. + /// + /// + /// If the full representation of agile boards should be returned. + /// If this parameter is false, all the fields (and sub-fields) marked with the + /// are omitted (for more information, see + /// and related classes). + /// + /// + /// Uses the REST API + /// + /// Read a list of Agiles + /// + /// + /// A of available boards + /// When the call to the remote YouTrack server instance failed. + public Task> GetAgileBoards(bool verbose = false); + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/IssueBasedSwimlaneSettings.cs b/src/YouTrackSharp/Agiles/IssueBasedSwimlaneSettings.cs new file mode 100644 index 00000000..b818c2dd --- /dev/null +++ b/src/YouTrackSharp/Agiles/IssueBasedSwimlaneSettings.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using YouTrackSharp.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Base entity for different swimlane settings + /// + public class IssueBasedSwimlaneSettings : SwimlaneSettings + { + /// + /// CustomField which values are used to identify swimlane. + /// + [JsonProperty("field")] + [JsonConverter(typeof(KnownTypeConverter))] + public FilterField Field { get; set; } + + /// + /// Value of a field that a card would have by default. Can be null. + /// + [JsonProperty("defaultCardType")] + public SwimlaneValue DefaultCardType { get; set; } + + /// + /// When issue has one of this values, it becomes a swimlane on this board. + /// + [JsonProperty("values")] + public List Values { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/PredefinedFilterField.cs b/src/YouTrackSharp/Agiles/PredefinedFilterField.cs new file mode 100644 index 00000000..19e4a1a1 --- /dev/null +++ b/src/YouTrackSharp/Agiles/PredefinedFilterField.cs @@ -0,0 +1,9 @@ +namespace YouTrackSharp.Agiles +{ + /// + /// Represents a predefined field of the issue. Predefined fields are always present in an issue and |cannot be + /// customized in a project. For example, project, created, |updated, tags, and so on. + /// + public class PredefinedFilterField : FilterField + { } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/Project.cs b/src/YouTrackSharp/Agiles/Project.cs new file mode 100644 index 00000000..42576e88 --- /dev/null +++ b/src/YouTrackSharp/Agiles/Project.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents a project in the context of an agile board. The class only provides the id, short name and name of the + /// project see for more info on how to access a YouTrack project. + /// + public class Project + { + /// + /// Id of the Project. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// The ID of the project. This short name is also a prefix for an issue ID. Can be null. + /// + [JsonProperty("shortName")] + public string ShortName { get; set; } + + /// + /// The name of the issue folder. Can be null. + /// + [JsonProperty("name")] + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/ProjectBasedColorCoding.cs b/src/YouTrackSharp/Agiles/ProjectBasedColorCoding.cs new file mode 100644 index 00000000..13ede654 --- /dev/null +++ b/src/YouTrackSharp/Agiles/ProjectBasedColorCoding.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Allows to set card's color based on it's project + /// + public class ProjectBasedColorCoding : ColorCoding + { + /// + /// Collection of per-project color settings + /// + [JsonProperty("projectColors")] + public List ProjectColors { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/ProjectColor.cs b/src/YouTrackSharp/Agiles/ProjectColor.cs new file mode 100644 index 00000000..e4280f2e --- /dev/null +++ b/src/YouTrackSharp/Agiles/ProjectColor.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represent color setting for one project on the board. + /// + public class ProjectColor + { + /// + /// Id of the ProjectColor. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// A project, to which color this setting describes. Read-only. Can be null. + /// + [JsonProperty("project")] + public Project Project { get; set; } + + /// + /// A color, that issues of this project will have on the board. Read-only. Can be null. + /// + [JsonProperty("color")] + public FieldStyle Color { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/Sprint.cs b/src/YouTrackSharp/Agiles/Sprint.cs new file mode 100644 index 00000000..b7a9485d --- /dev/null +++ b/src/YouTrackSharp/Agiles/Sprint.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents a sprint in the context of an agile board. The class only provides the sprint's id and name, see for more info on how to access a YouTrack sprint. + /// + public class Sprint + { + /// + /// Id of the Sprint. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// Name of the sprint. Can be null. + /// + [JsonProperty("name")] + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/SprintsSettings.cs b/src/YouTrackSharp/Agiles/SprintsSettings.cs new file mode 100644 index 00000000..ab32bdea --- /dev/null +++ b/src/YouTrackSharp/Agiles/SprintsSettings.cs @@ -0,0 +1,64 @@ +using Newtonsoft.Json; +using YouTrackSharp.Projects; + +namespace YouTrackSharp.Agiles +{ + /// + /// Describes sprints configuration. + /// + public class SprintsSettings + { + /// + /// Id of the SprintsSettings. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// If true, issues should be added to the board manually. If false, issues are shown on the board based on the + /// query and/or value of a field. + /// + [JsonProperty("isExplicit")] + public bool IsExplicit { get; set; } + + /// + /// If true, cards can be present on several sprints of this board. + /// + [JsonProperty("cardOnSeveralSprints")] + public bool CardOnSeveralSprints { get; set; } + + /// + /// New cards are added to this sprint by default. This setting applies only if isExplicit == true. Can be null. + /// + [JsonProperty("defaultSprint")] + public Sprint DefaultSprint { get; set; } + + /// + /// If true, agile board has no distinct sprints in UI. However, in API it will look like it has only one active + /// (not-archived) sprint. + /// + [JsonProperty("disableSprints")] + public bool DisableSprints { get; set; } + + /// + /// Issues that match this query will appear on the board. This setting applies only if isExplicit == false. Can be + /// null. + /// + [JsonProperty("explicitQuery")] + public string ExplicitQuery { get; set; } + + /// + /// Based on the value of this field, issues will be assigned to the sprints. This setting applies only if + /// isExplicit == false. Can be null. + /// + [JsonProperty("sprintSyncField")] + public CustomField SprintSyncField { get; set; } + + /// + /// If true, subtasks of the cards, that are present on the board, will be hidden if they match board query. This + /// setting applies only if isExplicit == false. + /// + [JsonProperty("hideSubtasksOfCards")] + public bool HideSubtasksOfCards { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/SwimlaneEntityAttributeValue.cs b/src/YouTrackSharp/Agiles/SwimlaneEntityAttributeValue.cs new file mode 100644 index 00000000..d7773967 --- /dev/null +++ b/src/YouTrackSharp/Agiles/SwimlaneEntityAttributeValue.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents a single swimlane in case of AttributeBasedSwimlaneSettings. + /// + public class SwimlaneEntityAttributeValue : DatabaseAttributeValue + { + /// + /// Name of the swimlane. Can be null. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// If true, issues in this swimlane are considered to be resolved. Can be updated only for newly created value. + /// Read-only. + /// + [JsonProperty("isResolved")] + public bool IsResolved { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/SwimlaneSettings.cs b/src/YouTrackSharp/Agiles/SwimlaneSettings.cs new file mode 100644 index 00000000..479bc7f9 --- /dev/null +++ b/src/YouTrackSharp/Agiles/SwimlaneSettings.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; +using YouTrackSharp.SerializationAttributes; + +namespace YouTrackSharp.Agiles +{ + /// + /// Base entity for different swimlane settings + /// + [KnownType(typeof(AttributeBasedSwimlaneSettings))] + [KnownType(typeof(IssueBasedSwimlaneSettings))] + public class SwimlaneSettings + { + /// + /// Id of the SwimlaneSettings. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// Name of a value. Read-only. Can be null. + /// + [JsonProperty("name")] + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/SwimlaneValue.cs b/src/YouTrackSharp/Agiles/SwimlaneValue.cs new file mode 100644 index 00000000..5f2433a3 --- /dev/null +++ b/src/YouTrackSharp/Agiles/SwimlaneValue.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents single swimlane in case of IssueBasedSwimlaneSettings. + /// + public class SwimlaneValue + { + /// + /// Id of the SwimlaneValue. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// Name of a value. Read-only. Can be null. + /// + [JsonProperty("name")] + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/User.cs b/src/YouTrackSharp/Agiles/User.cs new file mode 100644 index 00000000..25f99515 --- /dev/null +++ b/src/YouTrackSharp/Agiles/User.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents a user in the context of an agile board. The class only provides the id, ringId (hub ID) and full name + /// of the user, see for more info on how to access a YouTrack user. + /// + public class User + { + /// + /// Id of the User. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// ID of the user in Hub. You can use this ID for operations in Hub, and for matching users between YouTrack and + /// Hub. Read-only. Can be null. + /// + [JsonProperty("ringId")] + public string RingId { get; set; } + + /// + /// Full name of the user. + /// + [JsonProperty("fullName")] + public string FullName { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/UserGroup.cs b/src/YouTrackSharp/Agiles/UserGroup.cs new file mode 100644 index 00000000..0c68e62e --- /dev/null +++ b/src/YouTrackSharp/Agiles/UserGroup.cs @@ -0,0 +1,52 @@ +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents a group of users. + /// + public class UserGroup + { + /// + /// Id of the UserGroup. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// The name of the group. Read-only. Can be null. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// ID of the group in Hub. Use this ID for operations in Hub, and for matching groups between YouTrack and Hub. Read-only. Can be null. + /// + [JsonProperty("ringId")] + public string RingId { get; set; } + + /// + /// The number of users in the group. Read-only. + /// + [JsonProperty("userCount")] + public long UserCount { get; set; } + + /// + /// The URL of the group icon. Read-only. Can be null. + /// + [JsonProperty("icon")] + public string Icon { get; set; } + + /// + /// True if this group contains all users, otherwise false. Read-only. + /// + [JsonProperty("allUsersGroup")] + public bool AllUsersGroup { get; set; } + + /// + /// Project that has this group set as a team. Returns null, if there is no such project. Read-only. Can be null. + /// + [JsonProperty("teamForProject")] + public Project TeamForProject { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Agiles/WIPLimit.cs b/src/YouTrackSharp/Agiles/WIPLimit.cs new file mode 100644 index 00000000..3d31b1b7 --- /dev/null +++ b/src/YouTrackSharp/Agiles/WIPLimit.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; + +namespace YouTrackSharp.Agiles +{ + /// + /// Represents WIP limits for particular column. If they are not satisfied, the column will be highlighted in UI. + /// + public class WIPLimit + { + /// + /// Id of the WIPLimit. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// Maximum number of cards in column. Can be null. + /// + [JsonProperty("max")] + public int? Max { get; set; } + + /// + /// Minimum number of cards in column. Can be null. + /// + [JsonProperty("min")] + public int? Min { get; set; } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/ConnectionExtensions.cs b/src/YouTrackSharp/ConnectionExtensions.cs index 9258eebb..117018be 100644 --- a/src/YouTrackSharp/ConnectionExtensions.cs +++ b/src/YouTrackSharp/ConnectionExtensions.cs @@ -1,5 +1,7 @@ using System; using YouTrackSharp.AgileBoards; +using YouTrackSharp.Agiles; +using YouTrackSharp.Internal; using YouTrackSharp.Issues; using YouTrackSharp.Management; using YouTrackSharp.Projects; @@ -71,6 +73,16 @@ public static IProjectCustomFieldsService ProjectCustomFieldsService(this Connec { return new ProjectCustomFieldsService(connection); } + + /// + /// Creates a . + /// + /// The to create a service with. + /// for working with YouTrack agile boards. + public static IAgileService CreateAgileService(this Connection connection) + { + return new AgileService(connection, new FieldSyntaxEncoder()); + } /// /// Creates a . diff --git a/src/YouTrackSharp/Internal/Field.cs b/src/YouTrackSharp/Internal/Field.cs new file mode 100644 index 00000000..31557d05 --- /dev/null +++ b/src/YouTrackSharp/Internal/Field.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; + +namespace YouTrackSharp.Internal +{ + /// + /// Simplified representation of an object's field. + /// It describes the name of the field and its sub-fields. + /// The 'subfields' are the members of the this field's type
+ ///
+ /// + /// This class allows to encode the recursive structure of an object's fields. + /// An object's field has a type, which itself has other members, each being of a type, and so on, down to the + /// primitive types.
+ ///
+ public class Field + { + /// + /// Name of the object's field + /// + public string Name { get; } + + /// + /// Sub-fields of this field's type + /// + public List Subfields { get; } + + /// + /// Creates an instance of , with the given name, type and subfields. + /// + /// Name of the field + /// Sub-fields of the field's type + public Field(string name, IEnumerable subfields) + { + Name = name; + Subfields = subfields.ToList(); + } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Internal/FieldSyntaxEncoder.cs b/src/YouTrackSharp/Internal/FieldSyntaxEncoder.cs new file mode 100644 index 00000000..53d210f1 --- /dev/null +++ b/src/YouTrackSharp/Internal/FieldSyntaxEncoder.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using YouTrackSharp.SerializationAttributes; + +namespace YouTrackSharp.Internal +{ + /// + /// Used to convert a given to the Youtrack REST API's 'fields' syntax, described at the following + /// location: + /// https://www.jetbrains.com/help/youtrack/standalone/api-fields-syntax.html + /// . + /// + public class FieldSyntaxEncoder + { + /// + /// Cached used to store types that were already resolved. + /// + /// + /// The result of the field query can vary depending on the verbose option and the max nesting level. + /// Therefore, the key used is a combination of the , verbosity and max depth. + /// + private Dictionary, string> Cache { get; } + + /// + /// Creates an instance of + /// + public FieldSyntaxEncoder() + { + Cache = new Dictionary, string>(); + } + + /// + /// Encodes the given 's members to the Youtrack REST API's 'fields'-syntax, described at + /// + /// https://www.jetbrains.com/help/youtrack/standalone/api-fields-syntax.html + /// .
+ /// This method uses reflexion to retrieve and encodes all public writable properties, decorated with a + /// .
+ /// It also concatenates the fields retrieved from known subclasses of the given type (defined by the + /// ).
+ /// Because the base class and the different subclasses may have fields with similar names (but different types and + /// sub-fields), all the fields discovered this way are "factored" together. Therefore, if two known subclasses both + /// have a field with the same name, but each with different types and so, different sub-fields, the two fields will + /// be factored into one containing the union of their respective sub-fields.
+ /// For example:
+ /// - The first subclass has a field named A with subfields B, C and D, ie. A(B,C,D)
+ /// - The second subclass also has a field A but with subfields D,E and F, ie. A(D,E,F)
+ /// In that case, these two fields will be combined into one, with name A and subfields B, C, D, E and F, ie. + /// A(B,C,D,E,F). Note that D appears only once. + ///
+ /// Type to encode + /// + /// Verbosity. If true, will include all public writable properties of the given , marked + /// with the . + /// But if false, it will exclude from these all the ones that were marked with the + /// . + /// + /// Max sub-field depth to go from the given type. + /// Fields of the given type, encoded to Youtrack + /// REST API's 'field' syntax + /// + /// + /// + /// This method does not work well with types that have (direct or indirect) reference loops. + /// For example, a class A that contains two fields of type B and C, where the type B itself contains a field of + /// type A. This would result in a stack-overflow. + /// + public string Encode(Type type, bool verbose = true, int maxDepth = Int32.MaxValue) + { + type = GetLeafType(type); + + Tuple key = new Tuple(type, verbose, maxDepth); + + if (Cache.ContainsKey(key)) + { + return Cache[key]; + } + + IEnumerable fields = GetFields(type, verbose, maxDepth, 0); + + Cache[key] = string.Join(",", fields.Select(ToString)); + + return Cache[key]; + } + + /// + /// Converts the recursive structure into a string representation, following Youtrack REST API's + /// 'field' syntax + /// Example: lines(fromPoint(x,y),toPoint(x,y)),color + /// + /// to convert + /// + private string ToString(Field field) + { + if (!field.Subfields.Any()) + { + return field.Name; + } + + string subfields = string.Join(",", field.Subfields.Select(ToString)); + + return $"{field.Name}({subfields})"; + } + + /// + /// Recursive method which builds the structure for the given 's properties. + /// This method uses reflexion to convert all the public writable properties of teh given , + /// decorated with a to a
. + /// It also retrieves the fields from known subclasses of the given type (defined by the + /// ).
+ /// Because the base class and the different subclasses may have fields with similar names (but different types and + /// sub-fields), all the fields discovered this way are "factored" together. Therefore, if two known subclasses both + /// have a field with the same name, but different type, each with different sub-fields, the two fields will + /// be factored into one containing the union of their respective sub-fields.
+ /// For example:
+ /// - The first subclass has a field named A with subfields B, C and D, ie. A(B,C,D)
+ /// - The second subclass also has a field A but with subfields D,E and F, ie. A(D,E,F)
+ /// In that case, these two fields will be combined into one, with name A and subfields B, C, D, E and F, ie. + /// A(B,C,D,E,F). Note that D appears only once. + ///
+ /// Type to convert + /// + /// Verbosity. If true, will include all public writable properties of the given , marked + /// with the . + /// But if false, it will exclude from these all the ones that were marked with the + /// . + /// + /// Max sub-field depth to go from the given type. + /// Current sub-field level (to compare with ) + /// Fields of the given type, encoded to Youtrack + /// REST API's 'field' syntax + /// + /// + /// + /// This method does not work well with types that have (direct or indirect) reference loops. + /// For example, a class A that contains two fields of type B and C, where the type B itself contains a field of + /// type A. This would result in a stack-overflow. + /// + private IEnumerable GetFields(Type type, bool verbose, int maxDepth, int level) + { + if (level == maxDepth) + { + return Enumerable.Empty(); + } + + List fields = new List(); + + IEnumerable properties = GetProperties(type, verbose); + foreach (PropertyInfo propertyInfo in properties) + { + Type propertyType = GetLeafType(propertyInfo.PropertyType); + string propertyName = propertyInfo.GetCustomAttribute()?.PropertyName; + + IEnumerable subfields = GetFields(propertyType, verbose, maxDepth, level + 1); + + fields.Add(new Field(propertyName, subfields)); + } + + return Merge(fields); + } + + /// + /// Merges the given fields into a factored representation, where fields with the same name are combined into one. + /// For example, if there are two fields named "A"
+ /// - The first with subfields B, C and D, ie. A(B,C,D)
+ /// - The second with subfields D,E and F, ie. A(D,E,F)
+ /// These two fields will be combined into one, with name A and subfields B, C, D, E and F, ie. + /// A(B,C,D,E,F). Note that D appears only once. + ///
+ /// s to factor + /// Factored fields + private IEnumerable Merge(IEnumerable fields) + { + IEnumerable> groups = fields.GroupBy(field => field.Name); + + foreach (IGrouping group in groups) + { + IEnumerable subfields = group.SelectMany(f => f.Subfields); + subfields = Merge(subfields); + + yield return new Field(group.Key, subfields); + } + } + + /// + /// Retrieves the of all public, writable properties marked with a + /// of the given and + /// all of its known subclasses (defined by the . + /// If is set to false, it will exclude the ones marked with a + /// . + /// + /// from which to retrieve the properties + /// + /// If false the properties marked with will be excluded from the results. + /// + /// + /// The of all public, writable properties marked with a + /// of the given and + /// all of its known subclasses. + /// + private IEnumerable GetProperties(Type type, bool verbose) + { + PropertyInfo[] properties = type.GetProperties(); + + IEnumerable propertyInfos = properties.Where(p => p.CanWrite) + .Where( + p => + p.GetCustomAttribute() != + null); + + if (!verbose) + { + propertyInfos = propertyInfos.Where(p => p.GetCustomAttribute() == null); + } + + IEnumerable knownSubtypes = + type.GetCustomAttributes(false).Select(attr => attr.Type); + IEnumerable subtypesProperties = + knownSubtypes.SelectMany(subtype => GetProperties(subtype, verbose)); + + return propertyInfos.Concat(subtypesProperties); + } + + /// + /// Returns the "leaf" type argument of any generic type.
+ /// Note: Generic types are nested, the "leaf" meaning the non-generic type at the bottom of the nesting chain + ///
+ /// For example, a of , will yield . + /// And a of of + /// will also yield .
+ ///
+ /// For generic types with multiple type parameters, it will always follow the last, which is usually the value type. + /// For example, a of key and value , + /// will yield .
+ /// A of key and value of + /// , will also yield . + ///
+ /// Input + /// Leaf type if the input type is generic, otherwise itself + /// + /// The returned type is guaranteed to be non-generic. + /// + private Type GetLeafType(Type type) + { + if (!type.IsGenericType) + { + return type; + } + + return GetLeafType(type.GetGenericArguments().Last()); + } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Json/KnownTypeConverter.cs b/src/YouTrackSharp/Json/KnownTypeConverter.cs new file mode 100644 index 00000000..5bbb793f --- /dev/null +++ b/src/YouTrackSharp/Json/KnownTypeConverter.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using YouTrackSharp.SerializationAttributes; + +namespace YouTrackSharp.Json +{ + /// + /// This allows to convert a JSON string into a concrete object that extends a given base + /// type.
+ /// This is used in conjunction with the , defined on + /// the base class, and which lists the possible sub-types of that class.

+ /// Among these, the concrete sub-type instantiated is inferred from the Json object's "$type" field, which is + /// compared to the defined known types (ignoring their namespace).
+ /// If the "$type" field is undefined, or no matching the "$type" parameter is + /// found, the base class is instantiated instead. + ///
+ /// Base type + public class KnownTypeConverter : JsonConverter where T : new() + { + private readonly TypedJObjectConverter _objectConverter; + + /// + public override bool CanRead => true; + + /// + public override bool CanWrite => false; + + public KnownTypeConverter() + { + _objectConverter = new TypedJObjectConverter(); + } + + /// + public override void WriteJson(JsonWriter writer, T value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + + /// + /// Reads the JSON representation of the object.
+ /// This method will instanciate a subclass of the given , based on the "$type" parameter + /// of the json string, which is compared to defined for that base class.
+ /// If the "$type" parameter is not defined, or no matching is found, the json is + /// deserialized to an instance of the base class directly. + ///
+ /// The to read from. + /// Type of the object. + /// The existing value of object being read. If there is no existing value then null will be used. + /// The existing value has a value. + /// The calling serializer. + /// The object value. + public override T ReadJson(JsonReader reader, Type objectType, T existingValue, bool hasExistingValue, + JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return default; + } + + List types = typeof(T).GetCustomAttributes().Select(attr => attr.Type).ToList(); + + JObject obj = JObject.Load(reader); + + return _objectConverter.ReadObject(obj, types, serializer); + } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Json/KnownTypeListConverter.cs b/src/YouTrackSharp/Json/KnownTypeListConverter.cs new file mode 100644 index 00000000..0d773245 --- /dev/null +++ b/src/YouTrackSharp/Json/KnownTypeListConverter.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using YouTrackSharp.SerializationAttributes; + +namespace YouTrackSharp.Json +{ + /// + /// This class allows to convert a JSON array into a list of objects, each extending a given base type.
+ /// This is used in conjunction with the , defined on + /// the base class, and which lists the possible sub-types of that class.

+ /// Among these, the concrete sub-type instantiated is inferred from each Json object's "$type" field, which is + /// compared to the defined known types (ignoring their namespace).
+ /// If the "$type" field is undefined, or no matching the "$type" parameter is + /// found, the base class is instantiated instead. + ///
+ /// Base type + public class KnownTypeListConverter : JsonConverter> where T : new() + { + private readonly TypedJObjectConverter _objectConverter; + + /// + public override bool CanRead => true; + + /// + public override bool CanWrite => false; + + public KnownTypeListConverter() + { + _objectConverter = new TypedJObjectConverter(); + } + + /// + public override void WriteJson(JsonWriter writer, List value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + + /// + /// Reads the JSON representation of the object.
+ /// This method will instanciate a subclass of the given , based on the "$type" parameter + /// of the json string, which is compared to defined for that base class.
+ /// If the "$type" parameter is not defined, or no matching is found, the json is + /// deserialized to an instance of the base class directly. + ///
+ /// The to read from. + /// Type of the object. + /// The existing value of object being read. If there is no existing value then null will be used. + /// The existing value has a value. + /// The calling serializer. + /// The object value. + public override List ReadJson(JsonReader reader, Type objectType, List existingValue, + bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return null; + } + + List knownTypes = + typeof(T).GetCustomAttributes().Select(attr => attr.Type).ToList(); + + JArray array = JArray.Load(reader); + + return array.Select(token => _objectConverter.ReadObject(token as JObject, knownTypes, serializer)) + .ToList(); + } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/Json/TypedJObjectConverter.cs b/src/YouTrackSharp/Json/TypedJObjectConverter.cs new file mode 100644 index 00000000..e66d75da --- /dev/null +++ b/src/YouTrackSharp/Json/TypedJObjectConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace YouTrackSharp.Json +{ + public class TypedJObjectConverter + { + public T ReadObject(JObject token, List knownTypes, JsonSerializer serializer) + { + if (token == null) + { + throw new ArgumentException("Invalid token"); + } + + string jsonType = token["$type"]?.ToString(); + if (jsonType == null) + { + return token.ToObject(serializer); + } + + Type type = knownTypes.FirstOrDefault(t => t.Name.EndsWith(jsonType)); + + if (type == null) + { + return token.ToObject(serializer); + } + + return (T)token.ToObject(type, serializer); + } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/SerializationAttributes/KnownTypeAttribute.cs b/src/YouTrackSharp/SerializationAttributes/KnownTypeAttribute.cs new file mode 100644 index 00000000..317b51a8 --- /dev/null +++ b/src/YouTrackSharp/SerializationAttributes/KnownTypeAttribute.cs @@ -0,0 +1,27 @@ +using System; + +namespace YouTrackSharp.SerializationAttributes +{ + /// + /// Attribute used to decorate a parent class with its known subtypes.
+ /// This is used to identify candidate subclasses when generating REST requests (to include the subclasses' + /// properties). + ///
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public class KnownTypeAttribute : Attribute + { + /// + /// Known subclass's type + /// + public Type Type { get; } + + /// + /// Creates an instance of the , with the given subclass' type. + /// + /// + public KnownTypeAttribute(Type type) + { + Type = type; + } + } +} \ No newline at end of file diff --git a/src/YouTrackSharp/SerializationAttributes/VerboseAttribute.cs b/src/YouTrackSharp/SerializationAttributes/VerboseAttribute.cs new file mode 100644 index 00000000..80b94321 --- /dev/null +++ b/src/YouTrackSharp/SerializationAttributes/VerboseAttribute.cs @@ -0,0 +1,11 @@ +using System; + +namespace YouTrackSharp.SerializationAttributes +{ + /// + /// This attribute is used to mark a property as being only retrieved when verbose information was requested. + /// + [AttributeUsage(AttributeTargets.Property)] + public class VerboseAttribute : Attribute + { } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Infrastructure/ConnectionStub.cs b/tests/YouTrackSharp.Tests/Infrastructure/ConnectionStub.cs new file mode 100644 index 00000000..22696a18 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Infrastructure/ConnectionStub.cs @@ -0,0 +1,41 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace YouTrackSharp.Tests.Infrastructure +{ + /// + /// Connection mock used to return predefined HTTP responses, for testing purposes. + /// + public class ConnectionStub : Connection + { + private readonly HttpClientHandler _handler; + private TimeSpan TimeOut => TimeSpan.FromSeconds(100); + + /// + /// Creates an instance of with give response delegate + /// + /// + /// to associate to this connection. + /// This can be used to pass a stub handler for testing purposes. + /// + public ConnectionStub(HttpClientHandler handler) : base("http://fake.connection.com/") + { + _handler = handler; + } + + /// + /// Creates an configured to return a predefined message and HTTP status + /// on request. + /// + /// configured to return a predefined message and HTTP status + public override Task GetAuthenticatedHttpClient() + { + HttpClient httpClient = new HttpClient(_handler); + httpClient.BaseAddress = ServerUri; + httpClient.Timeout = TimeOut; + + return Task.FromResult(httpClient); + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Infrastructure/Connections.cs b/tests/YouTrackSharp.Tests/Infrastructure/Connections.cs index 2a45479b..94d5a0ab 100644 --- a/tests/YouTrackSharp.Tests/Infrastructure/Connections.cs +++ b/tests/YouTrackSharp.Tests/Infrastructure/Connections.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Http; using System.Security.Authentication; @@ -22,7 +23,7 @@ public static string ServerUrl public static Connection Demo3Token => new BearerTokenConnection(ServerUrl, "perm:ZGVtbzM=.WW91VHJhY2tTaGFycA==.L04RdcCnjyW2UPCVg1qyb6dQflpzFy", ConfigureTestsHandler); - + public static class TestData { public static readonly List ValidConnections diff --git a/tests/YouTrackSharp.Tests/Infrastructure/JsonArrayHandler.cs b/tests/YouTrackSharp.Tests/Infrastructure/JsonArrayHandler.cs new file mode 100644 index 00000000..83765e16 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Infrastructure/JsonArrayHandler.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace YouTrackSharp.Tests.Infrastructure +{ + /// + /// This handler is used to return a json array from a range of give json strings, created from the $top and $skip + /// parameters of the HTTP request. + /// This handler can be used to simulate a server returning json arrays in batches. + /// + public class JsonArrayHandler : HttpClientHandler + { + private readonly ICollection _jsonObjects; + public int RequestsReceived { get; private set; } + + /// + /// Creates an instance of + /// + /// List of json objects that this instance will pick from + public JsonArrayHandler(params string[] jsonObjects) + { + _jsonObjects = jsonObjects; + RequestsReceived = 0; + } + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestsReceived++; + + GetRequestedRange(request, _jsonObjects.Count, out int skip, out int count); + string json = GetJsonArray(_jsonObjects, skip, count); + + HttpResponseMessage response = new HttpResponseMessage(HttpStatusCode.OK); + response.Content = new StringContent(json); + + return Task.FromResult(response); + } + + /// + /// Creates a JSON array from a range of the given json strings. + /// This allows to simulate returning a total number of elements, in batches. + /// + /// JSON objects from which the JSON array will be created + /// Number of items to skip + /// Number of items to return + /// + /// Json array + /// + private string GetJsonArray(ICollection jsonObjects, int skip, int count) + { + IEnumerable jsonObjectRange = jsonObjects.Skip(skip).Take(count); + string json = $"[{string.Join(",", jsonObjectRange)}]"; + + return json; + } + + /// + /// Parses the $skip and $top parameters from a Youtrack REST request URI, and computes the requested range + /// of objects to return (capped by ). + /// + /// HTTP request + /// Max index (range will not go beyond that index, even if $skip + $top is greater + /// Number of items to skip + /// Number of items to return + /// Range computed from request's $skip and $top + private void GetRequestedRange(HttpRequestMessage request, int maxIndex, out int skip, out int count) + { + string requestUri = request.RequestUri.ToString(); + + Match match = Regex.Match(requestUri, "&\\$top=(?[0-9]+)(&\\$skip=(?[0-9]+))?|&\\$skip=(?[0-9]+)(&\\$top=(?[0-9]+))?"); + + count = maxIndex; + if (match.Groups.ContainsKey("top") && match.Groups["top"].Success) + { + count = int.Parse(match.Groups["top"].Value); + } + + skip = 0; + if (match.Groups.ContainsKey("skip") && match.Groups["skip"].Success) + { + skip = int.Parse(match.Groups["skip"].Value); + } + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Infrastructure/JsonHandler.cs b/tests/YouTrackSharp.Tests/Infrastructure/JsonHandler.cs new file mode 100644 index 00000000..bfb5360c --- /dev/null +++ b/tests/YouTrackSharp.Tests/Infrastructure/JsonHandler.cs @@ -0,0 +1,33 @@ +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace YouTrackSharp.Tests.Infrastructure +{ + /// + /// This handler returns a predefined json string on every request. + /// + public class JsonHandler : HttpClientHandler + { + private readonly string _json; + + /// + /// Creates an instance of + /// + /// Json string that will be returned upon each request + public JsonHandler(string json) + { + _json = json; + } + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpResponseMessage response = new HttpResponseMessage(HttpStatusCode.OK); + response.Content = new StringContent(_json); + + return Task.FromResult(response); + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Integration/Agiles/AgileServiceTest.cs b/tests/YouTrackSharp.Tests/Integration/Agiles/AgileServiceTest.cs new file mode 100644 index 00000000..ee1b8941 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Integration/Agiles/AgileServiceTest.cs @@ -0,0 +1,33 @@ +using System.IO; +using System.Linq; +using System.Reflection; + +namespace YouTrackSharp.Tests.Integration.Agiles +{ + public partial class AgileServiceTest + { + private static string DemoBoardId => "108-2"; + private static string DemoBoardNamePrefix => "Test Board597fb561-ea1f-4095-9636-859ae4439605"; + + private static string DemoSprintId => "109-2"; + private static string DemoSprintName => "First sprint"; + + private static string FullAgile01 => GetTextResource("YouTrackSharp.Tests.Resources.FullAgile01.json"); + private static string FullAgile02 => GetTextResource("YouTrackSharp.Tests.Resources.FullAgile02.json"); + + private static string GetTextResource(string name) + { + Assembly assembly = Assembly.GetExecutingAssembly(); + + using Stream stream = assembly.GetManifestResourceStream(name); + if (stream == null) + { + return string.Empty; + } + + using StreamReader reader = new StreamReader(stream); + + return reader.ReadToEnd(); + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Integration/Agiles/GetAgiles.cs b/tests/YouTrackSharp.Tests/Integration/Agiles/GetAgiles.cs new file mode 100644 index 00000000..a9cea6d2 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Integration/Agiles/GetAgiles.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Xunit; +using YouTrackSharp.Agiles; +using YouTrackSharp.Tests.Infrastructure; + +namespace YouTrackSharp.Tests.Integration.Agiles +{ + [UsedImplicitly] + public partial class AgileServiceTest + { + public class GetAgiles + { + [Fact] + public async Task Valid_Connection_Return_Existing_Agiles_Verbose() + { + // Arrange + IAgileService agileService = Connections.Demo1Token.CreateAgileService(); + + // Act + ICollection result = await agileService.GetAgileBoards(true); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + + Agile demoBoard = result.FirstOrDefault(); + Assert.NotNull(demoBoard); + Assert.Equal(DemoBoardId, demoBoard.Id); + Assert.Equal(DemoBoardNamePrefix, demoBoard.Name); + } + + [Fact] + public async Task Verbose_Disabled_Returns_Agiles_Non_Verbose() + { + // Arrange + IAgileService agileService = Connections.Demo1Token.CreateAgileService(); + + // Act + ICollection result = await agileService.GetAgileBoards(false); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + + Agile demoBoard = result.FirstOrDefault(); + Assert.NotNull(demoBoard); + Assert.Equal(DemoBoardId, demoBoard.Id); + Assert.Equal(DemoBoardNamePrefix, demoBoard.Name); + } + + [Fact] + public async Task Invalid_Connection_Throws_UnauthorizedConnectionException() + { + // Arrange + IAgileService agileService = Connections.UnauthorizedConnection.CreateAgileService(); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await agileService.GetAgileBoards()); + } + + [Fact] + public async Task Mock_Connection_Returns_Full_Agiles() + { + // Arrange + string[] strings = { FullAgile01, FullAgile02 }; + + JsonArrayHandler handler = new JsonArrayHandler(strings); + ConnectionStub connection = new ConnectionStub(handler); + + IAgileService agileService = connection.CreateAgileService(); + + // Act + ICollection result = await agileService.GetAgileBoards(true); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + foreach (Agile agile in result) + { + Assert.NotNull(agile); + + Assert.True("109-1".Equals(agile.Id) || "109-2".Equals(agile.Id)); + + Assert.NotNull(agile.ColumnSettings); + Assert.NotNull(agile.Projects); + Assert.NotNull(agile.Sprints); + Assert.NotNull(agile.Projects); + Assert.NotNull(agile.Sprints); + Assert.NotNull(agile.Status); + Assert.NotNull(agile.ColumnSettings); + Assert.NotNull(agile.CurrentSprint); + Assert.NotNull(agile.EstimationField); + Assert.NotNull(agile.SprintsSettings); + Assert.NotNull(agile.SwimlaneSettings); + Assert.NotNull(agile.ColorCoding); + Assert.NotNull(agile.UpdateableBy); + Assert.NotNull(agile.VisibleFor); + Assert.NotNull(agile.OriginalEstimationField); + + Sprint sprint = agile.Sprints.FirstOrDefault(); + Assert.NotNull(sprint); + Assert.Equal(DemoSprintId, sprint.Id); + Assert.Equal(DemoSprintName, sprint.Name); + + if ("109-1".Equals(agile.Id)) + { + Assert.Equal("Full Board 01", agile.Name); + Assert.IsType(agile.ColorCoding); + Assert.IsType(agile.SwimlaneSettings); + Assert.IsType(((IssueBasedSwimlaneSettings)agile.SwimlaneSettings).Field); + } + else + { + Assert.Equal("Full Board 02", agile.Name); + Assert.IsType(agile.ColorCoding); + Assert.IsType(agile.SwimlaneSettings); + Assert.IsType(((AttributeBasedSwimlaneSettings)agile.SwimlaneSettings).Field); + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Integration/Agiles/GetAgilesInMultipleBatches.cs b/tests/YouTrackSharp.Tests/Integration/Agiles/GetAgilesInMultipleBatches.cs new file mode 100644 index 00000000..ae3b6404 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Integration/Agiles/GetAgilesInMultipleBatches.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using YouTrackSharp.Agiles; +using YouTrackSharp.Tests.Infrastructure; + +namespace YouTrackSharp.Tests.Integration.Agiles +{ + public partial class AgileServiceTest + { + public class GetAgilesInMultipleBatches + { + [Fact] + public async Task Mock_Connection_Return_Many_Agiles_In_Batches() + { + // Arrange + const int totalAgileCount = 53; + int expectedRequests = (int)Math.Ceiling(totalAgileCount / 10.0); + + string[] jsonStrings = Enumerable.Range(0, totalAgileCount).Select(i => FullAgile01).ToArray(); + JsonArrayHandler handler = new JsonArrayHandler(jsonStrings); + ConnectionStub connection = new ConnectionStub(handler); + IAgileService agileService = connection.CreateAgileService(); + + // Act + ICollection result = await agileService.GetAgileBoards(true); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Equal(totalAgileCount, result.Count); + Assert.Equal(expectedRequests, handler.RequestsReceived); + } + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Internal/FieldSyntaxEncoderTestFixtures.cs b/tests/YouTrackSharp.Tests/Internal/FieldSyntaxEncoderTestFixtures.cs new file mode 100644 index 00000000..b95cfad7 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Internal/FieldSyntaxEncoderTestFixtures.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using Newtonsoft.Json; +using YouTrackSharp.SerializationAttributes; + +namespace YouTrackSharp.Tests.Internal +{ + [UsedImplicitly] + public class FieldSyntaxEncoderTestFixtures + { + public class FlatType + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [Verbose] + [JsonProperty("description")] + public string Description { get; set; } + } + + public class NestedTypes + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("info")] + public Info Info { get; set; } + } + + public class DeepNestedTypes + { + [JsonProperty("id")] + public int Id { get; set; } + + [Verbose] + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("contact")] + public DetailedContact Contact { get; set; } + } + + public class DeepNestedTypesWithInheritance + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string name { get; set; } + + [JsonProperty("contact")] + public Contact Contact { get; set; } + } + + public class DeepNestedTypesWithCollection + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string name { get; set; } + + [JsonProperty("contacts")] + public IList Contacts { get; set; } + } + + public class CyclicReference + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string name { get; set; } + + [Verbose] + [JsonProperty("cyclic")] + public CyclicReference Cyclic { get; set; } + } + + [KnownType(typeof(SimpleContact))] + [KnownType(typeof(DetailedContact))] + public class Contact + { + [JsonProperty("id")] + public int Id { get; set; } + } + + public class SimpleContact : Contact + { + [JsonProperty("fullName")] + public string FullName { get; set; } + + [Verbose] + [JsonProperty("info")] + public string Info { get; set; } + } + + public class DetailedContact : Contact + { + [JsonProperty("firstName")] + public string FirstName { get; set; } + + [JsonProperty("lastName")] + public string LastName { get; set; } + + [Verbose] + [JsonProperty("info")] + public Info Info { get; set; } + } + + public class Info + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("address")] + public string Address { get; set; } + + [JsonProperty("phoneNumber")] + public string PhoneNumber { get; set; } + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Internal/FieldSyntaxEncoderTests.cs b/tests/YouTrackSharp.Tests/Internal/FieldSyntaxEncoderTests.cs new file mode 100644 index 00000000..c326991b --- /dev/null +++ b/tests/YouTrackSharp.Tests/Internal/FieldSyntaxEncoderTests.cs @@ -0,0 +1,167 @@ +using JetBrains.Annotations; +using Xunit; +using YouTrackSharp.Internal; + +namespace YouTrackSharp.Tests.Internal +{ + [UsedImplicitly] + public class FieldSyntaxEncoderTests + { + public class UrlEncoding + { + [Fact] + public void Encode_Flat_Type() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.FlatType)); + + string expected = "id,name,description"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Flat_Type_Non_Verbose() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.FlatType), false); + + string expected = "id,name"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Single_Nested_Types() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.NestedTypes)); + + string expected = "id,name,info(id,address,phoneNumber)"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Deep_Nested_Types() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.DeepNestedTypes)); + + string expected = "id,name,contact(firstName,lastName,info(id,address,phoneNumber),id)"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Nested_Types_With_Inheritance_Merges_Common_Field_Info() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.DeepNestedTypesWithInheritance)); + + string expected = "id,name,contact(id,fullName,info(id,address,phoneNumber),firstName,lastName)"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Nested_Types_With_Collection() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.DeepNestedTypesWithCollection)); + + string expected = "id,name,contacts(id,fullName,info(id,address,phoneNumber),firstName,lastName)"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Nested_Types_Max_Depth_0() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.DeepNestedTypes), true, 0); + + string expected = string.Empty; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Nested_Types_Max_Depth_1() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.DeepNestedTypes), true, 1); + + string expected = "id,name,contact"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Nested_Types_Max_Depth_2() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.DeepNestedTypes), true, 2); + + string expected = "id,name,contact(firstName,lastName,info,id)"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Nested_Types_Max_Depth_3() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.DeepNestedTypes), true, 3); + + string expected = "id,name,contact(firstName,lastName,info(id,address,phoneNumber),id)"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Flat_Type_Not_Verbose() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.FlatType), false); + + string expected = "id,name"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Encode_Nested_Type_Not_Verbose() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.DeepNestedTypes), false); + + string expected = "id,contact(firstName,lastName,id)"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Control_Cyclic_References_With_Max_Depth() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.CyclicReference), true, 3); + + string expected = "id,name,cyclic(id,name,cyclic(id,name,cyclic))"; + + Assert.Equal(expected, encoded); + } + + [Fact] + public void Skip_Cyclic_References_With_Verbose_Disabled() + { + FieldSyntaxEncoder encoder = new FieldSyntaxEncoder(); + string encoded = encoder.Encode(typeof(FieldSyntaxEncoderTestFixtures.CyclicReference), false); + + string expected = "id,name"; + + Assert.Equal(expected, encoded); + } + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Json/KnownTypeConverterTestFixtures.cs b/tests/YouTrackSharp.Tests/Json/KnownTypeConverterTestFixtures.cs new file mode 100644 index 00000000..cb45aa30 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Json/KnownTypeConverterTestFixtures.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using Newtonsoft.Json; +using YouTrackSharp.Json; +using YouTrackSharp.SerializationAttributes; + +namespace YouTrackSharp.Tests.Json +{ + [UsedImplicitly] + public class KnownTypeConverterTestFixtures + { + public class Json + { + /// + /// Creates a Json string representation of a instance, with given id and name. + /// + /// Id + /// Name + /// Json representation of with given id and name + public string GetJsonForChildA(int id, string name) + { + return $"{{ \"id\": {id}, \"name\": \"{name}\", \"$type\": \"ChildA\" }}"; + } + + /// + /// Creates a Json string representation of a instance, with given id and title. + /// + /// Id + /// Title + /// Json representation of with given id and title + public string GetJsonForChildB(int id, string title) + { + return $"{{ \"id\": {id}, \"title\": \"{title}\", \"$type\": \"ChildB\" }}"; + } + + /// + /// Creates a Json string representation of an unknown sub-type of (unknown to + /// serialization), with given id and title. + /// + /// Id of object + /// Title of object + /// Json representation of an unknown sub-type of + public string GetJsonForUnknownType(int id, string title) + { + return $"{{ \"id\": {id}, \"title\": \"{title}\", \"$type\": \"UnknownType\" }}"; + } + + /// + /// Creates a Json string representation of an object, with no "$type" field, with given id and title. + /// + /// Id of object + /// Title of object + /// Json representation of an untyped object + public string GetJsonForUnspecifiedType(int id, string title) + { + return $"{{ \"id\": {id}, \"title\": \"{title}\" }}"; + } + + /// + /// Creates a Json representation of a , with given id, name.
+ /// The instance is a json representation of concrete type , + /// with given child id and child name. + ///
+ /// Id + /// Name + /// Id of child + /// Name of child + /// + /// Json representation of , with instance variable. + /// + public string GetJsonForCompoundType(int id, string name, int childId, string childName) + { + string childJson = GetJsonForChildA(childId, childName); + + return $"{{ \"id\": {id}, \"name\": \"{name}\", \"child\": {childJson}, \"$type\": \"CompoundType\" }}"; + } + + /// + /// Creates a Json representation of a , with given id, name, but null child
+ ///
+ /// Id + /// Name + /// + /// Json representation of , with null child. + /// + public string GetJsonForCompoundTypeWithNullField(int id, string name) + { + return $"{{ \"id\": {id}, \"name\": \"{name}\", \"child\": null, \"$type\": \"CompoundType\" }}"; + } + + /// + /// Creates Json representation of , with given id and name. + /// The is a list made of multiple and + /// instances, created from the given enumerable. + /// This enumerable contains a tuple per child to create, with the concrete type to use ( or + /// , the child id and its name). + /// + /// Id + /// Name + /// Children specifications + /// + /// Json representation of , with array of children of types + /// or . + /// + public string GetJsonForCompoundTypeWithList(int id, string name, + IEnumerable> children) + { + IEnumerable childrenJson = + children.Select(child => GetJsonForChild(child.Item1, child.Item2, child.Item3)); + + string childrenJsonArray = string.Join(", ", childrenJson); + + return $"{{ \"id\": {id}, \"name\": \"{name}\", \"children\": " + + $"[{childrenJsonArray}], \"$type\": \"CompoundTypeWithList\" }}"; + } + + /// + /// Creates json representation of , with given id and name, but + /// with children field set to null. + /// + /// Id + /// Name + /// + /// Json representation of with null children. + /// + public string GetJsonForCompoundTypeWithNullList(int id, string name) + { + return + $"{{ \"id\": {id}, \"name\": \"{name}\", \"children\": null, \"$type\": \"CompoundTypeWithList\" }}"; + } + + /// + /// Creates json representation of , with given id and name, but + /// with the children field set to an empty array. + /// + /// Id + /// Name + /// + /// Json representation of with empty children array. + /// + public string GetJsonForCompoundTypeWithEmptyList(int id, string name) + { + return + $"{{ \"id\": {id}, \"name\": \"{name}\", \"children\": [], \"$type\": \"CompoundTypeWithList\" }}"; + } + + /// + /// Creates Json representation of or , depending on the given + /// + /// + /// Concrete type ( or ) + /// Id of child + /// Name (for ) or Title (for ) + /// Json representation of given + private string GetJsonForChild(Type concreteType, int id, string nameOrTitle) + { + if (concreteType == typeof(ChildA)) + { + return GetJsonForChildA(id, nameOrTitle); + } + + return GetJsonForChildB(id, nameOrTitle); + } + } + + public class CompoundTypeWithList + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("children")] + [JsonConverter(typeof(KnownTypeListConverter))] + public List Children { get; set; } + } + + public class CompoundType + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("child")] + [JsonConverter(typeof(KnownTypeConverter))] + public BaseType Child { get; set; } + } + + [KnownType(typeof(ChildA))] + [KnownType(typeof(ChildB))] + public class BaseType + { + [JsonProperty("id")] + public int Id { get; set; } + } + + public class ChildA : BaseType + { + [JsonProperty("name")] + public string Name { get; set; } + } + + public class ChildB : BaseType + { + [JsonProperty("title")] + public string Title { get; set; } + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Json/KnownTypeConverterTests.cs b/tests/YouTrackSharp.Tests/Json/KnownTypeConverterTests.cs new file mode 100644 index 00000000..40fd18c7 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Json/KnownTypeConverterTests.cs @@ -0,0 +1,152 @@ +using System.IO; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Xunit; +using YouTrackSharp.Json; +using Fixtures = YouTrackSharp.Tests.Json.KnownTypeConverterTestFixtures; + +namespace YouTrackSharp.Tests.Json +{ + [UsedImplicitly] + public class KnownTypeConverterTests + { + public class ReadJson + { + [Fact] + public void Identifies_Correct_Subtype_ChildTypeA() + { + KnownTypeConverter converter = new KnownTypeConverter(); + Fixtures.Json fixtures = new Fixtures.Json(); + + string expectedName = "Example Name"; + int expectedId = 123; + + using JsonTextReader reader = + new JsonTextReader(new StringReader(fixtures.GetJsonForChildA(expectedId, expectedName))); + reader.Read(); + + // Act + Fixtures.ChildA result = + converter.ReadJson(reader, typeof(Fixtures.BaseType), null, + new JsonSerializer()) as Fixtures.ChildA; + + Assert.NotNull(result); + Assert.Equal(expectedId, result.Id); + Assert.Equal(expectedName, result.Name); + } + + [Fact] + public void Identifies_Correct_Subtype_ChildTypeB() + { + KnownTypeConverter converter = new KnownTypeConverter(); + Fixtures.Json fixtures = new Fixtures.Json(); + + string expectedTitle = "Example Title"; + int expectedId = 123; + + using JsonTextReader reader = + new JsonTextReader(new StringReader(fixtures.GetJsonForChildB(expectedId, expectedTitle))); + reader.Read(); + + // Act + Fixtures.ChildB result = + converter.ReadJson(reader, typeof(Fixtures.BaseType), null, + new JsonSerializer()) as Fixtures.ChildB; + + Assert.NotNull(result); + Assert.Equal(expectedId, result.Id); + Assert.Equal(expectedTitle, result.Title); + } + + [Fact] + public void Unknown_Subtype_Defaults_To_BaseType() + { + KnownTypeConverter converter = new KnownTypeConverter(); + Fixtures.Json fixtures = new Fixtures.Json(); + + string expectedTitle = "Example Title"; + int expectedId = 123; + + using JsonTextReader reader = + new JsonTextReader(new StringReader(fixtures.GetJsonForUnknownType(expectedId, expectedTitle))); + reader.Read(); + + // Act + Fixtures.BaseType result = + converter.ReadJson(reader, typeof(Fixtures.BaseType), null, new JsonSerializer()) as + Fixtures.BaseType; + + Assert.NotNull(result); + Assert.Equal(expectedId, result.Id); + } + + [Fact] + public void Unspecified_Type_Defaults_To_BaseType() + { + KnownTypeConverter converter = new KnownTypeConverter(); + Fixtures.Json fixtures = new Fixtures.Json(); + + string expectedTitle = "Example Title"; + int expectedId = 123; + + using JsonTextReader reader = + new JsonTextReader(new StringReader(fixtures.GetJsonForUnspecifiedType(expectedId, expectedTitle))); + reader.Read(); + + // Act + Fixtures.BaseType result = + converter.ReadJson(reader, typeof(Fixtures.BaseType), null, new JsonSerializer()) as + Fixtures.BaseType; + + Assert.NotNull(result); + Assert.Equal(expectedId, result.Id); + } + + [Fact] + public void Deserialize_Type_With_Polymorphic_Field() + { + string expectedName = "Example Name"; + int expectedId = 123; + + string expectedChildName = "Child Name"; + int expectedChildId = 456; + + Fixtures.Json fixtures = new Fixtures.Json(); + string json = + fixtures.GetJsonForCompoundType(expectedId, expectedName, expectedChildId, expectedChildName); + + Fixtures.CompoundType result = JsonConvert.DeserializeObject(json); + + + Assert.NotNull(result); + Assert.Equal(expectedId, result.Id); + Assert.Equal(expectedName, result.Name); + + Fixtures.ChildA child = result.Child as Fixtures.ChildA; + Assert.NotNull(child); + Assert.Equal(expectedChildId, child.Id); + Assert.Equal(expectedChildName, child.Name); + } + + [Fact] + public void Deserialize_Type_With_Null_Polymorphic_Field() + { + string expectedName = "Example Name"; + int expectedId = 123; + + Fixtures.Json fixtures = new Fixtures.Json(); + string json = fixtures.GetJsonForCompoundTypeWithNullField(expectedId, expectedName); + + Fixtures.CompoundType result = JsonConvert.DeserializeObject(json); + + + Assert.NotNull(result); + Assert.Equal(expectedId, result.Id); + Assert.Equal(expectedName, result.Name); + + Fixtures.ChildA child = result.Child as Fixtures.ChildA; + Assert.Null(child); + } + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Json/KnownTypeListConverterTests.cs b/tests/YouTrackSharp.Tests/Json/KnownTypeListConverterTests.cs new file mode 100644 index 00000000..21c6e9c5 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Json/KnownTypeListConverterTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Xunit; +using Fixtures = YouTrackSharp.Tests.Json.KnownTypeConverterTestFixtures; + +namespace YouTrackSharp.Tests.Json +{ + [UsedImplicitly] + public class KnownTypeListConverterTests + { + public class ReadJson + { + [Fact] + public void Deserialize_Type_With_Polymorphic_Collection() + { + string expectedName = "Example Name"; + int expectedId = 123; + + IList> expectedChildren = new List>(); + + for (int i = 0; i < 10; i++) + { + Type type = i % 2 == 0 ? typeof(Fixtures.ChildA) : typeof(Fixtures.ChildB); + expectedChildren.Add(new Tuple(type, i, $"Child {i}")); + } + + Fixtures.Json fixtures = new Fixtures.Json(); + string json = fixtures.GetJsonForCompoundTypeWithList(expectedId, expectedName, expectedChildren); + + Fixtures.CompoundTypeWithList result = + JsonConvert.DeserializeObject(json); + + Assert.NotNull(result); + Assert.Equal(expectedId, result.Id); + Assert.Equal(expectedName, result.Name); + + Assert.NotNull(result.Children); + foreach (Fixtures.BaseType child in result.Children) + { + int id = child.Id; + Tuple expectedChild = expectedChildren.FirstOrDefault(c => c.Item2 == id); + + Assert.NotNull(expectedChild); + + Assert.Equal(expectedChild.Item1, child.GetType()); + + switch (child) + { + case Fixtures.ChildA childA: + Assert.Equal(expectedChild.Item3, childA.Name); + break; + + case Fixtures.ChildB childB: + Assert.Equal(expectedChild.Item3, childB.Title); + break; + + default: + Assert.True(false, "Child was not deserialized to recognizable type"); + break; + } + } + } + + [Fact] + public void Deserialize_Type_With_Polymorphic_Collection_Null() + { + string expectedName = "Example Name"; + int expectedId = 123; + + Fixtures.Json fixtures = new Fixtures.Json(); + string json = fixtures.GetJsonForCompoundTypeWithNullList(expectedId, expectedName); + + Fixtures.CompoundTypeWithList result = + JsonConvert.DeserializeObject(json); + + Assert.NotNull(result); + Assert.Equal(expectedId, result.Id); + Assert.Equal(expectedName, result.Name); + + Assert.Null(result.Children); + } + + [Fact] + public void Deserialize_Type_With_Polymorphic_Collection_Empty() + { + string expectedName = "Example Name"; + int expectedId = 123; + + Fixtures.Json fixtures = new Fixtures.Json(); + string json = fixtures.GetJsonForCompoundTypeWithEmptyList(expectedId, expectedName); + + Fixtures.CompoundTypeWithList result = + JsonConvert.DeserializeObject(json); + + Assert.NotNull(result); + Assert.Equal(expectedId, result.Id); + Assert.Equal(expectedName, result.Name); + Assert.NotNull(result.Children); + Assert.Empty(result.Children); + } + } + } +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Resources/CompleteAgile.json b/tests/YouTrackSharp.Tests/Resources/CompleteAgile.json new file mode 100644 index 00000000..0a65cffb --- /dev/null +++ b/tests/YouTrackSharp.Tests/Resources/CompleteAgile.json @@ -0,0 +1,200 @@ +{ + "projects": [ + { + "shortName": "DP1", + "name": "DemoProject1", + "id": "0-1", + "$type": "Project" + } + ], + "swimlaneSettings": { + "field": { + "customField": { + "name": "Type", + "$type": "CustomField" + }, + "presentation": "Type", + "id": "51-2", + "name": "Type", + "$type": "CustomFilterField" + }, + "values": [ + { + "name": "Feature", + "id": "Feature", + "$type": "SwimlaneValue" + } + ], + "defaultCardType": { + "name": "Task", + "id": "Task", + "$type": "SwimlaneValue" + }, + "id": "105-3", + "$type": "IssueBasedSwimlaneSettings" + }, + "estimationField": { + "name": "Estimation", + "$type": "CustomField" + }, + "sprints": [ + { + "name": "First sprint", + "id": "109-2", + "$type": "Sprint" + } + ], + "hideOrphansSwimlane": false, + "orphansAtTheTop": false, + "visibleForProjectBased": true, + "updateableByProjectBased": true, + "columnSettings": { + "field": { + "name": "State", + "$type": "CustomField" + }, + "columns": [ + { + "fieldValues": [ + { + "name": "Open", + "isResolved": false, + "id": "111-12", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 0, + "presentation": "Open", + "wipLimit": null, + "isResolved": false, + "id": "110-8", + "$type": "AgileColumn" + }, + { + "fieldValues": [ + { + "name": "In Progress", + "isResolved": false, + "id": "111-13", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 1, + "presentation": "In Progress", + "wipLimit": null, + "isResolved": false, + "id": "110-9", + "$type": "AgileColumn" + }, + { + "fieldValues": [ + { + "name": "Fixed", + "isResolved": true, + "id": "111-14", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 2, + "presentation": "Fixed", + "wipLimit": null, + "isResolved": true, + "id": "110-10", + "$type": "AgileColumn" + }, + { + "fieldValues": [ + { + "name": "Verified", + "isResolved": true, + "id": "111-15", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 3, + "presentation": "Verified", + "wipLimit": null, + "isResolved": true, + "id": "110-11", + "$type": "AgileColumn" + } + ], + "id": "108-2", + "$type": "ColumnSettings" + }, + "colorCoding": { + "prototype": { + "name": "Type", + "$type": "CustomField" + }, + "id": "108-5", + "$type": "FieldBasedColorCoding" + }, + "currentSprint": { + "name": "First sprint", + "id": "109-2", + "$type": "Sprint" + }, + "originalEstimationField": { + "name": "OriginalEstimationField", + "$type": "CustomField" + }, + "sprintsSettings": { + "explicitQuery": null, + "isExplicit": true, + "disableSprints": false, + "hideSubtasksOfCards": false, + "cardOnSeveralSprints": false, + "defaultSprint": null, + "sprintSyncField": null, + "id": "108-2", + "$type": "SprintsSettings" + }, + "visibleFor": { + "allUsersGroup" : false, + "icon" : "String", + "name" : "String", + "ringId" : "String", + "teamForProject" : { + "shortName": "DP1", + "name": "DemoProject1", + "id": "0-1", + "$type": "Project" + }, + "usersCount" : 2, + "id" : "String", + "$type" : "UserGroup" + }, + "updateableBy": { + "allUsersGroup" : false, + "icon" : "String", + "name" : "String", + "ringId" : "String", + "teamForProject" : { + "shortName": "DP1", + "name": "DemoProject1", + "id": "0-1", + "$type": "Project" + }, + "usersCount" : 2, + "id" : "String", + "$type" : "UserGroup" + }, + "status": { + "errors": [], + "valid": true, + "hasJobs": false, + "warnings": [], + "id": "boardStatus", + "$type": "AgileStatus" + }, + "owner": { + "fullName": "Demo User 1", + "ringId": "0692a47b-3670-452e-8e73-8b166a774705", + "id": "1-2", + "$type": "User" + }, + "name": "Test Board597fb561-ea1f-4095-9636-859ae4439605", + "id": "108-2", + "$type": "Agile" +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Resources/FullAgile01.json b/tests/YouTrackSharp.Tests/Resources/FullAgile01.json new file mode 100644 index 00000000..e12e6270 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Resources/FullAgile01.json @@ -0,0 +1,232 @@ +{ + "_comment": "Represents a full agile board (no fields are null), with FieldBasedColorCoding, IssueBasedSwimlaneSettings (and CustomFilterField)", + "projects": [ + { + "shortName": "DP1", + "name": "DemoProject1", + "id": "0-1", + "$type": "Project" + } + ], + "swimlaneSettings": { + "field": { + "customField": { + "name": "Type", + "$type": "CustomField" + }, + "presentation": "Type", + "id": "51-2", + "name": "Type", + "$type": "CustomFilterField" + }, + "values": [ + { + "name": "Feature", + "id": "Feature", + "$type": "SwimlaneValue" + } + ], + "defaultCardType": { + "name": "Task", + "id": "Task", + "$type": "SwimlaneValue" + }, + "id": "105-3", + "$type": "IssueBasedSwimlaneSettings" + }, + "estimationField": { + "name": "Estimation", + "$type": "CustomField" + }, + "sprints": [ + { + "name": "First sprint", + "id": "109-2", + "$type": "Sprint" + } + ], + "hideOrphansSwimlane": false, + "orphansAtTheTop": false, + "visibleForProjectBased": true, + "updateableByProjectBased": true, + "columnSettings": { + "field": { + "name": "State", + "$type": "CustomField" + }, + "columns": [ + { + "fieldValues": [ + { + "name": "Open", + "isResolved": true, + "id": "111-12", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 0, + "presentation": "Open", + "wipLimit": { + "id": "205-0", + "max": "10", + "min": "3", + "column": "3", + "$type": "WIPLimit" + }, + "isResolved": true, + "id": "110-8", + "$type": "AgileColumn" + }, + { + "fieldValues": [ + { + "name": "In Progress", + "isResolved": true, + "id": "111-13", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 1, + "presentation": "In Progress", + "wipLimit": { + "id": "205-1", + "max": "10", + "min": "3", + "column": "3", + "$type": "WIPLimit" + }, + "isResolved": true, + "id": "110-9", + "$type": "AgileColumn" + }, + { + "fieldValues": [ + { + "name": "Fixed", + "isResolved": true, + "id": "111-14", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 2, + "presentation": "Fixed", + "wipLimit": { + "id": "205-2", + "max": "10", + "min": "3", + "column": "3", + "$type": "WIPLimit" + }, + "isResolved": true, + "id": "110-10", + "$type": "AgileColumn" + }, + { + "fieldValues": [ + { + "name": "Verified", + "isResolved": true, + "id": "111-15", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 3, + "presentation": "Verified", + "wipLimit": { + "id": "205-3", + "max": "10", + "min": "3", + "column": "3", + "$type": "WIPLimit" + }, + "isResolved": true, + "id": "110-11", + "$type": "AgileColumn" + } + ], + "id": "108-2", + "$type": "ColumnSettings" + }, + "colorCoding": { + "prototype": { + "name": "Type", + "$type": "CustomField" + }, + "id": "108-5", + "$type": "FieldBasedColorCoding" + }, + "currentSprint": { + "name": "First sprint", + "id": "109-2", + "$type": "Sprint" + }, + "originalEstimationField": { + "name": "OriginalEstimationField", + "$type": "CustomField" + }, + "sprintsSettings": { + "explicitQuery": "project: DemoProject1", + "isExplicit": false, + "disableSprints": false, + "hideSubtasksOfCards": false, + "cardOnSeveralSprints": false, + "defaultSprint": { + "name": "First sprint", + "id": "109-2", + "$type": "Sprint" + }, + "sprintSyncField": { + "name": "State", + "$type": "CustomField" + }, + "id": "108-2", + "$type": "SprintsSettings" + }, + "visibleFor": { + "allUsersGroup" : false, + "icon" : "String", + "name" : "String", + "ringId" : "String", + "teamForProject" : { + "shortName": "DP1", + "name": "DemoProject1", + "id": "0-1", + "$type": "Project" + }, + "usersCount" : 2, + "id" : "String", + "$type" : "UserGroup" + }, + "updateableBy": { + "allUsersGroup" : false, + "icon" : "String", + "name" : "String", + "ringId" : "String", + "teamForProject" : { + "shortName": "DP1", + "name": "DemoProject1", + "id": "0-1", + "$type": "Project" + }, + "usersCount" : 2, + "id" : "String", + "$type" : "UserGroup" + }, + "status": { + "errors": [], + "valid": true, + "hasJobs": false, + "warnings": [], + "id": "boardStatus", + "$type": "AgileStatus" + }, + "owner": { + "fullName": "Demo User 1", + "ringId": "0692a47b-3670-452e-8e73-8b166a774705", + "id": "1-2", + "$type": "User" + }, + "name": "Full Board 01", + "id": "109-1", + "$type": "Agile" +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/Resources/FullAgile02.json b/tests/YouTrackSharp.Tests/Resources/FullAgile02.json new file mode 100644 index 00000000..6531da21 --- /dev/null +++ b/tests/YouTrackSharp.Tests/Resources/FullAgile02.json @@ -0,0 +1,251 @@ +{ + "_comment": "Represents a full agile board (no fields are null), with ProjectBasedColorCoding, AttributeBasedSwimlaneSettings (and PredefinedFilterField)", + "projects": [ + { + "shortName": "DP1", + "name": "DemoProject1", + "id": "0-1", + "$type": "Project" + } + ], + "swimlaneSettings": { + "field": { + "presentation": "Type", + "id": "51-3", + "name": "Type", + "$type": "PredefinedFilterField" + }, + "values": [ + { + "name": "Feature 01", + "isResolved": true, + "id": "61-3", + "$type": "SwimlaneEntityAttributeValue" + }, + { + "name": "Feature 02", + "isResolved": true, + "id": "61-4", + "$type": "SwimlaneEntityAttributeValue" + }, + { + "name": "Feature 03", + "isResolved": false, + "id": "61-5", + "$type": "SwimlaneEntityAttributeValue" + } + ], + "id": "105-3", + "name": "Swimlane Settings 01", + "$type": "AttributeBasedSwimlaneSettings" + }, + "estimationField": { + "name": "Estimation", + "$type": "CustomField" + }, + "sprints": [ + { + "name": "First sprint", + "id": "109-2", + "$type": "Sprint" + } + ], + "hideOrphansSwimlane": true, + "orphansAtTheTop": true, + "visibleForProjectBased": true, + "updateableByProjectBased": true, + "columnSettings": { + "field": { + "name": "State", + "$type": "CustomField" + }, + "columns": [ + { + "fieldValues": [ + { + "name": "Open", + "isResolved": true, + "id": "111-12", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 0, + "presentation": "Open", + "wipLimit": { + "id": "205-0", + "max": "10", + "min": "3", + "column": "3", + "$type": "WIPLimit" + }, + "isResolved": true, + "id": "110-8", + "$type": "AgileColumn" + }, + { + "fieldValues": [ + { + "name": "In Progress", + "isResolved": true, + "id": "111-13", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 1, + "presentation": "In Progress", + "wipLimit": { + "id": "205-1", + "max": "10", + "min": "3", + "column": "3", + "$type": "WIPLimit" + }, + "isResolved": true, + "id": "110-9", + "$type": "AgileColumn" + }, + { + "fieldValues": [ + { + "name": "Fixed", + "isResolved": true, + "id": "111-14", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 2, + "presentation": "Fixed", + "wipLimit": { + "id": "205-2", + "max": "10", + "min": "3", + "column": "3", + "$type": "WIPLimit" + }, + "isResolved": true, + "id": "110-10", + "$type": "AgileColumn" + }, + { + "fieldValues": [ + { + "name": "Verified", + "isResolved": true, + "id": "111-15", + "$type": "AgileColumnFieldValue" + } + ], + "ordinal": 3, + "presentation": "Verified", + "wipLimit": { + "id": "205-3", + "max": "10", + "min": "3", + "column": "3", + "$type": "WIPLimit" + }, + "isResolved": true, + "id": "110-11", + "$type": "AgileColumn" + } + ], + "id": "108-2", + "$type": "ColumnSettings" + }, + "colorCoding": { + "id": "108-5", + "projectColors": [ + { + "id": "120-1", + "project": { + "shortName": "DP1", + "name": "DemoProject1", + "id": "0-1", + "$type": "Project" + }, + "color": { + "id": "130-1", + "background": "White", + "foreground": "Black", + "$type": "FieldStyle" + }, + "$type": "ProjectColor" + } + ], + "$type": "ProjectBasedColorCoding" + }, + "currentSprint": { + "name": "First sprint", + "id": "109-2", + "$type": "Sprint" + }, + "originalEstimationField": { + "name": "OriginalEstimationField", + "$type": "CustomField" + }, + "sprintsSettings": { + "explicitQuery": "project: DemoProject1", + "isExplicit": false, + "disableSprints": false, + "hideSubtasksOfCards": false, + "cardOnSeveralSprints": false, + "defaultSprint": { + "name": "First sprint", + "id": "109-2", + "$type": "Sprint" + }, + "sprintSyncField": { + "name": "State", + "$type": "CustomField" + }, + "id": "108-2", + "$type": "SprintsSettings" + }, + "visibleFor": { + "allUsersGroup" : false, + "icon" : "String", + "name" : "String", + "ringId" : "String", + "teamForProject" : { + "shortName": "DP1", + "name": "DemoProject1", + "id": "0-1", + "$type": "Project" + }, + "usersCount" : 2, + "id" : "String", + "$type" : "UserGroup" + }, + "updateableBy": { + "allUsersGroup" : false, + "icon" : "String", + "name" : "String", + "ringId" : "String", + "teamForProject" : { + "shortName": "DP1", + "name": "DemoProject1", + "id": "0-1", + "$type": "Project" + }, + "usersCount" : 2, + "id" : "String", + "$type" : "UserGroup" + }, + "status": { + "errors": [], + "valid": true, + "hasJobs": false, + "warnings": [], + "id": "boardStatus", + "$type": "AgileStatus" + }, + "owner": { + "fullName": "Demo User 1", + "ringId": "0692a47b-3670-452e-8e73-8b166a774705", + "id": "1-2", + "$type": "User" + }, + "name": "Full Board 02", + "id": "109-2", + "$type": "Agile" +} \ No newline at end of file diff --git a/tests/YouTrackSharp.Tests/YouTrackSharp.Tests.csproj b/tests/YouTrackSharp.Tests/YouTrackSharp.Tests.csproj index 71924676..3802e6f7 100644 --- a/tests/YouTrackSharp.Tests/YouTrackSharp.Tests.csproj +++ b/tests/YouTrackSharp.Tests/YouTrackSharp.Tests.csproj @@ -18,4 +18,8 @@ YouTrackSharp + + + + \ No newline at end of file