1
\$\begingroup\$

In my Azure DevOps Project, I have a Git repository that I would like to copy to another Azure DevOps Project.

In other words, I should be able to copy the original repo into other Azure DevOps projects as needed.

To import work items into Azure DevOps, I have written the following code.

Would you be able to review and make suggestions? Especially, I want to optimize the way HttpClient is being passed to the core service layer from the controller..

Part of this code is already reviewed as you see in this post - Export and import work items from Azure DevOps.

Note:

  • Since the destination and/or source of the HttpClient changes every time, I would get the details from the payload.
  • At times, the Post method has to return "id" as int/string/nothing.
public class ImportController : Controller
{
    private readonly ILogger<ImportController> _logger;
    private readonly IImportFactory _importFactory;

    public ImportController(ILogger<ImportController> logger, IImportFactory importFactory)
    {
        _logger = logger;
        _importFactory = importFactory;
    }

    [HttpPost]
    public async Task<IActionResult> ImportData([FromForm]ImportData importData)
    {
        _importFactory.Initialize(importData.devOpsProjectSettings);
        await _importFactory.Import(importData.file);

        return Ok();
    }
}
    
public interface IImportService<T> where T : class
{
    Task<T> Post(string uri, HttpContent content);
    void SetHttpClient(HttpClient httpClient);
}

public class ImportService<T> : BaseService<T>, IImportService<T>
    where T : class
{
    private readonly ILogger<ImportService<T>> _logger;

    public ImportService(ILogger<ImportService<T>> logger) : base()
    {
        _logger = logger;
    }

    public async Task<T> Post(string uri, HttpContent content)
    {
        var result = await SendRequest(uri, content);

        return result;
    }

    public void SetHttpClient(HttpClient httpClient)
    {
        this._httpClient = httpClient;
    }
}

public class SprintCore
{
    [Newtonsoft.Json.JsonIgnore]
    public string id { get; set; }
}

public class WorkItemCore
{
    public int id { get; set; }
    public string identifier { get; set; }
}

public class ServiceEndpointCore
{
    public string id { get; set; }
}   

    
public class ImportFactory : IImportFactory
{
    private ConcurrentDictionary<int, int> idMapper = new ConcurrentDictionary<int, int>();
    private readonly ILogger<ImportFactory> _logger;
    private readonly DevOps _devopsConfiguration;
    private readonly IImportService<WorkItemCore> _importWorkItemService;
    private readonly IImportService<SprintCore> _importSprintService;
    private readonly IImportService<ServiceEndpointCore> _importRepositoryService;
    private const string WorkItemPathPrefix = "/fields/";
    private readonly string _versionQueryString;

    private DevOpsProjectSettings _devOpsProjectSettings { get; set; }
    private HttpClient _httpClient;
    private string _sprintCreationURL;
    private string _sprintPublishURL;
    private string _projectId;
    private string _repositoryCreationURL;

    public ImportFactory(ILogger<ImportFactory> logger, IConfiguration configuration, IImportService<SprintCore> importSprintService, IImportService<WorkItemCore> importWorkItemService, IImportService<ServiceEndpointCore> importRepositoryService)
    {
        _logger = logger;
        _devopsConfiguration = configuration.GetSection(nameof(DevOps)).Get<DevOps>();
        _importSprintService = importSprintService;
        _importWorkItemService = importWorkItemService;
        _importRepositoryService = importRepositoryService;
        _versionQueryString = $"?api-version={_devopsConfiguration.APIVersion}";
    }
    
    public void Initialize(DevOpsProjectSettings devOpsProjectSettings)
    {
        _devOpsProjectSettings = devOpsProjectSettings;
        _projectId = devOpsProjectSettings.ProjectId;
        _sprintCreationURL = $"{_projectId}/_apis/wit/classificationNodes/Iterations{_versionQueryString}";
        _sprintPublishURL = $"{_projectId}/{devOpsProjectSettings.TeamId}/_apis/work/teamsettings/iterations{_versionQueryString}";
        _repositoryCreationURL = $"_apis/git/repositories{_versionQueryString}";

        _httpClient = new HttpClient();
        _httpClient.BaseAddress = new Uri(devOpsProjectSettings.DevOpsOrgURL);
        _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "", devOpsProjectSettings.PersonalAccessToken))));

        _importSprintService.SetHttpClient(_httpClient);
        _importWorkItemService.SetHttpClient(_httpClient);
        _importRepositoryService.SetHttpClient(_httpClient);
    }

    public async Task<Board> Import(IFormFile file)
    {
        using var reader = new StreamReader(file.OpenReadStream());
        string fileContent = await reader.ReadToEndAsync();

        var board = JsonConvert.DeserializeObject<Board>(fileContent);

        await CreateSprints(board.sprints);
        await CreateWorkItems(board.workItemCollection);
        await CreateRepositories(board.repositories);
        await ImportRepository(board.repositories, _devOpsProjectSettings);

        return board;
    }

    private async Task ImportRepository(Repositories repositories, DevOpsProjectSettings devOpsProjectSettings)
    {
        var _serviceEndpointImportURL = string.Empty;
        var _serviceEndpointCreationURL = $"_apis/serviceendpoint/endpoints{_versionQueryString}";

        foreach (Repository repository in repositories.value)
        {
            _serviceEndpointImportURL = $"{_projectId}/_apis/git/repositories/{repository.name}/importRequests{_versionQueryString}";
            devOpsProjectSettings.serviceEndpoint.name = $"Import_External_Repo_{repository.name}";
            
            devOpsProjectSettings.serviceEndpoint.url = $"{devOpsProjectSettings.DevOpsSourceURL}{Uri.EscapeDataString(repository.name)}";
            devOpsProjectSettings.serviceEndpoint.serviceEndpointProjectReferences[0].name= $"Import_External_Repo_{repository.name}";
            
            var serviceEndpointId = await _importRepositoryService.Post(_serviceEndpointCreationURL, GetJsonContent(devOpsProjectSettings.serviceEndpoint));
            
            var importRepo = new ImportRepo();
            importRepo.parameters.serviceEndpointId = serviceEndpointId.id;
            importRepo.parameters.gitSource.url = devOpsProjectSettings.serviceEndpoint.url;

            await _importRepositoryService.Post(_serviceEndpointImportURL, GetJsonContent(importRepo));
        }
    }

    private async Task CreateRepositories(Repositories repositories)
    {
        foreach (Repository repository in repositories.value)
        {
            repository.project.id = _projectId;
            await _importSprintService.Post(_repositoryCreationURL, GetJsonContent(repository));
        }
    }

    private async Task CreateSprints(Sprints sprints)
    {
        foreach (Sprint sprint in sprints.value)
        {
            var result = await _importWorkItemService.Post(_sprintCreationURL, GetJsonContent(sprint));
            await _importSprintService.Post(_sprintPublishURL, GetJsonContent(new { id = result.identifier }));
        }
    }
    private async Task CreateWorkItems(Dictionary<string, WorkItemQueryResult> workItems)
    {
        foreach (var workItemCategory in workItems.Keys)
        {
            var categoryURL = $"{_projectId}/_apis/wit/workitems/%24{workItemCategory}{_versionQueryString}";

            foreach (var workItem in workItems[workItemCategory].workItems)
            {
                await CreateWorkItem(categoryURL, workItem);
            }
        }
    }

    private async Task CreateWorkItem(string categoryURL, WorkItem workItem)
    {
        var operations = new List<WorkItemOperation>
        {
            new WorkItemOperation()
            {
                path = $"{WorkItemPathPrefix}System.Title",
                value = workItem.details.fields.Title ?? ""
            },
            new WorkItemOperation()
            {
                path = $"{WorkItemPathPrefix}System.Description",
                value = workItem.details.fields.Description ?? ""
            },
            new WorkItemOperation()
            {
                path = $"{WorkItemPathPrefix}Microsoft.VSTS.Common.AcceptanceCriteria",
                value = workItem.details.fields.AcceptanceCriteria ?? ""
            },
            new WorkItemOperation()
            {
                path = $"{WorkItemPathPrefix}System.IterationPath",
                value = workItem.details.fields.IterationPath.Replace(_devOpsProjectSettings.SourceProjectName, _devOpsProjectSettings.TargetProjectName)
            }
        };

        var parentId = FindParentId(workItem.details);
        if (parentId != 0)
        {
            operations.Add(new WorkItemOperation()
            {
                path = "/relations/-",
                value = new Relationship()
                {
                    url = $"{_devOpsProjectSettings.DevOpsOrgURL}{_projectId}/_apis/wit/workitems/{idMapper[parentId]}",
                    attributes = new RelationshipAttribute()
                }
            });
        }

        var result = await _importWorkItemService.Post(categoryURL, GetJsonContent(operations, "application/json-patch+json"));

        if (!idMapper.ContainsKey(workItem.id))
        {
            idMapper.TryAdd(workItem.id, result.id);
        }
    }

    private int FindParentId(WorkItemDetails details)
    {
        var parentRelation = details.relations?.Where(relation => relation.attributes.name.Equals("Parent")).FirstOrDefault();
        return parentRelation == null ? 0 : int.Parse(parentRelation.url.Split("/")[parentRelation.url.Split("/").Length - 1]);
    }

    private HttpContent GetJsonContent(object data, string mediaType = "application/json")
    {
        var jsonString = JsonConvert.SerializeObject(data);
        return new StringContent(jsonString, Encoding.UTF8, mediaType);
    }
}
\$\endgroup\$
3
  • \$\begingroup\$ Please amend your post to include the link of your previous question. \$\endgroup\$ Sep 29, 2022 at 18:09
  • 1
    \$\begingroup\$ @PeterCsala - included the link to the previous question. \$\endgroup\$ Sep 29, 2022 at 19:12
  • \$\begingroup\$ @BCdotWEB, I will get it fixed. \$\endgroup\$ Sep 30, 2022 at 15:26

1 Answer 1

1
\$\begingroup\$

ImportController

ImportData

  • In software industry the data is a magic word for anything
    • Please try to be more precise with naming to bring clarity
  • _importFactory.Initialize seems pretty weird
    • The Factory suffix is usually used when you have applied the factory design pattern
      • Your current naming might be misleading
    • Also be aware of the fact that the compiler can't enforce you to call the Initialize before calling the Import
      • This makes your code fragile
  • As I can see the Import method call can throw several different exceptions
    • I hope there is a middleware in your pipeline which logs these exceptions and converts the response to 500
  • I would suggest to consider to return with 201 (Created) rather than 200 (Ok)
    • Since you have exposed an Import API that's why it would make sense to confirm that the import has created all the resources in the given system with success

ImportService

  • Without knowing what BaseService does it is impossible to provide insightful suggestions
  • Although here are several tiny observations:
    • The _logger is not used, just initialized
    • The string uri might contain invalid url
    • This SetHttpClient feels pretty weird
      • This api allows the consumer of this class to change the HttpClient between two Post calls
      • Also the compiler can't enforce that you should call the SetHttpClient before the call of Post
  • In your question you have mentioned the Post method has to return "id" as int/string/nothing
    • But you have restricted T to be a class, so you can't use int as type parameter

SprintCore, ... , ServiceEndpointCore

  • Please prefer C# naming convention for your public properties
    • Please prefer Id over id
    • Or use JsonPropertyAttribute for renaming (if needed at all)
  • What does this Core suffix mean?
    • They all look like DTO classes

ImportFactory

  • Since I have reviewed the majority of this class in your previous post that's why I will try to focus only on the new stuff

Initialize

  • As I have stated several times this Initialize "pattern" makes your code fragile, since you can't enforce the call of this method prior any other publicly exposed method
  • I'm not sure which .NET version are you using but if not the recent ones then please consider to use IHttpClientFactory to create a new HttpClient instance
  • Please prefer Uri.TryCreate over new Uri to parse string as Uri
  • Please try to avoid basic auth (AuthenticationHeaderValue("Basic" ...) since it is not secure by any means

ImportRepository

  • It is unnecessary to pass the _devOpsProjectSettings field as a parameter since this method can access that as well
  • Also please adjust naming
    • It imports repositories, not just a single one
  • The _serviceEndpointCreationURL can be constructed only once, there is no need to regenerate it every time when this method is being called
  • ImportRepo: Please try to avoid any unnecessary abbreviations Repo >> Repository

CreateRepositories

  • It might make sense to combine this method with the ImportRepositories since you iterate through the same collection
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.