Skip to content

Instantly share code, notes, and snippets.

@jnm2
Last active August 10, 2025 00:35
Show Gist options
  • Save jnm2/49e718553f48b9e19d9b780c00154f47 to your computer and use it in GitHub Desktop.
Save jnm2/49e718553f48b9e19d9b780c00154f47 to your computer and use it in GitHub Desktop.
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));
}
}
<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