476 lines
17 KiB
C#
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));
|
|
}
|