diff --git a/src/Tiger.Tests/AzdoClientTests.cs b/src/Tiger.Tests/AzdoClientTests.cs new file mode 100644 index 0000000..03e29d0 --- /dev/null +++ b/src/Tiger.Tests/AzdoClientTests.cs @@ -0,0 +1,146 @@ +using System.Net; +using Xunit; + +namespace Tiger.Tests; + +public class AzdoClientTests +{ + [Fact] + public async Task GetBuildsForRepositoryAsync_DefaultsToGitHubRepositoryType() + { + Uri? requestUri = null; + var client = AzdoClient.Create(new DelegateHandler((request, ct) => + { + requestUri = request.RequestUri; + return Task.FromResult(CreateJsonResponse("""{"count":0,"value":[]}""")); + })); + + await client.GetBuildsForRepositoryAsync("dotnet/roslyn"); + + Assert.NotNull(requestUri); + Assert.Contains("repositoryId=dotnet%2Froslyn", requestUri.ToString()); + Assert.Contains("repositoryType=GitHub", requestUri.ToString()); + } + + [Fact] + public async Task GetBuildsForRepositoryAsync_UsesConfiguredRepositoryType() + { + var requestUris = new List(); + var client = AzdoClient.Create(new DelegateHandler((request, ct) => + { + requestUris.Add(request.RequestUri!); + if (request.RequestUri!.ToString().Contains("_apis/git/repositories")) + { + return Task.FromResult(CreateJsonResponse(""" + { + "count": 1, + "value": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Repo" + } + ] + } + """)); + } + + return Task.FromResult(CreateJsonResponse(""" + { + "count": 1, + "value": [ + { + "id": 42, + "buildNumber": "20250622.1", + "status": "completed", + "result": "succeeded", + "uri": "vstfs:///Build/Build/42", + "sourceBranch": "refs/heads/main", + "sourceVersion": "abc123", + "definition": { "id": 7, "name": "CI" }, + "repository": { "id": "Repo", "name": "Repo", "type": "TfsGit" }, + "finishTime": "2026-06-22T20:00:00Z" + } + ] + } + """)); + })); + + var builds = await client.GetBuildsForRepositoryAsync("Repo", repositoryType: AzdoRepositoryTypes.TfsGit); + + Assert.Equal(2, requestUris.Count); + Assert.Contains("_apis/git/repositories", requestUris[0].ToString()); + Assert.Contains("repositoryId=11111111-1111-1111-1111-111111111111", requestUris[1].ToString()); + Assert.Contains("repositoryType=TfsGit", requestUris[1].ToString()); + var build = Assert.Single(builds); + Assert.Equal(AzdoRepositoryTypes.TfsGit, build.RepositoryType); + } + + [Fact] + public async Task GetCompletedBuildsSinceAsync_UsesConfiguredRepositoryType() + { + var requestUris = new List(); + var client = AzdoClient.Create(new DelegateHandler((request, ct) => + { + requestUris.Add(request.RequestUri!); + if (request.RequestUri!.ToString().Contains("_apis/git/repositories")) + { + return Task.FromResult(CreateJsonResponse(""" + { + "count": 1, + "value": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Repo" + } + ] + } + """)); + } + + return Task.FromResult(CreateJsonResponse("""{"count":0,"value":[]}""")); + })); + + await client.GetCompletedBuildsSinceAsync( + new DateTime(2026, 6, 22, 0, 0, 0, DateTimeKind.Utc), + repositoryId: "Repo", + repositoryType: AzdoRepositoryTypes.TfsGit); + + Assert.Equal(2, requestUris.Count); + Assert.Contains("_apis/git/repositories", requestUris[0].ToString()); + Assert.Contains("repositoryId=11111111-1111-1111-1111-111111111111", requestUris[1].ToString()); + Assert.Contains("repositoryType=TfsGit", requestUris[1].ToString()); + } + + [Fact] + public async Task GetBuildsForRepositoryAsync_TfsGitRepositoryId_DoesNotResolveName() + { + Uri? requestUri = null; + var repositoryId = "11111111-1111-1111-1111-111111111111"; + var client = AzdoClient.Create(new DelegateHandler((request, ct) => + { + requestUri = request.RequestUri; + return Task.FromResult(CreateJsonResponse("""{"count":0,"value":[]}""")); + })); + + await client.GetBuildsForRepositoryAsync(repositoryId, repositoryType: AzdoRepositoryTypes.TfsGit); + + Assert.NotNull(requestUri); + Assert.DoesNotContain("_apis/git/repositories", requestUri.ToString()); + Assert.Contains($"repositoryId={repositoryId}", requestUri.ToString()); + } + + private sealed class DelegateHandler( + Func> handler) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken ct) => handler(request, ct); + } + + private static HttpResponseMessage CreateJsonResponse(string json) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"), + }; + } +} diff --git a/src/Tiger.Tests/BuildIngestionServiceTests.cs b/src/Tiger.Tests/BuildIngestionServiceTests.cs index 2f61eb4..2887cb8 100644 --- a/src/Tiger.Tests/BuildIngestionServiceTests.cs +++ b/src/Tiger.Tests/BuildIngestionServiceTests.cs @@ -82,6 +82,54 @@ public void InsertBuild_WithPrNumber() Assert.Equal(99L, prNumber); } + [Fact] + public void InsertBuild_GitHubPr_CreatesPrInfoTask() + { + var build = new AzdoBuild + { + Id = 4, + BuildNumber = "20250101.4", + DefinitionName = "runtime", + DefinitionId = 42, + Status = "completed", + Result = "succeeded", + Uri = "https://dev.azure.com/org/proj/_build/results?buildId=4", + SourceBranch = "refs/pull/99/merge", + RepositoryName = "dotnet/runtime", + RepositoryType = AzdoRepositoryTypes.GitHub, + PrNumber = 99, + }; + + _service.InsertBuild("org", "proj", build); + + var taskCount = CountIngestionTasks(4, "pr_info"); + Assert.Equal(1L, taskCount); + } + + [Fact] + public void InsertBuild_AzureReposPr_DoesNotCreatePrInfoTask() + { + var build = new AzdoBuild + { + Id = 5, + BuildNumber = "20250101.5", + DefinitionName = "runtime", + DefinitionId = 42, + Status = "completed", + Result = "succeeded", + Uri = "https://dev.azure.com/org/proj/_build/results?buildId=5", + SourceBranch = "refs/pull/99/merge", + RepositoryName = "runtime", + RepositoryType = AzdoRepositoryTypes.TfsGit, + PrNumber = 99, + }; + + _service.InsertBuild("org", "proj", build); + + var taskCount = CountIngestionTasks(5, "pr_info"); + Assert.Equal(0L, taskCount); + } + [Fact] public void InsertBuild_Duplicate_IsIgnored() { @@ -327,4 +375,18 @@ private void InsertSampleBuild(int buildId) }); } + private long CountIngestionTasks(int buildId, string taskType) + { + return _db.WithCommand(cmd => + { + cmd.CommandText = """ + SELECT COUNT(*) FROM build_ingestion_tasks + WHERE organization = 'org' AND build_id = @buildId AND task_type = @taskType + """; + cmd.Parameters.AddWithValue("@buildId", buildId); + cmd.Parameters.AddWithValue("@taskType", taskType); + return (long)cmd.ExecuteScalar()!; + }); + } + } diff --git a/src/Tiger.Tests/BuildPollerTests.cs b/src/Tiger.Tests/BuildPollerTests.cs index 829f3b1..d882e3e 100644 --- a/src/Tiger.Tests/BuildPollerTests.cs +++ b/src/Tiger.Tests/BuildPollerTests.cs @@ -1,3 +1,4 @@ +using System.Net; using Xunit; namespace Tiger.Tests; @@ -126,6 +127,75 @@ public void FilterNewBuilds_LongRunningBuildNotMissed() Assert.Equal(150, result[0].Id); } + [Fact] + public async Task PollSourceAsync_UsesConfiguredRepositoryType() + { + var requestUris = new List(); + var source = new AzdoSource + { + Organization = "org", + Project = "proj", + RepositoryType = AzdoRepositoryTypes.TfsGit, + Repositories = ["Repo"], + }; + var config = new TigerConfig + { + Sources = [source], + }; + var handler = new DelegateHandler((request, ct) => + { + requestUris.Add(request.RequestUri!); + if (request.RequestUri!.ToString().Contains("_apis/git/repositories")) + { + return Task.FromResult(CreateJsonResponse(""" + { + "count": 1, + "value": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Repo" + } + ] + } + """)); + } + + return Task.FromResult(CreateJsonResponse(""" + { + "count": 1, + "value": [ + { + "id": 42, + "buildNumber": "20250622.1", + "status": "completed", + "result": "succeeded", + "uri": "vstfs:///Build/Build/42", + "sourceBranch": "refs/heads/main", + "definition": { "id": 7, "name": "CI" }, + "repository": { "id": "Repo", "name": "Repo", "type": "TfsGit" } + } + ] + } + """)); + }); + var poller = new BuildPoller(config, _db, new AzdoClientFactory((org, proj) => AzdoClient.Create(handler, org, proj))); + List? observedBuilds = null; + poller.OnNewBuilds = (_, _, builds) => + { + observedBuilds = builds; + return Task.CompletedTask; + }; + + await poller.PollSourceAsync(source, CancellationToken.None); + + Assert.Equal(2, requestUris.Count); + Assert.Contains("_apis/git/repositories", requestUris[0].ToString()); + Assert.Contains("repositoryId=11111111-1111-1111-1111-111111111111", requestUris[1].ToString()); + Assert.Contains("repositoryType=TfsGit", requestUris[1].ToString()); + var build = Assert.Single(observedBuilds!); + Assert.Equal(AzdoRepositoryTypes.TfsGit, build.RepositoryType); + } + private BuildPoller CreatePoller() { var config = new TigerConfig { Sources = [] }; @@ -143,4 +213,19 @@ private BuildPoller CreatePoller() DefinitionName = "test-def", FinishTime = finishTime, }; + + private sealed class DelegateHandler( + Func> handler) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken ct) => handler(request, ct); + } + + private static HttpResponseMessage CreateJsonResponse(string json) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"), + }; + } } diff --git a/src/Tiger/AzdoClient.cs b/src/Tiger/AzdoClient.cs index e967893..38e8889 100644 --- a/src/Tiger/AzdoClient.cs +++ b/src/Tiger/AzdoClient.cs @@ -97,6 +97,7 @@ private string GetBuildUri(int buildId) => DefinitionId = b.Definition?.Id ?? 0, SourceVersion = b.SourceVersion, RepositoryName = b.Repository?.Id ?? b.Repository?.Name, + RepositoryType = b.Repository?.Type, PrNumber = ExtractPrNumber(b.SourceBranch), FinishTime = b.FinishTime, }; @@ -143,20 +144,27 @@ public async Task> GetRecentBuildsAsync(int? definitionId = null public async Task> GetCompletedBuildsSinceAsync( DateTime minFinishTime, string? repositoryId = null, + string repositoryType = AzdoRepositoryTypes.GitHub, int pageSize = 100, CancellationToken ct = default) { var all = new List(); string? continuationToken = null; var minTimeStr = minFinishTime.ToUniversalTime().ToString("o"); + repositoryType = AzdoRepositoryTypes.Normalize(repositoryType); + var buildRepositoryId = repositoryId is not null + ? await ResolveRepositoryIdForBuildQueryAsync(repositoryId, repositoryType, ct) + : null; do { ct.ThrowIfCancellationRequested(); var url = $"_apis/build/builds?api-version=7.1&statusFilter=completed&minTime={Uri.EscapeDataString(minTimeStr)}&$top={pageSize}&queryOrder=finishTimeAscending"; - if (repositoryId is not null) - url += $"&repositoryId={Uri.EscapeDataString(repositoryId)}&repositoryType=GitHub"; + if (buildRepositoryId is not null) + { + url += $"&repositoryId={Uri.EscapeDataString(buildRepositoryId)}&repositoryType={Uri.EscapeDataString(repositoryType)}"; + } if (continuationToken is not null) url += $"&continuationToken={Uri.EscapeDataString(continuationToken)}"; @@ -179,9 +187,17 @@ public async Task> GetCompletedBuildsSinceAsync( return all; } - public async Task> GetBuildsForRepositoryAsync(string repository, int top = 10, string? reasonFilter = null, string? statusFilter = null, CancellationToken ct = default) + public async Task> GetBuildsForRepositoryAsync( + string repository, + int top = 10, + string? reasonFilter = null, + string? statusFilter = null, + string repositoryType = AzdoRepositoryTypes.GitHub, + CancellationToken ct = default) { - var url = $"_apis/build/builds?api-version=7.1&$top={top}&repositoryId={Uri.EscapeDataString(repository)}&repositoryType=GitHub"; + repositoryType = AzdoRepositoryTypes.Normalize(repositoryType); + var buildRepositoryId = await ResolveRepositoryIdForBuildQueryAsync(repository, repositoryType, ct); + var url = $"_apis/build/builds?api-version=7.1&$top={top}&repositoryId={Uri.EscapeDataString(buildRepositoryId)}&repositoryType={Uri.EscapeDataString(repositoryType)}"; if (reasonFilter is not null) { url += $"&reasonFilter={Uri.EscapeDataString(reasonFilter)}"; @@ -201,6 +217,36 @@ public async Task> GetBuildsForRepositoryAsync(string repository return result.Value.Select(MapBuild).ToList(); } + private async Task ResolveRepositoryIdForBuildQueryAsync( + string repository, + string repositoryType, + CancellationToken ct) + { + if (!repositoryType.Equals(AzdoRepositoryTypes.TfsGit, StringComparison.OrdinalIgnoreCase) || + Guid.TryParse(repository, out _)) + { + return repository; + } + + var response = await HttpClient.GetAsync("_apis/git/repositories?api-version=7.1", ct); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(ct); + var result = JsonSerializer.Deserialize>(json, s_jsonOptions) + ?? throw new InvalidOperationException("Failed to deserialize repositories response"); + + var match = result.Value.FirstOrDefault(r => + repository.Equals(r.Id, StringComparison.OrdinalIgnoreCase) || + repository.Equals(r.Name, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + throw new InvalidOperationException( + $"Azure Repos repository '{repository}' was not found in {Organization}/{Project}. Use the repository name or repository ID from AzDO."); + } + + return match.Id; + } + public async Task> GetBuildsForPullRequestAsync(string repository, int prNumber, int top = 10, CancellationToken ct = default) { var branchName = $"refs/pull/{prNumber}/merge"; @@ -573,6 +619,18 @@ private class AzdoBuildRepository [JsonPropertyName("name")] public string? Name { get; init; } + + [JsonPropertyName("type")] + public string? Type { get; init; } + } + + private class AzdoGitRepository + { + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } } private class AzdoTestRun diff --git a/src/Tiger/AzdoTypes.cs b/src/Tiger/AzdoTypes.cs index bc12f7f..fbc4cc2 100644 --- a/src/Tiger/AzdoTypes.cs +++ b/src/Tiger/AzdoTypes.cs @@ -35,6 +35,9 @@ public class AzdoBuild [JsonPropertyName("repositoryName")] public string? RepositoryName { get; init; } + [JsonPropertyName("repositoryType")] + public string? RepositoryType { get; init; } + [JsonPropertyName("prNumber")] public int? PrNumber { get; init; } diff --git a/src/Tiger/BuildBackfillService.cs b/src/Tiger/BuildBackfillService.cs index ad58d66..bf88c2f 100644 --- a/src/Tiger/BuildBackfillService.cs +++ b/src/Tiger/BuildBackfillService.cs @@ -147,7 +147,7 @@ private async Task BackfillAsync(bool forceFullWindow, CancellationToken ct return totalIngested; } - private async Task BackfillSourceAsync(AzdoSource source, bool forceFullWindow, CancellationToken ct) + internal async Task BackfillSourceAsync(AzdoSource source, bool forceFullWindow, CancellationToken ct) { DateTime since; if (forceFullWindow) @@ -172,8 +172,8 @@ private async Task BackfillSourceAsync(AzdoSource source, bool forceFullWin builds = []; foreach (var repo in source.Repositories) { - _log.Info("Backfill", $" Querying {repo}..."); - var repoBuilds = await client.GetCompletedBuildsSinceAsync(since, repositoryId: repo, ct: ct); + _log.Info("Backfill", $" Querying {repo} ({source.RepositoryType})..."); + var repoBuilds = await client.GetCompletedBuildsSinceAsync(since, repositoryId: repo, repositoryType: source.RepositoryType, ct: ct); builds.AddRange(repoBuilds); } } diff --git a/src/Tiger/BuildIngestionService.cs b/src/Tiger/BuildIngestionService.cs index 9c45048..c6d9ff9 100644 --- a/src/Tiger/BuildIngestionService.cs +++ b/src/Tiger/BuildIngestionService.cs @@ -152,7 +152,9 @@ internal void CreateIngestionTasks(SqliteConnection conn, SqliteTransaction tx, var taskTypes = new List { "tests", "timeline" }; // Only create pr_info task if this is a PR build and we don't already have the PR cached - if (build.PrNumber is not null && build.RepositoryName is not null) + if (build.PrNumber is not null && + build.RepositoryName is not null && + AzdoRepositoryTypes.IsGitHub(build.RepositoryType)) { if (!HasPullRequest(build.RepositoryName, build.PrNumber.Value)) { diff --git a/src/Tiger/BuildPoller.cs b/src/Tiger/BuildPoller.cs index ce341d0..ff716e1 100644 --- a/src/Tiger/BuildPoller.cs +++ b/src/Tiger/BuildPoller.cs @@ -87,7 +87,7 @@ private async Task PollLoopAsync(CancellationToken ct) } } - private async Task PollSourceAsync(AzdoSource source, CancellationToken ct) + internal async Task PollSourceAsync(AzdoSource source, CancellationToken ct) { var client = _clientFactory.Create(source.Organization, source.Project); @@ -101,7 +101,7 @@ private async Task PollSourceAsync(AzdoSource source, CancellationToken ct) builds = []; foreach (var repo in source.Repositories) { - var repoBuilds = await client.GetBuildsForRepositoryAsync(repo, top: 50, statusFilter: "completed"); + var repoBuilds = await client.GetBuildsForRepositoryAsync(repo, top: 50, statusFilter: "completed", repositoryType: source.RepositoryType, ct: ct); builds.AddRange(repoBuilds); } } diff --git a/src/Tiger/Commands/AzdoCommands.cs b/src/Tiger/Commands/AzdoCommands.cs index ca07dea..eb36b56 100644 --- a/src/Tiger/Commands/AzdoCommands.cs +++ b/src/Tiger/Commands/AzdoCommands.cs @@ -362,6 +362,10 @@ public class Settings : AzdoSettings [CommandOption("--top")] [Description("Maximum number of builds to return")] public int Top { get; set; } = 10; + + [CommandOption("--repository-type")] + [Description("AzDO repository type: GitHub or TfsGit")] + public string RepositoryType { get; set; } = AzdoRepositoryTypes.GitHub; } protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken ct) @@ -374,7 +378,7 @@ protected override async Task ExecuteAsync(CommandContext context, Settings }; var client = settings.CreateClient(); - var builds = await client.GetBuildsForRepositoryAsync(settings.Repository, settings.Top, reasonFilter); + var builds = await client.GetBuildsForRepositoryAsync(settings.Repository, settings.Top, reasonFilter, repositoryType: settings.RepositoryType, ct: ct); Console.WriteLine(JsonSerializer.Serialize(builds, JsonOptions.Indented)); return 0; } diff --git a/src/Tiger/Commands/ConfigCommands.cs b/src/Tiger/Commands/ConfigCommands.cs index 84e73b7..e0aa2d5 100644 --- a/src/Tiger/Commands/ConfigCommands.cs +++ b/src/Tiger/Commands/ConfigCommands.cs @@ -20,6 +20,7 @@ protected override int Execute(CommandContext context, CancellationToken ct) foreach (var source in config.Sources) { AnsiConsole.MarkupLine($" [green]{source.Organization}[/] / [green]{source.Project}[/]"); + AnsiConsole.MarkupLine($" Repository type: [blue]{source.RepositoryType}[/]"); if (source.Repositories.Count > 0) { foreach (var repo in source.Repositories) diff --git a/src/Tiger/Commands/ConfigEditor.cs b/src/Tiger/Commands/ConfigEditor.cs index 6d892f4..608eb13 100644 --- a/src/Tiger/Commands/ConfigEditor.cs +++ b/src/Tiger/Commands/ConfigEditor.cs @@ -30,6 +30,7 @@ public void Show() "[blue]R[/]epository — add to a source", "[blue]D[/]elete source", "Remove repository from a source ([blue]X[/])", + "Repository type — set for source ([blue]T[/])", }; var hotkeys = new Dictionary { @@ -37,6 +38,7 @@ public void Show() [ConsoleKey.R] = 1, [ConsoleKey.D] = 2, [ConsoleKey.X] = 3, + [ConsoleKey.T] = 4, }; while (true) @@ -65,6 +67,9 @@ public void Show() case 3: RemoveRepository(); break; + case 4: + SetRepositoryType(); + break; } } } @@ -93,7 +98,7 @@ private void RenderConfig() for (var i = 0; i < _config.Sources.Count; i++) { var source = _config.Sources[i]; - AnsiConsole.MarkupLine($" [green]{i + 1}.[/] [bold]{Markup.Escape(source.Organization)}[/] / [bold]{Markup.Escape(source.Project)}[/]"); + AnsiConsole.MarkupLine($" [green]{i + 1}.[/] [bold]{Markup.Escape(source.Organization)}[/] / [bold]{Markup.Escape(source.Project)}[/] [dim]({Markup.Escape(source.RepositoryType)})[/]"); if (source.Repositories.Count > 0) { foreach (var repo in source.Repositories) @@ -168,7 +173,9 @@ private void AddRepository() var source = SelectSource("Select source to add repository to:"); if (source is null) return; - var repo = BrowserUI.PromptPattern("Repository (e.g. dotnet/roslyn):"); + var repo = BrowserUI.PromptPattern(source.RepositoryType.Equals(AzdoRepositoryTypes.TfsGit, StringComparison.OrdinalIgnoreCase) + ? "Repository name or ID:" + : "Repository (e.g. dotnet/roslyn):"); if (repo is null) return; if (source.Repositories.Contains(repo, StringComparer.OrdinalIgnoreCase)) @@ -209,6 +216,28 @@ private void RemoveRepository() Console.ReadKey(true); } + private void SetRepositoryType() + { + var source = SelectSource("Select source to update:"); + if (source is null) return; + + var choices = new List + { + AzdoRepositoryTypes.GitHub, + AzdoRepositoryTypes.TfsGit, + }; + + AnsiConsole.WriteLine(); + var selected = BrowserUI.SelectWithEscape("Repository type:", choices); + if (selected < 0) return; + + source.RepositoryType = choices[selected]; + _config.Save(_configDirectory); + Changed = true; + AnsiConsole.MarkupLine($"[green]Set repository type for {Markup.Escape(source.Organization)}/{Markup.Escape(source.Project)} to {source.RepositoryType}[/]"); + Console.ReadKey(true); + } + private AzdoSource? SelectSource(string title) { if (_config.Sources.Count == 0) diff --git a/src/Tiger/TigerConfig.cs b/src/Tiger/TigerConfig.cs index b971259..c670a6b 100644 --- a/src/Tiger/TigerConfig.cs +++ b/src/Tiger/TigerConfig.cs @@ -3,6 +3,40 @@ namespace Tiger; +public static class AzdoRepositoryTypes +{ + public const string GitHub = "GitHub"; + public const string TfsGit = "TfsGit"; + + public static bool IsGitHub(string? repositoryType) => + repositoryType is null || repositoryType.Equals(GitHub, StringComparison.OrdinalIgnoreCase); + + public static bool IsSupported(string? repositoryType) => + repositoryType is not null && + (repositoryType.Equals(GitHub, StringComparison.OrdinalIgnoreCase) || + repositoryType.Equals(TfsGit, StringComparison.OrdinalIgnoreCase)); + + public static string Normalize(string? repositoryType) + { + if (repositoryType is null) + { + return GitHub; + } + + if (repositoryType.Equals(GitHub, StringComparison.OrdinalIgnoreCase)) + { + return GitHub; + } + + if (repositoryType.Equals(TfsGit, StringComparison.OrdinalIgnoreCase)) + { + return TfsGit; + } + + throw new InvalidOperationException($"Unsupported AzDO repository type '{repositoryType}'. Supported values are '{GitHub}' and '{TfsGit}'."); + } +} + /// /// An AzDO organization/project pair to monitor. /// @@ -14,6 +48,9 @@ public sealed class AzdoSource [JsonPropertyName("project")] public required string Project { get; set; } + [JsonPropertyName("repositoryType")] + public string RepositoryType { get; set; } = AzdoRepositoryTypes.GitHub; + [JsonPropertyName("repositories")] public List Repositories { get; set; } = []; } @@ -51,8 +88,10 @@ public static TigerConfig Load(string configDirectory) } var json = File.ReadAllText(path); - return JsonSerializer.Deserialize(json, s_jsonOptions) + var config = JsonSerializer.Deserialize(json, s_jsonOptions) ?? CreateDefault(); + config.Normalize(); + return config; } /// @@ -60,6 +99,7 @@ public static TigerConfig Load(string configDirectory) /// public void Save(string configDirectory) { + Normalize(); Directory.CreateDirectory(configDirectory); var path = GetConfigPath(configDirectory); var json = JsonSerializer.Serialize(this, s_jsonOptions); @@ -69,6 +109,14 @@ public void Save(string configDirectory) public static string GetConfigPath(string configDirectory) => Path.Combine(configDirectory, "config.json"); + private void Normalize() + { + foreach (var source in Sources) + { + source.RepositoryType = AzdoRepositoryTypes.Normalize(source.RepositoryType); + } + } + private static TigerConfig CreateDefault() => new() { PollIntervalSeconds = 300, diff --git a/src/Tiger/skills/tiger-cli/SKILL.md b/src/Tiger/skills/tiger-cli/SKILL.md index 4d2bb38..0e15a83 100644 --- a/src/Tiger/skills/tiger-cli/SKILL.md +++ b/src/Tiger/skills/tiger-cli/SKILL.md @@ -33,7 +33,7 @@ All output is structured **JSON** suitable for programmatic consumption. | `azdo download ` | Download an artifact from a build | | `azdo download-dumps ` | Download crash dump files from build artifacts | | `azdo pr-builds` | Get builds for a pull request | -| `azdo repo-builds` | Get builds for a repository | +| `azdo repo-builds` | Get builds for a repository; use `--repository-type TfsGit` for Azure Repos | ### `tiger helix` — Helix queries