Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
19 changes: 16 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"time"

"github.com/bit-issues/backend/internal/webhooks"
"github.com/go-core-fx/config"
)

Expand Down Expand Up @@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -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"},
},
},
}
}
Expand Down
5 changes: 3 additions & 2 deletions internal/config/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
},
),
Expand Down
10 changes: 8 additions & 2 deletions internal/webhooks/config.go
Original file line number Diff line number Diff line change
@@ -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
}
124 changes: 1 addition & 123 deletions internal/webhooks/domain.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions internal/webhooks/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package webhooks

import "errors"

var (
ErrInvalidSignature = errors.New("invalid webhook signature")
ErrEmptyKeyword = errors.New("must not be empty")
ErrInvalidStatus = errors.New("invalid keyword status")
)
Loading
Loading