Posted in:

I recently needed to make a one-line change to the NuGet.config files in hundreds of Git repos hosted in Azure DevOps. This seemed like the perfect opportunity for some automation, and I chose to do so using C#.

A lot of the difficult work was done for me thanks to an excellent article on CodeProject by Florian Rappl. His sample did about 90% of what I needed, but I did run into a few minor issues so I wanted to share my version of the code here.

The NuGet packages I'm referencing for this are Microsoft.TeamFoundationService.Client and Microsoft.VisualStudio.Services.InteractiveClient.

First, let's get connected to Azure DevOps and get hold of a GitHttpClient that we can use for all the operations we need. Obviously you'll need to supply your own collection Url and team project. You'll also need to create a Personal Access Token to log in. I believe you ought to be able to log in interactively but I never got that working, so settled for the PAT approach.

var pat = "abc123"; // your PAT token with sufficient privileges to push code and create PRs here
var vstsCollectionUrl = "https://dev.azure.com/MyOrg";
var teamProject = "MyProject";

var creds = new VssBasicCredential(string.Empty, pat);
var connection = new VssConnection(new Uri(vstsCollectionUrl), creds); 

var gitClient = connection.GetClient<GitHttpClient>();

Next, I loop through all the Git repos in my project, as I want to make the change in each one:

var repos = await gitClient.GetRepositoriesAsync(teamProject);
foreach(var repo in repos)
{
    await UpdateRepo(repo);
}

Let's look next at my UpdateRepo method. There's a lot going on in here, but essentially, I start by deciding which branch I want to update. Some of our repos have a master branch, and others have a main branch, so GetBaseBranchName is picking the branch. I've also defined the name of a new branch that I'll use for the PR.

In my case the file we want to update is NuGet.config, which conveniently sits at the root of our repos. One issue I ran into was that the casing of the filename was different from repo to repo - e.g. some might have nuget.config. The GetItemAsync method is able to find the item we're looking for with a case insensitive search, but when we create the push further down we must get the case exact, which is why I then overwrite path with item.Path.

I then use the GetItemContentAsync method to download the contents of the NuGet.config file, and perform some very rudimentary string replacement to update it with my new NuGet feed address. Some repos don't have a NuGet.config file and others don't reference the old feed, so I only create a PR if I actually need to.

If we do need to update the file, I first create a Git push with CreatePushAsync that pushes the updated file contents to my new branch. And then I create a Pull Request with CreatePullRequestAsync to request a merge of that change into the main branch of my repo.

async Task UpdateRepo(GitRepository repo)
{
    Console.WriteLine("=================");
    Console.WriteLine($"Processing {repo.Name}");
    var baseBranchName = await GetBaseBranchName(repo.Id);
    if (baseBranchName == null)
    {
        Console.WriteLine("No branches found to update");
        return;
    }
    
    var newBranchName = "code/nuget-feed-update";
    var branchDescriptor = new GitVersionDescriptor
    {
        Version = baseBranchName,
        VersionType = GitVersionType.Branch,
    };

    var path = "NuGet.config"; 
    
    try
    {
        var item = await gitClient.GetItemAsync
                   (repo.Id, path, versionDescriptor: branchDescriptor);
        path = item.Path; // fix up if the casing is wrong
                   
        var content = await gitClient.GetItemContentAsync
                   (repo.Id, path, includeContent: true, 
                   versionDescriptor: branchDescriptor);
        
        var oldContent = await GetContent(content);
        var newContent = oldContent.Replace(
            $"http://myoldnugetserver:8080/repository/MyOldFeed/",
            $"https://pkgs.dev.azure.com/MyOrg/_packaging/MyNewFeed/nuget/v3/index.json");

        if (newContent != oldContent)
        {
            newContent = newContent.Replace(
                    $"MyOldFeed",
                    $"MyNewFeed");
            var push = CreatePush(item.CommitId, path, newContent, newBranchName);
            await gitClient.CreatePushAsync(push, repo.Id);
            var pr = CreatePullRequest(baseBranchName, newBranchName);
            var result = await gitClient.CreatePullRequestAsync
                         (pr, repo.Id);
            Console.WriteLine($"Created PR {result.PullRequestId}");
        }
        else
        {
            Console.WriteLine("Nuget.config does not reference MyOldFeed");
            oldContent.Dump();
        }
    }
    catch (VssException ex) when (ex.Message.StartsWith("TF401174"))
    {
        Console.WriteLine("No Nuget.config file found");
    }
}

Let's look at a few of the helper methods I used. First, GetBaseBranchName works out which branch I should be updating:

async Task<string> GetBaseBranchName(Guid repoId)
{
    try
    {
        var branches = await gitClient.GetBranchesAsync(teamProject, repoId);
        return branches.Select(b => b.Name)
            .First(n => n == "master" || n == "main");
    }
    catch(VssServiceResponseException ex) when (ex.Message.StartsWith("VS403403"))
    {
        return null;
    }	
}

CreatePush constructs the GitPush object that defines where we are pushing to, the comment for the commit, and the updated contents of the file. It's this method that needs to use the exact correct casing of the path if the update is to work.

GitPush CreatePush(string commitId, string path, string content, 
    string newBranchName) => new GitPush
{
    RefUpdates = new List<GitRefUpdate>
    {
        new GitRefUpdate
        {
            Name = GetRefName(newBranchName),			
            OldObjectId = commitId,
        },
    },
    Commits = new List<GitCommitRef>
    {
        new GitCommitRef
        {
            Comment = "Automatic NuGet Feed Update #159948",
            Changes = new List<GitChange>
            {
                new GitChange
                {
                    ChangeType = VersionControlChangeType.Edit,
                    Item = new GitItem
                    {
                        Path = path,						
                    },
                    NewContent = new ItemContent
                    {
                        Content = content,
                        ContentType = ItemContentType.RawText,
                    },
                }
            },
        }
    },
};

These two helpers help us build a Git reference for a branch, and download the content of the file from Git. These are taken unchanged from the sample application I linked to above.

string GetRefName(String branchName) => $"refs/heads/{branchName}";

async Task<String> GetContent(Stream item)
{
    using (var ms = new MemoryStream())
    {
        await item.CopyToAsync(ms);
        var raw = ms.ToArray();
        return Encoding.UTF8.GetString(raw);
    }
}

Finally, the method that creates the GitPullRequest object I did customise a bit, because our PR policies require that every PR is linked to a a workitem. You can do this by setting the WorkItemRefs policy. In this example I'm linking the PR to work item 159948.

GitPullRequest CreatePullRequest(string baseBranchName, string newBranchName) 
    => new GitPullRequest 
{
    Title = "Automated NuGet Feed Update",
    Description = "Updated to point at the MyNewFeed in Azure DevOps #159948",
    TargetRefName = GetRefName(baseBranchName),
    SourceRefName = GetRefName(newBranchName),
    WorkItemRefs = new Microsoft.VisualStudio.Services.WebApi.ResourceRef[] {
        new ResourceRef() { Id = "159948", 
        Url = "https://dev.azure.com/MyOrg/_apis/wit/workItems/159948"}
    }
};

Hope this will be helpful to someone out there. This saved me from manually creating 200 pull requests, and I'm sure this code will come in handy if I ever need to roll out changes across many Azure DevOps hosted repositories again.

Update

I discovered that with some files, my code sample seemed to result in a text file that had the unicode BOM (byte order mark) twice. I have no idea why, but a workaround was to use ItemContentType.Base64Encoded instead of ItemContentType.RawText. Here's the code that builds the ItemContent using base 64 encoding in case you run into this issue

NewContent = new ItemContent
    {
        Content = ToBase64(content),
        ContentType = ItemContentType.Base64Encoded,
    },

// ...
string ToBase64(string input) => Convert.ToBase64String(UTF8Encoding.UTF8.GetBytes(input));