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
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 theImport
- This makes your code fragile
- The
- 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 twoPost
calls - Also the compiler can't enforce that you should call the
SetHttpClient
before 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
T
to be aclass
, so you can't useint
as type parameter
- But you have restricted
SprintCore
, ... , ServiceEndpointCore
- Please prefer C# naming convention for your public properties
- Please prefer
Id
overid
- Or use
JsonPropertyAttribute
for renaming (if needed at all)
- Please prefer
- 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 newHttpClient
instance - Please prefer
Uri.TryCreate
overnew Uri
to parsestring
asUri
- 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 abbreviationsRepo
>>Repository
CreateRepositories
- It might make sense to combine this method with the
ImportRepositories
since you iterate through the same collection
Explore related questions
See similar questions with these tags.