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-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)
|
slackClient := slacknotifier.New(&cfg.Notification.Slack, logger)
|
||||||
logger.Info().Msg("Slack notifier initialized")
|
logger.Info().Msg("Slack notifier initialized")
|
||||||
|
|
||||||
// Initialize cached identity resolver
|
// Initialize cached identity resolver with optional manual mappings
|
||||||
resolver := cache.NewCachedResolver(repo, emailLookup, slackClient, logger)
|
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")
|
logger.Info().Msg("identity resolver initialized")
|
||||||
|
|
||||||
// Create processor adapter that implements webhook.EventHandler
|
// Create processor adapter that implements webhook.EventHandler
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ set -e
|
|||||||
|
|
||||||
# Generate config.yaml from environment variables
|
# Generate config.yaml from environment variables
|
||||||
mkdir -p /app/config
|
mkdir -p /app/config
|
||||||
|
|
||||||
cat > /app/config/config.yaml << 'YAMLEOF'
|
cat > /app/config/config.yaml << 'YAMLEOF'
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
@@ -38,23 +37,25 @@ rules:
|
|||||||
notify_reviewers: true
|
notify_reviewers: true
|
||||||
YAMLEOF
|
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
|
# These bypass the Gitea API email lookup + Slack email lookup
|
||||||
|
|
||||||
if [ -n "$MANUAL_USER_MAPPINGS" ]; then
|
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 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_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_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
|
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 | \
|
echo "$MANUAL_USER_MAPPINGS" | tr ',' '\n' | while IFS=':' read -r name slack_id; do
|
||||||
while read -r gitea_user slack_id; do
|
name=$(echo "$name" | xargs)
|
||||||
[ -z "$gitea_user" ] && continue
|
slack_id=$(echo "$slack_id" | xargs)
|
||||||
|
[ -z "$name" ] && continue
|
||||||
|
[ -z "$slack_id" ] && continue
|
||||||
sqlite3 /app/data/notifications.db \
|
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
|
2>/dev/null || true
|
||||||
done
|
done
|
||||||
fi
|
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"
|
"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
|
// CachedResolver implements identity.Resolver with caching
|
||||||
// It stores resolved identities in the database and only queries
|
// It stores resolved identities in the database and only queries
|
||||||
// external APIs when a user is not found in the cache
|
// external APIs when a user is not found in the cache
|
||||||
type CachedResolver struct {
|
type CachedResolver struct {
|
||||||
repo storage.Repository
|
repo storage.Repository
|
||||||
emailLookup identity.EmailLookup
|
emailLookup identity.EmailLookup
|
||||||
slackLookup identity.SlackLookup
|
slackLookup identity.SlackLookup
|
||||||
logger zerolog.Logger
|
manualMappings map[string]string // GiteaUsername -> SlackID
|
||||||
|
logger zerolog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCachedResolver creates a new cached identity resolver
|
// NewCachedResolver creates a new cached identity resolver
|
||||||
@@ -27,18 +53,24 @@ func NewCachedResolver(
|
|||||||
repo storage.Repository,
|
repo storage.Repository,
|
||||||
emailLookup identity.EmailLookup,
|
emailLookup identity.EmailLookup,
|
||||||
slackLookup identity.SlackLookup,
|
slackLookup identity.SlackLookup,
|
||||||
|
manualMappings map[string]string,
|
||||||
logger zerolog.Logger,
|
logger zerolog.Logger,
|
||||||
) *CachedResolver {
|
) *CachedResolver {
|
||||||
|
if manualMappings == nil {
|
||||||
|
manualMappings = make(map[string]string)
|
||||||
|
}
|
||||||
return &CachedResolver{
|
return &CachedResolver{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
emailLookup: emailLookup,
|
emailLookup: emailLookup,
|
||||||
slackLookup: slackLookup,
|
slackLookup: slackLookup,
|
||||||
logger: logger.With().Str("component", "identity-resolver").Logger(),
|
manualMappings: manualMappings,
|
||||||
|
logger: logger.With().Str("component", "identity-resolver").Logger(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve returns the external identity for a Gitea user
|
// Resolve returns the external identity for a Gitea user
|
||||||
// It follows this strategy:
|
// 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
|
// 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)
|
// 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
|
// 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).
|
Str("webhook_email", user.Email).
|
||||||
Logger()
|
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
|
// Step 1: Try to find by Gitea username in cache
|
||||||
if user.GiteaUsername != "" {
|
if user.GiteaUsername != "" {
|
||||||
dbUser, err := r.repo.GetUserByGiteaUsername(ctx, 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
|
// Commit represents a Git commit in webhook payloads
|
||||||
type Commit struct {
|
type Commit struct {
|
||||||
ID string `json:"id"` // SHA
|
ID string `json:"id"` // SHA
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Author GitUser `json:"author"`
|
Author GitUser `json:"author"`
|
||||||
@@ -99,15 +99,15 @@ type GitUser struct {
|
|||||||
|
|
||||||
// PullRequestEvent is the payload for pull_request webhooks
|
// PullRequestEvent is the payload for pull_request webhooks
|
||||||
type PullRequestEvent struct {
|
type PullRequestEvent struct {
|
||||||
Action string `json:"action"` // opened, closed, reopened, edited, assigned, unassigned, review_requested, synchronize, etc.
|
Action string `json:"action"` // opened, closed, reopened, edited, assigned, unassigned, review_requested, synchronize, etc.
|
||||||
Number int64 `json:"number"`
|
Number int64 `json:"number"`
|
||||||
PullRequest PullRequest `json:"pull_request"`
|
PullRequest PullRequest `json:"pull_request"`
|
||||||
Repository Repository `json:"repository"`
|
Repository Repository `json:"repository"`
|
||||||
Sender GiteaUser `json:"sender"`
|
Sender GiteaUser `json:"sender"`
|
||||||
RequestedReviewers []GiteaUser `json:"requested_reviewers"`
|
RequestedReviewers []GiteaUser `json:"requested_reviewers"`
|
||||||
RequestedReviewer *GiteaUser `json:"requested_reviewer"` // Present on review_requested action (singular)
|
RequestedReviewer *GiteaUser `json:"requested_reviewer"` // Present on review_requested action (singular)
|
||||||
Assignee *GiteaUser `json:"assignee"` // Present on assigned/unassigned action
|
Assignee *GiteaUser `json:"assignee"` // Present on assigned/unassigned action
|
||||||
Commits []Commit `json:"commits"` // Present on synchronize action
|
Commits []Commit `json:"commits"` // Present on synchronize action
|
||||||
}
|
}
|
||||||
|
|
||||||
// PullRequestReviewEvent is the payload for pull_request_review webhooks
|
// PullRequestReviewEvent is the payload for pull_request_review webhooks
|
||||||
|
|||||||
Reference in New Issue
Block a user