Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
184bc7fb99 | ||
|
|
fb2f3cdbba | ||
|
|
ca59403ee8 | ||
|
|
409711cd7b | ||
|
|
ce845a7baf | ||
|
|
6337feeae1 | ||
|
|
c288c85b84 | ||
|
|
39f49fbba8 | ||
|
|
aafb820299 | ||
|
|
718569eda7 | ||
|
|
72b50b88f2 | ||
|
|
c8b2dbe9dd | ||
|
|
d33b1abd7e | ||
|
|
e7d6abb942 | ||
|
|
a4af4f4f78 | ||
|
|
357cecca84 | ||
|
|
b706bc2338 | ||
|
|
1ab139baa4 | ||
|
|
895b82096c | ||
|
|
673cefeb90 | ||
|
|
9e26698068 | ||
|
|
8f08345bb2 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+574
-1
File diff suppressed because one or more lines are too long
Vendored
+54
-8
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user