Skip to content

Commit e267682

Browse files
committed
[webhooks] configurable action keywords
1 parent 328116a commit e267682

8 files changed

Lines changed: 242 additions & 139 deletions

File tree

.env.example

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,34 @@ WEBAUTHN__RP_ORIGINS='["http://localhost:5173"]'
227227
# Example: 5242880 (5 MB)
228228
# Example: 20971520 (20 MB)
229229
ATTACHMENTS__MAX_SIZE=10485760
230+
231+
# =============================================================================
232+
# WEBHOOKS CONFIGURATION
233+
# =============================================================================
234+
235+
# Webhook Secret Key
236+
# Purpose: HMAC-SHA256 secret for verifying BitBucket webhook payloads
237+
# Format: String
238+
# Default: (empty - webhook verification disabled)
239+
# Example: my-secret-key
240+
WEBHOOKS__SECRET=
241+
242+
# Webhook Bot User Email
243+
# Purpose: Email of the bot user that posts status transition and mention comments
244+
# Format: Email string
245+
# Default: bot@bitissues.local
246+
# Example: bot@bitissues.local
247+
WEBHOOKS__BOT_USER_EMAIL=bot@bitissues.local
248+
249+
# Webhook Action Keywords
250+
# Purpose: JSON object mapping commit message keywords to task status transitions.
251+
# Each key is a keyword (matched case-insensitively in commit messages) and
252+
# each value is an object with "status" (the target task status) and optionally
253+
# "verb" (the past-tense label used in auto-comments). When verb is omitted it
254+
# defaults to the title-cased keyword.
255+
# Format: JSON object (must start with "{" to be parsed as JSON by koanf)
256+
# Default:
257+
# {"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"}}
258+
# Example: {"implements":{"status":"In Progress"},"closes":{"status":"Closed","verb":"Closed"}}
259+
# Valid statuses: New, Open, In Progress, Resolved, Closed, Reopened, Invalid, Duplicate, Wontfix, On Hold
260+
WEBHOOKS__ACTION_KEYWORDS='{"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"}}'

internal/config/config.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"time"
77

8+
"github.com/bit-issues/backend/internal/webhooks"
89
"github.com/go-core-fx/config"
910
)
1011

@@ -57,8 +58,9 @@ type cacheConfig struct {
5758
}
5859

5960
type webhooksConfig struct {
60-
Secret string `koanf:"secret"`
61-
BotUserEmail string `koanf:"bot_user_email"`
61+
Secret string `koanf:"secret"`
62+
BotUserEmail string `koanf:"bot_user_email"`
63+
ActionKeywords map[string]webhooks.KeywordEntry `koanf:"action_keywords"`
6264
}
6365

6466
type Config struct {
@@ -73,7 +75,7 @@ type Config struct {
7375
}
7476

7577
func Default() Config {
76-
//nolint:gosec,mnd // default values
78+
//nolint:gosec,mnd,goconst // default values
7779
return Config{
7880
HTTP: http{
7981
Address: "127.0.0.1:3000",
@@ -116,6 +118,21 @@ func Default() Config {
116118
Webhooks: webhooksConfig{
117119
Secret: "",
118120
BotUserEmail: "bot@bitissues.local",
121+
ActionKeywords: map[string]webhooks.KeywordEntry{
122+
"fixes": {Status: "Resolved", Verb: "Resolved"},
123+
"fixed": {Status: "Resolved", Verb: "Resolved"},
124+
"fix": {Status: "Resolved", Verb: "Resolved"},
125+
"resolves": {Status: "Resolved", Verb: "Resolved"},
126+
"resolved": {Status: "Resolved", Verb: "Resolved"},
127+
"resolve": {Status: "Resolved", Verb: "Resolved"},
128+
"closes": {Status: "Closed", Verb: "Closed"},
129+
"closed": {Status: "Closed", Verb: "Closed"},
130+
"close": {Status: "Closed", Verb: "Closed"},
131+
"blocks": {Status: "On Hold", Verb: "On Hold"},
132+
"blocked": {Status: "On Hold", Verb: "On Hold"},
133+
"block": {Status: "On Hold", Verb: "On Hold"},
134+
"on hold": {Status: "On Hold", Verb: "On Hold"},
135+
},
119136
},
120137
}
121138
}

internal/config/module.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ func Module() fx.Option {
6868
fx.Provide(
6969
func(cfg Config) webhooks.Config {
7070
return webhooks.Config{
71-
Secret: cfg.Webhooks.Secret,
72-
BotUserEmail: cfg.Webhooks.BotUserEmail,
71+
Secret: cfg.Webhooks.Secret,
72+
BotUserEmail: cfg.Webhooks.BotUserEmail,
73+
ActionKeywords: cfg.Webhooks.ActionKeywords,
7374
}
7475
},
7576
),

internal/webhooks/config.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
package webhooks
22

3+
type KeywordEntry struct {
4+
Status string `json:"status"`
5+
Verb string `json:"verb"`
6+
}
7+
38
type Config struct {
4-
Secret string `koanf:"secret"`
5-
BotUserEmail string `koanf:"bot_user_email"`
9+
Secret string
10+
BotUserEmail string
11+
ActionKeywords map[string]KeywordEntry
612
}

internal/webhooks/domain.go

Lines changed: 1 addition & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,132 +1,6 @@
11
package webhooks
22

3-
import (
4-
"regexp"
5-
"strconv"
6-
"strings"
7-
8-
"github.com/bit-issues/backend/internal/tasks"
9-
)
10-
11-
const (
12-
verbResolved = "Resolved"
13-
verbClosed = "Closed"
14-
verbOnHold = "On Hold"
15-
)
16-
17-
// keywordActions is the registry of recognized keywords.
18-
// Extend this map to add new auto-transition keywords.
19-
//
20-
//nolint:gochecknoglobals // this is a constant
21-
var keywordActions = map[string]KeywordAction{
22-
"fix": {Status: tasks.StatusResolved, Verb: verbResolved},
23-
"fixes": {Status: tasks.StatusResolved, Verb: verbResolved},
24-
"fixed": {Status: tasks.StatusResolved, Verb: verbResolved},
25-
"resolve": {Status: tasks.StatusResolved, Verb: verbResolved},
26-
"resolves": {Status: tasks.StatusResolved, Verb: verbResolved},
27-
"resolved": {Status: tasks.StatusResolved, Verb: verbResolved},
28-
"close": {Status: tasks.StatusClosed, Verb: verbClosed},
29-
"closes": {Status: tasks.StatusClosed, Verb: verbClosed},
30-
"closed": {Status: tasks.StatusClosed, Verb: verbClosed},
31-
"block": {Status: tasks.StatusOnHold, Verb: verbOnHold},
32-
"blocks": {Status: tasks.StatusOnHold, Verb: verbOnHold},
33-
"blocked": {Status: tasks.StatusOnHold, Verb: verbOnHold},
34-
"on hold": {Status: tasks.StatusOnHold, Verb: verbOnHold},
35-
}
36-
37-
// KeywordAction maps a commit message keyword to a task status transition.
38-
type KeywordAction struct {
39-
Status tasks.Status // target status
40-
Verb string // past-tense label for comment, e.g. "Resolved"
41-
}
42-
43-
var (
44-
// keywordRefPattern matches patterns like "fixes #123", "closes #456", etc.
45-
keywordRefPattern = regexp.MustCompile(
46-
`(?i)\b(fix|fixes|fixed|resolve|resolves|resolved|close|closes|closed|block|blocks|blocked|on hold)\s+#(\d+)\b`,
47-
)
48-
// hashRefPattern matches "#NUMBER" preceded by start-of-string or a non-word character.
49-
hashRefPattern = regexp.MustCompile(`(?:^|\W)#(\d+)\b`)
50-
)
51-
52-
type matchRange struct{ start, end int }
53-
54-
func (r matchRange) overlaps(other matchRange) bool {
55-
return r.start < other.end && other.start < r.end
56-
}
57-
58-
// ParsedReference represents a single task reference found in a commit message.
59-
type ParsedReference struct {
60-
TaskNumber int
61-
Action *KeywordAction // nil if bare #N with no keyword
62-
CommitHash string
63-
CommitMessage string
64-
}
65-
66-
// ParseCommitMessage scans a commit message for task references.
67-
// Returns a list of ParsedReference, one per unique #NUMBER found.
68-
// If both a keyword and bare reference exist for the same number, the keyword wins.
69-
func ParseCommitMessage(message string) []ParsedReference {
70-
keywordMatches := keywordRefPattern.FindAllStringSubmatchIndex(message, -1)
71-
72-
var keywordRanges []matchRange
73-
var refs []ParsedReference
74-
seen := make(map[int]bool)
75-
76-
for _, m := range keywordMatches {
77-
keyword := strings.ToLower(message[m[2]:m[3]])
78-
numStr := message[m[4]:m[5]]
79-
number, _ := strconv.Atoi(numStr)
80-
81-
if seen[number] {
82-
continue
83-
}
84-
seen[number] = true
85-
86-
keywordRanges = append(keywordRanges, matchRange{start: m[0], end: m[1]})
87-
88-
action := keywordActions[keyword]
89-
refs = append(refs, ParsedReference{
90-
TaskNumber: number,
91-
Action: &KeywordAction{Status: action.Status, Verb: action.Verb},
92-
CommitHash: "",
93-
CommitMessage: message,
94-
})
95-
}
96-
97-
// Find #NUMBER references not part of keyword matches
98-
hashMatches := hashRefPattern.FindAllStringSubmatchIndex(message, -1)
99-
for _, m := range hashMatches {
100-
numStr := message[m[2]:m[3]]
101-
number, _ := strconv.Atoi(numStr)
102-
103-
if seen[number] {
104-
continue
105-
}
106-
seen[number] = true
107-
108-
hr := matchRange{start: m[0], end: m[1]}
109-
consumed := false
110-
for _, kr := range keywordRanges {
111-
if kr.overlaps(hr) {
112-
consumed = true
113-
break
114-
}
115-
}
116-
if consumed {
117-
continue
118-
}
119-
120-
refs = append(refs, ParsedReference{
121-
TaskNumber: number,
122-
Action: nil,
123-
CommitHash: "",
124-
CommitMessage: message,
125-
})
126-
}
127-
128-
return refs
129-
}
3+
import "strings"
1304

1315
// PushCommit is a flattened commit used by the service layer.
1326
type PushCommit struct {

internal/webhooks/errors.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package webhooks
2+
3+
import "errors"
4+
5+
var (
6+
ErrInvalidSignature = errors.New("invalid webhook signature")
7+
ErrEmptyKeyword = errors.New("must not be empty")
8+
ErrInvalidStatus = errors.New("invalid keyword status")
9+
)

0 commit comments

Comments
 (0)