Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
- @azure Rule - Use Azure Best Practices: When generating code for Azure, running terminal commands for Azure, or performing operations related to Azure, invoke your `azure_development-get_best_practices` tool if available.
- @azure Rule - Use Azure Best Practices: When generating code for Azure, running terminal commands for Azure, or performing operations related to Azure, invoke your `azure_development-get_best_practices` tool if available.
11 changes: 11 additions & 0 deletions .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,14 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4

- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x

- name: Build azsdk cli
run: |
set -ex
dotnet publish -f net8.0 -c Release -r linux-x64 -p:PublishSingleFile=true --self-contained --output ./artifacts/linux-x64 ./tools/azsdk-cli/Azure.Sdk.Tools.Cli/Azure.Sdk.Tools.Cli.csproj
cp ./artifacts/linux-x64/azsdk /home/runner/azsdk
/home/runner/azsdk --help
10 changes: 10 additions & 0 deletions eng/pipelines/templates/stages/archetype-sdk-tool-dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,14 @@ extends:
name: ${{ test.Pool }}
os: ${{ test.Os }}

templateContext:
outputs:
- output: pipelineArtifact
targetPath: '$(Build.ArtifactStagingDirectory)/llm-artifacts'
artifactName: "LLM Artifacts - ${{ test.name }} - $(System.JobAttempt)"
condition: eq(variables['uploadLlmArtifacts'], 'true')
sbomEnabled: false

steps:
- template: /eng/pipelines/templates/steps/install-dotnet.yml

Expand All @@ -187,6 +195,8 @@ extends:
testResultsFormat: 'VSTest'
mergeTestResults: true

- template: /eng/pipelines/templates/steps/upload-llm-artifacts.yml

- ${{ if not(eq(length(parameters.DockerDeployments), 0)) }}:
- template: /eng/pipelines/publish-docker-image-isolated.yml
parameters:
Expand Down
30 changes: 30 additions & 0 deletions eng/pipelines/templates/steps/upload-llm-artifacts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# This template serves as a place to upload artifacts intended to be used by LLMs
# that handle data from the pipeline (for example github copilot).

steps:
- pwsh: |
$artifactsDirectory = "$(Build.ArtifactStagingDirectory)/llm-artifacts"
New-Item $artifactsDirectory -ItemType directory -Force
Write-Host "================="
Get-ChildItem -Path *.trx -Recurse -File
Write-Host "================="
foreach($testResultsFile in (Get-ChildItem -Path *.trx -Recurse -File))
{
$fileFullName = $testResultsFile.FullName
# Convert a path like
# D:\a\_work\1\s\tools\azsdk-cli\Azure.Sdk.Tools.Cli.Tests\TestResults\cloudtest_c199fa1ec000015_2025-07-11_19_03_59.trx
# to
# azsdk-cli-Azure.Sdk.Tools.Cli.Tests-cloudtest_c199fa1ec000015_2025-07-11_19_03_59.trx
$serviceAndPackage = ($fileFullName -split '[\\/]tools[\\/]|[\\/]TestResults')[1] -replace '[\\/]', '-'
$trxFile = Split-Path $fileFullName -Leaf
$fileName = "$serviceAndPackage-$trxFile"
Move-Item -Path $fileFullName -Destination "$artifactsDirectory/$fileName" -ErrorAction Continue
Write-Host "##vso[task.setvariable variable=uploadLlmArtifacts]true"
}
condition: succeededOrFailed()
displayName: Copy test results files to llm artifacts staging directory
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class EnforceToolsExceptionHandlingAnalyzer : DiagnosticAnalyzer
private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
Id,
"McpServerTool methods must wrap body in try/catch, see the README within the tools directory for examples",
"Method '{0}' must have its entire body inside try{{}}catch(Exception)",
"Method '{0}' must have its entire body inside 'try {} catch(Exception) {}'",
"Reliability",
DiagnosticSeverity.Error,
isEnabledByDefault: true);
Expand Down Expand Up @@ -57,21 +57,27 @@ private static void AnalyzeMethod(SyntaxNodeAnalysisContext ctx)
return;
}

// check that there is one statement surrounding the body
// check that try statements surround the entire body
var stmts = body.Statements;
if (stmts.Count != 1 || !(stmts[0] is TryStatementSyntax tryStmt))
foreach (var stmt in stmts)
{
ctx.ReportDiagnostic(Diagnostic.Create(Rule, md.Identifier.GetLocation(), md.Identifier.Text));
return;
if (stmt is LocalDeclarationStatementSyntax)
{
continue;
}
if (!(stmt is TryStatementSyntax tryStmt))
{
ctx.ReportDiagnostic(Diagnostic.Create(Rule, md.Identifier.GetLocation(), md.Identifier.Text));
continue;
}
// verify there’s a catch(Exception)
bool hasExCatch = tryStmt.Catches.Any(c => c.Declaration?.Type.ToString() == "Exception");
if (!hasExCatch)
{
ctx.ReportDiagnostic(Diagnostic.Create(Rule, md.Identifier.GetLocation(), md.Identifier.Text));
}
}

// verify there’s a catch(Exception)
bool hasExCatch = tryStmt.Catches
.Any(c => c.Declaration?.Type.ToString() == "Exception");
if (!hasExCatch)
{
ctx.ReportDiagnostic(Diagnostic.Create(Rule, md.Identifier.GetLocation(), md.Identifier.Text));
}
}
}
}
29 changes: 29 additions & 0 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/CliIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,35 @@ private Tuple<Command, TestLogger<T>> GetTestInstanceWithLogger<T>() where T : M
return tuple;
}

[Test]
public async Task TestWindows()
{
if (!OperatingSystem.IsWindows())
{
Assert.Ignore("This test is only applicable on Windows.");
}

var (cmd, logger) = GetTestInstanceWithLogger<HelloWorldTool>();

var output = "";
outputServiceMock
.Setup(s => s.Output(It.IsAny<string>()))
.Callback<string>(s => output = s);

var exitCode = await cmd.InvokeAsync(["hello-world", "HI. MY NAME IS"]);
Assert.That(exitCode, Is.EqualTo(0));

var expected = @"
Message: RESPONDING TO 'HI. MY NAME IS' with SUCCESS: 0
Result: null
Duration: 1ms".TrimStart();

outputServiceMock
.Verify(s => s.Output(It.IsAny<string>()), Times.Once);

Assert.That(output, Is.EqualTo(expected));
}

[Test]
public async Task TestHelloWorldCLIOptions()
{
Expand Down
12 changes: 7 additions & 5 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,25 @@ namespace Azure.Sdk.Tools.Cli.Commands
{
public static class SharedOptions
{
public static readonly List<Type> ToolsList = new List<Type>(){
typeof(AnalyzePipelinesTool),
typeof(PipelineDetailsTool),
public static readonly List<Type> ToolsList = [
typeof(CleanupTool),
typeof(LogAnalysisTool),
typeof(HostServerTool),
typeof(PipelineAnalysisTool),
typeof(PipelineDetailsTool),
typeof(PipelineTestsTool),
typeof(ReleasePlanTool),
typeof(ReleaseReadinessTool),
typeof(SpecCommonTools),
typeof(SpecPullRequestTools),
typeof(SpecWorkflowTool),
typeof(SpecValidationTools),
typeof(ReleaseReadinessTool),
typeof(TestAnalysisTool),
#if DEBUG
// only add this tool in debug mode
typeof(HelloWorldTool),
#endif
};
];

public static Option<string> ToolOption = new("--tools")
{
Expand Down
29 changes: 23 additions & 6 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/LogAnalysisHelper.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Policy;
using Azure.Sdk.Tools.Cli.Models;

namespace Azure.Sdk.Tools.Cli.Helpers;
Expand Down Expand Up @@ -39,6 +40,9 @@ public class LogAnalysisHelper(ILogger<LogAnalysisHelper> logger) : ILogAnalysis
{
private readonly ILogger<LogAnalysisHelper> logger = logger;

public const int DEFAULT_BEFORE_LINES = 20;
public const int DEFAULT_AFTER_LINES = 20;

// Built-in error keywords for robust error detection
private static readonly HashSet<Keyword> defaultErrorKeywords =
[
Expand Down Expand Up @@ -75,6 +79,12 @@ public class LogAnalysisHelper(ILogger<LogAnalysisHelper> logger) : ILogAnalysis
];

public async Task<List<LogEntry>> AnalyzeLogContent(string filePath, List<string>? keywordOverrides, int? beforeLines, int? afterLines)
{
using var stream = new StreamReader(filePath);
return await AnalyzeLogContent(stream, keywordOverrides, beforeLines, afterLines, filePath: filePath);
}

public async Task<List<LogEntry>> AnalyzeLogContent(StreamReader reader, List<string>? keywordOverrides, int? beforeLines, int? afterLines, string url = "", string filePath = "")
{
var keywords = defaultErrorKeywords;
if (keywordOverrides?.Count > 0)
Expand All @@ -86,14 +96,13 @@ public async Task<List<LogEntry>> AnalyzeLogContent(string filePath, List<string
}
}

beforeLines ??= 3;
afterLines ??= 20;
beforeLines ??= DEFAULT_BEFORE_LINES;
afterLines ??= DEFAULT_AFTER_LINES;
var before = new Queue<string>((int)beforeLines);
var after = new Queue<string>((int)afterLines);
var maxAfterLines = afterLines ?? 100;

var errors = new List<LogEntry>();
using var reader = new StreamReader(filePath);

var lineNumber = 0;
string? line;
Expand Down Expand Up @@ -127,12 +136,20 @@ public async Task<List<LogEntry>> AnalyzeLogContent(string filePath, List<string
var fullContext = before.Concat(after).ToList();
before.Clear();
after.Clear();
errors.Add(new LogEntry
var entry = new LogEntry
{
File = filePath,
Line = lineNumber,
Message = string.Join(Environment.NewLine, fullContext)
});
};
if (!string.IsNullOrEmpty(url))
{
entry.Url = url;
}
if (!string.IsNullOrEmpty(filePath))
{
entry.File = filePath;
}
errors.Add(entry);
}
}

Expand Down
98 changes: 98 additions & 0 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/TestHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Xml;
using Azure.Sdk.Tools.Cli.Models;

namespace Azure.Sdk.Tools.Cli.Services;

public interface ITestHelper
{
Task<List<FailedTestRunResponse>> GetFailedTestCases(string trxFilePath, string filterTitle = "");
Task<FailedTestRunResponse> GetFailedTestCaseData(string trxFilePath, string testCaseTitle);
Task<List<FailedTestRunResponse>> GetFailedTestRunDataFromTrx(string trxFilePath);
}

public class TestHelper(IOutputService output, ILogger<TestHelper> logger) : ITestHelper
{
private readonly IOutputService output = output;
private readonly ILogger<TestHelper> logger = logger;

public async Task<List<FailedTestRunResponse>> GetFailedTestCases(string trxFilePath, string filterTitle = "")
{
var failedTestRuns = await GetFailedTestRunDataFromTrx(trxFilePath);

return failedTestRuns
.Where(run => string.IsNullOrEmpty(filterTitle) || run.TestCaseTitle.Contains(filterTitle, StringComparison.OrdinalIgnoreCase))
.Select(run => new FailedTestRunResponse
{
TestCaseTitle = run.TestCaseTitle,
Uri = run.Uri
})
.ToList();
}

public async Task<FailedTestRunResponse> GetFailedTestCaseData(string trxFilePath, string testCaseTitle)
{
var failedTestRuns = await GetFailedTestRunDataFromTrx(trxFilePath);
return failedTestRuns.FirstOrDefault(run => run.TestCaseTitle.Equals(testCaseTitle, StringComparison.OrdinalIgnoreCase));
}

public async Task<List<FailedTestRunResponse>> GetFailedTestRunDataFromTrx(string trxFilePath)
{
var failedTestRuns = new List<FailedTestRunResponse>();
if (!File.Exists(trxFilePath))
{
logger.LogError("TRX file not found: {trxFilePath}", trxFilePath);
return failedTestRuns;
}

var xmlContent = await File.ReadAllTextAsync(trxFilePath);
var doc = new XmlDocument();
doc.LoadXml(xmlContent);
var unitTestResults = doc.GetElementsByTagName("UnitTestResult");
foreach (XmlNode resultNode in unitTestResults)
{
var outcome = resultNode.Attributes?["outcome"]?.Value ?? "";
if (!string.Equals(outcome, "Failed", StringComparison.OrdinalIgnoreCase))
{
continue;
}

var testId = resultNode.Attributes?["testId"]?.Value ?? "";
var testName = resultNode.Attributes?["testName"]?.Value ?? "";
var errorMessage = "";
var stackTrace = "";

var outputNode = resultNode.ChildNodes.OfType<XmlNode>().FirstOrDefault(n => n.Name == "Output");
if (outputNode != null)
{
var errorInfoNode = outputNode.ChildNodes.OfType<XmlNode>().FirstOrDefault(n => n.Name == "ErrorInfo");
if (errorInfoNode != null)
{
var messageNode = errorInfoNode.ChildNodes.OfType<XmlNode>().FirstOrDefault(n => n.Name == "Message");
if (messageNode != null)
{
errorMessage = messageNode.InnerText ?? "";
}

var stackTraceNode = errorInfoNode.ChildNodes.OfType<XmlNode>().FirstOrDefault(n => n.Name == "StackTrace");
if (stackTraceNode != null)
{
stackTrace = stackTraceNode.InnerText ?? "";
}
}
}

failedTestRuns.Add(new FailedTestRunResponse
{
TestCaseTitle = testName,
ErrorMessage = errorMessage,
StackTrace = stackTrace,
Outcome = outcome,
Uri = trxFilePath
});
}

return failedTestRuns;
}
}
12 changes: 6 additions & 6 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/TokenUsageHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,12 @@ public void LogCost()
Console.WriteLine("--------------------------------------------------------------------------------");
}

public static TokenUsageHelper operator +(TokenUsageHelper a, TokenUsageHelper b) => new()
public static TokenUsageHelper operator +(TokenUsageHelper a, TokenUsageHelper? b) => new()
{
Models = a.Models.Union(b.Models).ToList(),
PromptTokens = a.PromptTokens + b.PromptTokens,
CompletionTokens = a.CompletionTokens + b.CompletionTokens,
InputCost = a.InputCost + b.InputCost,
OutputCost = a.OutputCost + b.OutputCost,
Models = a.Models.Union(b?.Models ?? []).ToList(),
PromptTokens = a.PromptTokens + (b?.PromptTokens ?? 0),
CompletionTokens = a.CompletionTokens + (b?.CompletionTokens ?? 0),
InputCost = a.InputCost + (b?.InputCost ?? 0),
OutputCost = a.OutputCost + (b?.OutputCost ?? 0),
};
}
Loading