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);
}
}
-
\$\begingroup\$ Please amend your post to include the link of your previous question. \$\endgroup\$Peter Csala– Peter Csala2022年09月29日 18:09:03 +00:00Commented Sep 29, 2022 at 18:09
-
1\$\begingroup\$ @PeterCsala - included the link to the previous question. \$\endgroup\$One Developer– One Developer2022年09月29日 19:12:13 +00:00Commented Sep 29, 2022 at 19:12
-
\$\begingroup\$ @BCdotWEB, I will get it fixed. \$\endgroup\$One Developer– One Developer2022年09月30日 15:26:52 +00:00Commented Sep 30, 2022 at 15:26
1 Answer 1
ImportController
ImportData
- In software industry the
datais a magic word for anything- Please try to be more precise with naming to bring clarity
_importFactory.Initializeseems pretty weird- The
Factorysuffix 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
Initializebefore calling theImport- This makes your code fragile
- The
- As I can see the
Importmethod 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
BaseServicedoes it is impossible to provide insightful suggestions - Although here are several tiny observations:
- The
_loggeris not used, just initialized - The
string urimight contain invalid url - This
SetHttpClientfeels pretty weird- This api allows the consumer of this class to change the
HttpClientbetween twoPostcalls - Also the compiler can't enforce that you should call the
SetHttpClientbefore the call ofPost
- This api allows the consumer of this class to change the
- The
- In your question you have mentioned the Post method has to return "id" as int/string/nothing
- But you have restricted
Tto be aclass, so you can't useintas type parameter
- But you have restricted
SprintCore, ... , ServiceEndpointCore
- Please prefer C# naming convention for your public properties
- Please prefer
Idoverid - Or use
JsonPropertyAttributefor renaming (if needed at all)
- Please prefer
- What does this
Coresuffix 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
IHttpClientFactoryto create a newHttpClientinstance - Please prefer
Uri.TryCreateovernew Urito parsestringasUri - Please try to avoid basic auth (
AuthenticationHeaderValue("Basic" ...) since it is not secure by any means
ImportRepository
- It is unnecessary to pass the
_devOpsProjectSettingsfield as a parameter since this method can access that as well - Also please adjust naming
- It imports repositories, not just a single one
- The
_serviceEndpointCreationURLcan 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 abbreviationsRepo>>Repository
CreateRepositories
- It might make sense to combine this method with the
ImportRepositoriessince you iterate through the same collection
You must log in to answer this question.
Explore related questions
See similar questions with these tags.