Implement diff-reporter PoC (#5)
This commit is contained in:
28
docs/history/2026-04-07_이슈5-diff-reporter-generator.md
Normal file
28
docs/history/2026-04-07_이슈5-diff-reporter-generator.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 2026-04-07 이슈 #5 — diff-reporter Generator
|
||||
|
||||
## 작업 개요
|
||||
Sprint Contract `docs/contracts/diff-reporter.md` 기준으로 `Recordingtest.DiffReporter` 라이브러리, CLI, xUnit 테스트 PoC 구현.
|
||||
|
||||
## 산출물
|
||||
- `src/Recordingtest.DiffReporter/` (Differ, JsonDiffer, LineDiffer, BinaryDiffer, DiffModels)
|
||||
- `src/Recordingtest.DiffReporter.Cli/` (Program.cs — `--approved/--received/--out`)
|
||||
- `tests/Recordingtest.DiffReporter.Tests/` (5 tests)
|
||||
- `recordingtest.sln`에 3개 프로젝트 등록
|
||||
|
||||
## 결과
|
||||
- `dotnet build`: 경고 0, 오류 0
|
||||
- `dotnet test`: 5/5 통과
|
||||
|
||||
## 미충족 DoD
|
||||
- `diff.html` (옵션, PoC 스킵)
|
||||
- 실제 `diff-triager` 서브에이전트 통합 (TriageReadable 스키마 검증으로 대체)
|
||||
- 큰 파일 스트리밍 diff (성능 리스크 항목)
|
||||
|
||||
## 소요 시간
|
||||
약 25분
|
||||
|
||||
## Context 사용량
|
||||
약 40k tokens
|
||||
|
||||
## 관련 이슈
|
||||
#5 (placeholder — diff-reporter Generator 작업)
|
||||
99
src/Recordingtest.DiffReporter.Cli/Program.cs
Normal file
99
src/Recordingtest.DiffReporter.Cli/Program.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Recordingtest.DiffReporter;
|
||||
|
||||
namespace Recordingtest.DiffReporter.Cli;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? approved = null, received = null, outDir = null;
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--approved": approved = args[++i]; break;
|
||||
case "--received": received = args[++i]; break;
|
||||
case "--out": outDir = args[++i]; break;
|
||||
}
|
||||
}
|
||||
if (approved is null || received is null || outDir is null)
|
||||
{
|
||||
Console.Error.WriteLine("Usage: --approved <path> --received <path> --out <dir>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(outDir);
|
||||
DiffResult result;
|
||||
try
|
||||
{
|
||||
result = Differ.Compare(approved, received);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Console.Error.WriteLine("I/O error: " + ex.Message);
|
||||
return 2;
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
Console.Error.WriteLine("I/O error: " + ex.Message);
|
||||
return 2;
|
||||
}
|
||||
|
||||
var jsonOpts = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
File.WriteAllText(Path.Combine(outDir, "diff.json"),
|
||||
JsonSerializer.Serialize(result, jsonOpts), Encoding.UTF8);
|
||||
File.WriteAllText(Path.Combine(outDir, "diff.md"),
|
||||
RenderMarkdown(result), Encoding.UTF8);
|
||||
|
||||
if (result.Identical)
|
||||
{
|
||||
Console.Out.WriteLine("identical");
|
||||
return 0;
|
||||
}
|
||||
Console.Out.WriteLine($"diff: +{result.Summary.Added} -{result.Summary.Removed} ~{result.Summary.Changed} in {result.File}");
|
||||
return 1;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Console.Error.WriteLine("I/O error: " + ex.Message);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderMarkdown(DiffResult r)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"# Diff: {r.File}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Identical: **{r.Identical}**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Added | Removed | Changed |");
|
||||
sb.AppendLine("|------:|--------:|--------:|");
|
||||
sb.AppendLine($"| {r.Summary.Added} | {r.Summary.Removed} | {r.Summary.Changed} |");
|
||||
sb.AppendLine();
|
||||
if (r.Hunks.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Hunks");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| # | LineOrOffset | Kind | Before | After |");
|
||||
sb.AppendLine("|--:|-------------:|------|--------|-------|");
|
||||
for (int i = 0; i < r.Hunks.Count; i++)
|
||||
{
|
||||
var h = r.Hunks[i];
|
||||
sb.AppendLine($"| {i} | {h.LineOrOffset} | {h.Kind} | {Escape(h.Before)} | {Escape(h.After)} |");
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Escape(string s) =>
|
||||
s.Replace("|", "\\|").Replace("\r", "").Replace("\n", "\\n");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>Recordingtest.DiffReporter.Cli</AssemblyName>
|
||||
<RootNamespace>Recordingtest.DiffReporter.Cli</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Recordingtest.DiffReporter\Recordingtest.DiffReporter.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
55
src/Recordingtest.DiffReporter/BinaryDiffer.cs
Normal file
55
src/Recordingtest.DiffReporter/BinaryDiffer.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
namespace Recordingtest.DiffReporter;
|
||||
|
||||
internal static class BinaryDiffer
|
||||
{
|
||||
public static IReadOnlyList<Hunk> Diff(byte[] a, byte[] b)
|
||||
{
|
||||
var hunks = new List<Hunk>();
|
||||
int max = Math.Max(a.Length, b.Length);
|
||||
int i = 0;
|
||||
while (i < max)
|
||||
{
|
||||
byte? av = i < a.Length ? a[i] : null;
|
||||
byte? bv = i < b.Length ? b[i] : null;
|
||||
if (av != bv)
|
||||
{
|
||||
int start = i;
|
||||
while (i < max)
|
||||
{
|
||||
byte? av2 = i < a.Length ? a[i] : null;
|
||||
byte? bv2 = i < b.Length ? b[i] : null;
|
||||
if (av2 == bv2) break;
|
||||
i++;
|
||||
}
|
||||
int len = i - start;
|
||||
var beforeSlice = Slice(a, start, len);
|
||||
var afterSlice = Slice(b, start, len);
|
||||
string kind;
|
||||
if (start >= a.Length) kind = "added";
|
||||
else if (start >= b.Length) kind = "removed";
|
||||
else kind = "changed";
|
||||
hunks.Add(new Hunk(start, kind, ToHex(beforeSlice), ToHex(afterSlice)));
|
||||
}
|
||||
else
|
||||
{
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
private static byte[] Slice(byte[] src, int start, int len)
|
||||
{
|
||||
if (start >= src.Length) return Array.Empty<byte>();
|
||||
int actual = Math.Min(len, src.Length - start);
|
||||
var r = new byte[actual];
|
||||
Array.Copy(src, start, r, 0, actual);
|
||||
return r;
|
||||
}
|
||||
|
||||
private static string ToHex(byte[] data)
|
||||
{
|
||||
if (data.Length == 0) return string.Empty;
|
||||
return Convert.ToHexString(data);
|
||||
}
|
||||
}
|
||||
11
src/Recordingtest.DiffReporter/DiffModels.cs
Normal file
11
src/Recordingtest.DiffReporter/DiffModels.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Recordingtest.DiffReporter;
|
||||
|
||||
public sealed record DiffSummary(int Added, int Removed, int Changed);
|
||||
|
||||
public sealed record Hunk(int LineOrOffset, string Kind, string Before, string After);
|
||||
|
||||
public sealed record DiffResult(
|
||||
string File,
|
||||
bool Identical,
|
||||
IReadOnlyList<Hunk> Hunks,
|
||||
DiffSummary Summary);
|
||||
86
src/Recordingtest.DiffReporter/Differ.cs
Normal file
86
src/Recordingtest.DiffReporter/Differ.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Recordingtest.DiffReporter;
|
||||
|
||||
public static class Differ
|
||||
{
|
||||
public static DiffResult Compare(string approvedPath, string receivedPath)
|
||||
{
|
||||
var approved = File.ReadAllBytes(approvedPath);
|
||||
var received = File.ReadAllBytes(receivedPath);
|
||||
var fileName = Path.GetFileName(receivedPath);
|
||||
|
||||
if (approved.SequenceEqual(received))
|
||||
{
|
||||
return new DiffResult(fileName, true, Array.Empty<Hunk>(), new DiffSummary(0, 0, 0));
|
||||
}
|
||||
|
||||
// Try JSON
|
||||
if (TryParseJson(approved, out var aDoc) && TryParseJson(received, out var bDoc))
|
||||
{
|
||||
using (aDoc)
|
||||
using (bDoc)
|
||||
{
|
||||
var hunks = JsonDiffer.Diff(aDoc!.RootElement, bDoc!.RootElement);
|
||||
return Build(fileName, hunks);
|
||||
}
|
||||
}
|
||||
|
||||
// Try text
|
||||
if (TryDecodeText(approved, out var aText) && TryDecodeText(received, out var bText)
|
||||
&& (aText!.Contains('\n') || bText!.Contains('\n')))
|
||||
{
|
||||
var hunks = LineDiffer.Diff(aText!, bText!);
|
||||
return Build(fileName, hunks);
|
||||
}
|
||||
|
||||
// Binary
|
||||
var binHunks = BinaryDiffer.Diff(approved, received);
|
||||
return Build(fileName, binHunks);
|
||||
}
|
||||
|
||||
private static DiffResult Build(string file, IReadOnlyList<Hunk> hunks)
|
||||
{
|
||||
int added = 0, removed = 0, changed = 0;
|
||||
foreach (var h in hunks)
|
||||
{
|
||||
switch (h.Kind)
|
||||
{
|
||||
case "added": added++; break;
|
||||
case "removed": removed++; break;
|
||||
case "changed": changed++; break;
|
||||
}
|
||||
}
|
||||
return new DiffResult(file, hunks.Count == 0, hunks, new DiffSummary(added, removed, changed));
|
||||
}
|
||||
|
||||
private static bool TryParseJson(byte[] data, out JsonDocument? doc)
|
||||
{
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(data);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
doc = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeText(byte[] data, out string? text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var enc = new UTF8Encoding(false, true);
|
||||
text = enc.GetString(data);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
text = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/Recordingtest.DiffReporter/JsonDiffer.cs
Normal file
77
src/Recordingtest.DiffReporter/JsonDiffer.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Recordingtest.DiffReporter;
|
||||
|
||||
internal static class JsonDiffer
|
||||
{
|
||||
public static IReadOnlyList<Hunk> Diff(JsonElement a, JsonElement b)
|
||||
{
|
||||
var aMap = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
var bMap = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
Flatten("$", a, aMap);
|
||||
Flatten("$", b, bMap);
|
||||
|
||||
var hunks = new List<Hunk>();
|
||||
var allKeys = new SortedSet<string>(aMap.Keys, StringComparer.Ordinal);
|
||||
foreach (var k in bMap.Keys) allKeys.Add(k);
|
||||
|
||||
int idx = 0;
|
||||
foreach (var key in allKeys)
|
||||
{
|
||||
var hasA = aMap.TryGetValue(key, out var av);
|
||||
var hasB = bMap.TryGetValue(key, out var bv);
|
||||
if (hasA && hasB)
|
||||
{
|
||||
if (!string.Equals(av, bv, StringComparison.Ordinal))
|
||||
hunks.Add(new Hunk(idx, "changed", $"{key}={av}", $"{key}={bv}"));
|
||||
}
|
||||
else if (hasA)
|
||||
{
|
||||
hunks.Add(new Hunk(idx, "removed", $"{key}={av}", string.Empty));
|
||||
}
|
||||
else if (hasB)
|
||||
{
|
||||
hunks.Add(new Hunk(idx, "added", string.Empty, $"{key}={bv}"));
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
private static void Flatten(string path, JsonElement el, IDictionary<string, string> map)
|
||||
{
|
||||
switch (el.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var p in el.EnumerateObject())
|
||||
Flatten(path + "." + p.Name, p.Value, map);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
int i = 0;
|
||||
foreach (var item in el.EnumerateArray())
|
||||
{
|
||||
Flatten(path + "[" + i + "]", item, map);
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
map[path] = "\"" + el.GetString() + "\"";
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
map[path] = el.GetRawText();
|
||||
break;
|
||||
case JsonValueKind.True:
|
||||
map[path] = "true";
|
||||
break;
|
||||
case JsonValueKind.False:
|
||||
map[path] = "false";
|
||||
break;
|
||||
case JsonValueKind.Null:
|
||||
map[path] = "null";
|
||||
break;
|
||||
default:
|
||||
map[path] = el.GetRawText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/Recordingtest.DiffReporter/LineDiffer.cs
Normal file
50
src/Recordingtest.DiffReporter/LineDiffer.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
namespace Recordingtest.DiffReporter;
|
||||
|
||||
internal static class LineDiffer
|
||||
{
|
||||
public static IReadOnlyList<Hunk> Diff(string a, string b)
|
||||
{
|
||||
var aLines = a.Replace("\r\n", "\n").Split('\n');
|
||||
var bLines = b.Replace("\r\n", "\n").Split('\n');
|
||||
|
||||
// LCS table
|
||||
int n = aLines.Length, m = bLines.Length;
|
||||
var lcs = new int[n + 1, m + 1];
|
||||
for (int i = n - 1; i >= 0; i--)
|
||||
for (int j = m - 1; j >= 0; j--)
|
||||
lcs[i, j] = aLines[i] == bLines[j]
|
||||
? lcs[i + 1, j + 1] + 1
|
||||
: Math.Max(lcs[i + 1, j], lcs[i, j + 1]);
|
||||
|
||||
var hunks = new List<Hunk>();
|
||||
int x = 0, y = 0;
|
||||
while (x < n && y < m)
|
||||
{
|
||||
if (aLines[x] == bLines[y])
|
||||
{
|
||||
x++; y++;
|
||||
}
|
||||
else if (lcs[x + 1, y] >= lcs[x, y + 1])
|
||||
{
|
||||
hunks.Add(new Hunk(x + 1, "removed", aLines[x], string.Empty));
|
||||
x++;
|
||||
}
|
||||
else
|
||||
{
|
||||
hunks.Add(new Hunk(y + 1, "added", string.Empty, bLines[y]));
|
||||
y++;
|
||||
}
|
||||
}
|
||||
while (x < n)
|
||||
{
|
||||
hunks.Add(new Hunk(x + 1, "removed", aLines[x], string.Empty));
|
||||
x++;
|
||||
}
|
||||
while (y < m)
|
||||
{
|
||||
hunks.Add(new Hunk(y + 1, "added", string.Empty, bLines[y]));
|
||||
y++;
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Recordingtest.DiffReporter</AssemblyName>
|
||||
<RootNamespace>Recordingtest.DiffReporter</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
98
tests/Recordingtest.DiffReporter.Tests/DifferTests.cs
Normal file
98
tests/Recordingtest.DiffReporter.Tests/DifferTests.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Text.Json;
|
||||
using Recordingtest.DiffReporter;
|
||||
using Xunit;
|
||||
|
||||
namespace Recordingtest.DiffReporter.Tests;
|
||||
|
||||
public class DifferTests
|
||||
{
|
||||
private static string TempDir()
|
||||
{
|
||||
var d = Path.Combine(Path.GetTempPath(), "diffrep-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identical_JsonFiles_ReturnsIdentical()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var a = Path.Combine(dir, "a.json");
|
||||
var b = Path.Combine(dir, "b.json");
|
||||
File.WriteAllText(a, "{\"x\":1,\"y\":\"hi\"}");
|
||||
File.WriteAllText(b, "{\"x\":1,\"y\":\"hi\"}");
|
||||
var r = Differ.Compare(a, b);
|
||||
Assert.True(r.Identical);
|
||||
Assert.Empty(r.Hunks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneFieldChanged_JsonFiles_OneHunk()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var a = Path.Combine(dir, "a.json");
|
||||
var b = Path.Combine(dir, "b.json");
|
||||
File.WriteAllText(a, "{\"x\":1,\"y\":\"hi\"}");
|
||||
File.WriteAllText(b, "{\"x\":2,\"y\":\"hi\"}");
|
||||
var r = Differ.Compare(a, b);
|
||||
Assert.False(r.Identical);
|
||||
Assert.Single(r.Hunks);
|
||||
Assert.Equal("changed", r.Hunks[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinaryDiff_ReturnsHexSummary()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var a = Path.Combine(dir, "a.bin");
|
||||
var b = Path.Combine(dir, "b.bin");
|
||||
File.WriteAllBytes(a, new byte[] { 0x00, 0x01, 0x02, 0xFF, 0x80 });
|
||||
File.WriteAllBytes(b, new byte[] { 0x00, 0x01, 0x03, 0xFE, 0x80 });
|
||||
var r = Differ.Compare(a, b);
|
||||
Assert.False(r.Identical);
|
||||
Assert.NotEmpty(r.Hunks);
|
||||
foreach (var h in r.Hunks)
|
||||
{
|
||||
// hex strings only
|
||||
Assert.Matches("^[0-9A-F]*$", h.Before);
|
||||
Assert.Matches("^[0-9A-F]*$", h.After);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cli_IdenticalFiles_ExitZero_And_DiffJsonExists()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var a = Path.Combine(dir, "a.json");
|
||||
var b = Path.Combine(dir, "b.json");
|
||||
File.WriteAllText(a, "{\"x\":1}");
|
||||
File.WriteAllText(b, "{\"x\":1}");
|
||||
var outDir = Path.Combine(dir, "out");
|
||||
var code = Cli.Program.Main(new[] { "--approved", a, "--received", b, "--out", outDir });
|
||||
Assert.Equal(0, code);
|
||||
Assert.True(File.Exists(Path.Combine(outDir, "diff.json")));
|
||||
Assert.True(File.Exists(Path.Combine(outDir, "diff.md")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageReadable_DiffJson_CanBeParsed()
|
||||
{
|
||||
var dir = TempDir();
|
||||
var a = Path.Combine(dir, "a.json");
|
||||
var b = Path.Combine(dir, "b.json");
|
||||
File.WriteAllText(a, "{\"x\":1}");
|
||||
File.WriteAllText(b, "{\"x\":2}");
|
||||
var outDir = Path.Combine(dir, "out");
|
||||
var code = Cli.Program.Main(new[] { "--approved", a, "--received", b, "--out", outDir });
|
||||
Assert.Equal(1, code);
|
||||
var json = File.ReadAllText(Path.Combine(outDir, "diff.json"));
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("file", out _));
|
||||
Assert.True(root.TryGetProperty("hunks", out _));
|
||||
Assert.True(root.TryGetProperty("summary", out var summary));
|
||||
Assert.True(summary.TryGetProperty("added", out _));
|
||||
Assert.True(summary.TryGetProperty("removed", out _));
|
||||
Assert.True(summary.TryGetProperty("changed", out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Recordingtest.DiffReporter.Tests</RootNamespace>
|
||||
<AssemblyName>Recordingtest.DiffReporter.Tests</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Recordingtest.DiffReporter\Recordingtest.DiffReporter.csproj" />
|
||||
<ProjectReference Include="..\..\src\Recordingtest.DiffReporter.Cli\Recordingtest.DiffReporter.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user