commit 4de4d36282da954619051a5af74c000e3c31f5a8 Author: seungmuk.oh Date: Wed Dec 17 17:03:50 2025 +0900 초기 커밋. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bc1dcfe --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*.cs] + +# CS8602: null 가능 참조에 대한 역참조입니다. +dotnet_diagnostic.CS8602.severity = none + +# CS8618: null을 허용하지 않는 필드는 생성자를 종료할 때 null이 아닌 값을 포함해야 합니다. 'required' 한정자를 추가하거나 nullable로 선언하는 것이 좋습니다. +dotnet_diagnostic.CS8618.severity = none diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fde703 --- /dev/null +++ b/.gitignore @@ -0,0 +1,485 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp +*.bkp diff --git a/VTSFetcher/App.xaml b/VTSFetcher/App.xaml new file mode 100644 index 0000000..9e211a0 --- /dev/null +++ b/VTSFetcher/App.xaml @@ -0,0 +1,8 @@ + + + + + diff --git a/VTSFetcher/App.xaml.cs b/VTSFetcher/App.xaml.cs new file mode 100644 index 0000000..2f7151f --- /dev/null +++ b/VTSFetcher/App.xaml.cs @@ -0,0 +1,155 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Serilog.Formatting.Json; +using System.IO; +using System.IO.Pipes; +using System.Windows; +using VTSFetcher.Models; +using VTSFetcher.Repositories; +using VTSFetcher.Services; +using VTSFetcher.ViewModels; +using VTSFetcher.Views; + +namespace VTSFetcher; + +/// +/// Interaction logic for App.xaml +/// +public partial class App : System.Windows.Application +{ + private const string MutexName = "VTSFetcher_SingleInstance"; + private const string PipeName = "VTSFetcher_SingleInstance_Pipe"; + private Mutex _mutex; + + protected override void OnStartup(StartupEventArgs e) + { + bool createdNew; + _mutex = new Mutex(true, MutexName, out createdNew); + if(!createdNew) + { + // 이미 실행 중이면 파이프를 통해 기존 창 열기 요청 + using (var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out)) + { + try + { + client.Connect(500); // 0.5초 대기 + using (var writer = new StreamWriter(client)) + { + writer.WriteLine("SHOW"); + writer.Flush(); + } + } + catch + { + // 연결 실패 시 무시 + } + } + + Shutdown(); + return; + } + + // Start named pipe server for single instance communication + Task.Run(() => StartPipeServer()); + + base.OnStartup(e); + + var config = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + + SetSerilog(); + + var services = new ServiceCollection(); + services.Configure(config); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddDbContext(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + + var provider = services.BuildServiceProvider(); + try + { + using (var scope = provider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + } + var mainWindow = provider.GetRequiredService(); + mainWindow.Show(); + } + catch(Exception ex) + { + Log.Error("Unhandled exception occured during starting application: {error_message}", ex.Message); + } + + Log.Information("Application started."); + } + protected override void OnExit(ExitEventArgs e) + { + Log.Information("Application ended."); + + Log.CloseAndFlush(); + base.OnExit(e); + } + private void StartPipeServer() + { + while (true) + { + try + { + using (var server = new NamedPipeServerStream(PipeName, PipeDirection.In)) + { + server.WaitForConnection(); + using (var reader = new StreamReader(server)) + { + string command = reader.ReadLine(); + if (command == "SHOW") + { + Dispatcher.Invoke(() => + { + if (Current.MainWindow != null) + { + if (Current.MainWindow.WindowState == WindowState.Minimized) + Current.MainWindow.WindowState = WindowState.Normal; + + Current.MainWindow.Show(); + Current.MainWindow.Activate(); + Current.MainWindow.Topmost = true; // 맨 위로 올림 + Current.MainWindow.Topmost = false; // 다시 원래대로 + } + }); + } + } + } + } + catch + { + // 에러 발생 시 루프 재시작 + } + } + } + private void SetSerilog() + { + var exePath = AppDomain.CurrentDomain.BaseDirectory; + var logDir = Path.Combine(exePath, "logs"); + if (!Directory.Exists(logDir)) + Directory.CreateDirectory(logDir); + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .WriteTo.File( + formatter: new JsonFormatter(renderMessage: true), + path: Path.Combine(logDir, "log-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 30 + ) + .WriteTo.Seq("http://localhost:5341") + .CreateLogger(); + } +} diff --git a/VTSFetcher/AssemblyInfo.cs b/VTSFetcher/AssemblyInfo.cs new file mode 100644 index 0000000..b0ec827 --- /dev/null +++ b/VTSFetcher/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/VTSFetcher/Commands/RelayCommand.cs b/VTSFetcher/Commands/RelayCommand.cs new file mode 100644 index 0000000..eb45846 --- /dev/null +++ b/VTSFetcher/Commands/RelayCommand.cs @@ -0,0 +1,42 @@ +using System.Windows.Input; + +namespace VTSFetcher.Commands; + +/// +/// MVVM에서 버튼 클릭 등 UI 이벤트를 ViewModel 메서드로 중계하는 ICommand 구현 +/// +public class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Func? _canExecute; + + /// + /// 생성자 + /// + /// 실행할 Action + /// 실행 가능 조건 (선택) + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + /// + /// 명령 실행 가능 여부 + /// + public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; + + /// + /// 명령 실행 + /// + public void Execute(object? parameter) => _execute(parameter); + + /// + /// CanExecuteChanged 이벤트 (UI 갱신용) + /// + public event EventHandler? CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } +} diff --git a/VTSFetcher/Documents/VtsFetcherWorkflow.drawio b/VTSFetcher/Documents/VtsFetcherWorkflow.drawio new file mode 100644 index 0000000..5310e05 --- /dev/null +++ b/VTSFetcher/Documents/VtsFetcherWorkflow.drawio @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VTSFetcher/Helpers/Schedule.cs b/VTSFetcher/Helpers/Schedule.cs new file mode 100644 index 0000000..7d50cf9 --- /dev/null +++ b/VTSFetcher/Helpers/Schedule.cs @@ -0,0 +1,16 @@ +namespace VTSFetcher.Helpers; + +public static class Schedule +{ + public static bool CheckWorkingTime() + { + var now = DateTime.Now; + if (now.DayOfWeek >= DayOfWeek.Monday && + now.DayOfWeek <= DayOfWeek.Friday && + now.Hour >= 8 && now.Hour < 19) + { + return true; + } + return false; + } +} diff --git a/VTSFetcher/Models/AnytypePage.cs b/VTSFetcher/Models/AnytypePage.cs new file mode 100644 index 0000000..789b89c --- /dev/null +++ b/VTSFetcher/Models/AnytypePage.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace VTSFetcher.Modelsl; + +public class AnytypePage +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + [JsonPropertyName("type_key")] + public string TypeKey { get; } = "page"; + [JsonPropertyName("template_id")] + public string TemplateId { get; set; } = string.Empty; + [JsonPropertyName("properties")] + public List Properties { get; set; } = new(); +} +public class PageProperty +{ + [JsonPropertyName("key")] + public string Key { get; set; } = string.Empty; + [JsonPropertyName("url")] + public string Url { get; set; } + [JsonPropertyName("select")] + public string Select { get; set; } + [JsonPropertyName("text")] + public string Text { get; set; } + [JsonPropertyName("date")] + public string Date { get; set; } +} + diff --git a/VTSFetcher/Models/AnytypePageResponse.cs b/VTSFetcher/Models/AnytypePageResponse.cs new file mode 100644 index 0000000..4442b6e --- /dev/null +++ b/VTSFetcher/Models/AnytypePageResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace VTSFetcher.Models; + +internal class AnytypePageResponse +{ + [JsonPropertyName("object")] + public AnytypePageObject Object { get; set; } +} + +public class AnytypePageObject +{ + [JsonPropertyName("object")] + public string ObjectType { get; set; } + [JsonPropertyName("id")] + public string ObjectId { get; set; } + [JsonPropertyName("space_id")] + public string SpaceId { get; set; } +} \ No newline at end of file diff --git a/VTSFetcher/Models/AnytypePropertyResponse.cs b/VTSFetcher/Models/AnytypePropertyResponse.cs new file mode 100644 index 0000000..6f60a3b --- /dev/null +++ b/VTSFetcher/Models/AnytypePropertyResponse.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace VTSFetcher.Models; + +public class AnytypePropertyResponse +{ + public Property[] data { get; set; } + public Pagination pagination { get; set; } +} + +public class Pagination +{ + public int total { get; set; } + public int offset { get; set; } + public int limit { get; set; } + public bool has_more { get; set; } +} + +public class Property +{ + [JsonPropertyName("object")] + public string Object { get; set; } + public string Id { get; set; } + public string Key { get; set; } + public string Name { get; set; } + public string Format { get; set; } +} diff --git a/VTSFetcher/Models/AppSettings.cs b/VTSFetcher/Models/AppSettings.cs new file mode 100644 index 0000000..988e3de --- /dev/null +++ b/VTSFetcher/Models/AppSettings.cs @@ -0,0 +1,53 @@ +namespace VTSFetcher.Models; + +public class AppSettings +{ + public Logging Logging { get; set; } = new(); + public VTS VTS { get; set; } = new(); + public DB DB { get; set; } = new(); + public Anytype Anytype { get; set; } = new(); + public Slack Slack { get; set; } = new(); +} + +public class Logging +{ + public Loglevel LogLevel { get; set; } = new(); +} + +public class Loglevel +{ + public string Default { get; set; } = string.Empty; + public string MicrosoftHostingLifetime { get; set; } = string.Empty; +} + +public class DB +{ + public string Database { get; set; } = string.Empty; +} + +public class VTS +{ + public string BaseUrl { get; set; } = string.Empty ; + public string ApiBaseUrl { get; set; } = string.Empty; + public string AccessToken { get; set; } = string.Empty; + public List TargetProjects { get; set; } = []; + public int FetchInterval { get; set; } = 60; + public string SearchFields { get; set; } = string.Empty; +} + +public class Anytype +{ + public string ApiBaseUrl { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string ApiKey { get; set; } = string.Empty; + public string SpaceId { get; set; } = string.Empty; + public string TemplateId { get; set; } = string.Empty; + public string[] Properties { get; set; } = new string[0]; +} + +public class Slack +{ + public string ApiBaseUrl { get; set; } = string.Empty; + public string BotUserOAuthToken { get; set; } = string.Empty; + public string ChannelId { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/VTSFetcher/Models/VtsResponse.cs b/VTSFetcher/Models/VtsResponse.cs new file mode 100644 index 0000000..bbb12f4 --- /dev/null +++ b/VTSFetcher/Models/VtsResponse.cs @@ -0,0 +1,104 @@ +namespace VTSFetcher.Models; + +public class VTSResponse +{ + public string expand { get; set; } + public int startAt { get; set; } + public int maxResults { get; set; } + public int total { get; set; } + public Issue[] issues { get; set; } +} + +public class Issue +{ + public string expand { get; set; } + public string id { get; set; } + public string self { get; set; } + public string key { get; set; } // RYXMARUV1-NNNN + public Fields fields { get; set; } +} + +public class Fields +{ + public string summary { get; set; } // Title + public Assignee assignee { get; set; } + public Reporter reporter { get; set; } + public string updated { get; set; } + public Status status { get; set; } + public Issuetype issuetype { get; set; } + public DateTimeOffset UpdatedAt + { + get + { + // "2025-09-03T15:31:51.000+0900" → "2025-09-03T15:31:51.000+09:00" + var fixedValue = updated.Insert(updated.Length - 2, ":"); + return DateTimeOffset.Parse(fixedValue); + } + } + public DateTime? duedate { get; set; } + public Issue parent { get; set; } + public string customfield_10808 { get; set; } // Epic key +} + +public class Assignee +{ + public string self { get; set; } + public string name { get; set; } + public string key { get; set; } + public string emailAddress { get; set; } + public Avatarurls avatarUrls { get; set; } + public string displayName { get; set; } // Korean name + public bool active { get; set; } + public string timeZone { get; set; } +} + +public class Reporter +{ + public string self { get; set; } + public string name { get; set; } + public string key { get; set; } + public string emailAddress { get; set; } + public Avatarurls avatarUrls { get; set; } + public string displayName { get; set; } // Korean name + public bool active { get; set; } + public string timeZone { get; set; } +} + +public class Avatarurls +{ + public string _48x48 { get; set; } + public string _24x24 { get; set; } + public string _16x16 { get; set; } + public string _32x32 { get; set; } +} + +public class Status +{ + public string self { get; set; } + public string description { get; set; } + public string iconUrl { get; set; } + public string name { get; set; } // Status: Open InProgress ... + public string id { get; set; } + public Statuscategory statusCategory { get; set; } +} + +public class Statuscategory +{ + public string self { get; set; } + public int id { get; set; } + public string key { get; set; } + public string colorName { get; set; } + public string name { get; set; } +} + +public class Issuetype +{ + public string self { get; set; } + public string id { get; set; } + public string description { get; set; } + public string iconUrl { get; set; } + public string name { get; set; } // Type + public bool subtask { get; set; } + public int avatarId { get; set; } +} + diff --git a/VTSFetcher/Repositories/AnytpeVtsSqls.cs b/VTSFetcher/Repositories/AnytpeVtsSqls.cs new file mode 100644 index 0000000..2bc050c --- /dev/null +++ b/VTSFetcher/Repositories/AnytpeVtsSqls.cs @@ -0,0 +1,18 @@ +namespace VTSFetcher.Repositories; + +public partial class AnytypeVtsContext +{ + public const string UpsertVtsIssues = @" + INSERT INTO vtsIssues (Key, Summary, Type, Status, Assignee, Due, Updated, Parent) + VALUES ($Key, $Summary, $Type, $Status, $Assignee, $Due, $Updated, $Parent) + ON CONFLICT(Id) DO + UPDATE SET + Summary = excluded.Summary, + Type = excluded.Type, + Status = excluded.Status, + Assignee = excluded.Assignee, + Due = excluded.Due, + Updated = excluded.Updated, + Parent = excluded.Parent; + "; +} diff --git a/VTSFetcher/Repositories/AnytypeVtsContext.cs b/VTSFetcher/Repositories/AnytypeVtsContext.cs new file mode 100644 index 0000000..cdd8588 --- /dev/null +++ b/VTSFetcher/Repositories/AnytypeVtsContext.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.IO; +using VTSFetcher.Models; +using VTSFetcher.Repositories.Entities; + +namespace VTSFetcher.Repositories; + +public partial class AnytypeVtsContext : DbContext +{ + private readonly AppSettings _appSettings; + public DbSet VtsIssues { get;set; } + public DbSet Configs { get; set; } + public string DbPath { get; } + + public AnytypeVtsContext(IOptions options) + { + _appSettings = options.Value; + var folder = Environment.SpecialFolder.LocalApplicationData; + var path = Environment.GetFolderPath(folder); + DbPath = Path.Join(path, _appSettings.DB.Database); + } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + => options.UseSqlite($"Data Source={DbPath}"); +} \ No newline at end of file diff --git a/VTSFetcher/Repositories/AnytypeVtsRepository.cs b/VTSFetcher/Repositories/AnytypeVtsRepository.cs new file mode 100644 index 0000000..e3c132d --- /dev/null +++ b/VTSFetcher/Repositories/AnytypeVtsRepository.cs @@ -0,0 +1,82 @@ +using EFCore.BulkExtensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using VTSFetcher.Repositories.Entities; + +namespace VTSFetcher.Repositories +{ + public class AnytypeVtsRepository + { + private readonly IServiceScopeFactory _scopeFactory; + public AnytypeVtsRepository(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory; + } + + #region Configs + public Dictionary GetConfigs() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var result = db.Configs.ToDictionary(c => c.Key, c => c.Value); + return result; + } + + public void UpdateLastFetchTime(DateTimeOffset dateTimeOffert) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Configs + .Where(i => i.Key == Config.LastFetchTimeKey) + .ExecuteUpdate(s => s + .SetProperty(i => i.Value, dateTimeOffert.ToString("yyyy-MM-ddTHH:mm:sszzz")) + ); + db.SaveChanges(); + } + #endregion Configs + + #region VtsIssues + public async Task GetVtsIssueAsync(string key) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var result = await db.VtsIssues.FirstOrDefaultAsync(i => i.Key == key); + return result; + } + public async Task> GetVtsIssues(List keys) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var result = await db.VtsIssues + .Where(i => keys.Contains(i.Key)) + .ToListAsync(); + return result; + } + + public void UpsertVtsIssues(List vtsIssues, List excludedColumns) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // BulkInsertOrUpdateAsync does not need SaveChangesAsync + db.BulkInsertOrUpdate(vtsIssues, new BulkConfig + { + PropertiesToExclude = excludedColumns + }); + } + + public List GetIssuesToPublish(int count = 1) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var result = db.VtsIssues + .AsEnumerable() // DB에서 먼저 가져오고, 이후 LINQ 연산은 메모리에서 수행 + .Where(p => p.Published < p.Updated) + //.OrderBy(p => p.Published) + .Take(count) + .ToList(); + return result; + } + #endregion VtsIsseus + } +} diff --git a/VTSFetcher/Repositories/Entities/Config.cs b/VTSFetcher/Repositories/Entities/Config.cs new file mode 100644 index 0000000..1f8e3f9 --- /dev/null +++ b/VTSFetcher/Repositories/Entities/Config.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace VTSFetcher.Repositories.Entities; + + +public class Config +{ + public const string LastFetchTimeKey = "LAST_FETCH_TIME"; + + [Key] + public string Key { get; set; } + public string Value { get; set; } +} diff --git a/VTSFetcher/Repositories/Entities/VtsIssue.cs b/VTSFetcher/Repositories/Entities/VtsIssue.cs new file mode 100644 index 0000000..11cc44c --- /dev/null +++ b/VTSFetcher/Repositories/Entities/VtsIssue.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace VTSFetcher.Repositories.Entities; + +public class VtsIssue +{ + [Key] + public string Key { get; set; } + public string Summary { get; set; } + public string? Parent { get; set; } + public string Type { get; set; } + public string Status { get; set; } + public string Assignee { get; set; } + public string Manager { get; set; } + public DateTimeOffset Due { get; set; } + public DateTimeOffset Updated { get; set; } + public DateTimeOffset Published { get; set; } + public string? ObjectId { get; set; } + public int NeedNotify { get; set; } +} \ No newline at end of file diff --git a/VTSFetcher/Services/AnytypeService.cs b/VTSFetcher/Services/AnytypeService.cs new file mode 100644 index 0000000..33a9ad7 --- /dev/null +++ b/VTSFetcher/Services/AnytypeService.cs @@ -0,0 +1,173 @@ +using Microsoft.Extensions.Options; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using VTSFetcher.Models; +using VTSFetcher.Modelsl; +using VTSFetcher.Repositories.Entities; + +namespace VTSFetcher.Services; + +public class AnytypeService +{ + // Managed Property names + const string VtsKeyPropertyName = "VTS_Key"; + const string VtsLinkPropertyName = "VTS_Link"; + const string VtsTypePropertyName = "VTS_Type"; + const string VtsStatusPropertyName = "VTS_Status"; + const string VtsAssigneePropertyName = "VTS_Assignee"; + const string VtsManagerPropertyName = "VTS_Manager"; + const string VtsDueDatePropertyName = "VTS_Due"; + + private readonly AppSettings _appSettings; + + private Dictionary _managedProperties = new(); + private Dictionary _managedTags = new(); + + public AnytypeService(IOptions options) + { + _appSettings = options.Value; + + LoadProperties().Wait(); + } + + public async Task PublishPageAsync(VtsIssue issue) + { + List pageProperties = new(); + foreach (var propertyName in _managedProperties.Keys) + { + var property = _managedProperties[propertyName]; + var pageProperty = GetPageProperty(property, issue); + if(pageProperty != null) + pageProperties.Add(pageProperty); + } + var page = new AnytypePage + { + Name = $"{issue.Key} {issue.Summary}", + TemplateId = _appSettings.Anytype.TemplateId, + Properties = pageProperties, + }; + var options = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true + }; + var jsingString = JsonSerializer.Serialize(page, options); + var content = new StringContent(jsingString); + + using var client = new HttpClient(); + HttpRequestMessage request; + if (string.IsNullOrEmpty(issue.ObjectId)) + request = new HttpRequestMessage( + HttpMethod.Post, + $"{_appSettings.Anytype.ApiBaseUrl}/spaces/{_appSettings.Anytype.SpaceId}/objects" + ); + else + request = new HttpRequestMessage( + HttpMethod.Patch, + $"{_appSettings.Anytype.ApiBaseUrl}/spaces/{_appSettings.Anytype.SpaceId}/objects/{issue.ObjectId}" + ); + request.Headers.Add("Authorization", $"Bearer {_appSettings.Anytype.ApiKey}"); + request.Headers.Add("Anytype-Version", $"{_appSettings.Anytype.Version}"); + request.Content = content; + var response = await client.SendAsync(request).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var jsonResponse = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var pageResponse = JsonSerializer.Deserialize(jsonResponse, options); + issue.ObjectId = pageResponse.Object.ObjectId; + issue.Published = DateTimeOffset.Now; + return issue; + } + private async Task LoadProperties() + { + using var client = new HttpClient(); + var uri = $"{_appSettings.Anytype.ApiBaseUrl}/spaces/{_appSettings.Anytype.SpaceId}/properties"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Add("Accept", "application/json"); + request.Headers.Add("Authorization", $"Bearer {_appSettings.Anytype.ApiKey}"); + request.Headers.Add("Anytype-Version", $"{_appSettings.Anytype.Version}"); + + var response = await client.SendAsync(request).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var jsonResponse = await response.Content.ReadAsStringAsync(); + var jsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var anytypePropertyResponse = JsonSerializer.Deserialize(jsonResponse, jsonSerializerOptions); + + var managedPropertyNameSet = new HashSet(_appSettings.Anytype.Properties, StringComparer.OrdinalIgnoreCase); + var matchedProperties = anytypePropertyResponse.data + .Where(d => managedPropertyNameSet.Contains(d.Name)).ToList(); + foreach (var item in matchedProperties) + { + _managedProperties.Add(item.Name, item); + if (!string.Equals(item.Format, "select", StringComparison.OrdinalIgnoreCase)) + continue; + var tags = await LoadTags(item.Id); + _managedTags.Add(item.Name, tags); + } + } + + private async Task LoadTags(string propertyId) + { + using var client = new HttpClient(); + var uri = $"{_appSettings.Anytype.ApiBaseUrl}" + + $"/spaces/{_appSettings.Anytype.SpaceId}" + + $"/properties/{propertyId}/tags"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Add("Accept", "application/json"); + request.Headers.Add("Authorization", $"Bearer {_appSettings.Anytype.ApiKey}"); + request.Headers.Add("Anytype-Version", $"{_appSettings.Anytype.Version}"); + + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var jsonResponse = await response.Content.ReadAsStringAsync(); + var jsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var anytypeTagsesponse = JsonSerializer.Deserialize(jsonResponse, jsonSerializerOptions); + + var result = anytypeTagsesponse.data; + return result; + } + + private PageProperty? GetPageProperty(Property property, VtsIssue issue) + { + var pageProperty = new PageProperty(); + pageProperty.Key = property.Key; + switch (property.Name) + { + case VtsKeyPropertyName: + pageProperty.Text = issue.Key; + break; + case VtsLinkPropertyName: + pageProperty.Url = $"{_appSettings.VTS.BaseUrl}/browse/{issue.Key}"; + break; + case VtsTypePropertyName: + pageProperty.Select = GetTagId(property, issue.Type); + break; + case VtsStatusPropertyName: + pageProperty.Select = GetTagId(property, issue.Status); + break; + case VtsAssigneePropertyName: + pageProperty.Text = $"{issue.Assignee}"; + break; + case VtsManagerPropertyName: + pageProperty.Text = $"{issue.Manager}"; + break; + case VtsDueDatePropertyName: + if (issue.Due < new DateTime(2000, 1, 1, 0, 0, 0)) + return null; + pageProperty.Date = $"{issue.Due.ToString("yyyy-MM-ddT23:59:59Z")}"; + break; + default: + break; + } + return pageProperty; + } + + private string GetTagId(Property property, string tagName) + { + var tags = _managedTags[property.Name]; + var tag = tags.Where(t => t.Name == tagName).FirstOrDefault(); + return tag.Id; + } +} diff --git a/VTSFetcher/Services/SlackService.cs b/VTSFetcher/Services/SlackService.cs new file mode 100644 index 0000000..7aaf3af --- /dev/null +++ b/VTSFetcher/Services/SlackService.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Options; +using Serilog; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using VTSFetcher.Models; +using VTSFetcher.Repositories.Entities; + +namespace VTSFetcher.Services +{ + public class SlackService + { + private readonly AppSettings _appSettings; + public SlackService(IOptions options) + { + _appSettings = options.Value; + } + + public async Task SendSlackMessageAsync(List issues) + { + using var client = new HttpClient(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _appSettings.Slack.BotUserOAuthToken); + + var payload = CreateMessagePayload(issues); + try + { + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var response = await client.PostAsync(_appSettings.Slack.ApiBaseUrl, content); + var result = response.EnsureSuccessStatusCode(); + } + catch(Exception ex) + { + Log.Error("Failed to send message to slack. {error_message}", ex.Message); + } + finally + { + } + } + + private string CreateMessagePayload(List issues) + { + var payload = @$"{{ ""channel"":""{ _appSettings.Slack.ChannelId }"", "; + payload += @" ""blocks"": + [ + { + ""type"": ""section"", + ""text"": { + ""type"": ""mrkdwn"", + ""text"": ""@channel"" + } + }, + { + ""type"": ""divider"" + },"; + foreach(var issue in issues) + { + payload += $@" + {{ + ""type"": ""section"", + ""text"": {{ + ""type"": ""mrkdwn"", + ""text"": "" *[{issue.Key}]*\n *<{_appSettings.VTS.BaseUrl}/browse/{issue.Key}|{issue.Summary}>*"" + }}, + ""fields"": [ + {{ + ""type"": ""mrkdwn"", + ""text"": ""*Type:* `{issue.Type}`"" + }}, + {{ + ""type"": ""mrkdwn"", + ""text"": ""*Status:* `{issue.Status}`"" + }} + ] + }}, + {{ + ""type"": ""divider"" + }}, + "; + } + payload += "]}"; + return payload; + } + } +} diff --git a/VTSFetcher/Services/VtsService.cs b/VTSFetcher/Services/VtsService.cs new file mode 100644 index 0000000..05d07cd --- /dev/null +++ b/VTSFetcher/Services/VtsService.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.Options; +using Serilog; +using System.Net.Http; +using System.Text.Json; +using VTSFetcher.Models; +using VTSFetcher.Repositories.Entities; + +namespace VTSFetcher.Services; + +public class VtsService +{ + private readonly AppSettings _appSettings; + + public VtsService(IOptions options) + { + _appSettings = options.Value; + } + + public async Task FetchVtsIssues(DateTimeOffset lastFetchTime, string targetProject, + int startAt, CancellationToken ct) + { + var uri = $"{_appSettings.VTS.ApiBaseUrl}/" + + $"search?jql=project={targetProject} " + + $"AND updated >= \"{lastFetchTime.ToString("yyyy-MM-dd HH:mm")}\" " + + $"&fields={_appSettings.VTS.SearchFields}" + + $"&startAt={startAt}&maxResults=50"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Add("Accept", "application/json"); + request.Headers.Add("Authorization", $"Bearer {_appSettings.VTS.AccessToken}"); + Log.Debug("Fetch VTS issues ready. Fetch the issues updated after {last_fetch_time} in Project {target_project}.", + lastFetchTime.ToString("yyyy-MM-dd HH:mm"), targetProject); + + using var client = new HttpClient(); + var response = await client.SendAsync(request, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var jsonResponse = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + var vtsResponse = JsonSerializer.Deserialize(jsonResponse); + return vtsResponse; + } + + public async Task FetchVtsIssueAsync(string targetIssueKey, CancellationToken ct) + { + var uri = $"{_appSettings.VTS.ApiBaseUrl}/issue/{targetIssueKey}"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Add("Accept", "application/json"); + request.Headers.Add("Authorization", $"Bearer {_appSettings.VTS.AccessToken}"); + Log.Debug("Fetch a VTS issue({issue_key}) ready.", targetIssueKey); + + using var client = new HttpClient(); + var response = await client.SendAsync(request, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var jsonResponse = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + var issue = JsonSerializer.Deserialize(jsonResponse); + return issue; + } + + private List JsonDeserializeToVtsIssues(string json) + { + var result = new List(); + var jsonSerializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var vtsResponse = JsonSerializer.Deserialize(json, jsonSerializerOptions); + if (vtsResponse?.issues.Length > 0) + { + result = [.. vtsResponse.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, + })]; + } + + return result; + } +} diff --git a/VTSFetcher/VTSFetcher.csproj b/VTSFetcher/VTSFetcher.csproj new file mode 100644 index 0000000..f6bf007 --- /dev/null +++ b/VTSFetcher/VTSFetcher.csproj @@ -0,0 +1,36 @@ + + + + WinExe + net9.0-windows + enable + enable + true + true + 1.0.0.1 + + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/VTSFetcher/ViewModels/MainViewModel.cs b/VTSFetcher/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..4811bea --- /dev/null +++ b/VTSFetcher/ViewModels/MainViewModel.cs @@ -0,0 +1,373 @@ +using Microsoft.Extensions.Options; +using Serilog; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; +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.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 Dictionary _configs; + private readonly DispatcherTimer _fetchTimer; + private readonly DispatcherTimer _publishTimer; + private readonly DispatcherTimer _slackTimer; + private readonly CancellationTokenSource _cts; + private ConcurrentQueue _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; } + #endregion Fields + + public MainViewModel(IOptions options, AnytypeVtsRepository repo, + VtsService vtsService, AnytypeService anytypeService, SlackService slack) + { + _appSettings = options.Value; + _repo = repo; + _vts = vtsService; + _anytype = anytypeService; + _slack = slack; + _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(); + + FetchNowCommand = new RelayCommand(_ => FetchNow()); + + _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; + } + /// + /// Fetch VTS issues and update Anytype pages. + /// LastFetchTime is updated inside this method. + /// + /// + private bool FetchVtsIssues() + { + var now = DateTimeOffset.Now; + foreach(var project in _appSettings.VTS.TargetProjects) + { + try + { + List issues = []; + _sw.Restart(); + var vtsResponses = new List(); + 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, + })).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, + }; + issue.NeedNotify = (IsNeedNotify(issue)) ? 1 : 0; + _repo.UpsertVtsIssues(new List { 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 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 { 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 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)); +} diff --git a/VTSFetcher/Views/MainWindow.xaml b/VTSFetcher/Views/MainWindow.xaml new file mode 100644 index 0000000..a467894 --- /dev/null +++ b/VTSFetcher/Views/MainWindow.xaml @@ -0,0 +1,27 @@ + + + + + + + + + + + +