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\$CommentedSep 29, 2022 at 18:09
  • 1
    \$\begingroup\$@PeterCsala - included the link to the previous question.\$\endgroup\$CommentedSep 29, 2022 at 19:12
  • \$\begingroup\$@BCdotWEB, I will get it fixed.\$\endgroup\$CommentedSep 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\$

    Start asking to get answers

    Find the answer to your question by asking.

    Ask question

    Explore related questions

    See similar questions with these tags.