Last active
August 10, 2025 00:35
-
-
Save jnm2/49e718553f48b9e19d9b780c00154f47 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Microsoft.TeamFoundation.SourceControl.WebApi; | |
using Microsoft.TeamFoundation.WorkItemTracking.WebApi; | |
using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; | |
using Microsoft.VisualStudio.Services.Common; | |
using Microsoft.VisualStudio.Services.WebApi; | |
using System.Reactive.Linq; | |
using System.Text.RegularExpressions; | |
public sealed partial class Program | |
{ | |
// Expected args: a single URL that includes /_git | |
// This will be the repo whose branches get reported on. | |
public static async Task Main(string[] args) | |
{ | |
var (collectionUrl, projectName, repoName) = ParseRepoUrl(args.Single()); | |
using var connection = new VssConnection(new Uri(collectionUrl), new VssCredentials()); | |
using var gitClient = connection.GetClient<GitHttpClient>(); | |
if (!Guid.TryParse(repoName, out var repositoryId)) | |
repositoryId = (await gitClient.GetRepositoryAsync(projectName, repoName)).Id; | |
var branches = await gitClient.GetBranchesAsync(repositoryId); | |
using var wiClient = connection.GetClient<WorkItemTrackingHttpClient>(); | |
var wiqlResult = await wiClient.QueryByWiqlAsync(new Wiql { Query = "SELECT [System.Id] FROM WorkItems WHERE [System.ExternalLinkCount] > 0" }); | |
var workItems = await wiqlResult.WorkItems | |
.Select(wi => wi.Id) | |
.Batch(200) | |
.ToObservable() | |
.SelectMany(async workItemIdBatch => await wiClient.GetWorkItemsAsync(workItemIdBatch, expand: WorkItemExpand.Relations)) | |
.SelectMany(workItemBatch => workItemBatch) | |
.ToList(); | |
var remainingBranches = branches.ToDictionary(branch => branch.Name); | |
Console.WriteLine("## Branches with work items"); | |
Console.WriteLine(); | |
foreach (var workItemsByBranchName in | |
from workItem in workItems | |
from relation in workItem.Relations | |
where relation.Rel == "ArtifactLink" && relation.Attributes["name"] is "Branch" | |
let branch = ParseBranchUrl(relation.Url) | |
where branch.RepositoryId == repositoryId | |
group workItem by branch.BranchName into workItemsByBranchName | |
orderby workItemsByBranchName.Key | |
select workItemsByBranchName) | |
{ | |
if (!remainingBranches.Remove(workItemsByBranchName.Key, out var branch)) | |
continue; | |
Console.WriteLine($"{workItemsByBranchName.Key}:"); | |
Console.WriteLine($" -{branch.BehindCount} +{branch.AheadCount}, last updated {branch.Commit.Committer.Date.ToLocalTime()} by {branch.Commit.Committer.Name}"); | |
foreach (var workItem in workItemsByBranchName) | |
Console.WriteLine($" {collectionUrl}/{projectName}/_workitems/edit/{workItem.Id!.Value} ({workItem.Fields["System.State"]})"); | |
Console.WriteLine(); | |
} | |
Console.WriteLine("## Branches without work items"); | |
Console.WriteLine(); | |
foreach (var branch in remainingBranches.Values.OrderBy(branch => branch.Name)) | |
{ | |
Console.WriteLine($"{branch.Name}:"); | |
Console.WriteLine($" -{branch.BehindCount} +{branch.AheadCount}, last updated {branch.Commit.Committer.Date.ToLocalTime()} by {branch.Commit.Committer.Name}"); | |
Console.WriteLine(); | |
} | |
} | |
[GeneratedRegex("/(?<project>[^/]+)/_git(/(?<repo>[^/]+))?", RegexOptions.IgnoreCase)] | |
private static partial Regex GetRepoUrlRegex(); | |
private static (string CollectionUrl, string ProjectName, string RepoName) ParseRepoUrl(string url) | |
{ | |
var match = GetRepoUrlRegex().Match(url); | |
if (!match.Success) | |
throw new ArgumentException("Invalid repository URL format.", nameof(url)); | |
var projectGroup = match.Groups["project"]; | |
var repoGroup = match.Groups["repo"]; | |
return ( | |
url[..(projectGroup.Index - 1)], | |
projectGroup.Value, | |
repoGroup.Success ? repoGroup.Value : projectGroup.Value); | |
} | |
private static (Guid RepositoryId, string BranchName) ParseBranchUrl(string url) | |
{ | |
var splitBeforeRepoId = url.IndexOf("%2F", StringComparison.OrdinalIgnoreCase); | |
if (splitBeforeRepoId == -1) | |
throw new ArgumentException("Invalid branch URL format.", nameof(url)); | |
var repoIdStart = splitBeforeRepoId + "%2F".Length; | |
var splitAfterRepoId = url.IndexOf("%2F", repoIdStart, StringComparison.OrdinalIgnoreCase); | |
if (splitAfterRepoId == -1) | |
throw new ArgumentException("Invalid branch URL format.", nameof(url)); | |
var gitNameStart = splitAfterRepoId + "%2F".Length; | |
if (!url.AsSpan(gitNameStart).StartsWith("GB", StringComparison.OrdinalIgnoreCase)) | |
throw new ArgumentException("Invalid branch URL format.", nameof(url)); | |
return ( | |
Guid.Parse(url.AsSpan()[repoIdStart..splitAfterRepoId]), | |
url[(gitNameStart + "GB".Length)..].Replace("%2F", "/", StringComparison.OrdinalIgnoreCase)); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<Project Sdk="Microsoft.NET.Sdk"> | |
<PropertyGroup> | |
<OutputType>Exe</OutputType> | |
<TargetFramework>net9.0</TargetFramework> | |
<ImplicitUsings>enable</ImplicitUsings> | |
<Nullable>enable</Nullable> | |
</PropertyGroup> | |
<ItemGroup> | |
<PackageReference Include="Microsoft.TeamFoundationServer.Client" Version="19.225.1" /> | |
<PackageReference Include="System.Reactive" Version="6.0.1" /> | |
</ItemGroup> | |
</Project> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment