From cf87f0ff28b06f6989b6ce5d9203dd70cfc9b6a3 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 9 Jul 2026 09:59:38 +0700 Subject: [PATCH 1/3] [webhooks] configurable action keywords --- .env.example | 31 +++++++ internal/config/config.go | 19 ++++- internal/config/module.go | 5 +- internal/webhooks/config.go | 10 ++- internal/webhooks/domain.go | 124 +--------------------------- internal/webhooks/errors.go | 9 ++ internal/webhooks/parser.go | 156 +++++++++++++++++++++++++++++++++++ internal/webhooks/service.go | 16 ++-- 8 files changed, 235 insertions(+), 135 deletions(-) create mode 100644 internal/webhooks/errors.go create mode 100644 internal/webhooks/parser.go diff --git a/.env.example b/.env.example index f8b345a..f5642f6 100644 --- a/.env.example +++ b/.env.example @@ -227,3 +227,34 @@ WEBAUTHN__RP_ORIGINS='["http://localhost:5173"]' # Example: 5242880 (5 MB) # Example: 20971520 (20 MB) ATTACHMENTS__MAX_SIZE=10485760 + +# ============================================================================= +# WEBHOOKS CONFIGURATION +# ============================================================================= + +# Webhook Secret Key +# Purpose: HMAC-SHA256 secret for verifying BitBucket webhook payloads +# Format: String +# Default: (empty - webhook verification disabled) +# Example: my-secret-key +WEBHOOKS__SECRET= + +# Webhook Bot User Email +# Purpose: Email of the bot user that posts status transition and mention comments +# Format: Email string +# Default: bot@bitissues.local +# Example: bot@bitissues.local +WEBHOOKS__BOT_USER_EMAIL=bot@bitissues.local + +# Webhook Action Keywords +# Purpose: JSON object mapping commit message keywords to task status transitions. +# Each key is a keyword (matched case-insensitively in commit messages) and +# each value is an object with "status" (the target task status) and optionally +# "verb" (the past-tense label used in auto-comments). When verb is omitted it +# defaults to the title-cased keyword. +# Format: JSON object (must start with "{" to be parsed as JSON by koanf) +# Default: +# {"fixes":{"status":"Resolved","verb":"Resolved"},"fixed":{"status":"Resolved","verb":"Resolved"},"resolves":{"status":"Resolved","verb":"Resolved"},"resolved":{"status":"Resolved","verb":"Resolved"},"closes":{"status":"Closed","verb":"Closed"},"closed":{"status":"Closed","verb":"Closed"},"blocks":{"status":"On Hold","verb":"On Hold"},"blocked":{"status":"On Hold","verb":"On Hold"},"on hold":{"status":"On Hold","verb":"On Hold"}} +# Example: {"implements":{"status":"In Progress"},"closes":{"status":"Closed","verb":"Closed"}} +# Valid statuses: New, Open, In Progress, Resolved, Closed, Reopened, Invalid, Duplicate, Wontfix, On Hold +WEBHOOKS__ACTION_KEYWORDS= diff --git a/internal/config/config.go b/internal/config/config.go index 5891172..fa499d1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "time" + "github.com/bit-issues/backend/internal/webhooks" "github.com/go-core-fx/config" ) @@ -57,8 +58,9 @@ type cacheConfig struct { } type webhooksConfig struct { - Secret string `koanf:"secret"` - BotUserEmail string `koanf:"bot_user_email"` + Secret string `koanf:"secret"` + BotUserEmail string `koanf:"bot_user_email"` + ActionKeywords map[string]webhooks.KeywordEntry `koanf:"action_keywords"` } type Config struct { @@ -73,7 +75,7 @@ type Config struct { } func Default() Config { - //nolint:gosec,mnd // default values + //nolint:gosec,mnd,goconst // default values return Config{ HTTP: http{ Address: "127.0.0.1:3000", @@ -116,6 +118,17 @@ func Default() Config { Webhooks: webhooksConfig{ Secret: "", BotUserEmail: "bot@bitissues.local", + ActionKeywords: map[string]webhooks.KeywordEntry{ + "fixes": {Status: "Resolved", Verb: "Resolved"}, + "fixed": {Status: "Resolved", Verb: "Resolved"}, + "resolves": {Status: "Resolved", Verb: "Resolved"}, + "resolved": {Status: "Resolved", Verb: "Resolved"}, + "closes": {Status: "Closed", Verb: "Closed"}, + "closed": {Status: "Closed", Verb: "Closed"}, + "blocks": {Status: "On Hold", Verb: "On Hold"}, + "blocked": {Status: "On Hold", Verb: "On Hold"}, + "on hold": {Status: "On Hold", Verb: "On Hold"}, + }, }, } } diff --git a/internal/config/module.go b/internal/config/module.go index 7234c2c..6c50773 100644 --- a/internal/config/module.go +++ b/internal/config/module.go @@ -68,8 +68,9 @@ func Module() fx.Option { fx.Provide( func(cfg Config) webhooks.Config { return webhooks.Config{ - Secret: cfg.Webhooks.Secret, - BotUserEmail: cfg.Webhooks.BotUserEmail, + Secret: cfg.Webhooks.Secret, + BotUserEmail: cfg.Webhooks.BotUserEmail, + ActionKeywords: cfg.Webhooks.ActionKeywords, } }, ), diff --git a/internal/webhooks/config.go b/internal/webhooks/config.go index fc416de..c4b57ce 100644 --- a/internal/webhooks/config.go +++ b/internal/webhooks/config.go @@ -1,6 +1,12 @@ package webhooks +type KeywordEntry struct { + Status string `json:"status"` + Verb string `json:"verb"` +} + type Config struct { - Secret string `koanf:"secret"` - BotUserEmail string `koanf:"bot_user_email"` + Secret string + BotUserEmail string + ActionKeywords map[string]KeywordEntry } diff --git a/internal/webhooks/domain.go b/internal/webhooks/domain.go index ba62b47..d710884 100644 --- a/internal/webhooks/domain.go +++ b/internal/webhooks/domain.go @@ -1,128 +1,6 @@ package webhooks -import ( - "regexp" - "strconv" - "strings" - - "github.com/bit-issues/backend/internal/tasks" -) - -const ( - verbResolved = "Resolved" - verbClosed = "Closed" - verbOnHold = "On Hold" -) - -// keywordActions is the registry of recognized keywords. -// Extend this map to add new auto-transition keywords. -// -//nolint:gochecknoglobals // this is a constant -var keywordActions = map[string]KeywordAction{ - "fixes": {Status: tasks.StatusResolved, Verb: verbResolved}, - "fixed": {Status: tasks.StatusResolved, Verb: verbResolved}, - "resolves": {Status: tasks.StatusResolved, Verb: verbResolved}, - "resolved": {Status: tasks.StatusResolved, Verb: verbResolved}, - "closes": {Status: tasks.StatusClosed, Verb: verbClosed}, - "closed": {Status: tasks.StatusClosed, Verb: verbClosed}, - "blocks": {Status: tasks.StatusOnHold, Verb: verbOnHold}, - "blocked": {Status: tasks.StatusOnHold, Verb: verbOnHold}, - "on hold": {Status: tasks.StatusOnHold, Verb: verbOnHold}, -} - -// KeywordAction maps a commit message keyword to a task status transition. -type KeywordAction struct { - Status tasks.Status // target status - Verb string // past-tense label for comment, e.g. "Resolved" -} - -var ( - // keywordRefPattern matches patterns like "fixes #123", "closes #456", etc. - keywordRefPattern = regexp.MustCompile( - `(?i)\b(fixes|fixed|resolves|resolved|closes|closed|blocks|blocked)\s+#(\d+)\b`, - ) - // hashRefPattern matches "#NUMBER" preceded by start-of-string or a non-word character. - hashRefPattern = regexp.MustCompile(`(?:^|\W)#(\d+)\b`) -) - -type matchRange struct{ start, end int } - -func (r matchRange) overlaps(other matchRange) bool { - return r.start < other.end && other.start < r.end -} - -// ParsedReference represents a single task reference found in a commit message. -type ParsedReference struct { - TaskNumber int - Action *KeywordAction // nil if bare #N with no keyword - CommitHash string - CommitMessage string -} - -// ParseCommitMessage scans a commit message for task references. -// Returns a list of ParsedReference, one per unique #NUMBER found. -// If both a keyword and bare reference exist for the same number, the keyword wins. -func ParseCommitMessage(message string) []ParsedReference { - keywordMatches := keywordRefPattern.FindAllStringSubmatchIndex(message, -1) - - var keywordRanges []matchRange - var refs []ParsedReference - seen := make(map[int]bool) - - for _, m := range keywordMatches { - keyword := strings.ToLower(message[m[2]:m[3]]) - numStr := message[m[4]:m[5]] - number, _ := strconv.Atoi(numStr) - - if seen[number] { - continue - } - seen[number] = true - - keywordRanges = append(keywordRanges, matchRange{start: m[0], end: m[1]}) - - action := keywordActions[keyword] - refs = append(refs, ParsedReference{ - TaskNumber: number, - Action: &KeywordAction{Status: action.Status, Verb: action.Verb}, - CommitHash: "", - CommitMessage: message, - }) - } - - // Find #NUMBER references not part of keyword matches - hashMatches := hashRefPattern.FindAllStringSubmatchIndex(message, -1) - for _, m := range hashMatches { - numStr := message[m[2]:m[3]] - number, _ := strconv.Atoi(numStr) - - if seen[number] { - continue - } - seen[number] = true - - hr := matchRange{start: m[0], end: m[1]} - consumed := false - for _, kr := range keywordRanges { - if kr.overlaps(hr) { - consumed = true - break - } - } - if consumed { - continue - } - - refs = append(refs, ParsedReference{ - TaskNumber: number, - Action: nil, - CommitHash: "", - CommitMessage: message, - }) - } - - return refs -} +import "strings" // PushCommit is a flattened commit used by the service layer. type PushCommit struct { diff --git a/internal/webhooks/errors.go b/internal/webhooks/errors.go new file mode 100644 index 0000000..05eaae1 --- /dev/null +++ b/internal/webhooks/errors.go @@ -0,0 +1,9 @@ +package webhooks + +import "errors" + +var ( + ErrInvalidSignature = errors.New("invalid webhook signature") + ErrEmptyKeywords = errors.New("webhooks: action_keywords must not be empty") + ErrInvalidStatus = errors.New("webhooks: invalid keyword status") +) diff --git a/internal/webhooks/parser.go b/internal/webhooks/parser.go new file mode 100644 index 0000000..b147b65 --- /dev/null +++ b/internal/webhooks/parser.go @@ -0,0 +1,156 @@ +package webhooks + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/bit-issues/backend/internal/tasks" +) + +// KeywordAction maps a commit message keyword to a task status transition. +type KeywordAction struct { + Status tasks.Status // target status + Verb string // past-tense label for comment, e.g. "Resolved" +} + +// hashRefPattern matches "#NUMBER" preceded by start-of-string or a non-word character. +var hashRefPattern = regexp.MustCompile(`(?:^|\W)#(\d+)\b`) + +// KeywordParser holds the configured keyword-to-action mappings and a compiled +// regex for matching them in commit messages. +type KeywordParser struct { + actions map[string]KeywordAction + pattern *regexp.Regexp +} + +// NewKeywordParser builds a KeywordParser from a config map of keyword entries. +// It validates each status and defaults the Verb to the title-cased keyword +// when empty. The regex is compiled from sorted keys for determinism. +func NewKeywordParser(entries map[string]KeywordEntry) (*KeywordParser, error) { + if len(entries) == 0 { + return nil, ErrEmptyKeywords + } + + actions := make(map[string]KeywordAction, len(entries)) + keys := make([]string, 0, len(entries)) + + for keyword, entry := range entries { + kw := strings.ToLower(keyword) + status := tasks.Status(entry.Status) + if !status.IsValid() { + return nil, fmt.Errorf("%w: status %q for keyword %q", ErrInvalidStatus, entry.Status, keyword) + } + + verb := entry.Verb + if verb == "" { + verb = titleCase(kw) + } + + actions[kw] = KeywordAction{Status: status, Verb: verb} + keys = append(keys, regexp.QuoteMeta(kw)) + } + + sort.Strings(keys) + + pattern := regexp.MustCompile( + `(?i)\b(` + strings.Join(keys, "|") + `)\s+#(\d+)\b`, + ) + + return &KeywordParser{actions: actions, pattern: pattern}, nil +} + +// ParseCommitMessage scans a commit message for task references. +// Returns a list of ParsedReference, one per unique #NUMBER found. +// If both a keyword and bare reference exist for the same number, the keyword wins. +func (kp *KeywordParser) ParseCommitMessage(message string) []ParsedReference { + keywordMatches := kp.pattern.FindAllStringSubmatchIndex(message, -1) + + var keywordRanges []matchRange + var refs []ParsedReference + seen := make(map[int]bool) + + for _, m := range keywordMatches { + keyword := strings.ToLower(message[m[2]:m[3]]) + numStr := message[m[4]:m[5]] + number, _ := strconv.Atoi(numStr) + + if seen[number] { + continue + } + seen[number] = true + + keywordRanges = append(keywordRanges, matchRange{start: m[0], end: m[1]}) + + action := kp.actions[keyword] + refs = append(refs, ParsedReference{ + TaskNumber: number, + Action: &KeywordAction{Status: action.Status, Verb: action.Verb}, + CommitHash: "", + CommitMessage: message, + }) + } + + // Find #NUMBER references not part of keyword matches + hashMatches := hashRefPattern.FindAllStringSubmatchIndex(message, -1) + for _, m := range hashMatches { + numStr := message[m[2]:m[3]] + number, _ := strconv.Atoi(numStr) + + if seen[number] { + continue + } + seen[number] = true + + hr := matchRange{start: m[0], end: m[1]} + consumed := false + for _, kr := range keywordRanges { + if kr.overlaps(hr) { + consumed = true + break + } + } + if consumed { + continue + } + + refs = append(refs, ParsedReference{ + TaskNumber: number, + Action: nil, + CommitHash: "", + CommitMessage: message, + }) + } + + return refs +} + +type matchRange struct{ start, end int } + +func (r matchRange) overlaps(other matchRange) bool { + return r.start < other.end && other.start < r.end +} + +// ParsedReference represents a single task reference found in a commit message. +type ParsedReference struct { + TaskNumber int + Action *KeywordAction // nil if bare #N with no keyword + CommitHash string + CommitMessage string +} + +// titleCase capitalises the first letter of each space-separated word. +func titleCase(s string) string { + words := strings.Fields(s) + for i, w := range words { + runes := []rune(w) + if len(runes) > 0 { + runes[0] = unicode.ToTitle(runes[0]) + words[i] = string(runes) + } + } + return strings.Join(words, " ") +} diff --git a/internal/webhooks/service.go b/internal/webhooks/service.go index 231cdfe..46b31fa 100644 --- a/internal/webhooks/service.go +++ b/internal/webhooks/service.go @@ -16,8 +16,6 @@ import ( "go.uber.org/zap" ) -var ErrInvalidSignature = errors.New("invalid webhook signature") - type Service struct { config Config @@ -27,7 +25,8 @@ type Service struct { usersSvc *users.Service logger *zap.Logger - botUserID int64 + keywordParser *KeywordParser + botUserID int64 } func NewService( @@ -47,11 +46,18 @@ func NewService( usersSvc: usersSvc, logger: logger, - botUserID: -1, + keywordParser: nil, + botUserID: -1, }, nil } func (s *Service) Init(ctx context.Context) error { + parser, err := NewKeywordParser(s.config.ActionKeywords) + if err != nil { + return fmt.Errorf("failed to build keyword parser: %w", err) + } + s.keywordParser = parser + botUser, err := s.usersSvc.GetByEmail(ctx, s.config.BotUserEmail) if err != nil { return fmt.Errorf("failed to resolve bot user %q: %w", s.config.BotUserEmail, err) @@ -144,7 +150,7 @@ func (s *Service) processCommit( commit PushCommit, result *ProcessResult, ) { - refs := ParseCommitMessage(commit.Message) + refs := s.keywordParser.ParseCommitMessage(commit.Message) if len(refs) == 0 { return } From 055b884e6cecf09b596df08c18e5f6db71598d6a Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Sun, 12 Jul 2026 20:45:30 +0700 Subject: [PATCH 2/3] [webhooks] minor fixes --- internal/webhooks/parser.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/webhooks/parser.go b/internal/webhooks/parser.go index b147b65..4f2b574 100644 --- a/internal/webhooks/parser.go +++ b/internal/webhooks/parser.go @@ -39,6 +39,9 @@ func NewKeywordParser(entries map[string]KeywordEntry) (*KeywordParser, error) { keys := make([]string, 0, len(entries)) for keyword, entry := range entries { + if keyword == "" { + return nil, fmt.Errorf("webhooks: keyword must not be empty") + } kw := strings.ToLower(keyword) status := tasks.Status(entry.Status) if !status.IsValid() { From ac9d0a6512c4e4c613fe63cd9d51cef5f440ffb1 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Sun, 12 Jul 2026 20:48:42 +0700 Subject: [PATCH 3/3] [webhooks] minor fixes --- internal/webhooks/errors.go | 4 ++-- internal/webhooks/parser.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/webhooks/errors.go b/internal/webhooks/errors.go index 05eaae1..1ccdaf0 100644 --- a/internal/webhooks/errors.go +++ b/internal/webhooks/errors.go @@ -4,6 +4,6 @@ import "errors" var ( ErrInvalidSignature = errors.New("invalid webhook signature") - ErrEmptyKeywords = errors.New("webhooks: action_keywords must not be empty") - ErrInvalidStatus = errors.New("webhooks: invalid keyword status") + ErrEmptyKeyword = errors.New("must not be empty") + ErrInvalidStatus = errors.New("invalid keyword status") ) diff --git a/internal/webhooks/parser.go b/internal/webhooks/parser.go index 4f2b574..e00d8f7 100644 --- a/internal/webhooks/parser.go +++ b/internal/webhooks/parser.go @@ -32,7 +32,7 @@ type KeywordParser struct { // when empty. The regex is compiled from sorted keys for determinism. func NewKeywordParser(entries map[string]KeywordEntry) (*KeywordParser, error) { if len(entries) == 0 { - return nil, ErrEmptyKeywords + return nil, fmt.Errorf("%w: action_keywords", ErrEmptyKeyword) } actions := make(map[string]KeywordAction, len(entries)) @@ -40,7 +40,7 @@ func NewKeywordParser(entries map[string]KeywordEntry) (*KeywordParser, error) { for keyword, entry := range entries { if keyword == "" { - return nil, fmt.Errorf("webhooks: keyword must not be empty") + return nil, fmt.Errorf("%w: keyword", ErrEmptyKeyword) } kw := strings.ToLower(keyword) status := tasks.Status(entry.Status)