Initial commit.
Linting and tests / Linting (push) Failing after 30s
Linting and tests / Tests (push) Successful in 25s

This commit is contained in:
2026-06-10 10:23:00 +05:00
commit 3d43b8a84e
34 changed files with 1325 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
---
name: "Linting and tests"
run-name: "Linting and tests"
on:
- push
jobs:
Linting:
runs-on: "ubuntu-22.04"
steps:
- name: "checkout source"
uses: actions/checkout@v4
- name: "lint"
uses: docker://code.pztrn.name/containers/go-toolbox:v10
with:
command: "/usr/local/bin/task lint"
Tests:
runs-on: "ubuntu-22.04"
steps:
- name: "checkout source"
uses: actions/checkout@v4
- name: "tests"
uses: docker://code.pztrn.name/containers/go-toolbox:v10
with:
command: "/usr/local/bin/task test"
+3
View File
@@ -0,0 +1,3 @@
*DS_Store*
_build
.task
+93
View File
@@ -0,0 +1,93 @@
---
version: "2"
linters:
default: all
disable:
- containedctx
- depguard
- exhaustruct
- gochecknoglobals
- interfacebloat
- ireturn
- mnd
- testpackage
- tparallel
- unused
- varnamelen
- noinlineerr
- wsl
settings:
cyclop:
max-complexity: 30
package-average: 30
forbidigo:
forbid:
- pattern: ^(fmt\.Print(|f|ln)|print|println)$
- pattern: ^time\.Now\(\)($|\.F|\.A|\.B|\.L|\.UTC\(\)\.I|,|\))(# Calls of time\.Now() without \.UTC() is prohibited\.)?
funlen:
lines: 200
statements: 60
ignore-comments: true
gocyclo:
min-complexity: 20
govet:
enable-all: true
funcorder:
constructor: true
struct-method: false
alphabetical: true
lll:
line-length: 120
nestif:
min-complexity: 20
tagliatelle:
case:
rules:
json: snake
yaml: camel
use-field-name: true
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
exclusions:
generated: lax
rules:
- linters:
- gosec
path: .+_test\.go
- linters:
- godox
text: TODO
- linters:
- govet
text: declaration of "err" shadows
- path: (.+)\.go$
text: ST1000
- path: (.+)\.go$
text: package-comments
- linters:
- cyclop
path: (.+)_test\.go
paths:
- third_party$
- builtin$
- examples$
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofmt
- gofumpt
settings:
gofumpt:
module-path: "go.dev.pztrn.name/vikunja-notifier"
extra-rules: true
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+4
View File
@@ -0,0 +1,4 @@
{
"line-length": false,
"first-line-h1": false
}
+6
View File
@@ -0,0 +1,6 @@
---
repos:
- repo: https://code.pztrn.name/misc/pre-commit-hooks-golang
rev: cb3db89b1c4bee10eec85a7fa1dbbd35fb347478
hooks:
- id: golangci-lint
+26
View File
@@ -0,0 +1,26 @@
{
// Используйте IntelliSense, чтобы узнать о возможных атрибутах.
// Наведите указатель мыши, чтобы просмотреть описания существующих атрибутов.
// Для получения дополнительной информации посетите: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "vikunja-notifier docker debug",
"type": "go",
"debugAdapter": "dlv-dap",
"request": "attach",
"mode": "remote",
"port": 49001,
"host": "127.0.0.1",
"stopOnEntry": true,
"substitutePath": [
{
"from": "${workspaceFolder}",
"to": "/vikunja-notifier"
}
],
"preLaunchTask": "Run vikunja-notifier debug build",
"postDebugTask": "Stop vikunja-notifier debug build"
}
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run vikunja-notifier debug build",
"type": "shell",
"command": "task deployments:localdev:vikunja-notifier:down; task deployments:localdev:vikunja-notifier:up-debug",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
// Do not open terminal every time debug is launched.
"reveal": "silent"
}
},
{
"label": "Stop vikunja-notifier debug build",
"type": "shell",
"command": "task deployments:localdev:vikunja-notifier:down-debug",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
// Do not open terminal every time debug is launched.
"reveal": "silent"
}
}
]
}
+13
View File
@@ -0,0 +1,13 @@
---
extends: default
ignore: |
./vendor
rules:
line-length: disable
braces:
ignore:
- openapi.yaml
min-spaces-inside: 1
max-spaces-inside: 1
+94
View File
@@ -0,0 +1,94 @@
# Contributing to vikunja-notifier
Hey, developer/user! Please, read this file carefully before doing any contributions on vikunja-notifier.
## Hello from Russia
First of all, hello from Russia and thanks for stopping by.
Lead developer of this project living in Yekaterinburg, Russia. If you're uncomfortable with it - please do not use vikunja-notifier and/or do not try to contribute to it.
## Issues
## Submitting issues
Before submitting issues please make sure you:
1. Searched for similar issues in tracker.
## Working on issues
See The Flow below.
## Developing vikunja-notifier
### Copyright, licenses, DCOs
Code is licensed under terms and conditions of GNU General Public License version 3. By submitting any portion of code via pull requests you accept that your code will be licensed under this license.
By submitting code via Pull Request you should signoff it (via `--signoff` command line parameter to git). This will mean:
1. You signing [DCO](DCO), meaning that code you submitted was really written by you and not copied from other software (especially with incompatible licensing).
2. You agree to transfer all rights on submitted code to [@pztrn](https://code.pztrn.name/pztrn).
Non-signed-off pull requests won't be accepted.
### Preparing your development environment
First of all you should properly configure your development environment.
vikunja-notifier is developed using VSCode with Go plugin, which utilises gopls and golangci-lint. Ensure linter is running before you commit anything to repository to save your time:
- If you're using IDEA or IDEA-based products (like OpenIDE): use [pre-commit](https://pre-commit.com/) to run linter on git commit. It's configuration already added to repository.
- If you're on VSCode: configure linter to be run on whole workspace to see problems in near-realtime. Fix them before committing code.
- If you're on any other editor: you're on your own. As example you can take a look on [Sublime Text 4 as Golang IDE](https://wiki.pztrn.name/software/sublimetext/golang_ide_v4/) (russian) or use pre-commit hooks.
### The always stable main nonsense
We (and you) should try hard to keep code in main correct, so anyone should be able to checkout code from main branch, build it and deploy anywhere.
### Selecting task to work on
Take a look on issues in Gitea's web interface. If you found desired task - pick it up by expressing your intentions in issue's comments and work on it.
If issue was created by you still you have to express your intentions so other people will know that this task is being worked on.
### Git branches types
There are 3 branch types:
1. `main` branch. Trying to keep always buildable and deployable. Only @pztrn is able to merge or commit here.
2. Feature branches. Named in form of `feature/TASKID_feature_name`, e.g. `feature/2_cicd` or `feature/123_tasks`. These branches created for working on couple of tasks and you should create personal branches from it. Only @pztrn is able to merge or commit here.
3. Personal branch. These branches located in your personal fork, from these branches you're making merge requests.
### The Flow
vikunja-notifier uses own flow that can be described like that:
- An issue must be created in issue tracker. All merge requests should be sticked to it.
- If this is a bug feature - ask @pztrn to create feature branch for making merge requests.
- Create merge requests early, so other developers and involved users can review you code at any point of time. Follow merge requests template as much as possible and describe your work with as much words as possible.
- Give commits meaningful descriptions. It can be short, like `implement feature X for Y`, or `linting` (if this commit is only about linting), and can be repeated (if you're kind-of ADHD and forgot to fix linting issues on some files while fixing linting issues in others), but it still should be meaningful.
- Rebase on main or merge it in your branch before calling people to review your code.
- Your branch should not contain any code that isn't belonging to feature you implement or bug you fix.
- CI pipeline for linting and tests should pass. Do not call for review if your pipeline failed.
- Of course, there might be exceptions, e.g. you can't understand why it failing. Request help from @pztrn in comments.
- Everything should be covered with documentational changes in `docs` directory, if needed.
- @pztrn approve is required to merge. Deal with it for now. This can be changed in future.
- Merge requests will be automatically squashed in one commit on merging, giving full information about merge request in this commit.
Git flow can be described like that:
1. Clone vikunja-notifier to your account.
2. Checkout your branch from main branch (or feature branch if specified in task).
3. Write some code and push it to your copy. Don't forgen about documentational changes if needed.
4. Create merge request into main repository. Follow merge request template as close as possible. Select proper branch (main or feature branch).
5. Reiterate 3 and 4 as long as needed.
6. Ensure your branch can be merged in destination. Rebase on/merge target in your branch if needed.
7. Call developers to review your code.
8. If needed, reiterate 3-7 until code is feature complete, CI is green, no objections left in merge request discussions and @pztrn approve is gathered.
9. Ping @pztrn every day until he merges.
---
*These rules can be changed in any time without further notice.*
+33
View File
@@ -0,0 +1,33 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
+16
View File
@@ -0,0 +1,16 @@
FROM code.pztrn.name/containers/go-toolbox:v10 AS build
WORKDIR /vikunja-notifier
COPY . /vikunja-notifier
RUN --mount=type=cache,target="/home/container/go" task build --force
FROM debian:13-slim
RUN apt-get update && \
apt-get install -y ca-certificates iputils-ping coreutils && \
rm -rf /var/lib/apt/* /var/cache/apt/*
COPY --from=build /vikunja-notifier/_build/vikunja-notifier /vikunja-notifier
COPY --from=build /usr/local/bin/dlv /dlv
ENTRYPOINT ["/vikunja-notifier"]
+18
View File
@@ -0,0 +1,18 @@
FROM code.pztrn.name/containers/go-toolbox:v10 AS build
WORKDIR /vikunja-notifier
COPY . /vikunja-notifier
RUN --mount=type=cache,target="/home/container/go" GOFLAGS="" task build-debug --force
FROM debian:13-slim
RUN apt-get update && \
apt-get install -y ca-certificates iputils-ping coreutils curl net-tools && \
rm -rf /var/lib/apt/* /var/cache/apt/*
COPY --from=build /vikunja-notifier/_build/vikunja-notifier /vikunja-notifier
COPY --from=build /usr/local/bin/dlv /dlv
HEALTHCHECK --interval=1s --timeout=10s --start-period=1s --retries=3 CMD netstat -an | grep 4000 > /dev/null; if [ 0 != $? ]; then exit 1; fi;
ENTRYPOINT ["/dlv", "--listen=:4000", "--headless=true", "--log=true", "--accept-multiclient", "--api-version=2", "exec", "/vikunja-notifier"]
+18
View File
@@ -0,0 +1,18 @@
# Vikunja notifier
Simple daemon that accepts Vikunja webhooks and sends notifications to various channels.
Currently supported channels:
- ntfy
## Installation
TBW
## Configuration
All configuration is done using environment variables:
* `VN_HTTP_ADDRESS` - address for listening for HTTP requests.
+72
View File
@@ -0,0 +1,72 @@
---
version: "3"
vars:
VERSION:
sh: scripts/version_generator.sh
BRANCH:
sh: git rev-parse --abbrev-ref HEAD
COMMIT:
sh: git rev-parse --short HEAD
BUILD:
sh: git rev-list --count HEAD
BUILD_DATE:
sh: TZ=UTC date +'%Y-%m-%d %H:%M:%S %Z'
BASIC_LDFLAGS: "-X 'go.dev.pztrn.name/vikunja-notifier/internal/commons.Version={{.VERSION}}' -X 'go.dev.pztrn.name/vikunja-notifier/internal/commons.Branch={{.BRANCH}}' -X 'go.dev.pztrn.name/vikunja-notifier/internal/commons.Commit={{.COMMIT}}' -X 'go.dev.pztrn.name/vikunja-notifier/internal/commons.Build={{.BUILD}}' -X 'go.dev.pztrn.name/vikunja-notifier/internal/commons.BuildDate={{.BUILD_DATE}}'"
env:
GOFLAGS: "-trimpath"
includes:
deployments: ./deployments
tasks:
build:
desc: "Builds vikunja-notifier binary."
cmds:
- task: ensure-builddir
- task: cleanup
- go build -ldflags="{{ .BASIC_LDFLAGS }}" -o _build/vikunja-notifier{{exeExt}} main.go
sources:
- ./Taskfile.yml
- ./go.mod
- ./main.go
- ./internal/*
generates:
- ./_build/vikunja-notifier{{exeExt}}
method: timestamp
build-debug:
desc: "Builds vikunja-notifier binary."
cmds:
- task: ensure-builddir
- task: cleanup
- go build -ldflags="{{ .BASIC_LDFLAGS }}" --gcflags "all=-N -l" -o _build/vikunja-notifier{{exeExt}} main.go
sources:
- ./Taskfile.yml
- ./go.mod
- ./main.go
- ./internal/*
generates:
- ./_build/vikunja-notifier{{exeExt}}
method: timestamp
cleanup:
desc: "Cleanup _build directory."
cmds:
- rm _build/* || exit 0
ensure-builddir:
internal: true
cmds:
- mkdir -p _build
lint:
desc: "Lints whole workspace."
cmds:
- golangci-lint run
test:
desc: "Test whole workspace"
cmds:
- go test -test.v ./...
+5
View File
@@ -0,0 +1,5 @@
---
version: "3"
includes:
localdev: ./localdev
+24
View File
@@ -0,0 +1,24 @@
---
version: "3"
includes:
common: ./common
vikunja-notifier: ./vikunja-notifier
tasks:
down:
desc: "Stops all containers for devzone with data removal."
cmds:
- task: vikunja-notifier:down
- task: common:network-down
stop:
desc: "Stops all containers."
cmds:
- task: vikunja-notifier:down
up:
desc: "Starts all containers and (up -d)."
cmds:
- task: common:network-up
- task: vikunja-notifier:up
+16
View File
@@ -0,0 +1,16 @@
---
version: "3"
tasks:
network-up:
desc: "Creates or updates network definition."
dir: "./deployments/localdev/common"
cmds:
- docker compose -p vikunja-notifier-common -f network.yaml up -d
run: once
network-down:
desc: "Deletes network definition."
dir: "./deployments/localdev/common"
cmds:
- docker compose -p vikunja-notifier-common -f network.yaml down
+19
View File
@@ -0,0 +1,19 @@
---
# dummy service for network creation.
services:
dummy:
image: busybox
container_name: vikunja-notifier-dummy
hostname: dummy
networks:
vikunja-notifier:
ipv4_address: 10.30.1.254
networks:
vikunja-notifier:
driver: bridge
name: "vikunja-notifier"
ipam:
config:
- subnet: 10.30.1.0/24
gateway: 10.30.1.1
@@ -0,0 +1,91 @@
---
version: "3"
tasks:
build:
desc: "Builds vikunja-notifier's container."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier -f docker-compose.yaml build
build-debug:
desc: "Builds vikunja-notifier's debug build container."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier-debug -f docker-compose.debug.yaml build
down:
desc: "Deletes all vikunja-notifier's data (down)."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier -f docker-compose.yaml down --volumes
down-debug:
desc: "Deletes all vikunja-notifier's debug build data (down)."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier-debug -f docker-compose.debug.yaml down --volumes
logs:
desc: "Show vikunja-notifier logs."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier -f docker-compose.yaml logs -f
logs-debug:
desc: "Show vikunja-notifier debug build logs."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier-debug -f docker-compose.debug.yaml logs -f
restart:
desc: "Restart vikunja-notifier."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier -f docker-compose.yaml restart
restart-debug:
desc: "Restart vikunja-notifier debug build."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier-debug -f docker-compose.debug.yaml restart
start:
desc: "Start vikunja-notifier."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier -f docker-compose.yaml start
start-debug:
desc: "Start vikunja-notifier debug build."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier -f docker-compose.yaml start
stop:
desc: "Stop vikunja-notifier without deleting it's data."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier -f docker-compose.yaml stop
stop-debug:
desc: "Stop vikunja-notifier debug build without deleting it's data."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- docker compose -p vikunja-notifier-debug -f docker-compose.debug.yaml stop
up:
desc: "Start vikunja-notifier (up -d)."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- task: :common:network-up
- task: build
- docker compose -p vikunja-notifier -f docker-compose.yaml up -d
up-debug:
desc: "Start vikunja-notifier debug build (up -d)."
dir: "./deployments/localdev/vikunja-notifier"
cmds:
- task: :common:network-up
- task: build-debug
- docker compose -p vikunja-notifier-debug -f docker-compose.debug.yaml up -d --wait
@@ -0,0 +1,22 @@
---
services:
vikunja-notifier-debug:
container_name: "vikunja-notifier-debug"
build:
context: ../../../
dockerfile: Dockerfile.debug
ports:
- "49000:49000"
- "49001:4000"
networks:
vikunja-notifier:
ipv4_address: 10.30.1.2
environment:
VN_HTTP_ADDRESS: "0.0.0.0:49000"
VN_LOGGER_LEVEL: "debug"
cap_add:
- SYS_PTRACE
networks:
vikunja-notifier:
external: true
@@ -0,0 +1,21 @@
---
services:
vikunja-notifier:
container_name: "vikunja-notifier"
build:
context: ../../../
dockerfile: Dockerfile
ports:
- "49000:49000"
networks:
vikunja-notifier:
ipv4_address: 10.30.1.2
environment:
VN_HTTP_ADDRESS: "0.0.0.0:49000"
VN_LOGGER_LEVEL: "debug"
cap_add:
- SYS_PTRACE
networks:
vikunja-notifier:
external: true
+3
View File
@@ -0,0 +1,3 @@
module go.dev.pztrn.name/vikunja-notifier
go 1.26.4
+203
View File
@@ -0,0 +1,203 @@
package application
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"time"
)
var errApplication = errors.New("application")
// Application is a lifecycle controlling structure for application.
type Application struct {
shutdownChan chan struct{}
ctx context.Context
cancelFunc context.CancelFunc
baseLogger *slog.Logger
appLogger *slog.Logger
services []Service
}
// New creates new instance of lifecycle controlling structure.
func New() *Application {
appl := &Application{}
appl.initialize()
return appl
}
func (a *Application) configure() error {
// First iteration - core services.
for _, service := range a.services {
if !strings.Contains(service.Name(), "core/") {
continue
}
a.appLogger.Debug("Launching configuration procedure for service", "service", service.Name())
if err := service.Configure(); err != nil {
return fmt.Errorf("configure service '%s': %w", service.Name(), err)
}
}
// Second iteration - rest of the services.
for _, service := range a.services {
if strings.Contains(service.Name(), "core/") {
continue
}
a.appLogger.Debug("Launching configuration procedure for service", "service", service.Name())
if err := service.Configure(); err != nil {
return fmt.Errorf("configure service '%s': %w", service.Name(), err)
}
}
return nil
}
func (a *Application) connectDependencies() error {
// First iteration - core services.
for _, service := range a.services {
if !strings.Contains(service.Name(), "core/") {
continue
}
a.appLogger.Debug("Connecting dependencies for service.", "service", service.Name())
if err := service.ConnectDependencies(); err != nil {
return fmt.Errorf("connect dependencies for service '%s': %w", service.Name(), err)
}
}
// Second iteration - rest of the services.
for _, service := range a.services {
if strings.Contains(service.Name(), "core/") {
continue
}
a.appLogger.Debug("Connecting dependencies for service.", "service", service.Name())
if err := service.ConnectDependencies(); err != nil {
return fmt.Errorf("connect dependencies for service '%s': %w", service.Name(), err)
}
}
return nil
}
// Context returns application-wide context.
func (a *Application) Context() context.Context {
return a.ctx
}
// ContextWithTimeout returns context.Context with requested timeout.
func (a *Application) ContextWithTimeout(timeout time.Duration) context.Context {
ctx, cancelFunc := context.WithTimeout(a.ctx, timeout)
// As we do not need to call cancelFunc - make linter happy.
// This probably will lead to context leak, so it should be investigated.
go func(_ context.CancelFunc) {}(cancelFunc)
return ctx
}
func (a *Application) initialize() {
a.ctx, a.cancelFunc = context.WithCancel(context.Background())
a.initializeLogger()
a.services = make([]Service, 0)
}
func (a *Application) launchStartupTasks() error {
for _, service := range a.services {
if strings.Contains(service.Name(), "mainwindow") {
continue
}
if !strings.Contains(service.Name(), "core/") {
continue
}
a.appLogger.Debug("Launching startup tasks for service.", "service", service.Name())
if err := service.LaunchStartupTasks(); err != nil {
return fmt.Errorf("launch startup tasks for core/%s: %w", service.Name(), err)
}
}
for _, service := range a.services {
if strings.Contains(service.Name(), "core/") {
continue
}
a.appLogger.Debug("Launching startup tasks for service.", "service", service.Name())
if err := service.LaunchStartupTasks(); err != nil {
return fmt.Errorf("launch startup tasks for core/%s: %w", service.Name(), err)
}
}
return nil
}
// Shutdown stops application.
func (a *Application) Shutdown() error {
a.appLogger.Info("Stopping vikunja-notifier...")
a.cancelFunc()
for _, service := range a.services {
if !strings.Contains(service.Name(), "features/") {
continue
}
a.appLogger.Debug("Shutting down service.", "service", service.Name())
if err := service.Shutdown(); err != nil {
return fmt.Errorf("shutting down service '%s': %w", service.Name(), err)
}
}
for _, service := range a.services {
if !strings.Contains(service.Name(), "core/") {
continue
}
a.appLogger.Debug("Shutting down service.", "service", service.Name())
if err := service.Shutdown(); err != nil {
return fmt.Errorf("shutting down service '%s': %w", service.Name(), err)
}
}
os.Exit(0)
return nil
}
// Start starts application.
// Server application will start a goroutine that monitors SIGTERM and sends empty struct to channel.
func (a *Application) Start() error {
if err := a.connectDependencies(); err != nil {
return fmt.Errorf("%w: %w", errApplication, err)
}
if err := a.configure(); err != nil {
return fmt.Errorf("%w: %w", errApplication, err)
}
if err := a.launchStartupTasks(); err != nil {
return fmt.Errorf("%w: %w", errApplication, err)
}
a.startServer()
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package application
import (
"log/slog"
"os"
"strings"
)
const (
defaultLogLevel slog.Level = slog.LevelInfo
logLevelEnvVar = "VN_LOG_LEVEL"
)
func (a *Application) initializeLogger() {
logLevel := defaultLogLevel
logLevelAsString, found := os.LookupEnv(logLevelEnvVar)
if found {
switch strings.ToLower(logLevelAsString) {
case "debug":
logLevel = slog.LevelDebug
case "info":
logLevel = slog.LevelInfo
case "warn":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
}
}
slog.Warn("Setting log level.", "level", logLevel.String())
a.baseLogger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: logLevel,
}))
a.appLogger = a.baseLogger.With("module", "application")
}
// NewLogger creates new sub-instance of base logger and adds some additional data to it for persistent output.
func (a *Application) NewLogger(withs ...any) *slog.Logger {
return a.baseLogger.With(withs...)
}
+26
View File
@@ -0,0 +1,26 @@
package application
import (
"os"
"os/signal"
"syscall"
)
// ShutdownChan returns shutdown channel for main function.
func (a *Application) ShutdownChan() chan struct{} {
return a.shutdownChan
}
func (a *Application) startServer() {
a.shutdownChan = make(chan struct{})
go func() {
listener := make(chan os.Signal, 1)
signal.Notify(listener, syscall.SIGTERM, os.Interrupt)
<-listener
a.shutdownChan <- struct{}{}
}()
}
+80
View File
@@ -0,0 +1,80 @@
package application
import (
"errors"
"fmt"
)
var (
// ErrServiceAlreadyRegistered returns if trying to register a service with name already taken by other service.
ErrServiceAlreadyRegistered = errors.New("service with such name already registered")
// ErrServiceNotFound returns if trying to gather service with unknown name.
ErrServiceNotFound = errors.New("service with such name wasn't found")
)
// Service is an interface every service should conform to. Specific services will have own interface for
// cross-service interation.
type Service interface {
// Configure configures service. Called after ConnectDependencies and before LaunchStartupTasks.
Configure() error
// ConnectDependencies gets necessary dependencies.
ConnectDependencies() error
// Initialize initializes service's internal state. Called while registering service with Application
// lifecycle controller.
Initialize() error
// Name returns service name.
Name() string
// LaunchStartupTasks launches tasks on application start. Called after ConnectDependencies and Configure.
LaunchStartupTasks() error
// Shutdown stops service.
Shutdown() error
}
// RegisterService registering service with lifecycle controller for later use in any other service.
func (a *Application) RegisterService(srv Service) error {
var found bool
for _, knownService := range a.services {
if srv.Name() == knownService.Name() {
found = true
break
}
}
if found {
return fmt.Errorf(
"%w: RegisterService: check for service '%s' registration: %w",
errApplication,
srv.Name(),
ErrServiceAlreadyRegistered,
)
}
if err := srv.Initialize(); err != nil {
return fmt.Errorf("%w: RegisterService: initialize service '%s': %w", errApplication, srv.Name(), err)
}
a.services = append(a.services, srv)
return nil
}
// Service returns requested service.
func (a *Application) Service(name string) Service {
var srv Service
for _, knownService := range a.services {
if knownService.Name() == name {
srv = knownService
break
}
}
if srv == nil {
return nil
}
return srv
}
+14
View File
@@ -0,0 +1,14 @@
package commons
var (
// Branch это ветка, из которой собрано приложение.
Branch string
// Build это порядковый номер сборки.
Build string
// BuildDate это таймштамп сборки приложения.
BuildDate string
// Commit это коммит, из которого собрано приложение.
Commit string
// Version это версия, из которой собрано приложение.
Version string
)
+23
View File
@@ -0,0 +1,23 @@
package core
import (
"errors"
"net/http"
)
// ServiceNameHTTPServer is a name for HTTP server service.
const ServiceNameHTTPServer = "core/http_server"
// ErrHTTPServerIsInvalid appears when HTTP server service implementation is invalid.
var ErrHTTPServerIsInvalid = errors.New("HTTP server service implementation is invalid")
// HTTPServer is an interface for HTTP server service.
type HTTPServer interface {
// RegisterHandler registers HTTP handler.
RegisterHandler(method, path string, handler http.HandlerFunc)
// RegisterMiddleware registers HTTP server middlewares.
RegisterMiddleware(middleware HTTPMiddlewareFunc)
}
// HTTPMiddlewareFunc is a function that acts as middleware for HTTP requests.
type HTTPMiddlewareFunc func(fn http.HandlerFunc) http.HandlerFunc
@@ -0,0 +1,8 @@
package httpserver
import "net/http"
func (h *httpServer) defaultHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("Unknown path."))
}
@@ -0,0 +1,84 @@
package httpserver
import (
"errors"
"fmt"
"log/slog"
"net/http"
"go.dev.pztrn.name/vikunja-notifier/internal/application"
"go.dev.pztrn.name/vikunja-notifier/internal/services/core"
)
var (
_ = core.HTTPServer(&httpServer{})
errHTTPServer = errors.New("HTTP server core service")
)
type httpServer struct {
app *application.Application
logger *slog.Logger
httpSrv *http.Server
httpMux *http.ServeMux
middlewares []core.HTTPMiddlewareFunc
}
// Initialize initializes service.
func Initialize(app *application.Application) error {
httpSrv := &httpServer{
app: app,
}
if err := app.RegisterService(httpSrv); err != nil {
return fmt.Errorf("%w: %w", errHTTPServer, err)
}
return nil
}
func (h *httpServer) Configure() error {
h.logger.Debug("Configuring service...")
if err := h.configureHTTPServer(); err != nil {
return fmt.Errorf("configure: %w", err)
}
return nil
}
func (h *httpServer) ConnectDependencies() error {
return nil
}
func (h *httpServer) Initialize() error {
h.logger = h.app.NewLogger("service", core.ServiceNameHTTPServer)
h.logger.Info("Initializing...")
h.middlewares = make([]core.HTTPMiddlewareFunc, 0)
h.RegisterMiddleware(h.requestLoggingMiddleware)
return nil
}
func (h *httpServer) Name() string {
return core.ServiceNameHTTPServer
}
func (h *httpServer) LaunchStartupTasks() error {
h.logger.Debug("Launching startup tasks...")
go h.startHTTPServer()
return nil
}
func (h *httpServer) Shutdown() error {
if err := h.stopHTTPServer(); err != nil {
return fmt.Errorf("%w: Shutdown: %w", errHTTPServer, err)
}
return nil
}
@@ -0,0 +1,24 @@
package httpserver
import (
"fmt"
"net/http"
"time"
)
func (h *httpServer) requestLoggingMiddleware(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
fn(w, r)
h.logger.Info(
"HTTP request.",
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
"host", r.Host,
"path", fmt.Sprintf("%s %s", r.Method, r.RequestURI),
"duration", time.Since(startTime),
)
}
}
@@ -0,0 +1,81 @@
package httpserver
import (
"errors"
"fmt"
"net"
"net/http"
"os"
"time"
"go.dev.pztrn.name/vikunja-notifier/internal/services/core"
)
const httpServerAddrEnvVar = "VN_HTTP_ADDRESS"
var (
errHTTPServerAddrInvalid = errors.New("VN_HTTP_ADDRESS environment variable contains invalid address to " +
"listen, should be 'host:port'")
errHTTPServerAddrNotFound = errors.New("VN_HTTP_ADDRESS environment variable empty")
)
func (h *httpServer) configureHTTPServer() error {
httpSrvAddr, found := os.LookupEnv(httpServerAddrEnvVar)
if !found {
return fmt.Errorf("configure HTTP server: get address from environment variable: %w", errHTTPServerAddrNotFound)
}
host, port, err := net.SplitHostPort(httpSrvAddr)
if err != nil {
return fmt.Errorf("configure HTTP server: validate HTTP server address: %w", err)
}
if httpSrvAddr != host+":"+port {
return fmt.Errorf("configure HTTP server: validate HTTP server address: %w", errHTTPServerAddrInvalid)
}
h.httpMux = new(http.ServeMux)
// Default catch-all handler.
h.RegisterHandler("", "/", h.defaultHandler)
h.httpSrv = &http.Server{
Addr: httpSrvAddr,
Handler: h.httpMux,
ReadHeaderTimeout: time.Second * 3,
}
return nil
}
func (h *httpServer) RegisterHandler(method, path string, handler http.HandlerFunc) {
h.httpMux.HandleFunc(fmt.Sprintf("%s %s", method, path), func(w http.ResponseWriter, r *http.Request) {
for i := len(h.middlewares) - 1; i >= 0; i-- {
handler = h.middlewares[i](handler)
}
handler(w, r)
})
}
func (h *httpServer) RegisterMiddleware(middleware core.HTTPMiddlewareFunc) {
h.middlewares = append(h.middlewares, middleware)
}
func (h *httpServer) startHTTPServer() {
h.logger.Info("Starting listening for HTTP requests.", "address", h.httpSrv.Addr)
if err := h.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
h.logger.Warn("Error when listening to ", "error", err.Error())
}
}
func (h *httpServer) stopHTTPServer() error {
h.logger.Info("Stopping HTTP server...")
if err := h.httpSrv.Shutdown(h.app.ContextWithTimeout(time.Second * 3)); err != nil {
return fmt.Errorf("stopping HTTP server: %w", err)
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"os"
"go.dev.pztrn.name/vikunja-notifier/internal/application"
"go.dev.pztrn.name/vikunja-notifier/internal/commons"
"go.dev.pztrn.name/vikunja-notifier/internal/services/core/httpserver"
)
func main() {
app := application.New()
lgr := app.NewLogger("module", "main")
lgr.Info(
"Starting vikunja-notifier...",
"version", commons.Version,
"build_no", commons.Build,
"buint_on", commons.BuildDate,
"commit", commons.Commit,
"branch", commons.Branch,
)
if err := app.Start(); err != nil {
lgr.Error("Failed to start vikunja-notifier!", "error", err.Error())
os.Exit(1)
}
if err := httpserver.Initialize(app); err != nil {
lgr.Error("Failed to initialize vikunja-notifier", "error", err.Error())
}
if err := app.Start(); err != nil {
lgr.Error("Failed to start vikunja-notifier", "error", err.Error())
os.Exit(2)
}
lgr.Info("vikunja-notifier started.")
<-app.ShutdownChan()
lgr.Info("Shutting down vikunja-notifier...")
if err := app.Shutdown(); err != nil {
lgr.Error("Failed to shutdown vikunja-notifier!", "error", err.Error())
os.Exit(1)
}
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Generates version based on git tag.
LATEST_TAG=$(git tag | tail -n 1)
# Check latest tag commit. If it is equal to current - use that tag as version.
if [ "${LATEST_TAG}" != "" ]; then
LATEST_TAG_COMMIT=$(git rev-list -n 1 $LATEST_TAG)
LATEST_COMMIT=$(git rev-list -n 1 HEAD)
if [ "${LATEST_TAG_COMMIT}" == "${LATEST_COMMIT}" ]; then
echo "${LATEST_TAG}"
exit 0
fi
else
# No tags in repo. Assuming latest tag is "v0.0.0".
LATEST_TAG="0.0.0"
fi
# If we're here, then latest tag commit is not latest commit on current branch.
# We should increase second number and add "-dev" postfix.
IFS=. read MAJOR MINOR PATCH <<<"${LATEST_TAG}"
MINOR=$(($MINOR + 1))
case $1 in
client)
echo "${MAJOR}.${MINOR}.${PATCH}"
;;
*)
echo "v${MAJOR}.${MINOR}.${PATCH}-dev"
;;
esac