Compare commits

...

8 Commits

11 changed files with 288 additions and 44 deletions

View File

@@ -17,4 +17,19 @@ public class JiraIssue
public DateTimeOffset Published { get; set; } public DateTimeOffset Published { get; set; }
public string? ObjectId { get; set; } public string? ObjectId { get; set; }
public int NeedNotify { get; set; } public int NeedNotify { get; set; }
public JiraIssue() { }
protected JiraIssue(JiraIssue issue)
{
this.Key = issue.Key;
this.Summary = issue.Summary;
this.Parent = issue.Parent;
this.Type = issue.Type;
this.Status = issue.Status;
this.Assignee = issue.Assignee;
this.Manager = issue.Manager;
this.Published = issue.Published;
this.ObjectId = issue.ObjectId;
this.NeedNotify = issue.NeedNotify;
}
} }

View File

@@ -15,9 +15,9 @@ public class Trilium
{ {
public string EtapiToken { get; set; } = string.Empty; public string EtapiToken { get; set; } = string.Empty;
public string EtapiBaseUrl { get; set; } = "https://localhost/etapi"; public string EtapiBaseUrl { get; set; } = "https://localhost/etapi";
public string JiraOpenedIssuePageId = string.Empty; public string JiraOpenedIssuePageId { get; set; } = string.Empty;
public string JiraWorkingIssuePageId = string.Empty; public string JiraWorkingIssuePageId { get; set; } = string.Empty;
public string JiraResolvedIssuePageId = string.Empty; public string JiraResolvedIssuePageId { get; set; } = string.Empty;
public string DividendPageId { get; set; } = String.Empty; public string DividendPageId { get; set; } = String.Empty;
} }

29
Models/TriliumNote.cs Normal file
View File

@@ -0,0 +1,29 @@
using System.Text.Json;
using TriliumMind.Data.Entities;
namespace TriliumMind.Models;
public class TriliumNote : JiraIssue
{
public string ParentNoteId { get; set; } = string.Empty;
public TriliumResponse? TriliumNoteData { get; set; } = null;
public TriliumNote(JiraIssue issue, string parentNoteId) : base(issue)
{
this.ParentNoteId = parentNoteId;
}
public StringContent ToNoteContent()
{
var noteData = new
{
parentNoteId = this.ParentNoteId,
title = $"{this.Key} {this.Summary}",
type = "text",
content = ""
};
var jsonContent = JsonSerializer.Serialize(noteData);
return new StringContent(jsonContent, null, "application/json");
}
}

48
Models/TriliumResponse.cs Normal file
View File

@@ -0,0 +1,48 @@

public class TriliumResponse
{
public Note note { get; set; }
public Branch branch { get; set; }
}
public class Note
{
public string noteId { get; set; }
public bool isProtected { get; set; }
public string title { get; set; }
public string type { get; set; }
public string mime { get; set; }
public string blobId { get; set; }
public string dateCreated { get; set; }
public string dateModified { get; set; }
public string utcDateCreated { get; set; }
public string utcDateModified { get; set; }
public string[] parentNoteIds { get; set; }
public object[] childNoteIds { get; set; }
public string[] parentBranchIds { get; set; }
public object[] childBranchIds { get; set; }
public Attribute[] attributes { get; set; }
}
public class Attribute
{
public string attributeId { get; set; }
public string noteId { get; set; }
public string type { get; set; }
public string name { get; set; }
public string value { get; set; }
public int position { get; set; }
public bool isInheritable { get; set; }
public string utcDateModified { get; set; }
}
public class Branch
{
public string branchId { get; set; }
public string noteId { get; set; }
public string parentNoteId { get; set; }
public object prefix { get; set; }
public int notePosition { get; set; }
public bool isExpanded { get; set; }
public string utcDateModified { get; set; }
}

View File

@@ -94,16 +94,20 @@ public class AppDbService
} }
#endregion Table: Configs #endregion Table: Configs
#region Table: JiraIssues
public async Task UpsertJiraIssueAsync(JiraIssue issue, CancellationToken ct = default) public async Task<(bool isInserted, bool isUpdated)> UpsertJiraIssueAsync(JiraIssue issue, CancellationToken ct = default)
{ {
try try
{ {
var existing = await _db.JiraIssues.FindAsync([issue.Key], ct); var existing = await _db.JiraIssues.FindAsync([issue.Key], ct);
bool isInserted = false;
bool isUpdated = false;
if (existing == null) if (existing == null)
{ {
_db.JiraIssues.Add(issue); _db.JiraIssues.Add(issue);
isInserted = true;
_log.Debug("Inserting new Jira issue: {key}", issue.Key); _log.Debug("Inserting new Jira issue: {key}", issue.Key);
} }
else else
@@ -122,6 +126,7 @@ public class AppDbService
existing.NeedNotify = issue.NeedNotify; existing.NeedNotify = issue.NeedNotify;
_db.JiraIssues.Update(existing); _db.JiraIssues.Update(existing);
isUpdated = true;
_log.Debug("Updating existing Jira issue: {key}", issue.Key); _log.Debug("Updating existing Jira issue: {key}", issue.Key);
} }
else else
@@ -131,6 +136,7 @@ public class AppDbService
} }
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return (isInserted, isUpdated);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -139,27 +145,29 @@ public class AppDbService
} }
} }
public async Task UpsertJiraIssuesBatchAsync(IEnumerable<JiraIssue> issues, CancellationToken ct = default) public async Task<(List<string> inserted, List<string> updated)> UpsertJiraIssuesBatchAsync(IEnumerable<JiraIssue> issues, CancellationToken ct = default)
{ {
try try
{ {
var issueList = issues.ToList(); var issueList = issues.ToList();
if (!issueList.Any()) if (!issueList.Any())
return; return (new List<string>(), new List<string>());
var keys = issueList.Select(i => i.Key).ToList(); var keys = issueList.Select(i => i.Key).ToList();
var existingIssues = await _db.JiraIssues var existingIssues = await _db.JiraIssues
.Where(ji => keys.Contains(ji.Key)) .Where(ji => keys.Contains(ji.Key))
.ToDictionaryAsync(ji => ji.Key, ct); .ToDictionaryAsync(ji => ji.Key, ct);
int insertCount = 0, updateCount = 0, skipCount = 0; var insertedKeys = new List<string>();
var updatedKeys = new List<string>();
int skipCount = 0;
foreach (var issue in issueList) foreach (var issue in issueList)
{ {
if (!existingIssues.TryGetValue(issue.Key, out var existing)) if (!existingIssues.TryGetValue(issue.Key, out var existing))
{ {
_db.JiraIssues.Add(issue); _db.JiraIssues.Add(issue);
insertCount++; insertedKeys.Add(issue.Key);
} }
else if (issue.Updated > existing.Updated) else if (issue.Updated > existing.Updated)
{ {
@@ -175,7 +183,7 @@ public class AppDbService
existing.NeedNotify = issue.NeedNotify; existing.NeedNotify = issue.NeedNotify;
_db.JiraIssues.Update(existing); _db.JiraIssues.Update(existing);
updateCount++; updatedKeys.Add(issue.Key);
} }
else else
{ {
@@ -185,7 +193,9 @@ public class AppDbService
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
_log.Information("Batch completed: {insert} inserted, {update} updated, {skip} skipped out of {total} issues", _log.Information("Batch completed: {insert} inserted, {update} updated, {skip} skipped out of {total} issues",
insertCount, updateCount, skipCount, issueList.Count); insertedKeys.Count, updatedKeys.Count, skipCount, issueList.Count);
return (insertedKeys, updatedKeys);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -193,4 +203,23 @@ public class AppDbService
throw; throw;
} }
} }
public async Task<List<JiraIssue>> GetUnpublishedJiraIssuesAsync(CancellationToken ct = default)
{
try
{
var unpublishedIssues = await _db.JiraIssues
.Where(ji => ji.Published < ji.Updated)
.ToListAsync(ct);
_log.Information("Found {count} unpublished Jira issues", unpublishedIssues.Count);
return unpublishedIssues;
}
catch (Exception ex)
{
_log.Error(ex, "Failed to get unpublished Jira issues");
throw;
}
}
#endregion Table: JiraIssues
} }

View File

@@ -24,6 +24,7 @@ public class JiraService
+ $"AND updated >= \"{lastFetchTime.ToString("yyyy-MM-dd HH:mm")}\" " + $"AND updated >= \"{lastFetchTime.ToString("yyyy-MM-dd HH:mm")}\" "
+ $"&fields={_configs.AppSettings.Jira.SearchFields}" + $"&fields={_configs.AppSettings.Jira.SearchFields}"
+ $"&startAt={startAt}&maxResults=50"; + $"&startAt={startAt}&maxResults=50";
Log.Verbose("Jira API Request URI: {uri}", uri);
var request = new HttpRequestMessage(HttpMethod.Get, uri); var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Add("Accept", "application/json"); request.Headers.Add("Accept", "application/json");
request.Headers.Add("Authorization", $"Bearer {_configs.AppSettings.Jira.AccessToken}"); request.Headers.Add("Authorization", $"Bearer {_configs.AppSettings.Jira.AccessToken}");

View File

@@ -1,10 +1,14 @@
using TriliumMind.Data.Entities; using System;
using System.Reflection.Metadata;
using TriliumMind.Data.Entities;
using TriliumMind.Models; using TriliumMind.Models;
namespace TriliumMind.Services.Mappers; namespace TriliumMind.Services.Mappers;
public static class JiraIssueMapper public static class JiraIssueMapper
{ {
private static readonly char[] _specialCharacters = ['(', '/'];
public static JiraIssue ToEntity(this Issue issue) public static JiraIssue ToEntity(this Issue issue)
{ {
return new JiraIssue return new JiraIssue
@@ -13,14 +17,25 @@ public static class JiraIssueMapper
Summary = issue.fields.summary ?? string.Empty, Summary = issue.fields.summary ?? string.Empty,
Parent = issue.fields.parent?.key ?? issue.fields.customfield_10808, Parent = issue.fields.parent?.key ?? issue.fields.customfield_10808,
Type = issue.fields.issuetype?.name ?? string.Empty, Type = issue.fields.issuetype?.name ?? string.Empty,
Status = issue.fields.status?.description ?? string.Empty, Status = issue.fields.status?.name ?? string.Empty,
Assignee = issue.fields.assignee?.displayName ?? string.Empty, Assignee = CleanDisplayName(issue.fields.assignee?.displayName),
Manager = issue.fields.reporter?.displayName ?? string.Empty, Manager = CleanDisplayName(issue.fields.reporter?.displayName),
Due = issue.fields.duedate?.ToUniversalTime() ?? DateTimeOffset.MinValue, Due = issue.fields.duedate?.ToUniversalTime() ?? DateTimeOffset.MinValue.ToUniversalTime(),
Updated = issue.fields.UpdatedAt, Updated = issue.fields.UpdatedAt.ToUniversalTime(),
Published = DateTimeOffset.MinValue, Published = DateTimeOffset.MinValue.ToUniversalTime(),
ObjectId = null, ObjectId = null,
NeedNotify = 0 NeedNotify = 0
}; };
} }
private static string CleanDisplayName(string? displayName)
{
if (string.IsNullOrWhiteSpace(displayName))
return string.Empty;
var specialCharIndex = displayName.IndexOfAny(_specialCharacters);
if (specialCharIndex >= 0)
displayName = displayName[..specialCharIndex];
return displayName.Trim();
}
} }

View File

@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text; using System.Text;
using TriliumMind.Data.Entities;
using TriliumMind.Models; using TriliumMind.Models;
namespace TriliumMind.Services; namespace TriliumMind.Services;
@@ -18,7 +19,73 @@ public class TriliumService
_log = Log.ForContext<TriliumService>(); _log = Log.ForContext<TriliumService>();
_configs = configs; _configs = configs;
} }
public async Task<string> FettchPageContentsAsync(Issue issue)
public async Task<IEnumerable<JiraIssue>> PublishNotesAsync(IEnumerable<JiraIssue> jiraIssues)
{
var issuesToPatch = new List<JiraIssue>();
var issuesToPost = new List<JiraIssue>();
foreach (var jiraIssue in jiraIssues)
{
if(jiraIssue.Published > DateTimeOffset.MinValue)
issuesToPatch.Add(jiraIssue);
else
issuesToPost.Add(jiraIssue);
break;
}
// Post note does not support attribute, so additional work is required.
var postedNotes = await PostNotesAsync(issuesToPost);
//issuesToPatch.AddRange(postedIssues);
//var patchedIssues = await PatchNotesAsync(issuesToPatch);
return issuesToPatch;
}
public async Task<IEnumerable<TriliumNote>> PostNotesAsync(IEnumerable<JiraIssue> jiraIssues)
{
var baseUrl = _configs.AppSettings.Trilium.EtapiBaseUrl;
var token = _configs.AppSettings.Trilium.EtapiToken;
var apiUrl = $"{baseUrl}/create-note";
var triliumNotes = new List<TriliumNote>();
var lastRequestTime = DateTimeOffset.MinValue;
foreach (var jiraIssue in jiraIssues)
{
var timeSinceLastRequest = DateTimeOffset.UtcNow - lastRequestTime;
if (timeSinceLastRequest < TimeSpan.FromSeconds(1))
{
var delayTime = TimeSpan.FromSeconds(1) - timeSinceLastRequest;
await Task.Delay(delayTime);
}
var note = new TriliumNote(jiraIssue, GetParentPageId(jiraIssue.Status));
var request = new HttpRequestMessage(HttpMethod.Post, apiUrl);
request.Headers.TryAddWithoutValidation("Authorization", token);
request.Content = note.ToNoteContent();
using var client = new HttpClient();
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
// Deserialize to TriliumResponse
var triliumResponse = await response.Content.ReadFromJsonAsync<TriliumResponse>();
lastRequestTime = DateTimeOffset.UtcNow;
note.ObjectId = triliumResponse?.note.noteId;
note.Published = lastRequestTime;
note.TriliumNoteData = triliumResponse;
triliumNotes.Add(note);
}
return triliumNotes;
}
public Task<IEnumerable<JiraIssue>> PatchNotesAsync(IEnumerable<JiraIssue> jiraIssues)
{
throw new NotImplementedException();
// Update attributes
}
public async Task<string> FetchPageContentsAsync(Issue issue)
{ {
var baseUrl = _configs.AppSettings.Trilium.EtapiBaseUrl; var baseUrl = _configs.AppSettings.Trilium.EtapiBaseUrl;
var pageId = "QxxFqCNAtIOy"; var pageId = "QxxFqCNAtIOy";
@@ -34,4 +101,26 @@ public class TriliumService
// Return new page ID after creating a new page // Return new page ID after creating a new page
return ""; return "";
} }
private string GetParentPageId(string issueStatus)
{
var readyPageId = _configs.AppSettings.Trilium.JiraOpenedIssuePageId;
var workingPageId = _configs.AppSettings.Trilium.JiraWorkingIssuePageId;
var resolvedPageId = _configs.AppSettings.Trilium.JiraResolvedIssuePageId;
string pageId = string.Empty;
if (issueStatus.Equals("Open", StringComparison.OrdinalIgnoreCase)
|| issueStatus.Equals("Reopend", StringComparison.OrdinalIgnoreCase))
{
pageId = readyPageId;
}
else if (issueStatus.Equals("In progress", StringComparison.OrdinalIgnoreCase))
{
pageId = workingPageId;
}
else
{
pageId = resolvedPageId;
}
return pageId;
}
} }

View File

@@ -78,7 +78,6 @@ public class AppDbWorker : BackgroundService
var db = scope.ServiceProvider.GetRequiredService<AppDbService>(); var db = scope.ServiceProvider.GetRequiredService<AppDbService>();
var jiraIssues = batch.Select(i => i.ToEntity()).ToList(); var jiraIssues = batch.Select(i => i.ToEntity()).ToList();
await db.UpsertJiraIssuesBatchAsync(jiraIssues, stoppingToken); await db.UpsertJiraIssuesBatchAsync(jiraIssues, stoppingToken);
_log.Information("Processed batch of {count} Jira issues", batch.Count); _log.Information("Processed batch of {count} Jira issues", batch.Count);
batch.Clear(); batch.Clear();
} }
@@ -109,20 +108,6 @@ public class AppDbWorker : BackgroundService
{ {
_log.Error(ex, "Fatal error in JiraDbWorker"); _log.Error(ex, "Fatal error in JiraDbWorker");
} }
//await foreach (var issue in _issueChannel.Reader.ReadAllAsync(stoppingToken))
//{
// try
// {
// var jiraIssue = issue.ToEntity();
// await _db.UpsertJiraIssueAsync(jiraIssue, stoppingToken);
// _log.Debug("Processed Jira issue: {key}", issue.key);
// }
// catch (Exception ex)
// {
// _log.Error(ex, "Failed to process Jira issue: {key}", issue.key);
// }
//}
_log.Information("AppDbWorker stopped"); _log.Information("AppDbWorker stopped");
} }
} }

View File

@@ -11,13 +11,16 @@ public class JiraWorker : BackgroundService
{ {
private readonly Serilog.ILogger _log; private readonly Serilog.ILogger _log;
private readonly AppConfigs _configs; private readonly AppConfigs _configs;
private readonly IServiceScopeFactory _scopeFactory; // Singleton services cannot directly inject Scoped services.
private readonly JiraService _jira; private readonly JiraService _jira;
private readonly Channel<Issue> _issueChannel; private readonly Channel<Issue> _issueChannel;
public JiraWorker(AppConfigs configs, JiraService jaraService, Channel<Issue> issueChannel) public JiraWorker(AppConfigs configs, IServiceScopeFactory serviceScopeFactory,
JiraService jaraService, Channel<Issue> issueChannel)
{ {
_log = Log.ForContext<JiraWorker>(); ; _log = Log.ForContext<JiraWorker>(); ;
_configs = configs; _configs = configs;
_scopeFactory = serviceScopeFactory;
_jira = jaraService; _jira = jaraService;
_issueChannel = issueChannel; _issueChannel = issueChannel;
} }
@@ -28,15 +31,27 @@ public class JiraWorker : BackgroundService
{ {
_log.Debug("Worker running at: {time}", DateTimeOffset.Now); _log.Debug("Worker running at: {time}", DateTimeOffset.Now);
var lastFetchTime = DateTimeOffset.Parse(_configs.RuntimeConfigs[Consts.ConfigLastFetchTimeKey]); var configLastFetchTime = _configs.RuntimeConfigs.GetValueOrDefault(Consts.ConfigLastFetchTimeKey);
if (string.IsNullOrEmpty(configLastFetchTime))
{
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbService>();
var lastFetchTimeText = await db.GetConfigAsync(Consts.ConfigLastFetchTimeKey);
configLastFetchTime = (lastFetchTimeText != null)
? lastFetchTimeText : DateTimeOffset.Now.AddDays(-1).ToString("yyyy-MM-ddTHH:mm:sszzz");
}
}
var lastFetchTime = DateTimeOffset.Parse(configLastFetchTime);
var issueList = new List<Issue>(); var issueList = new List<Issue>();
var currentFetchTime = DateTimeOffset.Now;
foreach (var targetProject in _configs.AppSettings.Jira.TargetProjects) foreach (var targetProject in _configs.AppSettings.Jira.TargetProjects)
{ {
int startAt = 0, total = 0; int startAt = 0, total = 0;
do do
{ {
var response = await _jira.FetchJiraIssuesAsync(DateTimeOffset.Now.AddDays(-1), targetProject, startAt, stoppingToken); var response = await _jira.FetchJiraIssuesAsync(lastFetchTime, targetProject, startAt, stoppingToken);
if (response != null) if (response != null)
{ {
total = response.total; total = response.total;
@@ -49,6 +64,14 @@ public class JiraWorker : BackgroundService
{ {
await _issueChannel.Writer.WriteAsync(item, stoppingToken); await _issueChannel.Writer.WriteAsync(item, stoppingToken);
} }
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbService>();
// Update last fetch time
await db.SetConfigAsync(Consts.ConfigLastFetchTimeKey, currentFetchTime.ToString("yyyy-MM-ddTHH:mm:sszzz"));
_configs.RuntimeConfigs[Consts.ConfigLastFetchTimeKey] = currentFetchTime.ToString("yyyy-MM-ddTHH:mm:sszzz");
}
await Task.Delay(_configs.AppSettings.Jira.FetchInterval * 1000, stoppingToken); await Task.Delay(_configs.AppSettings.Jira.FetchInterval * 1000, stoppingToken);
} }
} }

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Serilog; using Serilog;
using System.Threading.Channels; using System.Threading.Channels;
using TriliumMind.Data.Entities;
using TriliumMind.Models; using TriliumMind.Models;
using TriliumMind.Services; using TriliumMind.Services;
@@ -10,24 +11,33 @@ public class TriliumWorker : BackgroundService
{ {
private readonly Serilog.ILogger _log; private readonly Serilog.ILogger _log;
private readonly AppConfigs _config; private readonly AppConfigs _config;
private readonly TriliumService _triliumService; private readonly IServiceScopeFactory _scopeFactory; // Singleton services cannot directly inject Scoped services.
private readonly TriliumService _trilium;
private readonly Channel<Issue> _issueChannel; private readonly Channel<Issue> _issueChannel;
public TriliumWorker(AppConfigs configs, TriliumService triliumService, Channel<Issue> issueChannel) public TriliumWorker(AppConfigs configs, IServiceScopeFactory serviceScopeFactory,
TriliumService triliumService, Channel<Issue> issueChannel)
{ {
_log = Log.ForContext<TriliumWorker>(); _log = Log.ForContext<TriliumWorker>();
_config = configs; _config = configs;
_triliumService = triliumService; _scopeFactory = serviceScopeFactory;
_trilium = triliumService;
_issueChannel = issueChannel; _issueChannel = issueChannel;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
await foreach(var item in _issueChannel.Reader.ReadAllAsync(stoppingToken)) while (!stoppingToken.IsCancellationRequested)
{ {
_log.Information("Processing issue {issue_key} - {issue_summary}", item.key, item.fields.summary); _log.Debug("Worker running at: {time}", DateTimeOffset.Now);
// Add your processing logic here
//await _triliumService.FettchPageContentsAsync(item); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbService>();
var unpublishedIssues = await db.GetUnpublishedJiraIssuesAsync(stoppingToken);
if (unpublishedIssues != null)
await _trilium.PublishNotesAsync(unpublishedIssues);
await Task.Delay(10 *1000, stoppingToken);
} }
} }
} }