initial commit

This commit is contained in:
2026-01-18 21:40:51 +01:00
commit 197a01440c
14 changed files with 374 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
cache.json
updater

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# Use an official Go runtime as a parent image
FROM golang:1.25.5-alpine
# Set the working directory in the container
WORKDIR /app
# Copy the go mod and sum files to download dependencies
COPY go.mod go.sum ./
# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed
RUN go mod download
# Copy the source code from the current directory to the working directory inside the container
COPY . .
# Command to run the application
CMD ["go", "run", "main.go"]

9
docker-compose.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
updater:
build: .
container_name: updater-app
restart: unless-stopped
volumes:
- ./:/app
env_file:
- .env

12
env-example Normal file
View File

@@ -0,0 +1,12 @@
WIKI_BASE_URL=
WIKI_TOKEN=
REDMINE_BASE_URL=
REDMINE_KEY=
GITEA_TOKEN=
GITEA_BASE_URL=
GITEA_REPOS=org/repo1,org/repo2
DISCORD_WEBHOOK=
INTERVAL_MINUTES=5

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module updater
go 1.25.5
require github.com/joho/godotenv v1.5.1

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

80
lib/cache.go Normal file
View File

@@ -0,0 +1,80 @@
package lib
import (
"encoding/json"
"os"
)
const cacheFile = "cache.json"
type CacheItem struct {
Fetcher string
Value string
}
type Cache struct {
Items []CacheItem
}
func (c *Cache) Update(fetcher string, value string) {
for i := range c.Items {
if c.Items[i].Fetcher == fetcher {
c.Items[i].Value = value
return
}
}
c.Items = append(c.Items, CacheItem{
Fetcher: fetcher,
Value: value,
})
}
func (c *Cache) Get(fetcher string) (string, bool) {
for _, item := range c.Items {
if item.Fetcher == fetcher {
return item.Value, true
}
}
return "", false
}
func (c *Cache) Has(fetcher string) bool {
_, found := c.Get(fetcher)
return found
}
func (c *Cache) IsChanged(fetcher string, value string) bool {
cachedValue, found := c.Get(fetcher)
if !found || cachedValue != value {
return true
}
return false
}
func (c *Cache) TryUpdate(fetcher string, value string) bool {
if c.IsChanged(fetcher, value) {
c.Update(fetcher, value)
return true
}
return false
}
func (c *Cache) Load() {
f, err := os.Open(cacheFile)
if err != nil {
return
}
defer f.Close()
_ = json.NewDecoder(f).Decode(&c.Items)
}
func (c *Cache) Save() {
f, err := os.Create(cacheFile)
if err != nil {
return
}
defer f.Close()
_ = json.NewEncoder(f).Encode(c.Items)
}

15
lib/config.go Normal file
View File

@@ -0,0 +1,15 @@
package lib
import "time"
type Config struct {
WikiBaseURL string
WikiToken string
RedmineBaseURL string
RedmineKey string
GiteaToken string
GiteaBaseURL string
GiteaRepos []string
Webhook string
Interval time.Duration
}

44
lib/fetcher.gitea.go Normal file
View File

@@ -0,0 +1,44 @@
package lib
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
type GiteaFetcher struct {
URL string
Token string
Cache *Cache
}
func (f GiteaFetcher) Fetch() string {
req, _ := http.NewRequest("GET", f.URL, nil)
if f.Token != "" {
req.Header.Set("Authorization", "token "+f.Token)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return ""
}
defer res.Body.Close()
var r []struct {
Sha string `json:"sha"`
Commit struct {
Message string `json:"message"`
} `json:"commit"`
HTMLURL string `json:"html_url"`
}
json.NewDecoder(res.Body).Decode(&r)
if len(r) == 0 {
return ""
}
c := r[0]
if f.Cache.TryUpdate("gitea_"+url.QueryEscape(f.URL), c.Sha) {
return fmt.Sprintf("Gitea: %s <%s>", c.Commit.Message, c.HTMLURL)
}
return ""
}

43
lib/fetcher.redmine.go Normal file
View File

@@ -0,0 +1,43 @@
package lib
import (
"encoding/json"
"fmt"
"net/http"
)
type RedmineFetcher struct {
Config Config
Cache *Cache
}
func (f RedmineFetcher) Fetch() string {
redmineURL := fmt.Sprintf("%s/issues.json", f.Config.RedmineBaseURL)
req, _ := http.NewRequest("GET", redmineURL, nil)
req.Header.Set("X-Redmine-API-Key", f.Config.RedmineKey)
res, err := http.DefaultClient.Do(req)
if err != nil {
return ""
}
defer res.Body.Close()
var r struct {
Issues []struct {
ID int
UpdatedOn string
Subject string
}
}
json.NewDecoder(res.Body).Decode(&r)
if len(r.Issues) == 0 {
return ""
}
i := r.Issues[0]
if f.Cache.TryUpdate("redmine", i.UpdatedOn) {
url := fmt.Sprintf("%s/issues/%d", f.Config.RedmineBaseURL, i.ID)
return fmt.Sprintf("Redmine: #%d %s <%s>", i.ID, i.Subject, url)
}
return ""
}

48
lib/fetcher.wikijs.go Normal file
View File

@@ -0,0 +1,48 @@
package lib
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
type WikiFetcher struct {
Config Config
Cache *Cache
}
func (f WikiFetcher) Fetch() string {
q := `{"query":"{ pages { list(orderBy: UPDATED, orderByDirection: DESC, limit: 1){ updatedAt title }}}"}`
wikiURL := fmt.Sprintf("%s/graphql", f.Config.WikiBaseURL)
req, _ := http.NewRequest("POST", wikiURL, bytes.NewBuffer([]byte(q)))
req.Header.Set("Authorization", "Bearer "+f.Config.WikiToken)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return ""
}
defer res.Body.Close()
var r struct {
Data struct {
Pages struct {
List []struct {
UpdatedAt string
Title string
Path string
}
}
}
}
json.NewDecoder(res.Body).Decode(&r)
if len(r.Data.Pages.List) == 0 {
return ""
}
u := r.Data.Pages.List[0]
if f.Cache.TryUpdate("wiki", u.UpdatedAt) {
url := fmt.Sprintf("%s/%s", f.Config.WikiBaseURL, u.Path)
return fmt.Sprintf("Wiki: %s <%s>", u.Title, url)
}
return ""
}

69
lib/runner.go Normal file
View File

@@ -0,0 +1,69 @@
package lib
import (
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
type Fetcher interface {
Fetch() string
}
func Runner() {
if err := godotenv.Load(); err != nil {
log.Println("Warning: .env file not found, using environment variables")
}
intervalMinutes, err := strconv.Atoi(os.Getenv("INTERVAL_MINUTES"))
if err != nil {
intervalMinutes = 5
}
config := Config{
WikiBaseURL: os.Getenv("WIKI_BASE_URL"),
WikiToken: os.Getenv("WIKI_TOKEN"),
RedmineBaseURL: os.Getenv("REDMINE_BASE_URL"),
RedmineKey: os.Getenv("REDMINE_KEY"),
GiteaToken: os.Getenv("GITEA_TOKEN"),
GiteaBaseURL: os.Getenv("GITEA_BASE_URL"),
GiteaRepos: strings.Split(os.Getenv("GITEA_REPOS"), ","),
Webhook: os.Getenv("DISCORD_WEBHOOK"),
Interval: time.Duration(intervalMinutes) * time.Minute,
}
cache := Cache{}
cache.Load()
discord := DiscordSender{Config: config}
var fetchers []Fetcher
for _, repo := range config.GiteaRepos {
if repo == "" {
continue
}
giteaURL := fmt.Sprintf("%s/api/v1/repos/%s/commits", config.GiteaBaseURL, strings.TrimSpace(repo))
fetchers = append(fetchers, &GiteaFetcher{URL: giteaURL, Token: config.GiteaToken, Cache: &cache})
}
fetchers = append(fetchers, &WikiFetcher{Config: config, Cache: &cache})
fetchers = append(fetchers, &RedmineFetcher{Config: config, Cache: &cache})
for {
fmt.Println("Update")
for _, f := range fetchers {
if msg := f.Fetch(); msg != "" {
discord.Send(msg)
}
}
cache.Save()
time.Sleep(config.Interval)
}
}

18
lib/sender.discord.go Normal file
View File

@@ -0,0 +1,18 @@
package lib
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
type DiscordSender struct {
Config Config
}
func (d DiscordSender) Send(msg string) {
fmt.Printf("Send to Discord: %s\n", msg)
b, _ := json.Marshal(map[string]string{"content": msg})
http.Post(d.Config.Webhook, "application/json", bytes.NewBuffer(b))
}

9
main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import (
"updater/lib"
)
func main() {
lib.Runner()
}