Files
VTSFetcher/VTSFetcher/ViewModels/MainViewModel.cs

476 lines
17 KiB
C#

using Microsoft.Extensions.Options;
using Serilog;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics.X86;
using System.Windows.Input;
using System.Windows.Threading;
using VTSFetcher.Commands;
using VTSFetcher.Helpers;
using VTSFetcher.Models;
using VTSFetcher.Repositories;
using VTSFetcher.Repositories.Entities;
using VTSFetcher.Services;
namespace VTSFetcher.ViewModels;
public class MainViewModel : INotifyPropertyChanged
{
#region Fields
const string MyName = "오승묵";
const string StatusReopen = "Reopened";
private readonly Stopwatch _sw = new();
private readonly AppSettings _appSettings;
private readonly AnytypeVtsRepository _repo;
private readonly VtsService _vts;
private readonly AnytypeService _anytype;
private readonly SlackService _slack;
private readonly ExcelExportService _excelExport;
private readonly Dictionary<string, string> _configs;
private readonly DispatcherTimer _fetchTimer;
private readonly DispatcherTimer _publishTimer;
private readonly DispatcherTimer _slackTimer;
private readonly DispatcherTimer _statusTimer;
private readonly CancellationTokenSource _cts;
private ConcurrentQueue<VtsIssue> _publishedIssuesQueue;
private DateTimeOffset _lastFetchTimeOffset;
private string _lastFetchTime;
public string LastFetchTime
{
get => _lastFetchTime;
set
{
if (_lastFetchTime != value)
{
_lastFetchTime = value;
OnPropertyChanged(nameof(LastFetchTime));
}
}
}
private int _nextFetchInSeconds; // Seconds
private string _nextFetchIn; // "M:SS" format
public string NextFetchIn
{
get => _nextFetchIn;
set
{
if (_nextFetchIn != value)
{
_nextFetchIn = value;
OnPropertyChanged(nameof(NextFetchIn));
}
}
}
private string _targetIssueKey;
public string TargetIssueKey
{
get => _targetIssueKey;
set
{
if (_targetIssueKey != value)
{
_targetIssueKey = value;
OnPropertyChanged(nameof(TargetIssueKey));
}
}
}
private bool _isWorking = true;
public ICommand FetchNowCommand { get; }
public ICommand SaveStatusCommand { get; }
public ICommand ReportCommand { get; }
#endregion Fields
public MainViewModel(IOptions<AppSettings> options, AnytypeVtsRepository repo,
VtsService vtsService, AnytypeService anytypeService, SlackService slack,
ExcelExportService excelExport)
{
_appSettings = options.Value;
_repo = repo;
_vts = vtsService;
_anytype = anytypeService;
_slack = slack;
_excelExport = excelExport;
_configs = _repo.GetConfigs();
_lastFetchTimeOffset = DateTimeOffset.Parse(_configs[Config.LastFetchTimeKey]);
_nextFetchInSeconds = 5; // Initial fetch after 5 seconds
UpdateLabels();
_fetchTimer = new DispatcherTimer();
_fetchTimer.Interval = TimeSpan.FromSeconds(1);
_fetchTimer.Tick += FetchTimerTick;
_fetchTimer.Start();
_publishTimer = new DispatcherTimer();
_publishTimer.Interval = TimeSpan.FromSeconds(1);
_publishTimer.Tick += PublishTimerTick;
_publishTimer.Start();
_slackTimer = new DispatcherTimer();
_slackTimer.Interval = TimeSpan.FromSeconds(30);
_slackTimer.Tick += SlackTick;
_slackTimer.Start();
_statusTimer = new DispatcherTimer();
_statusTimer.Interval = TimeSpan.FromSeconds(30);
_statusTimer.Tick += StatusTick;
_statusTimer.Start();
FetchNowCommand = new RelayCommand(_ => FetchNow());
SaveStatusCommand = new RelayCommand(_ => {
UpdateStatusData();
});
ReportCommand = new RelayCommand(_ => {
GenerateReport();
} );
_cts = new CancellationTokenSource();
_publishedIssuesQueue = new();
}
#region FetchEvents
private void FetchTimerTick(object? sender, EventArgs e)
{
var isScheduled = CheckSchedule();
if (_isWorking && !isScheduled)
{
_isWorking = false;
_nextFetchInSeconds = 1;
Log.Information("Stop working by schedue.");
}
else if (!_isWorking && isScheduled)
{
_isWorking = true;
_nextFetchInSeconds = 1;
Log.Information("Start working by schedue.");
}
else if(!_isWorking && !isScheduled)
{
return;
}
_nextFetchInSeconds--;
if (_nextFetchInSeconds <= 0)
{
Log.Information("Start fetching by timer...");
if(FetchVtsIssues())
{
_nextFetchInSeconds = _appSettings.VTS.FetchInterval;
}
}
UpdateLabels();
}
private bool CheckSchedule()
{
DateTime now = DateTime.Now;
bool isWeekday = now.DayOfWeek >= DayOfWeek.Monday && now.DayOfWeek <= DayOfWeek.Friday;
TimeSpan start = new TimeSpan(8, 00, 0); // 08:00:00
TimeSpan end = new TimeSpan(19, 00, 0); // 19:00:00
bool isWorkingHours = now.TimeOfDay >= start && now.TimeOfDay <= end;
return isWeekday && isWorkingHours;
}
/// <summary>
/// Fetch VTS issues and update Anytype pages.
/// LastFetchTime is updated inside this method.
/// </summary>
/// <returns></returns>
private bool FetchVtsIssues()
{
var now = DateTimeOffset.Now;
foreach(var project in _appSettings.VTS.TargetProjects)
{
try
{
List<VtsIssue> issues = [];
_sw.Restart();
var vtsResponses = new List<VTSResponse>();
VTSResponse vtsResponse;
vtsResponse = _vts.FetchVtsIssues(_lastFetchTimeOffset, project, 0, _cts.Token).GetAwaiter().GetResult();
vtsResponses.Add(vtsResponse);
var startAt = vtsResponse?.maxResults ?? 0 ;
var total = vtsResponse?.total ?? 0;
while(startAt < total)
{
vtsResponse = _vts.FetchVtsIssues(_lastFetchTimeOffset, project, startAt, _cts.Token).GetAwaiter().GetResult();
vtsResponses.Add(vtsResponse);
startAt += vtsResponse?.maxResults ?? 50;
}
issues = vtsResponses.SelectMany(r => r.issues.Select(i => new VtsIssue
{
Key = i.key,
Summary = i.fields.summary,
Type = i.fields.issuetype.name,
Status = i.fields.status.name,
Assignee = i.fields.assignee?.displayName ?? "",
Manager = i.fields.reporter?.displayName ?? "",
Due = i.fields.duedate ?? new DateTime(1999, 12, 23, 23, 59, 59),
Updated = i.fields.UpdatedAt,
Parent = i.fields.parent?.key,
Sprint = SprintHelper.ExtractActiveSprintName(i.fields.customfield_10806)
})).ToList();
_sw.Stop();
Log.Information("Fetched {issue_count} issues from VTS in {operation_time}ms"
, issues.Count, _sw.ElapsedMilliseconds);
foreach(var i in issues)
{
i.NeedNotify = (IsNeedNotify(i)) ? 1 : 0;
Log.Debug("{issue_key}: {issue_summary} Updated at {issue_updated}", i.Key, i.Summary, i.Updated);
}
_repo.UpsertVtsIssues(issues, ["Published", "ObjectId"]);
_repo.UpdateLastFetchTime(now); // Update the timer after fetch
_lastFetchTimeOffset = now;
}
catch (Exception ex)
{
Log.Error("Unhandled exception occured during fetching VTS issues: {error_message}", ex.Message);
return false;
}
}
return true;
}
private bool FetchVtsIssue(string targetIssueKey)
{
try
{
_sw.Restart();
var response = _vts.FetchVtsIssueAsync(targetIssueKey, _cts.Token).GetAwaiter().GetResult();
var issue = new VtsIssue
{
Key = response.key,
Summary = response.fields.summary,
Type = response.fields.issuetype.name,
Status = response.fields.status.name,
Assignee = response.fields.assignee?.displayName ?? "",
Manager = response.fields.reporter?.displayName ?? "",
Due = response.fields.duedate ?? new DateTime(1999, 12, 23, 23, 59, 59),
Updated = response.fields.UpdatedAt,
Parent = response.fields.parent?.key,
Sprint = SprintHelper.ExtractActiveSprintName(response.fields.customfield_10806),
};
issue.NeedNotify = (IsNeedNotify(issue)) ? 1 : 0;
_repo.UpsertVtsIssues(new List<VtsIssue> { issue }, ["Published", "ObjectId"]);
}
catch (Exception ex)
{
Log.Error("Unhandled exception occured during fetching VTS issue({issue_key}): {error_message}", targetIssueKey, ex.Message);
return false;
}
finally
{
_sw.Stop();
Log.Information("Fetched an issue({issue_key}) from VTS in {operation_time}ms"
, targetIssueKey, _sw.ElapsedMilliseconds);
}
return true;
}
private void FetchNow()
{
Log.Information("Start fetching by the button...");
if(!string.IsNullOrEmpty(_targetIssueKey))
{
var result = FetchVtsIssue(_targetIssueKey);
if (result)
TargetIssueKey = "";
return;
}
if (FetchVtsIssues())
{
_lastFetchTime = DateTimeOffset.Now.ToString();
_nextFetchInSeconds = _nextFetchInSeconds = _appSettings.VTS.FetchInterval;
UpdateLabels();
}
}
#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
private void PublishTimerTick(object? sender, EventArgs e)
{
var issues = _repo.GetIssuesToPublish();
var issueKeys = issues.Select(i => i.Key).ToList();
var existingIssues = _repo.GetVtsIssues(issueKeys).GetAwaiter().GetResult();
var newIssues = issues.Where(i => !existingIssues.Any(e => e.Key == i.Key)).ToList();
issues = newIssues.Concat(existingIssues).ToList();
_sw.Restart();
foreach (var issue in issues)
{
try
{
var existingIssue = existingIssues.Where(i => issue.Key == i.Key).FirstOrDefault();
var publishedIssue = _anytype.PublishPageAsync(issue).GetAwaiter().GetResult();
_repo.UpsertVtsIssues(new List<VtsIssue> { publishedIssue }, []); // Since it will be performed one by one anyway, we will proceed with Upsert right here.
Log.Debug("Issue {issue_key} is {publishd_or_updated} to the page({page_link})",
publishedIssue.Key, (string.IsNullOrEmpty(existingIssue.ObjectId)) ? "published" : "updated",
$"https://object.any.coop/{publishedIssue.ObjectId}?spaceId={_appSettings.Anytype.SpaceId}");
}
catch (Exception ex)
{
Log.Error("Unhandled exception occured during publishing Anytype page({issue_key}): {error_message}",
issue.Key, ex.Message);
}
}
_sw.Stop();
}
#endregion PublishEvents
#region SlackEvents
private bool IsNeedNotify(VtsIssue issue)
{
var orgIssue = _repo.GetVtsIssueAsync(issue.Key).GetAwaiter().GetResult();
if (orgIssue == null) // A new issue assigned to me.
{
Log.Debug("{method_name} - A new issue({target_keys}) assigned to me.", nameof(IsNeedNotify), issue.Key, issue.Assignee);
if (issue.Assignee == MyName)
{
_publishedIssuesQueue.Enqueue(issue);
return true;
}
}
else
{
if (orgIssue.Assignee != MyName && issue.Assignee == MyName)
{
Log.Debug("{method_name} - Existing issue({target_key}) assigned to me.", nameof(IsNeedNotify), issue.Key);
_publishedIssuesQueue.Enqueue(issue);
return true;
}
else if(orgIssue.Status != StatusReopen && issue.Status == StatusReopen)
{
Log.Debug("{method_name} - My issue({target_key}) has been reopened.", nameof(IsNeedNotify), issue.Key);
_publishedIssuesQueue.Enqueue(issue);
return true;
}
}
return false;
}
private void SlackTick(object? sender, EventArgs e)
{
var updatedIssues = new List<VtsIssue>();
VtsIssue issue;
while(_publishedIssuesQueue.TryDequeue(out issue))
{
if (issue == null) break;
Log.Debug("{method_name} - Get published issue {issue_key}", nameof(SlackTick), issue);
updatedIssues.Add(issue);
}
if (updatedIssues.Count < 1) return;
_slack.SendSlackMessageAsync(updatedIssues);
}
#endregion SlackEvents
#region UIEvents
private void UpdateLabels()
{
UpdateNextFetchLabel();
UpdateLastFetchTimeLabel();
}
private void UpdateNextFetchLabel()
{
int minutes = _nextFetchInSeconds / 60;
int seconds = _nextFetchInSeconds % 60;
NextFetchIn = $"{minutes}:{seconds:D2}";
}
private void UpdateLastFetchTimeLabel()
{
LastFetchTime = _lastFetchTimeOffset.ToString("MM'/'dd HH:mm");
}
#endregion UIEvents
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged(string propertyName)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}