Author SHA1 Message Date
JimKarvo 184bc7fb99 Merge pull request 'docs: README' (#4) from docs/readme into main
Reviewed-on: #4
2026-10-01 13:41:47 +00:00
JimKarvoandClaude Opus 5.5 fb2f3cdbba docs: README με περιγραφή, εγκατάσταση και σχετικά repos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:34:14 +03:00
JimKarvo ca59403ee8 fix: typo NotificationReasol -> NotificationReason (fixes build) 2026-09-03 17:09:52 +00:00
JimKarvo 409711cd7b fix: don't filter actor for review_requested to allow CI self-request (fixes PR 185) 2026-09-03 13:15:17 +00:00
JimKarvo ce845a7baf fix: review_requested uses normalized reviewers (fixes PR 184 slack notification) 2026-09-03 11:09:03 +00:00
JimKarvo 6337feeae1 fix: add -debug flag to enable processor DEBUG logs 2026-06-29 20:54:17 +00:00
JimKarvo c288c85b84 fix: replace base64 content with actual shell script 2026-06-29 20:42:11 +00:00
JimKarvo 39f49fbba8 fix: write actual shell script instead of base64 content 2026-06-29 20:41:06 +00:00
JimKarvo aafb820299 fix: correct docker-entrypoint.sh - replace base64-encoded content with actual shell script 2026-06-29 20:39:28 +00:00
JimKarvo 718569eda7 fix: restore events.go whitespace to canonical form 2026-06-29 20:24:48 +00:00
JimKarvo 72b50b88f2 fix: restore processor.go to correct EventHandler architecture (was slack.Client corruption) 2026-06-29 20:24:40 +00:00
JimKarvo c8b2dbe9dd fix: restore main.go to correct architecture (was urfave/cli corruption) 2026-06-29 20:24:18 +00:00
JimKarvo d33b1abd7e fix: rewrite main.go with raw Go source (was base64-corrupted) 2026-06-29 20:16:40 +00:00
JimKarvo e7d6abb942 fix: rewrite processor.go with raw Go source (was base64-corrupted) 2026-06-29 20:16:20 +00:00
JimKarvo a4af4f4f78 fix: rewrite cache.go with raw Go source (was base64-corrupted) 2026-06-29 20:15:13 +00:00
JimKarvo 357cecca84 fix: rewrite events.go with raw Go source (was base64-corrupted) 2026-06-29 20:14:47 +00:00
JimKarvo b706bc2338 fix: align docker-entrypoint.sh MANUAL_USER_MAPPINGS format with Go code
The entrypoint used jq to parse MANUAL_USER_MAPPINGS as JSON, but
ParseManualMappings() in Go uses "GiteaUsername:SlackID,GiteaUser2:SlackID2"
format. The mismatch caused "set -e" to kill the shell when jq failed
on non-JSON input, preventing the Go binary from ever starting.

Fix: replace jq JSON parsing with comma/colon splitting to match
the Go code's expected format.
2026-06-29 20:05:30 +00:00
JimKarvo 1ab139baa4 feat: support LOG_LEVEL env var for runtime log level config
Adds LOG_LEVEL env var support (debug/info/warn/error) that overrides
the --debug flag, enabling dynamic log level changes without rebuild.
2026-06-29 19:51:02 +00:00
JimKarvo 895b82096c fix: also check singular RequestedReviewer field for review_requested events
Gitea sends `requested_reviewer` (singular) field for `review_requested`
webhook actions, not always the `requested_reviewers` (plural/array).
The processor only checked the array form, so `usersToNotify` was always
empty and no notification was sent.

Also add more INFO-level logging for debugging the review_requested path.

Fixes: manual mappings worked but were never reached because the
processor never added the reviewer to the notification list.
2026-06-29 19:42:19 +00:00
JimKarvo 673cefeb90 fix: add MANUAL_USER_MAPPINGS support for users with mismatched emails
JimKarvo's Gitea email (dimitris@jksoftware.gr) differs from Slack
email (jimkarvo@gmail.com), so the email-based Slack lookup fails.
Add MANUAL_USER_MAPPINGS env var (format: GiteaUsername:SlackID,...)
to bypass email lookup when a manual mapping exists.

Changes:
- cache.go: ParseManualMappings() parses env var format
- cache.go: CachedResolver checks manual mappings first (step 0)
- main.go: reads MANUAL_USER_MAPPINGS env var, passes to resolver
2026-06-29 22:34:22 +03:00
JimKarvo 9e26698068 fix: use event.Reviewers instead of e.RequestedReviewer for review_requested 2026-06-29 18:54:59 +00:00
JimKarvo 8f08345bb2 chore: disable Renovate completely 2026-06-25 08:52:10 +00:00
6 changed files with 770 additions and 30 deletions
+114 -1
View File
@@ -1,3 +1,116 @@
# gitea-notification-hub
Gitea webhook to Slack DM middleware (modified for Coolify deployment)
Μικρή υπηρεσία σε Go που δέχεται webhooks από το Gitea (`git.dev1.jkapps.gr`) και στέλνει **προσωπικό μήνυμα στο Slack** σε όποιον αφορά ένα PR ή issue: τον συντάκτη, τους reviewers, τους assignees και όσους γίνονται `@mention`.
Είναι τροποποιημένη έκδοση του `github.com/vincentc-afk/gitea-notification-hub` (άδεια AGPL-3.0, βλ. `LICENSE`), προσαρμοσμένη για deploy σε Coolify.
## Περιεχόμενα
- [Πώς δουλεύει](#πώς-δουλεύει)
- [Ποιος ειδοποιείται](#ποιος-ειδοποιείται)
- [Εγκατάσταση](#εγκατάσταση)
- [Ρύθμιση Gitea](#ρύθμιση-gitea)
- [Ρύθμιση Slack](#ρύθμιση-slack)
- [Αλλαγές σε σχέση με το upstream](#αλλαγές-σε-σχέση-με-το-upstream)
- [Δομή](#δομή)
## Πώς δουλεύει
1. Το Gitea στέλνει `POST /webhook` με υπογραφή HMAC στην κεφαλίδα `X-Gitea-Signature`. Αιτήματα χωρίς σωστή υπογραφή απορρίπτονται.
2. Το event (`X-Gitea-Event`) μετατρέπεται σε κοινή μορφή και αποφασίζεται ποιοι χρήστες πρέπει να ειδοποιηθούν. Αυτός που έκανε την ενέργεια αφαιρείται από τη λίστα.
3. Κάθε χρήστης Gitea αντιστοιχίζεται σε χρήστη Slack, με αυτή τη σειρά:
1. χειροκίνητη αντιστοίχιση από το `MANUAL_USER_MAPPINGS`
2. cache στη SQLite (`data/notifications.db`)
3. το πραγματικό email από το Gitea API (`/api/v1/users/{username}`). Το email του webhook δεν χρησιμοποιείται, γιατί μπορεί να είναι noreply.
4. αναζήτηση στο Slack με αυτό το email. Το αποτέλεσμα αποθηκεύεται στην cache.
4. Το μήνυμα στέλνεται ως DM (Slack blocks). Τα `@mention` του Gitea μέσα στο κείμενο γίνονται mentions του Slack.
Health check: `GET /health`.
## Ποιος ειδοποιείται
| Event | Ενέργεια | Ειδοποιούνται |
|---|---|---|
| `pull_request` | opened | assignees, reviewers |
| | closed / merged | συντάκτης |
| | assigned | ο νέος assignee |
| | review_requested | οι reviewers που ζητήθηκαν. Ο αποστολέας δεν αφαιρείται εδώ, ώστε να δουλεύει και όταν το CI ζητά review εκ μέρους του χρήστη. |
| | synchronize (νέα commits) | συντάκτης, reviewers, assignees |
| `pull_request_review` | | συντάκτης του PR |
| `pull_request_comment` | | συντάκτης, reviewers, mentions |
| `issues` | opened | assignees |
| | closed | συντάκτης |
| `issue_comment` | | συντάκτης, mentions |
Οι κανόνες ενεργοποιούνται ή απενεργοποιούνται στο `rules` του config. Το `docker-entrypoint.sh` τους έχει όλους ενεργούς.
## Εγκατάσταση
Με Docker Compose ή Coolify. Το `docker-entrypoint.sh` γράφει το `config/config.yaml` από τις μεταβλητές περιβάλλοντος σε κάθε εκκίνηση. Το `config/config.example.yaml` είναι μόνο για αναφορά.
| Μεταβλητή | Υποχρεωτική | Περιγραφή |
|---|---|---|
| `GITEA_WEBHOOK_SECRET` | ναι | Ίδιο με το Secret του webhook στο Gitea |
| `GITEA_URL` | ναι | π.χ. `https://git.dev1.jkapps.gr`, χωρίς `/` στο τέλος |
| `GITEA_API_TOKEN` | ναι | Token Gitea με δικαίωμα ανάγνωσης χρηστών |
| `SLACK_BOT_TOKEN` | ναι | `xoxb-...` |
| `MANUAL_USER_MAPPINGS` | όχι | `giteaUser:SLACKID,άλλος:SLACKID2`. Για χρήστες με διαφορετικό email στο Gitea και στο Slack. |
Δημιουργήστε `.env` με τις παραπάνω μεταβλητές και τρέξτε:
```bash
docker compose up -d --build
```
Το `docker-compose.yml` περνάει στο container μόνο τις τέσσερις υποχρεωτικές μεταβλητές. Για να φτάσει και το `MANUAL_USER_MAPPINGS`, προσθέστε `- MANUAL_USER_MAPPINGS=${MANUAL_USER_MAPPINGS}` στο `environment` του compose.
Η υπηρεσία ακούει στο port `8080`. Η βάση SQLite μένει στο `./data`, που είναι volume.
Τοπικά, χωρίς Docker (απαιτεί Go 1.24 και CGO για τη SQLite):
```bash
go build -o gitea-notification-hub ./cmd/server
./gitea-notification-hub -config config/config.yaml -debug
```
## Ρύθμιση Gitea
Στο repo, στον οργανισμό ή σε όλο το Gitea: **Settings > Webhooks > Add Webhook > Gitea**.
- Target URL: `https://<host>/webhook`
- Content type: `application/json`
- Secret: η τιμή του `GITEA_WEBHOOK_SECRET`
- Events: Pull request, Pull request review, Pull request comment, Issues, Issue comment
## Ρύθμιση Slack
Slack app με Bot Token και scopes:
- `chat:write`: αποστολή μηνυμάτων
- `im:write`: άνοιγμα DM
- `users:read` και `users:read.email`: αναζήτηση χρήστη με email
## Αλλαγές σε σχέση με το upstream
- Το config γράφεται από μεταβλητές περιβάλλοντος στο `docker-entrypoint.sh`, για το Coolify.
- Υποστήριξη `MANUAL_USER_MAPPINGS`, στον κώδικα Go και στο entrypoint, που γράφει τις αντιστοιχίσεις στη SQLite με το `sqlite3`.
- Διορθώσεις στο `review_requested`: χρησιμοποιούνται όλοι οι reviewers του event και δεν αφαιρείται ο αποστολέας.
- Το entrypoint ξεκινά πάντα με `-debug`.
Ζητήματα που υπάρχουν σήμερα:
- Το `default_channel` υπάρχει στο παράδειγμα config, αλλά το entrypoint δεν το ορίζει.
- Το παράδειγμα config αναφέρει PostgreSQL, αλλά υλοποιείται μόνο SQLite.
- Η ρύθμιση `LOG_LEVEL` αναφέρεται στο ιστορικό των commits, αλλά δεν υπάρχει στον κώδικα.
## Δομή
| Φάκελος | Περιεχόμενο |
|---|---|
| `cmd/server` | `main.go`, σύνδεση όλων των κομματιών |
| `internal/webhook` | Έλεγχος υπογραφής, τύποι events, handler |
| `internal/event` | Κανόνες ειδοποίησης, mentions, μορφή μηνύματος |
| `internal/identity` | Αντιστοίχιση Gitea → Slack με cache |
| `internal/notifier/slack` | Αποστολή DM |
| `internal/storage/sqlite` | Πίνακας `users` |
| `migrations/` | Σχήμα SQLite |
+9 -2
View File
@@ -67,8 +67,15 @@ func main() {
slackClient := slacknotifier.New(&cfg.Notification.Slack, logger)
logger.Info().Msg("Slack notifier initialized")
// Initialize cached identity resolver
resolver := cache.NewCachedResolver(repo, emailLookup, slackClient, logger)
// Initialize cached identity resolver with optional manual mappings
manualMappings := cache.ParseManualMappings(os.Getenv("MANUAL_USER_MAPPINGS"))
if len(manualMappings) > 0 {
logger.Info().Int("count", len(manualMappings)).Msg("manual user mappings configured")
for username := range manualMappings {
logger.Debug().Str("username", username).Msg("manual mapping registered")
}
}
resolver := cache.NewCachedResolver(repo, emailLookup, slackClient, manualMappings, logger)
logger.Info().Msg("identity resolver initialized")
// Create processor adapter that implements webhook.EventHandler
+9 -8
View File
@@ -3,7 +3,6 @@ set -e
# Generate config.yaml from environment variables
mkdir -p /app/config
cat > /app/config/config.yaml << 'YAMLEOF'
server:
port: 8080
@@ -38,23 +37,25 @@ rules:
notify_reviewers: true
YAMLEOF
# Seed manual user mappings from MANUAL_USER_MAPPINGS env var (JSON: {"gitea_username":"slack_id",...})
# Seed manual user mappings from MANUAL_USER_MAPPINGS env var
# Format: "GiteaUsername:SlackID,GiteaUser2:SlackID2" (same as Go's ParseManualMappings)
# These bypass the Gitea API email lookup + Slack email lookup
if [ -n "$MANUAL_USER_MAPPINGS" ]; then
# Create table and indexes using single-line calls to avoid sh issues with multi-line SQL
sqlite3 /app/data/notifications.db "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, gitea_username TEXT UNIQUE, gitea_id INTEGER, email TEXT UNIQUE, full_name TEXT, slack_id TEXT, slack_name TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);" 2>/dev/null || true
sqlite3 /app/data/notifications.db "CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);" 2>/dev/null || true
sqlite3 /app/data/notifications.db "CREATE INDEX IF NOT EXISTS idx_users_gitea_username ON users(gitea_username);" 2>/dev/null || true
sqlite3 /app/data/notifications.db "CREATE INDEX IF NOT EXISTS idx_users_slack_id ON users(slack_id);" 2>/dev/null || true
echo "$MANUAL_USER_MAPPINGS" | jq -r 'to_entries[] | "\(.key) \(.value)"' 2>/dev/null | \
while read -r gitea_user slack_id; do
[ -z "$gitea_user" ] && continue
echo "$MANUAL_USER_MAPPINGS" | tr ',' '\n' | while IFS=':' read -r name slack_id; do
name=$(echo "$name" | xargs)
slack_id=$(echo "$slack_id" | xargs)
[ -z "$name" ] && continue
[ -z "$slack_id" ] && continue
sqlite3 /app/data/notifications.db \
"INSERT OR REPLACE INTO users(gitea_username, slack_id, slack_name, updated_at) VALUES('$gitea_user','$slack_id','$gitea_user',datetime('now'));" \
"INSERT OR REPLACE INTO users(gitea_username, slack_id, slack_name, updated_at) VALUES('$name','$slack_id','$name',datetime('now'));" \
2>/dev/null || true
done
fi
exec ./gitea-notification-hub -config /app/config/config.yaml
exec ./gitea-notification-hub -config /app/config/config.yaml -debug
File diff suppressed because one or more lines are too long
+54 -8
View File
@@ -12,14 +12,40 @@ import (
"github.com/vincentc-afk/gitea-notification-hub/internal/storage"
)
// ParseManualMappings parses a MANUAL_USER_MAPPINGS env var string
// Format: "GiteaUsername:SlackID,GiteaUser2:SlackID2"
func ParseManualMappings(raw string) map[string]string {
mappings := make(map[string]string)
raw = strings.TrimSpace(raw)
if raw == "" {
return mappings
}
for _, pair := range strings.Split(raw, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
parts := strings.SplitN(pair, ":", 2)
if len(parts) == 2 {
username := strings.TrimSpace(parts[0])
slackID := strings.TrimSpace(parts[1])
if username != "" && slackID != "" {
mappings[username] = slackID
}
}
}
return mappings
}
// CachedResolver implements identity.Resolver with caching
// It stores resolved identities in the database and only queries
// external APIs when a user is not found in the cache
type CachedResolver struct {
repo storage.Repository
emailLookup identity.EmailLookup
slackLookup identity.SlackLookup
logger zerolog.Logger
repo storage.Repository
emailLookup identity.EmailLookup
slackLookup identity.SlackLookup
manualMappings map[string]string // GiteaUsername -> SlackID
logger zerolog.Logger
}
// NewCachedResolver creates a new cached identity resolver
@@ -27,18 +53,24 @@ func NewCachedResolver(
repo storage.Repository,
emailLookup identity.EmailLookup,
slackLookup identity.SlackLookup,
manualMappings map[string]string,
logger zerolog.Logger,
) *CachedResolver {
if manualMappings == nil {
manualMappings = make(map[string]string)
}
return &CachedResolver{
repo: repo,
emailLookup: emailLookup,
slackLookup: slackLookup,
logger: logger.With().Str("component", "identity-resolver").Logger(),
repo: repo,
emailLookup: emailLookup,
slackLookup: slackLookup,
manualMappings: manualMappings,
logger: logger.With().Str("component", "identity-resolver").Logger(),
}
}
// Resolve returns the external identity for a Gitea user
// It follows this strategy:
// 0. Check manual mappings (MANUAL_USER_MAPPINGS env var) - highest priority
// 1. Check DB by Gitea username - if found with Slack ID, return cached result
// 2. If not found, use Gitea API to get the real email (not the webhook email which may be noreply)
// 3. Lookup Slack by email
@@ -49,6 +81,20 @@ func (r *CachedResolver) Resolve(ctx context.Context, user event.User) (*identit
Str("webhook_email", user.Email).
Logger()
// Step 0: Check manual mappings first (highest priority)
if user.GiteaUsername != "" {
if slackID, ok := r.manualMappings[user.GiteaUsername]; ok {
logger.Info().
Str("slack_id", slackID).
Msg("resolved user via manual mapping")
return &identity.ResolvedIdentity{
Email: user.Email,
SlackID: slackID,
SlackName: "",
}, nil
}
}
// Step 1: Try to find by Gitea username in cache
if user.GiteaUsername != "" {
dbUser, err := r.repo.GetUserByGiteaUsername(ctx, user.GiteaUsername)
+10 -10
View File
@@ -82,7 +82,7 @@ type Review struct {
// Commit represents a Git commit in webhook payloads
type Commit struct {
ID string `json:"id"` // SHA
ID string `json:"id"` // SHA
Message string `json:"message"`
URL string `json:"url"`
Author GitUser `json:"author"`
@@ -99,15 +99,15 @@ type GitUser struct {
// PullRequestEvent is the payload for pull_request webhooks
type PullRequestEvent struct {
Action string `json:"action"` // opened, closed, reopened, edited, assigned, unassigned, review_requested, synchronize, etc.
Number int64 `json:"number"`
PullRequest PullRequest `json:"pull_request"`
Repository Repository `json:"repository"`
Sender GiteaUser `json:"sender"`
RequestedReviewers []GiteaUser `json:"requested_reviewers"`
RequestedReviewer *GiteaUser `json:"requested_reviewer"` // Present on review_requested action (singular)
Assignee *GiteaUser `json:"assignee"` // Present on assigned/unassigned action
Commits []Commit `json:"commits"` // Present on synchronize action
Action string `json:"action"` // opened, closed, reopened, edited, assigned, unassigned, review_requested, synchronize, etc.
Number int64 `json:"number"`
PullRequest PullRequest `json:"pull_request"`
Repository Repository `json:"repository"`
Sender GiteaUser `json:"sender"`
RequestedReviewers []GiteaUser `json:"requested_reviewers"`
RequestedReviewer *GiteaUser `json:"requested_reviewer"` // Present on review_requested action (singular)
Assignee *GiteaUser `json:"assignee"` // Present on assigned/unassigned action
Commits []Commit `json:"commits"` // Present on synchronize action
}
// PullRequestReviewEvent is the payload for pull_request_review webhooks