Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
- Fixed branch policy check for classic pipelines with prefixed repository names to use repository ID directly when available, preventing 404 errors during rewiring
- Skip branch policy checks for disabled repositories with a warning instead of attempting API calls that may fail
- Skip pipeline rewiring entirely for disabled repositories, exiting early with an appropriate warning message
- Fixed misleading success message that appeared even when pipeline rewiring was skipped for disabled repositories
- Fixed monitor timeout minutes to only display when --dry-run mode is enabled, reducing confusion during regular pipeline rewiring operations
- Check if pipeline is disabled before attempting to queue a test build, preventing 400 Bad Request errors and providing clear warning messages
- bbs2gh : Added validation for `--archive-path` and `--bbs-shared-home` options to fail fast with clear error messages if the provided paths do not exist or are not accessible. Archive path is now logged before upload operations to help with troubleshooting
2 changes: 1 addition & 1 deletion src/Octoshift/Commands/CommandArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public abstract class CommandArgs
public virtual void Validate(OctoLogger log)
{ }

public void Log(OctoLogger log)
public virtual void Log(OctoLogger log)
{
if (log is null)
{
Expand Down
12 changes: 12 additions & 0 deletions src/Octoshift/Services/AdoApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,18 @@ public virtual async Task ShareServiceConnection(string adoOrg, string adoTeamPr
return (defaultBranch, clean, checkoutSubmodules, triggers);
}

public virtual async Task<bool> IsPipelineEnabled(string org, string teamProject, int pipelineId)
{
var url = $"{_adoBaseUrl}/{org.EscapeDataString()}/{teamProject.EscapeDataString()}/_apis/build/definitions/{pipelineId}?api-version=6.0";

var response = await _client.GetAsync(url);
var data = JObject.Parse(response);

// Check the queueStatus field - it can be "enabled", "disabled", or "paused"
var queueStatus = (string)data["queueStatus"];
return string.IsNullOrEmpty(queueStatus) || queueStatus.Equals("enabled", StringComparison.OrdinalIgnoreCase);
}

public virtual async Task<string> GetBoardsGithubRepoId(string org, string teamProject, string teamProjectId, string endpointId, string githubOrg, string githubRepo)
{
var url = $"{_adoBaseUrl}/{org.EscapeDataString()}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1";
Expand Down
115 changes: 88 additions & 27 deletions src/Octoshift/Services/AdoPipelineTriggerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class AdoPipelineTriggerService
private readonly string _adoBaseUrl;

// Cache for repository IDs and branch policies to avoid redundant API calls
private readonly Dictionary<string, string> _repositoryIdCache = [];
private readonly Dictionary<string, (string id, bool isDisabled)> _repositoryCache = [];
private readonly Dictionary<string, AdoBranchPolicyResponse> _branchPolicyCache = [];

public AdoPipelineTriggerService(AdoApi adoApi, OctoLogger log, string adoBaseUrl)
Expand All @@ -36,7 +36,8 @@ public AdoPipelineTriggerService(AdoApi adoApi, OctoLogger log, string adoBaseUr
/// Changes a pipeline's repository configuration from ADO to GitHub, applying
/// trigger configuration based on branch policy requirements and existing settings.
/// </summary>
public virtual async Task RewirePipelineToGitHub(
/// <returns>True if the pipeline was successfully rewired, false if it was skipped.</returns>
public virtual async Task<bool> RewirePipelineToGitHub(
string adoOrg,
string teamProject,
int pipelineId,
Expand All @@ -49,59 +50,104 @@ public virtual async Task RewirePipelineToGitHub(
JToken originalTriggers = null,
string targetApiUrl = null)
{
ArgumentNullException.ThrowIfNull(adoOrg);
ArgumentNullException.ThrowIfNull(teamProject);

var url = $"{_adoBaseUrl}/{adoOrg.EscapeDataString()}/{teamProject.EscapeDataString()}/_apis/build/definitions/{pipelineId}?api-version=6.0";

try
{
var response = await _adoApi.GetAsync(url);
var data = JObject.Parse(response);

var newRepo = CreateGitHubRepositoryConfiguration(githubOrg, githubRepo, defaultBranch, clean, checkoutSubmodules, connectedServiceId, targetApiUrl);
var currentRepoName = data["repository"]?["name"]?.ToString();
var isPipelineRequiredByBranchPolicy = await IsPipelineRequiredByBranchPolicy(adoOrg, teamProject, currentRepoName, pipelineId);
var currentRepoId = data["repository"]?["id"]?.ToString();

// Check if repository is disabled - skip rewiring if it is
if (!string.IsNullOrEmpty(currentRepoId) || !string.IsNullOrEmpty(currentRepoName))
{
var identifier = !string.IsNullOrEmpty(currentRepoId) ? currentRepoId : currentRepoName;
var (_, isDisabled) = await GetRepositoryInfoWithCache(adoOrg, teamProject, currentRepoId, currentRepoName);

if (isDisabled)
{
_log.LogWarning($"Repository {adoOrg}/{teamProject}/{identifier} is disabled. Skipping pipeline rewiring for pipeline {pipelineId}.");
return false;
}
}

var newRepo = CreateGitHubRepositoryConfiguration(githubOrg, githubRepo, defaultBranch, clean, checkoutSubmodules, connectedServiceId, targetApiUrl);
var isPipelineRequiredByBranchPolicy = await IsPipelineRequiredByBranchPolicy(adoOrg, teamProject, currentRepoName, currentRepoId, pipelineId);

LogBranchPolicyCheckResults(pipelineId, isPipelineRequiredByBranchPolicy);

var payload = BuildPipelinePayload(data, newRepo, originalTriggers, isPipelineRequiredByBranchPolicy);

await _adoApi.PutAsync(url, payload.ToObject(typeof(object)));
return true;
}
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
{
// Pipeline not found - log warning and skip
_log.LogWarning($"Pipeline {pipelineId} not found in {adoOrg}/{teamProject}. Skipping pipeline rewiring.");
return;
return false;
}
catch (HttpRequestException ex)
{
// Other HTTP errors during pipeline retrieval
_log.LogWarning($"HTTP error retrieving pipeline {pipelineId} in {adoOrg}/{teamProject}: {ex.Message}. Skipping pipeline rewiring.");
return;
return false;
}
}

/// <summary>
/// Analyzes branch policies to determine if a pipeline is required for branch protection.
/// </summary>
public async Task<bool> IsPipelineRequiredByBranchPolicy(string adoOrg, string teamProject, string repoName, int pipelineId)
public async Task<bool> IsPipelineRequiredByBranchPolicy(string adoOrg, string teamProject, string repoName, string repoId, int pipelineId)
{
ArgumentNullException.ThrowIfNull(adoOrg);
ArgumentNullException.ThrowIfNull(teamProject);

if (string.IsNullOrEmpty(repoName))
if (string.IsNullOrEmpty(repoName) && string.IsNullOrEmpty(repoId))
{
_log.LogWarning($"Branch policy check skipped for pipeline {pipelineId} - repository name not available. Pipeline trigger configuration may not preserve branch policy requirements.");
_log.LogWarning($"Branch policy check skipped for pipeline {pipelineId} - repository name and ID not available. Pipeline trigger configuration may not preserve branch policy requirements.");
return false;
}

try
{
// Get repository information first (with caching)
var repositoryId = await GetRepositoryIdWithCache(adoOrg, teamProject, repoName);
// Use repository ID directly if available, otherwise look it up by name
string repositoryId;
var isRepositoryDisabled = false;

if (string.IsNullOrEmpty(repositoryId))
if (!string.IsNullOrEmpty(repoId))
{
_log.LogWarning($"Repository ID not found for {adoOrg}/{teamProject}/{repoName}. Branch policy check cannot be performed for pipeline {pipelineId}.");
_log.LogVerbose($"Using repository ID from pipeline definition for branch policy check: {repoId}");
repositoryId = repoId;

// Check if repository is disabled by fetching its details
var (_, disabled) = await GetRepositoryInfoWithCache(adoOrg, teamProject, repoId, repoName);
isRepositoryDisabled = disabled;
}
else
{
// Get repository information by name (with caching)
var (id, disabled) = await GetRepositoryInfoWithCache(adoOrg, teamProject, null, repoName);
repositoryId = id;
isRepositoryDisabled = disabled;

if (string.IsNullOrEmpty(repositoryId))
{
_log.LogWarning($"Repository ID not found for {adoOrg}/{teamProject}/{repoName}. Branch policy check cannot be performed for pipeline {pipelineId}.");
return false;
}
}

// Skip branch policy check if repository is disabled
if (isRepositoryDisabled)
{
var repoIdentifier = repoName ?? repoId ?? "unknown";
_log.LogWarning($"Repository {adoOrg}/{teamProject}/{repoIdentifier} is disabled. Branch policy check skipped for pipeline {pipelineId}. Pipeline trigger configuration may not preserve branch policy requirements.");
return false;
}

Expand Down Expand Up @@ -451,51 +497,66 @@ private bool HasTriggerType(JToken originalTriggers, string triggerType)
#region Private Helper Methods - Caching

/// <summary>
/// Gets the repository ID with caching to avoid redundant API calls for the same repository.
/// Gets the repository information (ID and disabled status) with caching to avoid redundant API calls for the same repository.
/// </summary>
private async Task<string> GetRepositoryIdWithCache(string adoOrg, string teamProject, string repoName)
private async Task<(string id, bool isDisabled)> GetRepositoryInfoWithCache(string adoOrg, string teamProject, string repoId, string repoName)
{
var cacheKey = $"{adoOrg.ToUpper()}/{teamProject.ToUpper()}/{repoName.ToUpper()}";
var identifier = !string.IsNullOrEmpty(repoId) ? repoId : repoName;
var cacheKey = $"{adoOrg.ToUpper()}/{teamProject.ToUpper()}/{identifier.ToUpper()}";

if (_repositoryIdCache.TryGetValue(cacheKey, out var cachedId))
if (_repositoryCache.TryGetValue(cacheKey, out var cachedInfo))
{
_log.LogVerbose($"Using cached repository ID for {adoOrg}/{teamProject}/{repoName}");
return cachedId;
_log.LogVerbose($"Using cached repository information for {adoOrg}/{teamProject}/{identifier}");
return cachedInfo;
}

_log.LogVerbose($"Fetching repository ID for {adoOrg}/{teamProject}/{repoName}");
_log.LogVerbose($"Fetching repository information for {adoOrg}/{teamProject}/{identifier}");

try
{
var repoUrl = $"{_adoBaseUrl}/{adoOrg.EscapeDataString()}/{teamProject.EscapeDataString()}/_apis/git/repositories/{repoName.EscapeDataString()}?api-version=6.0";
var repoUrl = $"{_adoBaseUrl}/{adoOrg.EscapeDataString()}/{teamProject.EscapeDataString()}/_apis/git/repositories/{identifier.EscapeDataString()}?api-version=6.0";
var repoResponse = await _adoApi.GetAsync(repoUrl);
var repoData = JObject.Parse(repoResponse);
var repositoryId = repoData["id"]?.ToString();
var isDisabled = repoData["isDisabled"]?.ToString().Equals("true", StringComparison.OrdinalIgnoreCase) ?? false;

if (!string.IsNullOrEmpty(repositoryId))
{
_repositoryIdCache[cacheKey] = repositoryId;
_log.LogVerbose($"Cached repository ID {repositoryId} for {adoOrg}/{teamProject}/{repoName}");
var info = (repositoryId, isDisabled);
_repositoryCache[cacheKey] = info;
_log.LogVerbose($"Cached repository information (ID: {repositoryId}, Disabled: {isDisabled}) for {adoOrg}/{teamProject}/{identifier}");
return info;
}

return repositoryId;
return (null, false);
}
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
{
// 404 typically means the repository is disabled or doesn't exist
// Treat it as disabled to avoid further API calls
// Log as verbose since the caller will log a more specific warning about the disabled repository
// Return (null, true) to indicate repository ID is unknown but repository is disabled
_log.LogVerbose($"Repository {adoOrg}/{teamProject}/{identifier} returned 404 - likely disabled or not found.");
var info = ((string)null, true); // Mark as disabled with null ID since identifier may be a name
_repositoryCache[cacheKey] = info;
return info;
}
catch (HttpRequestException ex)
{
// Don't cache failed requests - let the caller handle the error
_log.LogVerbose($"Failed to fetch repository ID for {adoOrg}/{teamProject}/{repoName}: {ex.Message}");
_log.LogVerbose($"Failed to fetch repository information for {adoOrg}/{teamProject}/{identifier}: {ex.Message}");
throw;
}
catch (TaskCanceledException ex)
{
// Don't cache timeouts - let the caller handle the error
_log.LogVerbose($"Timeout fetching repository ID for {adoOrg}/{teamProject}/{repoName}: {ex.Message}");
_log.LogVerbose($"Timeout fetching repository information for {adoOrg}/{teamProject}/{identifier}: {ex.Message}");
throw;
}
catch (JsonException ex)
{
// Don't cache JSON parsing errors - let the caller handle the error
_log.LogVerbose($"JSON parsing error for repository {adoOrg}/{teamProject}/{repoName}: {ex.Message}");
_log.LogVerbose($"JSON parsing error for repository {adoOrg}/{teamProject}/{identifier}: {ex.Message}");
throw;
}
}
Expand Down
10 changes: 10 additions & 0 deletions src/Octoshift/Services/PipelineTestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ public async Task<PipelineTestResult> TestPipeline(PipelineTestArgs args)
args.PipelineId = pipelineId;
}

// Check if pipeline is disabled before attempting to queue a build
var isEnabled = await _adoApi.IsPipelineEnabled(args.AdoOrg, args.AdoTeamProject, args.PipelineId.Value);
if (!isEnabled)
{
_log.LogWarning($"Pipeline '{args.PipelineName}' (ID: {args.PipelineId.Value}) is disabled. Skipping pipeline test.");
testResult.ErrorMessage = "Pipeline is disabled";
testResult.EndTime = DateTime.UtcNow;
return testResult;
}

// Get original repository information for restoration
(originalRepoName, _, originalDefaultBranch, originalClean, originalCheckoutSubmodules) =
await _adoApi.GetPipelineRepository(args.AdoOrg, args.AdoTeamProject, args.PipelineId.Value);
Expand Down
75 changes: 75 additions & 0 deletions src/OctoshiftCLI.Tests/Octoshift/Services/AdoApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,81 @@ public async Task GetPipeline_Should_Return_Pipeline()
Triggers.Should().NotBeNull();
}

[Fact]
public async Task IsPipelineEnabled_Should_Return_True_For_Enabled_Pipeline()
{
var pipelineId = 826263;

var endpoint = $"https://dev.azure.com/{ADO_ORG.EscapeDataString()}/{ADO_TEAM_PROJECT.EscapeDataString()}/_apis/build/definitions/{pipelineId}?api-version=6.0";
var response = new
{
id = pipelineId,
queueStatus = "enabled"
};

_mockAdoClient.Setup(x => x.GetAsync(endpoint).Result).Returns(response.ToJson());

var result = await sut.IsPipelineEnabled(ADO_ORG, ADO_TEAM_PROJECT, pipelineId);

result.Should().BeTrue();
}

[Fact]
public async Task IsPipelineEnabled_Should_Return_False_For_Disabled_Pipeline()
{
var pipelineId = 826263;

var endpoint = $"https://dev.azure.com/{ADO_ORG.EscapeDataString()}/{ADO_TEAM_PROJECT.EscapeDataString()}/_apis/build/definitions/{pipelineId}?api-version=6.0";
var response = new
{
id = pipelineId,
queueStatus = "disabled"
};

_mockAdoClient.Setup(x => x.GetAsync(endpoint).Result).Returns(response.ToJson());

var result = await sut.IsPipelineEnabled(ADO_ORG, ADO_TEAM_PROJECT, pipelineId);

result.Should().BeFalse();
}

[Fact]
public async Task IsPipelineEnabled_Should_Return_False_For_Paused_Pipeline()
{
var pipelineId = 826263;

var endpoint = $"https://dev.azure.com/{ADO_ORG.EscapeDataString()}/{ADO_TEAM_PROJECT.EscapeDataString()}/_apis/build/definitions/{pipelineId}?api-version=6.0";
var response = new
{
id = pipelineId,
queueStatus = "paused"
};

_mockAdoClient.Setup(x => x.GetAsync(endpoint).Result).Returns(response.ToJson());

var result = await sut.IsPipelineEnabled(ADO_ORG, ADO_TEAM_PROJECT, pipelineId);

result.Should().BeFalse();
}

[Fact]
public async Task IsPipelineEnabled_Should_Return_True_For_Missing_QueueStatus()
{
var pipelineId = 826263;

var endpoint = $"https://dev.azure.com/{ADO_ORG.EscapeDataString()}/{ADO_TEAM_PROJECT.EscapeDataString()}/_apis/build/definitions/{pipelineId}?api-version=6.0";
var response = new
{
id = pipelineId
};

_mockAdoClient.Setup(x => x.GetAsync(endpoint).Result).Returns(response.ToJson());

var result = await sut.IsPipelineEnabled(ADO_ORG, ADO_TEAM_PROJECT, pipelineId);

result.Should().BeTrue();
}

[Fact]
public async Task GetBoardsGithubRepoId_Should_Return_RepoId()
{
Expand Down
Loading
Loading