이슈 변경 사항을 추적하기 위한 서비스 구현.

- DB 추적 및 Excel 리포트 출력
This commit is contained in:
seungmuk.oh
2025-12-18 17:08:26 +09:00
parent 4de4d36282
commit 64a758a701
9 changed files with 482 additions and 2 deletions

View File

@@ -68,6 +68,7 @@ public partial class App : System.Windows.Application
services.AddSingleton<SlackService>(); services.AddSingleton<SlackService>();
services.AddDbContext<AnytypeVtsContext>(); services.AddDbContext<AnytypeVtsContext>();
services.AddSingleton<AnytypeVtsRepository>(); services.AddSingleton<AnytypeVtsRepository>();
services.AddSingleton<ExcelExportService>();
services.AddTransient<MainViewModel>(); services.AddTransient<MainViewModel>();
services.AddTransient<MainWindow>(); services.AddTransient<MainWindow>();

View File

@@ -12,6 +12,7 @@ public partial class AnytypeVtsContext : DbContext
private readonly AppSettings _appSettings; private readonly AppSettings _appSettings;
public DbSet<VtsIssue> VtsIssues { get; set; } public DbSet<VtsIssue> VtsIssues { get; set; }
public DbSet<Config> Configs { get; set; } public DbSet<Config> Configs { get; set; }
public DbSet<IssueStatus> IssueStatuses { get; set; }
public string DbPath { get; } public string DbPath { get; }
public AnytypeVtsContext(IOptions<AppSettings> options) public AnytypeVtsContext(IOptions<AppSettings> options)

View File

@@ -1,6 +1,9 @@
using EFCore.BulkExtensions; using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Transactions;
using System.Windows.Forms;
using VTSFetcher.Repositories.Entities; using VTSFetcher.Repositories.Entities;
namespace VTSFetcher.Repositories namespace VTSFetcher.Repositories
@@ -8,11 +11,69 @@ namespace VTSFetcher.Repositories
public class AnytypeVtsRepository public class AnytypeVtsRepository
{ {
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private IServiceScope _transactionScope;
private AnytypeVtsContext _transactionContext;
private IDbContextTransaction _transaction;
public AnytypeVtsRepository(IServiceScopeFactory scopeFactory) public AnytypeVtsRepository(IServiceScopeFactory scopeFactory)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
// 데이터베이스 및 테이블 생성
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
db.Database.EnsureCreated();
} }
#region Transaction
public void BeginTransaction()
{
_transactionScope = _scopeFactory.CreateScope();
_transactionContext = _transactionScope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
_transaction = _transactionContext.Database.BeginTransaction();
}
public void CommitTransaction()
{
try
{
_transactionContext?.SaveChanges();
_transaction?.Commit();
}
finally
{
_transaction?.Dispose();
_transactionContext?.Dispose();
_transactionScope?.Dispose();
_transaction = null;
_transactionContext = null;
_transactionScope = null;
}
}
public void RollbackTransaction()
{
try
{
_transaction?.Rollback();
}
finally
{
_transaction?.Dispose();
_transactionContext?.Dispose();
_transactionScope?.Dispose();
_transaction = null;
_transactionContext = null;
_transactionScope = null;
}
}
private AnytypeVtsContext GetContext()
{
return _transactionContext ?? throw new InvalidOperationException("No active transaction. Call BeginTransaction first.");
}
#endregion Transaction
#region Configs #region Configs
public Dictionary<string, string> GetConfigs() public Dictionary<string, string> GetConfigs()
{ {
@@ -52,6 +113,27 @@ namespace VTSFetcher.Repositories
.ToListAsync(); .ToListAsync();
return result; return result;
} }
public async Task<List<VtsIssue>> GetTodayNewVtsIssuesAsync()
{
var today = DateTimeOffset.Now.Date;
var tomorrow = today.AddDays(1);
var todayOffset = new DateTimeOffset(today);
var tomorrowOffset = new DateTimeOffset(tomorrow);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
var existingKeys = await db.IssueStatuses.Select(s => s.Key).ToListAsync();
var todayIssues = await db.VtsIssues
.Where(v => v.Updated >= todayOffset && v.Updated < tomorrowOffset)
.ToListAsync();
var result = todayIssues
.Where(v => !existingKeys.Contains(v.Key))
.ToList();
return result;
}
public void UpsertVtsIssues(List<VtsIssue> vtsIssues, List<string> excludedColumns) public void UpsertVtsIssues(List<VtsIssue> vtsIssues, List<string> excludedColumns)
{ {
@@ -78,5 +160,186 @@ namespace VTSFetcher.Repositories
return result; return result;
} }
#endregion VtsIsseus #endregion VtsIsseus
#region IssueStatuses
public async Task<List<VtsIssue>> GetIssues4StatusAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
var result = await db.VtsIssues
.Where(i => i.Type.ToLower() != "sub-task"
&& i.Type.ToLower() != "epic"
&& i.Status.ToLower() != "closed"
&& i.Status.ToLower() != "qa passed")
.ToListAsync();
return result;
}
public async Task<List<IssueStatus>> GetIssueStatusesAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
var result = await db.IssueStatuses.ToListAsync();
return result;
}
public async Task<int> DeleteOldIssueStatusesAsync(string status, int daysOld)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
var cutoffDate = DateTimeOffset.Now.AddDays(-daysOld);
var deletedCount = await db.IssueStatuses
.Where(i => i.Status.ToLower() == status.ToLower()
&& i.Changed < cutoffDate)
.ExecuteDeleteAsync();
return deletedCount;
}
public async Task InsertIssueStatusesAsync(List<IssueStatus> statuses)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
db.IssueStatuses.AddRange(statuses);
await db.SaveChangesAsync();
}
public async Task UpdateIssueStatusesAsync(List<IssueStatus> statuses)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
db.IssueStatuses.UpdateRange(statuses);
await db.SaveChangesAsync();
}
public async Task<List<IssueWithStatus>> GetIssuesWithStatusDetailAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AnytypeVtsContext>();
var result = await (
from status in db.IssueStatuses
join issue in db.VtsIssues
on status.Key equals issue.Key
select new IssueWithStatus
{
Key = issue.Key,
Summary = issue.Summary,
Parent = issue.Parent,
Type = issue.Type,
CurrentStatus = issue.Status,
Assignee = issue.Assignee,
Manager = issue.Manager,
Due = issue.Due,
Updated = issue.Updated,
PreviousStatus = status.Status,
Changed = status.Changed,
Duration = status.Duration
}
).ToListAsync();
return result;
}
#endregion IssueStatuses
#region IssueStatuses - Transaction Support
public List<IssueStatus> GetIssueStatusesInTransaction()
{
var db = GetContext();
var result = db.IssueStatuses.ToList();
return result;
}
public void UpdateIssueStatusesInTransaction(List<IssueStatus> statuses)
{
var db = GetContext();
foreach (var status in statuses)
{
// 이미 추적중인 엔티티가 있는지 확인
var existingEntity = db.IssueStatuses.Local
.FirstOrDefault(e => e.Key == status.Key);
if (existingEntity != null)
{
// 이미 추적중이면 값만 업데이트
existingEntity.Status = status.Status;
existingEntity.Changed = status.Changed;
existingEntity.Duration = status.Duration;
}
else
{
// 추적중이지 않으면 Attach 후 Modified 상태로 변경
db.IssueStatuses.Attach(status);
db.Entry(status).State = EntityState.Modified;
}
}
}
public List<IssueWithStatus> GetIssuesWithStatusDetailInTransaction()
{
var db = GetContext();
var result = (
from status in db.IssueStatuses
join issue in db.VtsIssues
on status.Key equals issue.Key
select new IssueWithStatus
{
Key = issue.Key,
Summary = issue.Summary,
Parent = issue.Parent,
Type = issue.Type,
CurrentStatus = issue.Status,
Assignee = issue.Assignee,
Manager = issue.Manager,
Due = issue.Due,
Updated = issue.Updated,
PreviousStatus = status.Status,
Changed = status.Changed,
Duration = status.Duration
}
).ToList();
return result;
}
public List<VtsIssue> GetTodayNewVtsIssuesInTransaction()
{
var today = DateTimeOffset.Now.Date;
var tomorrow = today.AddDays(1);
var todayOffset = new DateTimeOffset(today);
var tomorrowOffset = new DateTimeOffset(tomorrow);
var db = GetContext();
var existingKeys = db.IssueStatuses.Select(s => s.Key).ToList();
var todayIssues = db.VtsIssues
.AsEnumerable()
.Where(v => v.Updated >= todayOffset && v.Updated < tomorrowOffset)
.ToList();
var result = todayIssues
.Where(v => !existingKeys.Contains(v.Key))
.ToList();
return result;
}
public void InsertIssueStatusesInTransaction(List<IssueStatus> statuses)
{
var db = GetContext();
db.IssueStatuses.AddRange(statuses);
// SaveChanges는 CommitTransaction에서 일괄 처리
}
public int DeleteOldIssueStatusesInTransaction(string status, int daysOld)
{
var db = GetContext();
var cutoffDate = DateTimeOffset.Now.AddDays(-daysOld);
var itemsToDelete = db.IssueStatuses
.AsEnumerable() // 클라이언트 평가로 전환
.Where(i => i.Status.Equals(status, StringComparison.OrdinalIgnoreCase)
&& i.Changed < cutoffDate)
.ToList();
db.IssueStatuses.RemoveRange(itemsToDelete);
return itemsToDelete.Count;
}
#endregion IssueStatuses - Transaction Support
} }
} }

View File

@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
namespace VTSFetcher.Repositories.Entities;
public class IssueStatus
{
[Key]
public string Key { get; set; }
public string Status { get; set; }
public DateTimeOffset Changed { get; set; }
public int Duration { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace VTSFetcher.Repositories.Entities;
public class IssueWithStatus
{
public string Key { get; set; }
public string Summary { get; set; }
public string? Parent { get; set; }
public string Type { get; set; }
public string CurrentStatus { get; set; }
public string Assignee { get; set; }
public string Manager { get; set; }
public DateTimeOffset Due { get; set; }
public DateTimeOffset Updated { get; set; }
// IssueStatus 정보
public string? PreviousStatus { get; set; }
public DateTimeOffset? Changed { get; set; }
public int Duration { get; set; }
}

View File

@@ -0,0 +1,62 @@
using ClosedXML.Excel;
using Serilog;
using System.Globalization;
using VTSFetcher.Repositories.Entities;
namespace VTSFetcher.Services;
public class ExcelExportService
{
public bool ExportIssueStatusesToExcel(List<IssueWithStatus> issues, string filePath)
{
try
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("IssueStatus");
// 헤더 설정
worksheet.Cell(1, 1).Value = "Assignee";
worksheet.Cell(1, 2).Value = "Key";
worksheet.Cell(1, 3).Value = "Summary";
worksheet.Cell(1, 4).Value = "Status";
worksheet.Cell(1, 5).Value = "Duration";
worksheet.Cell(1, 6).Value = "Changed";
// 헤더 스타일
var headerRange = worksheet.Range(1, 1, 1, 6);
headerRange.Style.Font.Bold = true;
headerRange.Style.Fill.BackgroundColor = XLColor.LightGray;
// Assignee로 정렬
var sortedIssues = issues.OrderBy(i => i.Assignee).ThenBy(i => i.CurrentStatus).ToList();
// 데이터 입력
for (int i = 0; i < sortedIssues.Count; i++)
{
var issue = sortedIssues[i];
int row = i + 2;
worksheet.Cell(row, 1).Value = issue.Assignee;
worksheet.Cell(row, 2).Value = issue.Key;
worksheet.Cell(row, 3).Value = issue.Summary;
worksheet.Cell(row, 4).Value = issue.CurrentStatus;
worksheet.Cell(row, 5).Value = issue.Duration;
worksheet.Cell(row, 6).Value = issue.Changed?.ToString("yyMMdd HH:mm:ss", CultureInfo.InvariantCulture);
}
// 열 너비 자동 조정
worksheet.Columns().AdjustToContents();
// 파일 저장
workbook.SaveAs(filePath);
Log.Information("Excel file exported successfully: {file_path}", filePath);
return true;
}
catch (Exception ex)
{
Log.Error("Failed to export Excel file: {error_message}", ex.Message);
return false;
}
}
}

View File

@@ -15,6 +15,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" /> <PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />

View File

@@ -3,6 +3,7 @@ using Serilog;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics.X86; using System.Runtime.Intrinsics.X86;
@@ -27,10 +28,12 @@ public class MainViewModel : INotifyPropertyChanged
private readonly VtsService _vts; private readonly VtsService _vts;
private readonly AnytypeService _anytype; private readonly AnytypeService _anytype;
private readonly SlackService _slack; private readonly SlackService _slack;
private readonly ExcelExportService _excelExport;
private readonly Dictionary<string, string> _configs; private readonly Dictionary<string, string> _configs;
private readonly DispatcherTimer _fetchTimer; private readonly DispatcherTimer _fetchTimer;
private readonly DispatcherTimer _publishTimer; private readonly DispatcherTimer _publishTimer;
private readonly DispatcherTimer _slackTimer; private readonly DispatcherTimer _slackTimer;
private readonly DispatcherTimer _statusTimer;
private readonly CancellationTokenSource _cts; private readonly CancellationTokenSource _cts;
private ConcurrentQueue<VtsIssue> _publishedIssuesQueue; private ConcurrentQueue<VtsIssue> _publishedIssuesQueue;
@@ -80,16 +83,20 @@ public class MainViewModel : INotifyPropertyChanged
private bool _isWorking = true; private bool _isWorking = true;
public ICommand FetchNowCommand { get; } public ICommand FetchNowCommand { get; }
public ICommand SaveStatusCommand { get; }
public ICommand ReportCommand { get; }
#endregion Fields #endregion Fields
public MainViewModel(IOptions<AppSettings> options, AnytypeVtsRepository repo, public MainViewModel(IOptions<AppSettings> options, AnytypeVtsRepository repo,
VtsService vtsService, AnytypeService anytypeService, SlackService slack) VtsService vtsService, AnytypeService anytypeService, SlackService slack,
ExcelExportService excelExport)
{ {
_appSettings = options.Value; _appSettings = options.Value;
_repo = repo; _repo = repo;
_vts = vtsService; _vts = vtsService;
_anytype = anytypeService; _anytype = anytypeService;
_slack = slack; _slack = slack;
_excelExport = excelExport;
_configs = _repo.GetConfigs(); _configs = _repo.GetConfigs();
_lastFetchTimeOffset = DateTimeOffset.Parse(_configs[Config.LastFetchTimeKey]); _lastFetchTimeOffset = DateTimeOffset.Parse(_configs[Config.LastFetchTimeKey]);
@@ -112,7 +119,18 @@ public class MainViewModel : INotifyPropertyChanged
_slackTimer.Tick += SlackTick; _slackTimer.Tick += SlackTick;
_slackTimer.Start(); _slackTimer.Start();
//_statusTimer = new DispatcherTimer();
//_statusTimer.Interval = TimeSpan.FromSeconds(30);
//_statusTimer.Tick += StatusTick;
//_statusTimer.Start();
FetchNowCommand = new RelayCommand(_ => FetchNow()); FetchNowCommand = new RelayCommand(_ => FetchNow());
SaveStatusCommand = new RelayCommand(_ => {
UpdateStatusData();
});
ReportCommand = new RelayCommand(_ => {
GenerateReport();
} );
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
_publishedIssuesQueue = new(); _publishedIssuesQueue = new();
@@ -273,6 +291,87 @@ public class MainViewModel : INotifyPropertyChanged
} }
#endregion FetchEvents #endregion FetchEvents
#region SaveStatusEvents
private void StatusTick(object? sender, EventArgs e)
{
var now = DateTime.Now;
if (now.Hour == 21 && now.Minute == 0)
{
UpdateStatusData();
}
}
private bool UpdateStatusData()
{
Log.Debug("Start CreateReport");
try
{
_repo.BeginTransaction();
var issueStatuses = _repo.GetIssueStatusesInTransaction();
var issuesWithStatus = _repo.GetIssuesWithStatusDetailInTransaction();
var changedIssueStatuses = new List<IssueStatus>();
foreach (var issue in issuesWithStatus)
{
var issueStatus = issueStatuses.Find(s => s.Key == issue.Key);
if (issueStatus == null) continue;
if (issueStatus.Status.ToLower() == issue.CurrentStatus.ToLower())
{
issueStatus.Duration++;
}
else
{
issueStatus.Status = issue.CurrentStatus;
issueStatus.Duration = 0;
issueStatus.Changed = issue.Updated;
}
}
_repo.UpdateIssueStatusesInTransaction(issueStatuses);
// Add new issues.
var todayIssues = _repo.GetTodayNewVtsIssuesInTransaction();
var newIssueStatuses = new List<IssueStatus>();
foreach (var issue in todayIssues)
{
newIssueStatuses.Add(new IssueStatus
{
Key = issue.Key,
Changed = issue.Updated,
Duration = 0,
Status = issue.Status
});
}
_repo.InsertIssueStatusesInTransaction(newIssueStatuses);
// Remove old resolved issues
var deletedResolvedStatusCount = _repo.DeleteOldIssueStatusesInTransaction("Resolved", 5);
var deletedClosedStatusCount = _repo.DeleteOldIssueStatusesInTransaction("Closed", 1);
var deletedQaPassedStatusCount = _repo.DeleteOldIssueStatusesInTransaction("QA PASSED", 1);
_repo.CommitTransaction();
}
catch(Exception ex)
{
_repo.RollbackTransaction();
Log.Error("Unhandled exception occured during creating report: {error_message}", ex.Message);
return false;
}
return true;
}
private void GenerateReport()
{
// Generate report
var issues = _repo.GetIssuesWithStatusDetailAsync().Result;
var fileName = $"{DateTime.Now:yyyy-MM-dd}.xlsx";
var fileFolder = Path.Combine(AppContext.BaseDirectory, "Reports");
var filePath = Path.Combine(fileFolder, fileName);
var result = _excelExport.ExportIssueStatusesToExcel(issues, filePath);
if (result)
Log.Information("Successfully exported to Excel: {file_path}", filePath);
else
Log.Error("Failed to export to Excel: {file_path}", filePath);
}
#endregion SaveStatusEvents
#region PublishEvents #region PublishEvents
private void PublishTimerTick(object? sender, EventArgs e) private void PublishTimerTick(object? sender, EventArgs e)
{ {

View File

@@ -26,6 +26,8 @@ public partial class MainWindow : Window
var contextMenu = new ContextMenuStrip(); var contextMenu = new ContextMenuStrip();
contextMenu.Items.Add("Open", null, (s, e) => ShowWindow()); contextMenu.Items.Add("Open", null, (s, e) => ShowWindow());
contextMenu.Items.Add("Fetch", null, (s, e) => FetchNow()); contextMenu.Items.Add("Fetch", null, (s, e) => FetchNow());
contextMenu.Items.Add("Save status", null, (s, e) => SaveStatusNow());
contextMenu.Items.Add("Report", null, (s, e) => ReportNow());
contextMenu.Items.Add("Exit", null, (s, e) => ExitApp()); contextMenu.Items.Add("Exit", null, (s, e) => ExitApp());
_notifyIcon.ContextMenuStrip = contextMenu; _notifyIcon.ContextMenuStrip = contextMenu;
@@ -47,6 +49,20 @@ public partial class MainWindow : Window
vm.FetchNowCommand.Execute(null); vm.FetchNowCommand.Execute(null);
} }
} }
private void SaveStatusNow()
{
if (DataContext is MainViewModel vm)
{
vm.SaveStatusCommand.Execute(null);
}
}
private void ReportNow()
{
if (DataContext is MainViewModel vm)
{
vm.ReportCommand.Execute(null);
}
}
private void ExitApp() private void ExitApp()
{ {
_exitFlag = true; _exitFlag = true;