mirror of
https://github.com/yawaflua/Aoyo.Taiga.git
synced 2025-12-08 19:39:28 +02:00
Create project file
This commit is contained in:
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
61
.github/workflows/main.yml
vendored
Normal file
61
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: .NET CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "develop", "main" ]
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- '*.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Unit and Integration tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build
|
||||
run: dotnet build --no-restore
|
||||
- name: Test
|
||||
run: dotnet test --no-build --verbosity normal
|
||||
|
||||
compose:
|
||||
name: Push Docker image to ghcr.io
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
with:
|
||||
context: .
|
||||
file: Aoyo.Taiga/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
29
.github/workflows/nonmain.yml
vendored
Normal file
29
.github/workflows/nonmain.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: .NET CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "*" ]
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- '*.md'
|
||||
pull_request:
|
||||
branches: [ "*" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Unit and Integration tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build
|
||||
run: dotnet build --no-restore
|
||||
- name: Test
|
||||
run: dotnet test --no-build --verbosity normal
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
/packages/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
||||
22
Aoyo.Taiga.sln
Normal file
22
Aoyo.Taiga.sln
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aoyo.Taiga", "Aoyo.Taiga\Aoyo.Taiga.csproj", "{CAFC85B2-1606-44DF-A3CD-9DEAA07ADB78}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B8D94470-53F6-4C05-BADF-1A97910B3B74}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
compose.yaml = compose.yaml
|
||||
README.md = README.md
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{CAFC85B2-1606-44DF-A3CD-9DEAA07ADB78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CAFC85B2-1606-44DF-A3CD-9DEAA07ADB78}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CAFC85B2-1606-44DF-A3CD-9DEAA07ADB78}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CAFC85B2-1606-44DF-A3CD-9DEAA07ADB78}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
22
Aoyo.Taiga/Aoyo.Taiga.csproj
Normal file
22
Aoyo.Taiga/Aoyo.Taiga.csproj
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Discord.Net" Version="3.18.0" />
|
||||
<PackageReference Include="Discord.Net.WebSocket" Version="3.18.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
Aoyo.Taiga/Aoyo.Taiga.http
Normal file
6
Aoyo.Taiga/Aoyo.Taiga.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@Aoyo.Taiga_HostAddress = http://localhost:8080
|
||||
|
||||
GET {{Aoyo.Taiga_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
446
Aoyo.Taiga/Controllers/TaigaWebHook.cs
Normal file
446
Aoyo.Taiga/Controllers/TaigaWebHook.cs
Normal file
@@ -0,0 +1,446 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Aoyo.Taiga.WebHookTypes;
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Aoyo.Taiga.Controllers;
|
||||
|
||||
|
||||
[Route("api/v1/TaigaWebHook")]
|
||||
[ApiController]
|
||||
[SuppressMessage("ReSharper", "UnusedVariable")]
|
||||
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
||||
public class TaigaWebHook : Controller
|
||||
{
|
||||
private const string ChannelIdConfigurationPath = "Discord:Id";
|
||||
private const string TaigaKeyPath = "Taiga:Key";
|
||||
private readonly ulong _id;
|
||||
private ITextChannel? _logChannel;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly ILogger<TaigaWebHook> _logger;
|
||||
private readonly string _key;
|
||||
private IConfiguration _config;
|
||||
|
||||
public TaigaWebHook(DiscordSocketClient client, ILogger<TaigaWebHook> logger, IConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
_id = config.GetValue<ulong?>(ChannelIdConfigurationPath) ?? throw new NullReferenceException("Channel id should to be provided");
|
||||
_key = config.GetValue<string>(TaigaKeyPath) ?? throw new NullReferenceException("Taiga KEY should to be provided!") ;
|
||||
_logChannel = client.GetChannel(_id) as ITextChannel ?? throw new NullReferenceException("Channel not found.");
|
||||
}
|
||||
|
||||
[HttpPost("post")]
|
||||
public async Task<IActionResult> PostAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var signature = Request.Headers["X-TAIGA-WEBHOOK-SIGNATURE"].First()!;
|
||||
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var data = await reader.ReadToEndAsync();
|
||||
_logger.LogInformation(data);
|
||||
|
||||
if (_config.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Production")
|
||||
if (!VerifySignature(_key, data, signature))
|
||||
{
|
||||
return BadRequest("Invalid signature");
|
||||
}
|
||||
|
||||
|
||||
var webhookType = DetermineWebhookType(data);
|
||||
|
||||
switch (webhookType)
|
||||
{
|
||||
case "userstory":
|
||||
await HandleUserStoryWebhook(data);
|
||||
break;
|
||||
case "issue":
|
||||
await HandleIssueWebhook(data);
|
||||
break;
|
||||
case "task":
|
||||
await HandleTaskWebhook(data);
|
||||
break;
|
||||
default:
|
||||
_logger.LogWarning("Unsupported type of webhook: {WebhookType}", webhookType);
|
||||
return BadRequest($"Unsupported type of webhook: {webhookType}");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, exception.Message);
|
||||
return BadRequest(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifySignature(string key, string data, string signature)
|
||||
{
|
||||
using HMACSHA1 hmac = new HMACSHA1(Encoding.UTF8.GetBytes(key));
|
||||
var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||
var computedHash = Convert.ToHexString(hashBytes).ToLower();
|
||||
|
||||
return computedHash.Length == signature.Length;
|
||||
}
|
||||
|
||||
private async Task HandleUserStoryWebhook(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var webhook = TaigaWebhookParser.ParseUserStoryWebhook(json);
|
||||
var userStory = webhook.Data;
|
||||
var action = webhook.GetAction();
|
||||
|
||||
_logger.LogInformation("Handling User Story: {Subject} (ID: {Id}), Action: {Action}",
|
||||
userStory.Subject, userStory.Id, action);
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case WebhookAction.Create:
|
||||
await OnUserStoryCreated(userStory, webhook);
|
||||
break;
|
||||
case WebhookAction.Change:
|
||||
await OnUserStoryChanged(userStory, webhook);
|
||||
break;
|
||||
case WebhookAction.Delete:
|
||||
await OnUserStoryDeleted(userStory, webhook);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error when handled User Story webhook");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleIssueWebhook(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var webhook = TaigaWebhookParser.ParseIssueWebhook(json);
|
||||
var issue = webhook.Data;
|
||||
var action = webhook.GetAction();
|
||||
|
||||
_logger.LogInformation("Processing Issue: {Subject} (ID: {Id}), Action: {Action}",
|
||||
issue.Subject, issue.Id, action);
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case WebhookAction.Create:
|
||||
await OnIssueCreated(issue, webhook);
|
||||
break;
|
||||
case WebhookAction.Change:
|
||||
await OnIssueChanged(issue, webhook);
|
||||
break;
|
||||
case WebhookAction.Delete:
|
||||
await OnIssueDeleted(issue, webhook);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error when processing Issue webhook");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTaskWebhook(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var webhook = TaigaWebhookParser.ParseTaskWebhook(json);
|
||||
var task = webhook.Data;
|
||||
var action = webhook.GetAction();
|
||||
|
||||
_logger.LogInformation("Processing Task: {Subject} (ID: {Id}), Action: {Action}",
|
||||
task.Subject, task.Id, action);
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case WebhookAction.Create:
|
||||
await OnTaskCreated(task, webhook);
|
||||
break;
|
||||
case WebhookAction.Change:
|
||||
await OnTaskChanged(task, webhook);
|
||||
break;
|
||||
case WebhookAction.Delete:
|
||||
await OnTaskDeleted(task, webhook);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error when processing Task webhook");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnUserStoryCreated(TaigaUserStory userStory, UserStoryWebhookPayload? webhook)
|
||||
{
|
||||
_logger.LogInformation("Created new User Story: {Subject} in project {ProjectName}",
|
||||
userStory.Subject, userStory.Project.Name);
|
||||
|
||||
await SendDiscordNotification(new DiscordDTO
|
||||
{
|
||||
By = webhook.By,
|
||||
Action = "Created",
|
||||
Type = "User Story",
|
||||
Date = webhook.Date,
|
||||
Description = $"**{userStory.Subject}**\n" +
|
||||
$"> Project: {userStory.Project.Name}\n" +
|
||||
$"> Status: {userStory.Status.Name}\n" +
|
||||
$"> Points: {userStory.TotalPoints}",
|
||||
Color = Color.Green
|
||||
});
|
||||
}
|
||||
|
||||
private async Task OnUserStoryChanged(TaigaUserStory userStory, UserStoryWebhookPayload? webhook)
|
||||
{
|
||||
_logger.LogInformation("Changed User Story: {Subject}", userStory.Subject);
|
||||
|
||||
var changes = "";
|
||||
if (webhook.Change?.Diff != null)
|
||||
{
|
||||
foreach (var change in webhook.Change.Diff)
|
||||
{
|
||||
changes += $"• {change.Key}: {change.Value.From} → {change.Value.To}\n";
|
||||
}
|
||||
}
|
||||
|
||||
await SendDiscordNotification(new DiscordDTO
|
||||
{
|
||||
By = webhook.By,
|
||||
Action = "Changed",
|
||||
Type = "User Story",
|
||||
Date = webhook.Date,
|
||||
Description = $"**{userStory.Subject}**\n" +
|
||||
$"> Project: {userStory.Project.Name}\n" +
|
||||
$"> Changes:\n{changes}",
|
||||
Color = Color.LightOrange
|
||||
});
|
||||
}
|
||||
|
||||
private async Task OnUserStoryDeleted(TaigaUserStory userStory, UserStoryWebhookPayload? webhook)
|
||||
{
|
||||
_logger.LogInformation("Deleted User Story: {Subject} (ID: {Id})",
|
||||
userStory.Subject, userStory.Id);
|
||||
|
||||
await SendDiscordNotification(new DiscordDTO
|
||||
{
|
||||
By = webhook.By,
|
||||
Action = "Deleted",
|
||||
Type = "User Story",
|
||||
Date = webhook.Date,
|
||||
Description = $"**{userStory.Subject}**\n" +
|
||||
$"> Project: {userStory.Project.Name}\n" +
|
||||
$"> ID: {userStory.Id}",
|
||||
Color = Color.DarkRed
|
||||
});
|
||||
}
|
||||
|
||||
// Методы для обработки Issue событий
|
||||
private async Task OnIssueCreated(TaigaIssue issue, IssueWebhookPayload? webhook)
|
||||
{
|
||||
_logger.LogInformation("Created new Issue: {Subject} in project {ProjectName}",
|
||||
issue.Subject, issue.Project.Name);
|
||||
|
||||
await SendDiscordNotification(new DiscordDTO
|
||||
{
|
||||
By = webhook.By,
|
||||
Action = "Created",
|
||||
Type = "Issue",
|
||||
Date = webhook.Date,
|
||||
Description = $"**{issue.Subject}**\n" +
|
||||
$"> Project: {issue.Project.Name}\n" +
|
||||
$"> Type: {issue.Type.Name}\n" +
|
||||
$"> Prority: {issue.Priority.Name}",
|
||||
Color = Color.Blue
|
||||
});
|
||||
}
|
||||
|
||||
private async Task OnIssueChanged(TaigaIssue issue, IssueWebhookPayload? webhook)
|
||||
{
|
||||
_logger.LogInformation("Changed Issue: {Subject}", issue.Subject);
|
||||
|
||||
var changes = "";
|
||||
if (webhook.Change?.Diff != null)
|
||||
{
|
||||
foreach (var change in webhook.Change.Diff)
|
||||
{
|
||||
changes += $"> • {change.Key}: {change.Value.From} → {change.Value.To}\n";
|
||||
}
|
||||
}
|
||||
|
||||
await SendDiscordNotification(new DiscordDTO
|
||||
{
|
||||
By = webhook.By,
|
||||
Action = "Changed",
|
||||
Type = "Issue",
|
||||
Date = webhook.Date,
|
||||
Description = $"**{issue.Subject}**\n" +
|
||||
$"> Project: {issue.Project.Name}\n" +
|
||||
$"> Changes:\n{changes}",
|
||||
Color = Color.LightOrange
|
||||
});
|
||||
}
|
||||
|
||||
private async Task OnIssueDeleted(TaigaIssue issue, IssueWebhookPayload? webhook)
|
||||
{
|
||||
_logger.LogInformation("Deleted Issue: {Subject} (ID: {Id})",
|
||||
issue.Subject, issue.Id);
|
||||
|
||||
await SendDiscordNotification(new DiscordDTO
|
||||
{
|
||||
By = webhook.By,
|
||||
Action = "Deleted",
|
||||
Type = "Issue",
|
||||
Date = webhook.Date,
|
||||
Description = $"**{issue.Subject}**\n" +
|
||||
$"> Project: {issue.Project.Name}\n" +
|
||||
$"> ID: {issue.Id}",
|
||||
Color = Color.Red
|
||||
});
|
||||
}
|
||||
|
||||
private async Task OnTaskCreated(TaigaTask task, TaskWebhookPayload? webhook)
|
||||
{
|
||||
_logger.LogInformation("Created new Task: {Subject} in project {ProjectName}",
|
||||
task.Subject, task.Project.Name);
|
||||
|
||||
var description = $"**{task.Subject}**\n" +
|
||||
$"> Project: {task.Project.Name}\n" +
|
||||
$"> Исполнитель: {task.AssignedTo?.FullName ?? "Не назначен"}";
|
||||
|
||||
if (task.UserStory != null)
|
||||
{
|
||||
description += $"\n> User Story: {task.UserStory.Subject} (#{task.UserStory.Ref})";
|
||||
}
|
||||
|
||||
await SendDiscordNotification(new DiscordDTO
|
||||
{
|
||||
By = webhook.By,
|
||||
Action = "Created",
|
||||
Type = "Task",
|
||||
Date = webhook.Date,
|
||||
Description = description,
|
||||
Color = Color.Green
|
||||
});
|
||||
}
|
||||
|
||||
private async Task OnTaskChanged(TaigaTask task, TaskWebhookPayload? webhook)
|
||||
{
|
||||
_logger.LogInformation("Changed Task: {Subject}", task.Subject);
|
||||
|
||||
var changes = "";
|
||||
if (webhook.Change?.Diff != null)
|
||||
{
|
||||
foreach (var change in webhook.Change.Diff)
|
||||
{
|
||||
changes += $"• {change.Key}: {change.Value.From} → {change.Value.To}\n";
|
||||
}
|
||||
}
|
||||
|
||||
await SendDiscordNotification(new DiscordDTO
|
||||
{
|
||||
By = webhook.By,
|
||||
Action = "Changed",
|
||||
Type = "Task",
|
||||
Date = webhook.Date,
|
||||
Description = $"**{task.Subject}**\n" +
|
||||
$"> Project: {task.Project.Name}\n" +
|
||||
$"> Changes:\n{changes}",
|
||||
Color = Color.Orange
|
||||
});
|
||||
}
|
||||
|
||||
private async Task OnTaskDeleted(TaigaTask task, TaskWebhookPayload? webhook)
|
||||
{
|
||||
_logger.LogInformation("Deleted Task: {Subject} (ID: {Id})",
|
||||
task.Subject, task.Id);
|
||||
|
||||
await SendDiscordNotification(new DiscordDTO
|
||||
{
|
||||
By = webhook.By,
|
||||
Action = "Deleted",
|
||||
Type = "Task",
|
||||
Date = webhook.Date,
|
||||
Description = $"**{task.Subject}**\n" +
|
||||
$"> Project: {task.Project.Name}\n" +
|
||||
$"> ID: {task.Id}",
|
||||
Color = Color.Red
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SendDiscordNotification(DiscordDTO payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logChannel ??= _client.GetChannel(_id) as ITextChannel;
|
||||
|
||||
if (_logChannel == null)
|
||||
{
|
||||
_logger.LogError("Не удалось получить Discord канал с ID: {ChannelId}", _id);
|
||||
return;
|
||||
}
|
||||
|
||||
await _logChannel.SendMessageAsync(embed:
|
||||
new EmbedBuilder()
|
||||
.WithAuthor(payload.By.Username, payload.By.Photo)
|
||||
.WithTitle($"{payload.Action} {payload.Type.ToLower()}")
|
||||
.WithDescription(payload.Description)
|
||||
.WithColor(payload.Color)
|
||||
.WithTimestamp(payload.Date)
|
||||
.Build());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error when sending message into Discord");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private string DetermineWebhookType(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (root.TryGetProperty("type", out var typeElement))
|
||||
{
|
||||
return typeElement.GetString()?.ToLower()!;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("data", out var dataElement))
|
||||
{
|
||||
if (dataElement.TryGetProperty("points", out _))
|
||||
{
|
||||
return "userstory";
|
||||
}
|
||||
else if (dataElement.TryGetProperty("type", out _) && dataElement.TryGetProperty("priority", out _))
|
||||
{
|
||||
return "issue";
|
||||
}
|
||||
else if (dataElement.TryGetProperty("user_story", out _))
|
||||
{
|
||||
return "task";
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Cannot parse this type of webhook");
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
51
Aoyo.Taiga/Discord/DiscordHandler.cs
Normal file
51
Aoyo.Taiga/Discord/DiscordHandler.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Reflection;
|
||||
using Discord;
|
||||
using Discord.Interactions;
|
||||
using Discord.WebSocket;
|
||||
|
||||
namespace Aoyo.Taiga.Discord;
|
||||
|
||||
public class DiscordHandler(
|
||||
DiscordSocketClient client,
|
||||
InteractionService interactionService,
|
||||
ILogger<DiscordHandler> logger,
|
||||
IServiceScope provider)
|
||||
{
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
client.Ready += ClientOnReady;
|
||||
client.Log += ClientOnLog;
|
||||
await interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), provider.ServiceProvider);
|
||||
client.InteractionCreated += ClientOnInteractionCreated;
|
||||
}
|
||||
|
||||
private async Task ClientOnInteractionCreated(SocketInteraction arg)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = new SocketInteractionContext(client, arg);
|
||||
var result = await interactionService.ExecuteCommandAsync(context, provider.ServiceProvider);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
throw new Exception($"{result.ErrorReason}: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await arg.RespondAsync(":x: " + ex.Message + "", ephemeral: true);
|
||||
logger.LogError(ex, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private Task ClientOnLog(LogMessage arg)
|
||||
{
|
||||
logger.LogInformation(exception: arg.Exception, message:arg.Message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ClientOnReady()
|
||||
{
|
||||
await interactionService.RegisterCommandsGloballyAsync();
|
||||
}
|
||||
}
|
||||
27
Aoyo.Taiga/Discord/DiscordService.cs
Normal file
27
Aoyo.Taiga/Discord/DiscordService.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
|
||||
namespace Aoyo.Taiga.Discord;
|
||||
|
||||
public class DiscordService(
|
||||
DiscordSocketClient client,
|
||||
IConfiguration configuration,
|
||||
ILogger<DiscordService> logger,
|
||||
DiscordHandler interactionHandler)
|
||||
: BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
client.Log += ClientOnLog;
|
||||
await client.LoginAsync(TokenType.Bot, configuration.GetValue<string>("Discord:Token"));
|
||||
await client.StartAsync();
|
||||
await interactionHandler.InitializeAsync();
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
|
||||
private Task ClientOnLog(LogMessage arg)
|
||||
{
|
||||
logger.LogInformation(exception: arg.Exception, message:arg.Message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
39
Aoyo.Taiga/Dockerfile
Normal file
39
Aoyo.Taiga/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Aoyo.Taiga/Aoyo.Taiga.csproj", "Aoyo.Taiga/"]
|
||||
RUN dotnet restore "Aoyo.Taiga/Aoyo.Taiga.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/Aoyo.Taiga"
|
||||
RUN dotnet build "./Aoyo.Taiga.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./Aoyo.Taiga.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl && \
|
||||
rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD curl -f http://localhost:8080/aoyo/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "Aoyo.Taiga.dll"]
|
||||
|
||||
LABEL org.opencontainers.image.title="Aoyo.Taiga" \
|
||||
org.opencontainers.image.description="Taiga`s webhook catcher" \
|
||||
org.opencontainers.image.version="1.0.0" \
|
||||
org.opencontainers.image.url="https://github.com/yawaflua/Aoyo.Taiga" \
|
||||
org.opencontainers.image.source="https://github.com/yawaflua/Aoyo.Taiga" \
|
||||
org.opencontainers.image.licenses="Apache-2.0" \
|
||||
org.opencontainers.image.authors="Dmitrii Shimanskii <yawaflua@outlook.co.il>" \
|
||||
org.opencontainers.image.created="2025-07-29T12:00:00Z" \
|
||||
org.opencontainers.image.vendor="Diamond Studio"
|
||||
18
Aoyo.Taiga/Program.cs
Normal file
18
Aoyo.Taiga/Program.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore;
|
||||
|
||||
namespace Aoyo.Taiga;
|
||||
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
WebHost.
|
||||
CreateDefaultBuilder(args).
|
||||
UseStartup<Startup>().
|
||||
UseKestrel(
|
||||
l =>
|
||||
l.ListenAnyIP(8080)
|
||||
).
|
||||
Build().Run();
|
||||
}
|
||||
}
|
||||
23
Aoyo.Taiga/Properties/launchSettings.json
Normal file
23
Aoyo.Taiga/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:8080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"http_release": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:8080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Release"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
Aoyo.Taiga/Startup.cs
Normal file
53
Aoyo.Taiga/Startup.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Aoyo.Taiga.Discord;
|
||||
using Discord;
|
||||
using Discord.Interactions;
|
||||
using Discord.WebSocket;
|
||||
|
||||
namespace Aoyo.Taiga;
|
||||
|
||||
public class Startup(IConfiguration configuration)
|
||||
{
|
||||
private readonly DiscordSocketConfig _socketConfig = new()
|
||||
{
|
||||
GatewayIntents = GatewayIntents.All,
|
||||
AlwaysDownloadUsers = true
|
||||
};
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
var factory = LoggerFactory.Create(builder => builder.AddFilter(k => k >=
|
||||
(configuration.GetValue<LogLevel?>("Logging:LogLevel:Default") ??
|
||||
LogLevel.Information)).AddConsole());
|
||||
services.AddControllers();
|
||||
services.AddHttpLogging();
|
||||
services.AddSingleton(factory);
|
||||
services.AddRouting();
|
||||
services.AddSingleton(_socketConfig);
|
||||
services.AddSingleton(configuration);
|
||||
services.AddSingleton<DiscordSocketClient>();
|
||||
services.AddSingleton(x => new InteractionService(x.GetRequiredService<DiscordSocketClient>()));
|
||||
services.AddSingleton<DiscordHandler>();
|
||||
services.AddSingleton(x => x.CreateScope());
|
||||
services.AddHostedService<DiscordService>();
|
||||
services.AddHealthChecks();
|
||||
}
|
||||
|
||||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||||
{
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
}
|
||||
|
||||
app.UsePathBase("/aoyo/");
|
||||
app.UseHealthChecks("/health");
|
||||
app.UseHttpLogging();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
app.UseEndpoints(endpoints => {
|
||||
endpoints.MapControllers();
|
||||
endpoints.MapHealthChecks("/heath");
|
||||
});
|
||||
}
|
||||
}
|
||||
54
Aoyo.Taiga/WebHookTypes/BaseTaigaItem.cs
Normal file
54
Aoyo.Taiga/WebHookTypes/BaseTaigaItem.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public abstract class BaseTaigaItem : ITaigaItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("ref")]
|
||||
public int Ref { get; set; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string Subject { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonPropertyName("created_date")]
|
||||
public DateTime CreatedDate { get; set; }
|
||||
|
||||
[JsonPropertyName("modified_date")]
|
||||
public DateTime? ModifiedDate { get; set; }
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public TaigaUser Owner { get; set; }
|
||||
|
||||
[JsonPropertyName("assigned_to")]
|
||||
public TaigaUser? AssignedTo { get; set; }
|
||||
|
||||
[JsonPropertyName("project")]
|
||||
public TaigaProject Project { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public TaigaStatus Status { get; set; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; } = new List<string>();
|
||||
|
||||
[JsonPropertyName("is_closed")]
|
||||
public bool IsClosed { get; set; }
|
||||
|
||||
[JsonPropertyName("is_blocked")]
|
||||
public bool IsBlocked { get; set; }
|
||||
|
||||
[JsonPropertyName("blocked_note")]
|
||||
public string BlockedNote { get; set; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
[JsonPropertyName("watchers")]
|
||||
public List<int> Watchers { get; set; } = new List<int>();
|
||||
}
|
||||
13
Aoyo.Taiga/WebHookTypes/DiscordDTO.cs
Normal file
13
Aoyo.Taiga/WebHookTypes/DiscordDTO.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Discord;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class DiscordDTO
|
||||
{
|
||||
public TaigaUser By { get; set; }
|
||||
public string Action { get; set; }
|
||||
public string Type { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public string Description { get; set; }
|
||||
public Color Color { get; set; }
|
||||
}
|
||||
55
Aoyo.Taiga/WebHookTypes/ITaigaItem.cs
Normal file
55
Aoyo.Taiga/WebHookTypes/ITaigaItem.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public interface ITaigaItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("ref")]
|
||||
public int Ref { get; set; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string Subject { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonPropertyName("created_date")]
|
||||
public DateTime CreatedDate { get; set; }
|
||||
|
||||
[JsonPropertyName("modified_date")]
|
||||
public DateTime? ModifiedDate { get; set; }
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public TaigaUser Owner { get; set; }
|
||||
|
||||
[JsonPropertyName("assigned_to")]
|
||||
public TaigaUser AssignedTo { get; set; }
|
||||
|
||||
[JsonPropertyName("project")]
|
||||
public TaigaProject Project { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public TaigaStatus Status { get; set; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; }
|
||||
|
||||
[JsonPropertyName("is_closed")]
|
||||
public bool IsClosed { get; set; }
|
||||
|
||||
[JsonPropertyName("is_blocked")]
|
||||
public bool IsBlocked { get; set; }
|
||||
|
||||
[JsonPropertyName("blocked_note")]
|
||||
public string BlockedNote { get; set; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
[JsonPropertyName("watchers")]
|
||||
public List<int> Watchers { get; set; }
|
||||
|
||||
}
|
||||
5
Aoyo.Taiga/WebHookTypes/Payloads.cs
Normal file
5
Aoyo.Taiga/WebHookTypes/Payloads.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class UserStoryWebhookPayload : TaigaWebhookPayload<TaigaUserStory> { }
|
||||
public class IssueWebhookPayload : TaigaWebhookPayload<TaigaIssue> { }
|
||||
public class TaskWebhookPayload : TaigaWebhookPayload<TaigaTask> { }
|
||||
15
Aoyo.Taiga/WebHookTypes/TaigaChange.cs
Normal file
15
Aoyo.Taiga/WebHookTypes/TaigaChange.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaChange
|
||||
{
|
||||
[JsonPropertyName("comment")]
|
||||
public string Comment { get; set; }
|
||||
|
||||
[JsonPropertyName("comment_html")]
|
||||
public string CommentHtml { get; set; }
|
||||
|
||||
[JsonPropertyName("diff")]
|
||||
public Dictionary<string, TaigaFieldChange> Diff { get; set; } = new Dictionary<string, TaigaFieldChange>();
|
||||
}
|
||||
12
Aoyo.Taiga/WebHookTypes/TaigaFieldChange.cs
Normal file
12
Aoyo.Taiga/WebHookTypes/TaigaFieldChange.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaFieldChange
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public object From { get; set; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public object To { get; set; }
|
||||
}
|
||||
30
Aoyo.Taiga/WebHookTypes/TaigaIssue.cs
Normal file
30
Aoyo.Taiga/WebHookTypes/TaigaIssue.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaIssue : BaseTaigaItem
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public TaigaType Type { get; set; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public TaigaPriority Priority { get; set; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public TaigaSeverity Severity { get; set; }
|
||||
|
||||
[JsonPropertyName("milestone")]
|
||||
public TaigaMilestone Milestone { get; set; }
|
||||
|
||||
[JsonPropertyName("generated_user_stories")]
|
||||
public List<TaigaUserStory> GeneratedUserStories { get; set; } = new List<TaigaUserStory>();
|
||||
|
||||
[JsonPropertyName("external_reference")]
|
||||
public List<string> ExternalReference { get; set; } = new List<string>();
|
||||
|
||||
[JsonPropertyName("due_date")]
|
||||
public DateTime? DueDate { get; set; }
|
||||
|
||||
[JsonPropertyName("due_date_reason")]
|
||||
public string DueDateReason { get; set; }
|
||||
}
|
||||
21
Aoyo.Taiga/WebHookTypes/TaigaMilestone.cs
Normal file
21
Aoyo.Taiga/WebHookTypes/TaigaMilestone.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaMilestone
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; }
|
||||
|
||||
[JsonPropertyName("estimated_start")]
|
||||
public DateTime? EstimatedStart { get; set; }
|
||||
|
||||
[JsonPropertyName("estimated_finish")]
|
||||
public DateTime? EstimatedFinish { get; set; }
|
||||
}
|
||||
13
Aoyo.Taiga/WebHookTypes/TaigaPoint.cs
Normal file
13
Aoyo.Taiga/WebHookTypes/TaigaPoint.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaPoint
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
[JsonPropertyName("value")]
|
||||
public float? Value { get; set; }
|
||||
}
|
||||
18
Aoyo.Taiga/WebHookTypes/TaigaPriority.cs
Normal file
18
Aoyo.Taiga/WebHookTypes/TaigaPriority.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaPriority
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("color")]
|
||||
public string Color { get; set; }
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; set; }
|
||||
}
|
||||
18
Aoyo.Taiga/WebHookTypes/TaigaProject.cs
Normal file
18
Aoyo.Taiga/WebHookTypes/TaigaProject.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaProject
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
}
|
||||
18
Aoyo.Taiga/WebHookTypes/TaigaSeverity.cs
Normal file
18
Aoyo.Taiga/WebHookTypes/TaigaSeverity.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaSeverity
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("color")]
|
||||
public string Color { get; set; }
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; set; }
|
||||
}
|
||||
18
Aoyo.Taiga/WebHookTypes/TaigaStatus.cs
Normal file
18
Aoyo.Taiga/WebHookTypes/TaigaStatus.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaStatus
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("color")]
|
||||
public string Color { get; set; }
|
||||
|
||||
[JsonPropertyName("is_closed")]
|
||||
public bool IsClosed { get; set; }
|
||||
}
|
||||
22
Aoyo.Taiga/WebHookTypes/TaigaTask.cs
Normal file
22
Aoyo.Taiga/WebHookTypes/TaigaTask.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaTask : BaseTaigaItem
|
||||
{
|
||||
[JsonPropertyName("user_story")]
|
||||
public TaigaUserStory? UserStory { get; set; }
|
||||
|
||||
[JsonPropertyName("milestone")]
|
||||
public TaigaMilestone Milestone { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("external_reference")]
|
||||
public List<string> ExternalReference { get; set; } = new List<string>();
|
||||
|
||||
[JsonPropertyName("due_date")]
|
||||
public DateTime? DueDate { get; set; }
|
||||
|
||||
[JsonPropertyName("due_date_reason")]
|
||||
public string DueDateReason { get; set; }
|
||||
}
|
||||
15
Aoyo.Taiga/WebHookTypes/TaigaType.cs
Normal file
15
Aoyo.Taiga/WebHookTypes/TaigaType.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaType
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("color")]
|
||||
public string Color { get; set; }
|
||||
}
|
||||
21
Aoyo.Taiga/WebHookTypes/TaigaUser.cs
Normal file
21
Aoyo.Taiga/WebHookTypes/TaigaUser.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaUser
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; }
|
||||
|
||||
[JsonPropertyName("full_name")]
|
||||
public string FullName { get; set; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string Email { get; set; }
|
||||
|
||||
[JsonPropertyName("photo")]
|
||||
public string Photo { get; set; }
|
||||
}
|
||||
45
Aoyo.Taiga/WebHookTypes/TaigaUserStory.cs
Normal file
45
Aoyo.Taiga/WebHookTypes/TaigaUserStory.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaUserStory : BaseTaigaItem
|
||||
{
|
||||
[JsonPropertyName("points")]
|
||||
public List<TaigaPoint> Points { get; set; } = new ();
|
||||
|
||||
[JsonPropertyName("total_points")]
|
||||
public float? TotalPoints { get; set; }
|
||||
|
||||
[JsonPropertyName("milestone")]
|
||||
public TaigaMilestone Milestone { get; set; }
|
||||
|
||||
[JsonPropertyName("client_requirement")]
|
||||
public bool ClientRequirement { get; set; }
|
||||
|
||||
[JsonPropertyName("team_requirement")]
|
||||
public bool TeamRequirement { get; set; }
|
||||
|
||||
[JsonPropertyName("generated_from_issue")]
|
||||
public int? GeneratedFromIssue { get; set; }
|
||||
|
||||
[JsonPropertyName("generated_from_task")]
|
||||
public int? GeneratedFromTask { get; set; }
|
||||
|
||||
[JsonPropertyName("from_task_ref")]
|
||||
public int? FromTaskRef { get; set; }
|
||||
|
||||
[JsonPropertyName("external_reference")]
|
||||
public List<string> ExternalReference { get; set; } = new List<string>();
|
||||
|
||||
[JsonPropertyName("tribe_gig")]
|
||||
public string TribeGig { get; set; }
|
||||
|
||||
[JsonPropertyName("kanban_order")]
|
||||
public int KanbanOrder { get; set; }
|
||||
|
||||
[JsonPropertyName("sprint_order")]
|
||||
public int SprintOrder { get; set; }
|
||||
|
||||
[JsonPropertyName("backlog_order")]
|
||||
public int BacklogOrder { get; set; }
|
||||
}
|
||||
19
Aoyo.Taiga/WebHookTypes/TaigaWebhookParser.cs
Normal file
19
Aoyo.Taiga/WebHookTypes/TaigaWebhookParser.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaWebhookParser
|
||||
{
|
||||
public static UserStoryWebhookPayload? ParseUserStoryWebhook(string json)
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<UserStoryWebhookPayload>(json);
|
||||
}
|
||||
|
||||
public static IssueWebhookPayload? ParseIssueWebhook(string json)
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<IssueWebhookPayload>(json);
|
||||
}
|
||||
|
||||
public static TaskWebhookPayload? ParseTaskWebhook(string json)
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<TaskWebhookPayload>(json);
|
||||
}
|
||||
}
|
||||
35
Aoyo.Taiga/WebHookTypes/TaigaWebhookPayload.cs
Normal file
35
Aoyo.Taiga/WebHookTypes/TaigaWebhookPayload.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aoyo.Taiga.WebHookTypes;
|
||||
|
||||
public class TaigaWebhookPayload<T> where T : ITaigaItem
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonPropertyName("by")]
|
||||
public TaigaUser By { get; set; }
|
||||
|
||||
[JsonPropertyName("date")]
|
||||
public DateTime Date { get; set; }
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
public T Data { get; set; }
|
||||
|
||||
[JsonPropertyName("change")]
|
||||
public TaigaChange? Change { get; set; }
|
||||
|
||||
public WebhookAction GetAction()
|
||||
{
|
||||
return Action?.ToLower() switch
|
||||
{
|
||||
"create" => WebhookAction.Create,
|
||||
"change" => WebhookAction.Change,
|
||||
"delete" => WebhookAction.Delete,
|
||||
_ => WebhookAction.Change
|
||||
};
|
||||
}
|
||||
}
|
||||
10
Aoyo.Taiga/WebHookTypes/WebhookAction.cs
Normal file
10
Aoyo.Taiga/WebHookTypes/WebhookAction.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Aoyo.Taiga.WebHookTypes
|
||||
{
|
||||
public enum WebhookAction
|
||||
{
|
||||
Create,
|
||||
Change,
|
||||
Delete,
|
||||
Test
|
||||
}
|
||||
}
|
||||
8
Aoyo.Taiga/appsettings.Development.json
Normal file
8
Aoyo.Taiga/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Aoyo.Taiga/appsettings.json
Normal file
9
Aoyo.Taiga/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
201
LICENCE
Normal file
201
LICENCE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 Dmitri Shimanski
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Aoyo.Taiga
|
||||
|
||||
**Aoyo.Taiga** is a lightweight C# application that listens for webhooks from [Taiga](https://taiga.io/) and sends a notification to a specified Discord channel via a bot.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Receives webhook events from Taiga.
|
||||
- Sends formatted messages to a designated Discord channel.
|
||||
- Lightweight and easy to run (locally or in a container).
|
||||
- Supports configuration via `environment variables` or `appsettings.json`.
|
||||
|
||||
## ⚙️ Required Configuration
|
||||
|
||||
The app requires the following configuration keys to function:
|
||||
|
||||
### Environment variables (`ENV`)
|
||||
- `Discord__Token` – your Discord bot token.
|
||||
- `Discord__Id` – target Discord channel ID.
|
||||
- `Taiga__Key` – secret key to validate incoming Taiga webhooks.
|
||||
|
||||
### OR `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Discord": {
|
||||
"Token": "your_discord_token",
|
||||
"Id": "your_channel_id"
|
||||
},
|
||||
"Taiga": {
|
||||
"Key": "your_taiga_webhook_secret"
|
||||
}
|
||||
}
|
||||
```
|
||||
Only one method of configuration is required. You can choose between env vars or appsettings.json.
|
||||
🐳 Run with Docker
|
||||
|
||||
You can pull and run the prebuilt image from GitHub Container Registry (GHCR):
|
||||
```bash
|
||||
docker run -d \
|
||||
-e Discord__Token=your_token \
|
||||
-e Discord__Id=your_channel_id \
|
||||
-e Taiga__Key=your_webhook_key \
|
||||
-p 8080:80 \
|
||||
ghcr.io/yawaflua/aoyo.taiga:latest
|
||||
```
|
||||
|
||||
🧪 Run Locally
|
||||
|
||||
Make sure you have the .NET SDK installed.
|
||||
1. Clone the repo
|
||||
```shell
|
||||
git clone https://github.com/yawaflua/Aoyo.Taiga.git
|
||||
```
|
||||
cd Aoyo.Taiga
|
||||
|
||||
2. Configure credentials
|
||||
|
||||
Either set env variables or fill in appsettings.json.
|
||||
3. Run the app
|
||||
|
||||
```bash
|
||||
dotnet run --project Aoyo.Taiga
|
||||
```
|
||||
|
||||
By default, it will listen on http://localhost:8080/aoyo.
|
||||
🚀 Behavior
|
||||
|
||||
Once a webhook is sent from Taiga (with the correct key), the app will verify it and forward a message to the Discord channel defined by the Discord__Id.
|
||||
📫 Webhook Endpoint
|
||||
|
||||
You can point your Taiga webhook to:
|
||||
|
||||
`POST https://example.mycooldomain.co.il/aoyo/api/v1/TaigaWebHook/post`
|
||||
|
||||
Make sure to include the Taiga__Key as a query parameter or in the header to pass validation.
|
||||
|
||||
Project can be used under [Apache 2.0](LICENCE)
|
||||
|
||||
Reference in New Issue
Block a user