diff --git a/.github/workflows/auto-assign-pr.yml b/.github/workflows/auto-assign-pr.yml index 2523408..b9a7d8f 100644 --- a/.github/workflows/auto-assign-pr.yml +++ b/.github/workflows/auto-assign-pr.yml @@ -4,8 +4,7 @@ name: Auto Assign PR on: - pull_request: - types: [opened] + workflow_dispatch: jobs: assignAuthor: diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index a741b8c..c0bba93 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -75,6 +75,7 @@ jobs: mcr.microsoft.com:443 pipelines.actions.githubusercontent.com:443 pkg-containers.githubusercontent.com:443 + production.cloudflare.docker.com:443 registry-1.docker.io:443 storage.googleapis.com:443 @@ -82,9 +83,10 @@ jobs: uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 - name: Install Cosign - uses: sigstore/cosign-installer@9becc617647dfa20ae7b1151972e9b3a2c338a2b - with: - cosign-release: 'v1.9.0' + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Setup QEMU + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 - name: Setup Docker Buildx uses: docker/setup-buildx-action@8c0edbc76e98fa90f69d9a2c020dcb50019dc325 @@ -107,6 +109,7 @@ jobs: uses: docker/build-push-action@c56af957549030174b10d6867f20e78cfd7debc5 with: context: . + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/action.yml b/action.yml index bb3ee76..48b66e6 100644 --- a/action.yml +++ b/action.yml @@ -42,7 +42,7 @@ inputs: default: '50 75' runs: using: 'docker' - image: 'docker://ghcr.io/irongut/codecoveragesummary:v1.3.0' + image: 'docker://ghcr.io/dmaslov-at/codecoveragesummary:v1.4.2' args: - '--files' - ${{ inputs.filename }} diff --git a/src/CodeCoverageSummary/Program.cs b/src/CodeCoverageSummary/Program.cs index f0cc63f..b769f86 100644 --- a/src/CodeCoverageSummary/Program.cs +++ b/src/CodeCoverageSummary/Program.cs @@ -42,19 +42,18 @@ private static int Main(string[] args) } } - // parse code coverage file - CodeSummary summary = new(); + // parse code coverage files, merging coverage for + // packages/classes that appear in more than one file + // (e.g. separate unit test and functional test coverage runs) + Dictionary merge = new(); foreach (var file in matchingFiles) { Console.WriteLine($"Coverage File: {file}"); - summary = ParseTestResults(file, summary); + if (!ParseTestResults(file, merge)) + return -2; // error } - if (summary == null) - return -2; // error - - summary.LineRate /= matchingFiles.Count(); - summary.BranchRate /= matchingFiles.Count(); + CodeSummary summary = BuildSummary(merge); if (summary.Packages.Count == 0) { @@ -138,100 +137,175 @@ private static int Main(string[] args) _ => -1); // invalid arguments } - private static CodeSummary ParseTestResults(string filename, CodeSummary summary) + // per-line coverage, keyed by line number, merged across coverage files + private sealed class LineMerge + { + public bool Covered { get; set; } + + public int BranchesCovered { get; set; } + + public int BranchesValid { get; set; } + } + + private sealed class ClassMerge + { + public double Complexity { get; set; } + + public Dictionary Lines { get; } = new(); + } + + private sealed class PackageMerge { - if (summary == null) - return null; + public Dictionary Classes { get; } = new(); + } + private static bool ParseTestResults(string filename, Dictionary merge) + { try { string rss = File.ReadAllText(filename); var xdoc = XDocument.Parse(rss); // test coverage for solution - var coverage = from item in xdoc.Descendants("coverage") - select item; + var coverage = xdoc.Descendants("coverage"); if (!coverage.Any()) throw new Exception("Coverage file invalid, data not found"); - var lineR = from item in coverage.Attributes() - where item.Name == "line-rate" - select item; - - if (!lineR.Any()) - throw new Exception("Overall line rate not found"); - - summary.LineRate += double.Parse(lineR.First().Value); + // test coverage for individual packages + var packages = coverage.Descendants("package"); - var linesCovered = from item in coverage.Attributes() - where item.Name == "lines-covered" - select item; + if (!packages.Any()) + throw new Exception("No package data found"); - if (!linesCovered.Any()) - throw new Exception("Overall lines covered not found"); + int packageIndex = 1; + foreach (var packageElement in packages) + { + string packageName = string.IsNullOrWhiteSpace(packageElement.Attribute("name")?.Value) + ? $"{Path.GetFileNameWithoutExtension(filename)} Package {packageIndex}" + : packageElement.Attribute("name").Value; - summary.LinesCovered += int.Parse(linesCovered.First().Value); + if (!merge.TryGetValue(packageName, out PackageMerge packageMerge)) + { + packageMerge = new PackageMerge(); + merge[packageName] = packageMerge; + } - var linesValid = from item in coverage.Attributes() - where item.Name == "lines-valid" - select item; + var classes = packageElement.Descendants("class"); - if (!linesValid.Any()) - throw new Exception("Overall lines valid not found"); + if (!classes.Any()) + throw new Exception("No class data found"); - summary.LinesValid += int.Parse(linesValid.First().Value); + int classIndex = 1; + foreach (var classElement in classes) + { + string className = string.IsNullOrWhiteSpace(classElement.Attribute("name")?.Value) + ? $"{Path.GetFileNameWithoutExtension(filename)} Class {classIndex}" + : classElement.Attribute("name").Value; + + if (!packageMerge.Classes.TryGetValue(className, out ClassMerge classMerge)) + { + classMerge = new ClassMerge(); + packageMerge.Classes[className] = classMerge; + } + + classMerge.Complexity = Math.Max(classMerge.Complexity, + double.TryParse(classElement.Attribute("complexity")?.Value ?? "0", out double complex) ? complex : 0); + + foreach (var lineElement in classElement.Element("lines")?.Elements("line") ?? Enumerable.Empty()) + { + if (!int.TryParse(lineElement.Attribute("number")?.Value, out int lineNumber)) + continue; + + int hits = int.TryParse(lineElement.Attribute("hits")?.Value, out int h) ? h : 0; + (int branchesCovered, int branchesValid) = ParseConditionCoverage(lineElement.Attribute("condition-coverage")?.Value); + + if (!classMerge.Lines.TryGetValue(lineNumber, out LineMerge lineMerge)) + { + lineMerge = new LineMerge(); + classMerge.Lines[lineNumber] = lineMerge; + } + + lineMerge.Covered = lineMerge.Covered || hits > 0; + lineMerge.BranchesValid = Math.Max(lineMerge.BranchesValid, branchesValid); + lineMerge.BranchesCovered = Math.Max(lineMerge.BranchesCovered, branchesCovered); + } + + classIndex++; + } + + packageIndex++; + } - var branchR = from item in coverage.Attributes() - where item.Name == "branch-rate" - select item; + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Parsing Error: {ex.Message} - {filename}"); + return false; + } + } - if (branchR.Any()) - { - summary.BranchRate += double.TryParse(branchR.First().Value, out double bRate) ? bRate : 0; + private static (int covered, int valid) ParseConditionCoverage(string conditionCoverage) + { + // format: "50% (1/2)" + if (string.IsNullOrWhiteSpace(conditionCoverage)) + return (0, 0); - var branchesCovered = from item in coverage.Attributes() - where item.Name == "branches-covered" - select item; + int open = conditionCoverage.IndexOf('('); + int slash = conditionCoverage.IndexOf('/'); + int close = conditionCoverage.IndexOf(')'); - summary.BranchesCovered += int.TryParse(branchesCovered?.First().Value ?? "0", out int bCovered) ? bCovered : 0; + if (open < 0 || slash < 0 || close < 0 || slash < open || close < slash) + return (0, 0); - var branchesValid = from item in coverage.Attributes() - where item.Name == "branches-valid" - select item; + bool coveredOk = int.TryParse(conditionCoverage.AsSpan(open + 1, slash - open - 1), out int covered); + bool validOk = int.TryParse(conditionCoverage.AsSpan(slash + 1, close - slash - 1), out int valid); - summary.BranchesValid += int.TryParse(branchesValid?.First().Value ?? "0", out int bValid) ? bValid : 0; - } + return coveredOk && validOk ? (covered, valid) : (0, 0); + } - // test coverage for individual packages - var packages = from item in coverage.Descendants("package") - select item; + private static CodeSummary BuildSummary(Dictionary merge) + { + CodeSummary summary = new(); - if (!packages.Any()) - throw new Exception("No package data found"); + foreach (var (packageName, packageMerge) in merge) + { + int packageLinesCovered = 0, packageLinesValid = 0; + int packageBranchesCovered = 0, packageBranchesValid = 0; + double packageComplexity = 0; - int i = 1; - foreach (var item in packages) + foreach (var classMerge in packageMerge.Classes.Values) { - CodeCoverage packageCoverage = new() - { - Name = string.IsNullOrWhiteSpace(item.Attribute("name")?.Value) ? $"{Path.GetFileNameWithoutExtension(filename)} Package {i}" : item.Attribute("name").Value, - LineRate = double.Parse(item.Attribute("line-rate")?.Value ?? "0"), - BranchRate = double.TryParse(item.Attribute("branch-rate")?.Value ?? "0", out double bRate) ? bRate : 0, - Complexity = double.TryParse(item.Attribute("complexity")?.Value ?? "0", out double complex) ? complex : 0 - }; - summary.Packages.Add(packageCoverage); - summary.Complexity += packageCoverage.Complexity; - i++; + packageLinesValid += classMerge.Lines.Count; + packageLinesCovered += classMerge.Lines.Values.Count(l => l.Covered); + packageBranchesValid += classMerge.Lines.Values.Sum(l => l.BranchesValid); + packageBranchesCovered += classMerge.Lines.Values.Sum(l => l.BranchesCovered); + packageComplexity += classMerge.Complexity; } - return summary; - } - catch (Exception ex) - { - Console.WriteLine($"Parsing Error: {ex.Message} - {filename}"); - return null; + double packageLineRate = packageLinesValid == 0 ? 0 : (double)packageLinesCovered / packageLinesValid; + double packageBranchRate = packageBranchesValid == 0 ? 0 : (double)packageBranchesCovered / packageBranchesValid; + + summary.Packages.Add(new CodeCoverage + { + Name = packageName, + LineRate = packageLineRate, + BranchRate = packageBranchRate, + Complexity = packageComplexity + }); + + summary.LinesCovered += packageLinesCovered; + summary.LinesValid += packageLinesValid; + summary.BranchesCovered += packageBranchesCovered; + summary.BranchesValid += packageBranchesValid; + summary.Complexity += packageComplexity; } + + summary.LineRate = summary.LinesValid == 0 ? 0 : (double)summary.LinesCovered / summary.LinesValid; + summary.BranchRate = summary.BranchesValid == 0 ? 0 : (double)summary.BranchesCovered / summary.BranchesValid; + + return summary; } private static void SetThresholds(string thresholds)