diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..bb76300 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-azuretools.vscode-azurefunctions", + "ms-dotnettools.csharp" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..894cbe6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to .NET Functions", + "type": "coreclr", + "request": "attach", + "processId": "${command:azureFunctions.pickProcess}" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1fdd305 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "azureFunctions.deploySubpath": "AzureDevOpsReleaseNotes/bin/Release/net6.0/publish", + "azureFunctions.projectLanguage": "C#", + "azureFunctions.projectRuntime": "~4", + "debug.internalConsoleOptions": "neverOpen", + "azureFunctions.preDeployTask": "publish (functions)" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..f6d41e2 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,81 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "clean (functions)", + "command": "dotnet", + "args": [ + "clean", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/AzureDevOpsReleaseNotes" + } + }, + { + "label": "build (functions)", + "command": "dotnet", + "args": [ + "build", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "dependsOn": "clean (functions)", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/AzureDevOpsReleaseNotes" + } + }, + { + "label": "clean release (functions)", + "command": "dotnet", + "args": [ + "clean", + "--configuration", + "Release", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/AzureDevOpsReleaseNotes" + } + }, + { + "label": "publish (functions)", + "command": "dotnet", + "args": [ + "publish", + "--configuration", + "Release", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "dependsOn": "clean release (functions)", + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/AzureDevOpsReleaseNotes" + } + }, + { + "type": "func", + "dependsOn": "build (functions)", + "options": { + "cwd": "${workspaceFolder}/AzureDevOpsReleaseNotes/bin/Debug/net6.0" + }, + "command": "host start", + "isBackground": true, + "problemMatcher": "$func-dotnet-watch" + } + ] +} \ No newline at end of file diff --git a/AzureDevOpsReleaseNotes.sln b/AzureDevOpsReleaseNotes.sln index a83c288..108a768 100644 --- a/AzureDevOpsReleaseNotes.sln +++ b/AzureDevOpsReleaseNotes.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.27703.2042 +VisualStudioVersion = 17.5.002.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ADOReleaseNotes", "AzureDevOpsReleaseNotes\AzureDevOpsReleaseNotes.csproj", "{E92517EA-AA39-43EC-965C-04E371715592}" EndProject diff --git a/AzureDevOpsReleaseNotes/AzureDevOpsReleaseNotes.csproj b/AzureDevOpsReleaseNotes/AzureDevOpsReleaseNotes.csproj index 040c0c4..d87fa90 100644 --- a/AzureDevOpsReleaseNotes/AzureDevOpsReleaseNotes.csproj +++ b/AzureDevOpsReleaseNotes/AzureDevOpsReleaseNotes.csproj @@ -1,20 +1,22 @@ - netstandard2.0 - v2 + net6.0 + v4 + AzureDevOpsReleaseNotes Library - - - - - - - - + + + + + + + + + PreserveNewest diff --git a/AzureDevOpsReleaseNotes/ReleaseNotesWebHook.cs b/AzureDevOpsReleaseNotes/ReleaseNotesWebHook.cs index 0d94b54..b523dbb 100644 --- a/AzureDevOpsReleaseNotes/ReleaseNotesWebHook.cs +++ b/AzureDevOpsReleaseNotes/ReleaseNotesWebHook.cs @@ -1,4 +1,9 @@ +using Azure; +using Azure.AI.OpenAI; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Specialized; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.Extensions.Logging; @@ -7,19 +12,21 @@ using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.Common; using Microsoft.VisualStudio.Services.WebApi; -using Microsoft.WindowsAzure.Storage; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; namespace AzureDevOpsReleaseNotes { public static class ReleaseNotesWebHook { [FunctionName("ReleaseNotesWebHook")] - public static async void RunAsync([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)]HttpRequest req, ILogger log) + public static async Task Run( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req, + ILogger log) { //Extract data from request body string requestBody = new StreamReader(req.Body).ReadToEnd(); @@ -27,8 +34,8 @@ public static async void RunAsync([HttpTrigger(AuthorizationLevel.Anonymous, "ge string releaseName = data?.resource?.release?.name; string releaseBody = data?.resource?.release?.description; - VssBasicCredential credentials = new VssBasicCredential(Environment.GetEnvironmentVariable("DevOps.Username"), Environment.GetEnvironmentVariable("DevOps.AccessToken")); - VssConnection connection = new VssConnection(new Uri(Environment.GetEnvironmentVariable("DevOps.OrganizationURL")), credentials); + VssBasicCredential credentials = new(Environment.GetEnvironmentVariable("DevOps.Username"), Environment.GetEnvironmentVariable("DevOps.AccessToken")); + VssConnection connection = new(new Uri(Environment.GetEnvironmentVariable("DevOps.OrganizationURL")), credentials); //Time span of 14 days from today var dateSinceLastRelease = DateTime.Today.Subtract(new TimeSpan(14, 0, 0, 0)); @@ -36,32 +43,43 @@ public static async void RunAsync([HttpTrigger(AuthorizationLevel.Anonymous, "ge //Accumulate closed work items from the past 14 days in text format var workItems = GetClosedItems(connection, dateSinceLastRelease); var pulls = GetMergedPRs(connection, dateSinceLastRelease); - + //Create a new blob markdown file - CloudStorageAccount storageAccount = CloudStorageAccount.Parse(Environment.GetEnvironmentVariable("StorageAccountConnectionString")); - var blobClient = storageAccount.CreateCloudBlobClient(); - var container = blobClient.GetContainerReference("releases"); - var blob = container.GetBlockBlobReference(releaseName + ".md"); + BlobContainerClient container = new(Environment.GetEnvironmentVariable("StorageAccountConnectionString"), "releases"); + container.CreateIfNotExists(); + + var description = await GenerateReleaseDescriptionAsync(pulls, workItems); //Format text content of blob - var text = String.Format("# {0} \n {1} \n\n" + "# Work Items Resolved:" + workItems + "\n\n# Changes Merged:" + pulls, releaseName, releaseBody); + var text = string.Format("# {0} \n {1} \n\n" + "# Work Items Resolved:" + workItems + "\n\n# Changes Merged:" + pulls, releaseName, description.Choices[0].Message); + + var blob = container.GetAppendBlobClient(releaseName + ".md"); + blob.CreateIfNotExists(); + + var stream = new MemoryStream(); + stream.Write(System.Text.Encoding.UTF8.GetBytes(text)); + + //Append text to blob + stream.Position = 0; + await blob.AppendBlockAsync(stream); - //Add text to blob - await blob.UploadTextAsync(text); + return new OkObjectResult("Release Notes Updated"); + } public static string GetClosedItems(VssConnection connection, DateTime releaseSpan) { string project = Environment.GetEnvironmentVariable("DevOps.ProjectName"); var workItemTrackingHttpClient = connection.GetClient(); - + //Query that grabs all of the Work Items marked "Done" in the last 14 days - Wiql wiql = new Wiql() + Wiql wiql = new() { Query = "Select [State], [Title] " + "From WorkItems Where " + "[System.TeamProject] = '" + project + "' " + - "And [System.State] = 'Done' " + + "And [System.State] = 'Resolved' " + + "OR [System.State] = 'Closed' " + "And [Closed Date] >= '" + releaseSpan.ToString() + "' " + "Order By [State] Asc, [Changed Date] Desc" }; @@ -72,13 +90,13 @@ public static string GetClosedItems(VssConnection connection, DateTime releaseSp if (workItemQueryResult.WorkItems.Count() != 0) { - List list = new List(); + List list = new(); foreach (var item in workItemQueryResult.WorkItems) { list.Add(item.Id); } - //Extraxt desired work item fields + //Extract desired work item fields string[] fields = { "System.Id", "System.Title" }; var workItems = workItemTrackingHttpClient.GetWorkItemsAsync(list, fields, workItemQueryResult.AsOf).Result; @@ -86,7 +104,7 @@ public static string GetClosedItems(VssConnection connection, DateTime releaseSp string txtWorkItems = string.Empty; foreach (var workItem in workItems) { - txtWorkItems += String.Format("\n 1. #{0}-{1}", workItem.Id, workItem.Fields["System.Title"]); + txtWorkItems += string.Format("\n 1. #{0}-{1}", workItem.Id, workItem.Fields["System.Title"]); } return txtWorkItems; } @@ -102,15 +120,15 @@ public static string GetMergedPRs(VssConnection connection, DateTime releaseSpan using (gitClient) { //Get first repo in project - var releaseRepo = gitClient.GetRepositoriesAsync().Result[0]; + var releaseRepo = gitClient.GetRepositoriesAsync().Result[0]; //Grabs all completed PRs merged into master branch List prs = gitClient.GetPullRequestsAsync( releaseRepo.Id, new GitPullRequestSearchCriteria() { - TargetRefName = "refs/heads/master", - Status = PullRequestStatus.Completed + TargetRefName = "refs/heads/main", + Status = PullRequestStatus.Completed }).Result; @@ -118,14 +136,14 @@ public static string GetMergedPRs(VssConnection connection, DateTime releaseSpan { //Query that grabs PRs merged since the specified date var pulls = from p in prs - where p.ClosedDate >= releaseSpan - select p; + where p.ClosedDate >= releaseSpan + select p; //Format PR info into text var txtPRs = string.Empty; foreach (var pull in pulls) { - txtPRs += String.Format("\n 1. #{0}-{1}", pull.PullRequestId, pull.Title); + txtPRs += string.Format("\n 1. #{0}-{1}", pull.PullRequestId, pull.Title); } return txtPRs; @@ -133,5 +151,32 @@ public static string GetMergedPRs(VssConnection connection, DateTime releaseSpan return string.Empty; } } + + public static async Task GenerateReleaseDescriptionAsync(string pulls, string workItems) + { + OpenAIClient client = new OpenAIClient( + new Uri(Environment.GetEnvironmentVariable("OpenAIEndpoint")), + new AzureKeyCredential(Environment.GetEnvironmentVariable("OpenAIKey"))); + + Response completionsResponse = await client.GetChatCompletionsAsync( + new ChatCompletionsOptions() + { + Messages = + { + new ChatMessage(ChatRole.System, @"You are an AI powered software project manager who is in charge of managing release notes."), + new ChatMessage(ChatRole.User, @"Here's all the resolved work items and merged pull requests since the last release. Summarize them in a few sentences and make a generic description if there aren't any." + "\n\n" + "Work Items Resolved:" + workItems + "\n\n" + "Changes Merged:" + pulls ), + }, + Temperature = (float)0.7, + MaxTokens = 800, + NucleusSamplingFactor = (float)0.95, + FrequencyPenalty = 0, + PresencePenalty = 0, + DeploymentName=Environment.GetEnvironmentVariable("ModelDeploymentName") + }); + + ChatCompletions completions = completionsResponse.Value; + + return completions; + } } } diff --git a/AzureDevOpsReleaseNotes/dev.settings.json b/AzureDevOpsReleaseNotes/dev.settings.json deleted file mode 100644 index 0d846dd..0000000 --- a/AzureDevOpsReleaseNotes/dev.settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "AzureWebJobsDashboard": "UseDevelopmentStorage=true", - "StorageAccountConnectionString": "UseDevelopmentStorage=true", - "DevOps.OrganizationURL": "", - "DevOps.AccessToken": "", - "DevOps.ProjectName": "", - "DevOps.Username": "" - } -} \ No newline at end of file diff --git a/AzureDevOpsReleaseNotes/host.json b/AzureDevOpsReleaseNotes/host.json index 2c63c08..81e35b7 100644 --- a/AzureDevOpsReleaseNotes/host.json +++ b/AzureDevOpsReleaseNotes/host.json @@ -1,2 +1,3 @@ { -} + "version": "2.0" +} \ No newline at end of file