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=
Comment on lines +249 to +260

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n -C4 'ACTION_KEYWORDS|SetDefault' internal/config internal/webhooks

Repository: bit-issues/backend

Length of output: 156


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Files mentioning WEBHOOKS__ACTION_KEYWORDS ==\n'
rg -n -C3 'WEBHOOKS__ACTION_KEYWORDS|ACTION_KEYWORDS' .

printf '\n== Files mentioning koanf defaults or env loading ==\n'
rg -n -C3 'koanf|SetDefault|env\(|env provider|provider\.env|Load\(' internal . || true

printf '\n== Relevant .env.example lines ==\n'
if [ -f .env.example ]; then
  nl -ba .env.example | sed -n '240,270p'
fi

Repository: bit-issues/backend

Length of output: 206


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== .env.example around the relevant lines ==\n'
if [ -f .env.example ]; then
  nl -ba .env.example | sed -n '245,265p'
fi

printf '\n== Config files and loaders ==\n'
fd -a '.*(config|webhook).*' internal . 2>/dev/null || true

printf '\n== Loader/default references in internal ==\n'
rg -n -C4 'SetDefault|WEBHOOKS__|koanf|provider\.env|Load\(' internal . || true

Repository: bit-issues/backend

Length of output: 242


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== .env.example around the relevant lines ==\n'
if [ -f .env.example ]; then
  sed -n '245,265p' .env.example | cat -n
fi

printf '\n== Candidate config files ==\n'
fd -a 'config|webhook' internal . 2>/dev/null || true

printf '\n== Loader/default references in internal ==\n'
rg -n -C4 'SetDefault|WEBHOOKS__|koanf|provider\.env|Load\(' internal . || true

Repository: bit-issues/backend

Length of output: 15258


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== internal/config/config.go: defaults and load order ==\n'
sed -n '77,170p' internal/config/config.go | cat -n

printf '\n== internal/webhooks/config.go ==\n'
sed -n '1,220p' internal/webhooks/config.go | cat -n

Repository: bit-issues/backend

Length of output: 3440


🌐 Web query:

koanf env provider empty environment variable map field override behavior

💡 Result:

In koanf, the environment variable provider does not have a special "empty map field" override behavior where an empty environment variable explicitly clears or nullifies a nested field [1][2][3]. Instead, koanf's environment variable provider operates by loading key-value pairs and merging them into the existing configuration structure [1][4]. When you load configuration using the env provider: 1. Transformation and Nesting: If a delimiter (such as ".") is provided, the provider attempts to "unflatten" environment variable keys by splitting them at the delimiter [1][2][4]. For example, a key like "PARENT_CHILD" might be transformed into "parent.child" [1][2]. 2. Merging Behavior: The provider merges these loaded keys into the global configuration map [1][3]. It does not inherently distinguish between an "empty" value and a "missing" value in a way that triggers a deletion or nullification of a nested map field [1][3]. 3. Overwriting: If an environment variable key corresponds to an existing key in your configuration, the new value from the environment variable will overwrite the existing value [1][4]. If you provide an empty string as the value for an environment variable, it will simply overwrite the existing configuration value with an empty string, not remove or "empty out" a nested structure [1][2]. If you find that your nested maps are not being overridden as expected, it is often due to how keys are transformed or how the delimiter is used [5][6]. To ensure environment variables correctly map to your configuration structure, use a consistent transformation function to convert environment variable names into the expected configuration keys (e.g., converting "APP_DB_HOST" to "app.db.host") [1][4]. If you are experiencing issues where Unmarshal does not work as expected when using the env provider, ensure you are providing the correct delimiter to the provider to allow for proper map unflattening [7][8]. Without a delimiter, the provider may not correctly structure nested keys, which can lead to unexpected behavior during unmarshaling [7][8].

Citations:


Populate WEBHOOKS__ACTION_KEYWORDS in .env.example. The app has a built-in default map, but an empty env var will override that field during config load, so copied environments won’t get the documented webhook keyword transitions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 249 - 260, Set WEBHOOKS__ACTION_KEYWORDS in
.env.example to the documented default JSON object instead of leaving it empty,
preserving all listed keyword-to-status transitions and optional verbs so copied
environments retain the built-in webhook behavior.

23 changes: 20 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,21 @@ 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"},
"fix": {Status: "Resolved", Verb: "Resolved"},
"resolves": {Status: "Resolved", Verb: "Resolved"},
"resolved": {Status: "Resolved", Verb: "Resolved"},
"resolve": {Status: "Resolved", Verb: "Resolved"},
"closes": {Status: "Closed", Verb: "Closed"},
"closed": {Status: "Closed", Verb: "Closed"},
"close": {Status: "Closed", Verb: "Closed"},
"blocks": {Status: "On Hold", Verb: "On Hold"},
"blocked": {Status: "On Hold", Verb: "On Hold"},
"block": {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
}
128 changes: 1 addition & 127 deletions internal/webhooks/domain.go
Original file line number Diff line number Diff line change
@@ -1,132 +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{
"fix": {Status: tasks.StatusResolved, Verb: verbResolved},
"fixes": {Status: tasks.StatusResolved, Verb: verbResolved},
"fixed": {Status: tasks.StatusResolved, Verb: verbResolved},
"resolve": {Status: tasks.StatusResolved, Verb: verbResolved},
"resolves": {Status: tasks.StatusResolved, Verb: verbResolved},
"resolved": {Status: tasks.StatusResolved, Verb: verbResolved},
"close": {Status: tasks.StatusClosed, Verb: verbClosed},
"closes": {Status: tasks.StatusClosed, Verb: verbClosed},
"closed": {Status: tasks.StatusClosed, Verb: verbClosed},
"block": {Status: tasks.StatusOnHold, Verb: verbOnHold},
"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(fix|fixes|fixed|resolve|resolves|resolved|close|closes|closed|block|blocks|blocked|on hold)\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