From b7edf1e725b4aac42ff85fdbc0c0b50a9d53cd6e Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 15 Oct 2026 15:18:27 +0700 Subject: [PATCH 1/3] [webhooks] support project-slug commit references --- README.md | 2 +- internal/commitref/doc.go | 4 + internal/commitref/errors.go | 8 + internal/commitref/parser.go | 242 ++++++++++++++++++++++++++++++ internal/commitref/parser_test.go | 197 ++++++++++++++++++++++++ internal/commitref/types.go | 14 ++ internal/config/config.go | 10 +- internal/webhooks/config.go | 7 +- internal/webhooks/errors.go | 2 - internal/webhooks/export_test.go | 41 +++++ internal/webhooks/parser.go | 159 -------------------- internal/webhooks/service.go | 94 ++++++++++-- internal/webhooks/service_test.go | 108 +++++++++++++ 13 files changed, 705 insertions(+), 183 deletions(-) create mode 100644 internal/commitref/doc.go create mode 100644 internal/commitref/errors.go create mode 100644 internal/commitref/parser.go create mode 100644 internal/commitref/parser_test.go create mode 100644 internal/commitref/types.go create mode 100644 internal/webhooks/export_test.go delete mode 100644 internal/webhooks/parser.go create mode 100644 internal/webhooks/service_test.go diff --git a/README.md b/README.md index a1d9db6..88e42ac 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Here's why BitIssues exists: - **Rich Filtering & Sorting** — tasks filterable by project, author, assignee, status, priority, date range - **Dashboard Queries** — quick access to tasks assigned to or created by the current user - **Swagger/OpenAPI** — auto-generated API docs at `/api/v1/docs` -- **Bitbucket Webhooks** — commit messages (e.g. `fixes #5`) automatically transition task statuses with bot-authored comments +- **Bitbucket Webhooks** — commit messages (e.g. `fixes #5`, or `fixes resto-web#13` for task 13 in project `resto-web`) automatically transition task statuses with bot-authored comments - **Bitbucket OAuth** — admin "Connect with Bitbucket" flow to manage repository webhooks; tokens encrypted at rest (AES-GCM) - **Prometheus Metrics** — built-in metrics endpoint for monitoring diff --git a/internal/commitref/doc.go b/internal/commitref/doc.go new file mode 100644 index 0000000..3750cde --- /dev/null +++ b/internal/commitref/doc.go @@ -0,0 +1,4 @@ +// Package commitref parses commit messages for task references (bare #N, +// keyword refs, and slug#N cross-project refs) and maps action keywords to +// task statuses. +package commitref diff --git a/internal/commitref/errors.go b/internal/commitref/errors.go new file mode 100644 index 0000000..41bc2c1 --- /dev/null +++ b/internal/commitref/errors.go @@ -0,0 +1,8 @@ +package commitref + +import "errors" + +var ( + ErrEmptyKeyword = errors.New("must not be empty") + ErrInvalidStatus = errors.New("invalid keyword status") +) diff --git a/internal/commitref/parser.go b/internal/commitref/parser.go new file mode 100644 index 0000000..65b8e5c --- /dev/null +++ b/internal/commitref/parser.go @@ -0,0 +1,242 @@ +package commitref + +import ( + "fmt" + "regexp" + "slices" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/bit-issues/backend/internal/tasks" +) + +// hashRefPattern matches "#NUMBER" preceded by start-of-string or a non-word character. +var hashRefPattern = regexp.MustCompile(`(?:^|\W)#(\d+)\b`) + +// prefixedRefPattern matches "slug#NUMBER" where the slug is preceded by +// start-of-string or a single opening boundary character (whitespace or open +// punctuation). RE2 has no lookbehind, so the boundary character is consumed +// by a non-capturing group and the slug is captured in submatch 1. +var prefixedRefPattern = regexp.MustCompile(`(?i)(?:^|[\s\[("'])([a-z0-9][a-z0-9_-]*?)#(\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, fmt.Errorf("%w: action_keywords", ErrEmptyKeyword) + } + + actions := make(map[string]KeywordAction, len(entries)) + keys := make([]string, 0, len(entries)) + + for keyword, entry := range entries { + if keyword == "" { + return nil, fmt.Errorf("%w: keyword", ErrEmptyKeyword) + } + 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+(?:([a-z0-9][a-z0-9_-]*?)#|#)(\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 (slug, number) pair found. +// A reference may carry an optional project-slug prefix (e.g. "resto-web#13"); +// a bare "#NUMBER" keeps ProjectSlug == "" and refers to the current project. +// If both a keyword and bare reference exist for the same (slug, number), the +// keyword wins. +func (kp *KeywordParser) ParseCommitMessage(message string) []ParsedReference { + seen := make(map[refKey]bool) + + keywordRefs, keywordRanges := kp.parseKeywordRefs(message, seen) + prefixedRefs, prefixedRanges := parsePrefixedRefs(message, seen, keywordRanges) + bareRefs := parseBareRefs(message, seen, keywordRanges, prefixedRanges) + + var refs []ParsedReference + refs = append(refs, keywordRefs...) + refs = append(refs, prefixedRefs...) + refs = append(refs, bareRefs...) + + return refs +} + +// parseKeywordRefs scans keyword matches and records each unique reference, +// returning the references and the message ranges they occupy. +func (kp *KeywordParser) parseKeywordRefs(message string, seen map[refKey]bool) ([]ParsedReference, []matchRange) { + keywordMatches := kp.pattern.FindAllStringSubmatchIndex(message, -1) + + var refs []ParsedReference + var ranges []matchRange + + for _, m := range keywordMatches { + keyword := strings.ToLower(message[m[2]:m[3]]) + + slug := "" + if m[4] >= 0 { + slug = message[m[4]:m[5]] + } + number, _ := strconv.Atoi(message[m[6]:m[7]]) + + key := refKey{slug: strings.ToLower(slug), number: number} + if seen[key] { + continue + } + seen[key] = true + + ranges = append(ranges, matchRange{start: m[0], end: m[1]}) + + action := kp.actions[keyword] + refs = append(refs, ParsedReference{ + TaskNumber: number, + ProjectSlug: slug, + Action: &KeywordAction{Status: action.Status, Verb: action.Verb}, + CommitHash: "", + CommitMessage: message, + }) + } + + return refs, ranges +} + +// refKey identifies a reference by its lowercased project slug and task number. +// Bare references (no slug) use an empty slug. +type refKey struct { + slug string + number int +} + +type matchRange struct{ start, end int } + +func (r matchRange) overlaps(other matchRange) bool { + return r.start < other.end && other.start < r.end +} + +// overlapsAny reports whether r overlaps any range in ranges. +func overlapsAny(r matchRange, ranges []matchRange) bool { + return slices.ContainsFunc(ranges, r.overlaps) +} + +// parsePrefixedRefs scans slug#NUMBER references not already seen and not +// overlapping a keyword match, returning the references and their ranges. +func parsePrefixedRefs( + message string, + seen map[refKey]bool, + keywordRanges []matchRange, +) ([]ParsedReference, []matchRange) { + prefixedMatches := prefixedRefPattern.FindAllStringSubmatchIndex(message, -1) + + var refs []ParsedReference + var ranges []matchRange + + for _, m := range prefixedMatches { + slug := message[m[2]:m[3]] + number, _ := strconv.Atoi(message[m[4]:m[5]]) + + key := refKey{slug: strings.ToLower(slug), number: number} + if seen[key] { + continue + } + + pr := matchRange{start: m[0], end: m[1]} + if overlapsAny(pr, keywordRanges) { + continue + } + + seen[key] = true + ranges = append(ranges, pr) + + refs = append(refs, ParsedReference{ + TaskNumber: number, + ProjectSlug: slug, + Action: nil, + CommitHash: "", + CommitMessage: message, + }) + } + + return refs, ranges +} + +// parseBareRefs scans #NUMBER references not already seen and not overlapping a +// keyword or prefixed match. +func parseBareRefs(message string, seen map[refKey]bool, keywordRanges, prefixedRanges []matchRange) []ParsedReference { + hashMatches := hashRefPattern.FindAllStringSubmatchIndex(message, -1) + + var refs []ParsedReference + + for _, m := range hashMatches { + number, _ := strconv.Atoi(message[m[2]:m[3]]) + + key := refKey{slug: "", number: number} + if seen[key] { + continue + } + + hr := matchRange{start: m[0], end: m[1]} + if overlapsAny(hr, keywordRanges) || overlapsAny(hr, prefixedRanges) { + continue + } + + seen[key] = true + + refs = append(refs, ParsedReference{ + TaskNumber: number, + ProjectSlug: "", + Action: nil, + CommitHash: "", + CommitMessage: message, + }) + } + + return refs +} + +// ParsedReference represents a single task reference found in a commit message. +type ParsedReference struct { + TaskNumber int + ProjectSlug string // empty means the current project + 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/commitref/parser_test.go b/internal/commitref/parser_test.go new file mode 100644 index 0000000..82d8d32 --- /dev/null +++ b/internal/commitref/parser_test.go @@ -0,0 +1,197 @@ +package commitref_test + +import ( + "testing" + + "github.com/bit-issues/backend/internal/commitref" +) + +func testParser(t *testing.T) *commitref.KeywordParser { + t.Helper() + kp, err := commitref.NewKeywordParser(map[string]commitref.KeywordEntry{ + "fixes": {Status: "Resolved", Verb: "Resolved"}, + "closes": {Status: "Closed", Verb: "Closed"}, + "resolves": {Status: "Resolved", Verb: "Resolved"}, + }) + if err != nil { + t.Fatalf("failed to build parser: %v", err) + } + return kp +} + +// refView is a comparable projection of ParsedReference for table assertions. +type refView struct { + slug string + number int + hasVerb bool + verb string + status string +} + +func toViews(refs []commitref.ParsedReference) []refView { + views := make([]refView, 0, len(refs)) + for _, r := range refs { + v := refView{slug: r.ProjectSlug, number: r.TaskNumber} + if r.Action != nil { + v.hasVerb = true + v.verb = r.Action.Verb + v.status = string(r.Action.Status) + } + views = append(views, v) + } + return views +} + +func equalViews(a, b []refView) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestParseCommitMessage_Table(t *testing.T) { + kp := testParser(t) + + cases := []struct { + name string + msg string + want []refView + }{ + { + name: "bare prefixed", + msg: "resto-web#13", + want: []refView{{slug: "resto-web", number: 13}}, + }, + { + name: "keyword prefixed", + msg: "fixes resto-web#13", + want: []refView{{slug: "resto-web", number: 13, hasVerb: true, verb: "Resolved", status: "Resolved"}}, + }, + { + name: "keyword prefixed at start with surrounding text", + msg: "chore: fixes resto-web#13 now", + want: []refView{{slug: "resto-web", number: 13, hasVerb: true, verb: "Resolved", status: "Resolved"}}, + }, + { + name: "case-insensitive slug captured raw", + msg: "Resto-Web#13", + want: []refView{{slug: "Resto-Web", number: 13}}, + }, + { + name: "compat bare", + msg: "#13", + want: []refView{{slug: "", number: 13}}, + }, + { + name: "compat bare embedded", + msg: "some work refs #42 done", + want: []refView{{slug: "", number: 42}}, + }, + { + name: "compat keyword bare", + msg: "fixes #13", + want: []refView{{slug: "", number: 13, hasVerb: true, verb: "Resolved", status: "Resolved"}}, + }, + { + name: "multiple refs prefixed and bare", + msg: "fixes resto-web#13 and api#7 then #42", + want: []refView{ + {slug: "resto-web", number: 13, hasVerb: true, verb: "Resolved", status: "Resolved"}, + {slug: "api", number: 7}, + {slug: "", number: 42}, + }, + }, + { + name: "dedup prefixed and bare keep distinct slugs", + msg: "myproj#13 and #13", + want: []refView{ + {slug: "myproj", number: 13}, + {slug: "", number: 13}, + }, + }, + { + name: "dedup identical case-insensitive prefixed", + msg: "MyProj#13 and myproj#13", + want: []refView{{slug: "MyProj", number: 13}}, + }, + { + name: "keyword wins over prefixed bare same pair", + msg: "fixes resto-web#13 then resto-web#13 again", + want: []refView{{slug: "resto-web", number: 13, hasVerb: true, verb: "Resolved", status: "Resolved"}}, + }, + { + name: "negative email-like context", + msg: "a@b#1", + want: nil, + }, + { + name: "negative url fragment", + msg: "https://x/page#1", + want: nil, + }, + { + name: "negative hash without digits", + msg: "resto-web#", + want: nil, + }, + { + name: "boundary open bracket prefixed", + msg: "see [resto-web#13] please", + want: []refView{{slug: "resto-web", number: 13}}, + }, + { + name: "boundary parenthesis prefixed", + msg: "see (resto-web#13) please", + want: []refView{{slug: "resto-web", number: 13}}, + }, + { + name: "slug with hyphen and digits", + msg: "fix-2fa#9", + want: []refView{{slug: "fix-2fa", number: 9}}, + }, + { + name: "empty message", + msg: "", + want: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := toViews(kp.ParseCommitMessage(tc.msg)) + if !equalViews(got, tc.want) { + t.Fatalf("ParseCommitMessage(%q):\n got %+v\nwant %+v", tc.msg, got, tc.want) + } + }) + } +} + +// TestParseCommitMessage_FieldRouting verifies CommitHash/CommitMessage routing +// and that Action is nil for bare references and non-nil for keyword references. +func TestParseCommitMessage_FieldRouting(t *testing.T) { + kp := testParser(t) + + refs := kp.ParseCommitMessage("resto-web#13") + if len(refs) != 1 { + t.Fatalf("want 1 ref, got %d", len(refs)) + } + if refs[0].Action != nil { + t.Fatalf("bare prefixed ref should have nil Action, got %+v", refs[0].Action) + } + if refs[0].CommitMessage != "resto-web#13" { + t.Fatalf("CommitMessage not routed: %q", refs[0].CommitMessage) + } + if refs[0].CommitHash != "" { + t.Fatalf("CommitHash should be empty from parser: %q", refs[0].CommitHash) + } + + refs = kp.ParseCommitMessage("fixes #13") + if len(refs) != 1 || refs[0].Action == nil { + t.Fatalf("keyword ref should have non-nil Action, got %+v", refs) + } +} diff --git a/internal/commitref/types.go b/internal/commitref/types.go new file mode 100644 index 0000000..a1e3556 --- /dev/null +++ b/internal/commitref/types.go @@ -0,0 +1,14 @@ +package commitref + +import "github.com/bit-issues/backend/internal/tasks" + +type KeywordEntry struct { + Status string `json:"status"` + Verb string `json:"verb"` +} + +// 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" +} diff --git a/internal/config/config.go b/internal/config/config.go index 97cb63d..c880f9d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,7 +5,7 @@ import ( "os" "time" - "github.com/bit-issues/backend/internal/webhooks" + "github.com/bit-issues/backend/internal/commitref" "github.com/go-core-fx/config" ) @@ -58,9 +58,9 @@ type cacheConfig struct { } type webhooksConfig struct { - Secret string `koanf:"secret"` - BotUserEmail string `koanf:"bot_user_email"` - ActionKeywords map[string]webhooks.KeywordEntry `koanf:"action_keywords"` + Secret string `koanf:"secret"` + BotUserEmail string `koanf:"bot_user_email"` + ActionKeywords map[string]commitref.KeywordEntry `koanf:"action_keywords"` } type oauthConfig struct { @@ -125,7 +125,7 @@ func Default() Config { Webhooks: webhooksConfig{ Secret: "", BotUserEmail: "bot@bitissues.local", - ActionKeywords: map[string]webhooks.KeywordEntry{ + ActionKeywords: map[string]commitref.KeywordEntry{ "fixes": {Status: "Resolved", Verb: "Resolved"}, "fixed": {Status: "Resolved", Verb: "Resolved"}, "fix": {Status: "Resolved", Verb: "Resolved"}, diff --git a/internal/webhooks/config.go b/internal/webhooks/config.go index c4b57ce..7af4a4f 100644 --- a/internal/webhooks/config.go +++ b/internal/webhooks/config.go @@ -1,12 +1,9 @@ package webhooks -type KeywordEntry struct { - Status string `json:"status"` - Verb string `json:"verb"` -} +import "github.com/bit-issues/backend/internal/commitref" type Config struct { Secret string BotUserEmail string - ActionKeywords map[string]KeywordEntry + ActionKeywords map[string]commitref.KeywordEntry } diff --git a/internal/webhooks/errors.go b/internal/webhooks/errors.go index 1ccdaf0..b6abe6e 100644 --- a/internal/webhooks/errors.go +++ b/internal/webhooks/errors.go @@ -4,6 +4,4 @@ import "errors" var ( ErrInvalidSignature = errors.New("invalid webhook signature") - ErrEmptyKeyword = errors.New("must not be empty") - ErrInvalidStatus = errors.New("invalid keyword status") ) diff --git a/internal/webhooks/export_test.go b/internal/webhooks/export_test.go new file mode 100644 index 0000000..67fbf21 --- /dev/null +++ b/internal/webhooks/export_test.go @@ -0,0 +1,41 @@ +package webhooks + +import "github.com/bit-issues/backend/internal/commitref" + +// This file is compiled only during testing. It exposes the unexported +// resolution/dedup helpers to the external test package without expanding the +// production API (owner convention: white-box access via export_test.go). + +// NormalizeSlugForTest exposes normalizeSlug to the external test package. +func NormalizeSlugForTest(slug string) string { + return normalizeSlug(slug) +} + +// ResolvedRefView is a test-only projection of a service-resolved reference. +type ResolvedRefView struct { + ProjectID string + TaskNumber int +} + +// DedupResolvedRefsForTest exposes dedupResolvedRefs to the external test +// package using a comparable projection. +func DedupResolvedRefsForTest(refs []ResolvedRefView) []ResolvedRefView { + in := make([]resolvedRef, 0, len(refs)) + for _, r := range refs { + in = append(in, resolvedRef{ + projectID: r.ProjectID, + ref: commitref.ParsedReference{TaskNumber: r.TaskNumber}, + }) + } + + out := dedupResolvedRefs(in) + views := make([]ResolvedRefView, 0, len(out)) + for _, r := range out { + views = append(views, ResolvedRefView{ + ProjectID: r.projectID, + TaskNumber: r.ref.TaskNumber, + }) + } + + return views +} diff --git a/internal/webhooks/parser.go b/internal/webhooks/parser.go deleted file mode 100644 index e00d8f7..0000000 --- a/internal/webhooks/parser.go +++ /dev/null @@ -1,159 +0,0 @@ -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, fmt.Errorf("%w: action_keywords", ErrEmptyKeyword) - } - - actions := make(map[string]KeywordAction, len(entries)) - keys := make([]string, 0, len(entries)) - - for keyword, entry := range entries { - if keyword == "" { - return nil, fmt.Errorf("%w: keyword", ErrEmptyKeyword) - } - 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 46b31fa..ca82cf5 100644 --- a/internal/webhooks/service.go +++ b/internal/webhooks/service.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/bit-issues/backend/internal/comments" + "github.com/bit-issues/backend/internal/commitref" "github.com/bit-issues/backend/internal/projects" "github.com/bit-issues/backend/internal/tasks" "github.com/bit-issues/backend/internal/users" @@ -25,7 +26,7 @@ type Service struct { usersSvc *users.Service logger *zap.Logger - keywordParser *KeywordParser + keywordParser *commitref.KeywordParser botUserID int64 } @@ -52,7 +53,7 @@ func NewService( } func (s *Service) Init(ctx context.Context) error { - parser, err := NewKeywordParser(s.config.ActionKeywords) + parser, err := commitref.NewKeywordParser(s.config.ActionKeywords) if err != nil { return fmt.Errorf("failed to build keyword parser: %w", err) } @@ -161,15 +162,42 @@ func (s *Service) processCommit( zap.Int("refs", len(refs)), ) + resolved := make([]resolvedRef, 0, len(refs)) for _, ref := range refs { ref.CommitHash = commit.Hash ref.CommitMessage = commit.Message - task, err := s.tasksSvc.GetByProjectAndNumber(ctx, project.ID, ref.TaskNumber) + projectID := project.ID + if ref.ProjectSlug != "" { + target, err := s.projectsSvc.GetBySlug(ctx, normalizeSlug(ref.ProjectSlug)) + if err != nil { + if errors.Is(err, projects.ErrNotFound) { + s.logger.Info("project not found for reference", + zap.String("project_slug", ref.ProjectSlug), + zap.Int("number", ref.TaskNumber), + ) + } else { + s.logger.Error("failed to resolve project for reference", + zap.String("project_slug", ref.ProjectSlug), + zap.Int("number", ref.TaskNumber), + zap.Error(err), + ) + } + continue + } + + projectID = target.ID + } + + resolved = append(resolved, resolvedRef{projectID: projectID, ref: ref}) + } + + for _, r := range dedupResolvedRefs(resolved) { + task, err := s.tasksSvc.GetByProjectAndNumber(ctx, r.projectID, r.ref.TaskNumber) if err != nil { s.logger.Info("task not found for reference", - zap.String("project_slug", project.ID), - zap.Int("number", ref.TaskNumber), + zap.String("project_slug", r.projectID), + zap.Int("number", r.ref.TaskNumber), zap.Error(err), ) continue @@ -177,22 +205,64 @@ func (s *Service) processCommit( s.logger.Debug("found task for reference", zap.Int64("task_id", task.ID), - zap.Int("number", ref.TaskNumber), + zap.Int("number", r.ref.TaskNumber), zap.String("current_status", string(task.Status)), ) - if ref.Action != nil { - s.applyStatusTransition(ctx, task, ref, result) + if r.ref.Action != nil { + s.applyStatusTransition(ctx, task, r.ref, result) } else { - s.addMentionComment(ctx, task, ref, result) + s.addMentionComment(ctx, task, r.ref, result) + } + } +} + +// normalizeSlug lowercases a project slug before lookup. projects.GetBySlug is +// an exact match on the slug primary key, so case-insensitive slug matching is +// the webhooks layer's responsibility. +func normalizeSlug(slug string) string { + return strings.ToLower(slug) +} + +// resolvedRef pairs a parsed reference with the project it resolves to. For a +// ref without a slug prefix the project is the repository-matched one; for a +// prefixed ref it is the project returned by the slug lookup. +type resolvedRef struct { + ref commitref.ParsedReference + projectID string +} + +// resolvedRefKey identifies the task a resolved reference points at. +type resolvedRefKey struct { + projectID string + number int +} + +// dedupResolvedRefs collapses references that resolve to the same +// (project, task number) pair, preserving first-occurrence order. The parser +// already dedups raw (slug, number) pairs; this is the final pass over resolved +// targets, so it also collapses a prefixed ref with a bare one when both point +// at the same project (e.g. "myproj#13" and "#13" while processing myproj). +func dedupResolvedRefs(refs []resolvedRef) []resolvedRef { + seen := make(map[resolvedRefKey]struct{}, len(refs)) + out := make([]resolvedRef, 0, len(refs)) + + for _, r := range refs { + key := resolvedRefKey{projectID: r.projectID, number: r.ref.TaskNumber} + if _, ok := seen[key]; ok { + continue } + seen[key] = struct{}{} + out = append(out, r) } + + return out } func (s *Service) applyStatusTransition( ctx context.Context, task *tasks.Task, - ref ParsedReference, + ref commitref.ParsedReference, result *ProcessResult, ) { if task.Status == ref.Action.Status { @@ -249,7 +319,9 @@ func (s *Service) applyStatusTransition( result.AddResolved() } -func (s *Service) addMentionComment(ctx context.Context, task *tasks.Task, ref ParsedReference, result *ProcessResult) { +func (s *Service) addMentionComment( + ctx context.Context, task *tasks.Task, ref commitref.ParsedReference, result *ProcessResult, +) { commentContent := fmt.Sprintf("Mentioned in commit <>: %s", ref.CommitHash, firstLine(ref.CommitMessage), ) diff --git a/internal/webhooks/service_test.go b/internal/webhooks/service_test.go new file mode 100644 index 0000000..43eab73 --- /dev/null +++ b/internal/webhooks/service_test.go @@ -0,0 +1,108 @@ +package webhooks_test + +import ( + "reflect" + "testing" + + "github.com/bit-issues/backend/internal/webhooks" +) + +// The webhook Service depends on concrete services backed by a *bun.DB, and the +// repository offers no in-memory/unmocked DB harness (MySQL via bun; no SQLite +// driver in deps). Constructing a Service for an end-to-end +// TestProcessCommit_CrossProjectRef would require either a new test dependency +// or a testability seam on the Service, both disallowed by the plan and owner +// conventions. These tests therefore cover the pure resolution/dedup helpers +// that processCommit uses. What remains verified by inspection/manually: +// - projects.GetBySlug resolution of a lowercased slug (service.go processCommit) +// - log-and-skip on unknown slug without aborting remaining refs +// - task lookup, status transition, and bot comment for the resolved project + +func TestNormalizeSlug(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"already lowercase", "resto-web", "resto-web"}, + {"mixed case", "Resto-Web", "resto-web"}, + {"all upper", "API", "api"}, + {"with digits", "Fix-2FA", "fix-2fa"}, + {"empty", "", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := webhooks.NormalizeSlugForTest(tc.in); got != tc.want { + t.Fatalf("NormalizeSlugForTest(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestDedupResolvedRefs(t *testing.T) { + cases := []struct { + name string + in []webhooks.ResolvedRefView + want []webhooks.ResolvedRefView + }{ + { + name: "same project and number collapse", + in: []webhooks.ResolvedRefView{ + {ProjectID: "myproj", TaskNumber: 13}, + {ProjectID: "myproj", TaskNumber: 13}, + }, + want: []webhooks.ResolvedRefView{ + {ProjectID: "myproj", TaskNumber: 13}, + }, + }, + { + name: "different projects same number kept", + in: []webhooks.ResolvedRefView{ + {ProjectID: "resto-web", TaskNumber: 13}, + {ProjectID: "api", TaskNumber: 13}, + }, + want: []webhooks.ResolvedRefView{ + {ProjectID: "resto-web", TaskNumber: 13}, + {ProjectID: "api", TaskNumber: 13}, + }, + }, + { + name: "same project different numbers kept", + in: []webhooks.ResolvedRefView{ + {ProjectID: "resto-web", TaskNumber: 13}, + {ProjectID: "resto-web", TaskNumber: 7}, + }, + want: []webhooks.ResolvedRefView{ + {ProjectID: "resto-web", TaskNumber: 13}, + {ProjectID: "resto-web", TaskNumber: 7}, + }, + }, + { + name: "first occurrence wins and order preserved", + in: []webhooks.ResolvedRefView{ + {ProjectID: "resto-web", TaskNumber: 13}, + {ProjectID: "api", TaskNumber: 7}, + {ProjectID: "resto-web", TaskNumber: 13}, + }, + want: []webhooks.ResolvedRefView{ + {ProjectID: "resto-web", TaskNumber: 13}, + {ProjectID: "api", TaskNumber: 7}, + }, + }, + { + name: "empty input", + in: nil, + want: []webhooks.ResolvedRefView{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := webhooks.DedupResolvedRefsForTest(tc.in) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("DedupResolvedRefsForTest(%+v) = %+v, want %+v", tc.in, got, tc.want) + } + }) + } +} From 99d98f3d664bb952bcc695993b14e91f8fafbabb Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Wed, 21 Oct 2026 12:05:19 +0700 Subject: [PATCH 2/3] [lint] update golangci-lint config and fix reported issues --- .golangci.yml | 82 +++++++++++-------- internal/commitref/parser_test.go | 6 ++ internal/db/pagination.go | 3 +- internal/oauth/crypto_test.go | 10 +++ internal/oauth/states_test.go | 2 + internal/server/auth/handler.go | 2 +- .../server/middlewares/jwtauth/jwtauth.go | 2 +- internal/server/oauth/handler.go | 2 +- internal/server/passkey/handler.go | 2 +- internal/server/projects/handler.go | 2 +- internal/server/tasks/handler.go | 2 +- internal/server/users/handler.go | 2 +- internal/webauthn/sessions.go | 5 +- internal/webhooks/service_test.go | 8 ++ 14 files changed, 87 insertions(+), 43 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 1d8443c..9651b49 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,7 @@ # This file is licensed under the terms of the MIT license https://opensource.org/license/mit # Copyright (c) 2021-2025 Marat Reimers -## Golden config for golangci-lint v2.9.0 +## Golden config for golangci-lint v2.14.0 # # This is the best config for golangci-lint based on my experience and opinion. # It is very strict, but not extremely strict. @@ -23,12 +23,13 @@ formatters: - goimports # checks if the code and import statements are formatted according to the 'goimports' command - golines # checks if code is formatted, and fixes long lines - swaggo # formats swaggo comments + ## you may want to enable #- gci # checks if code and import statements are formatted, with additional rules #- gofmt # checks if the code is formatted according to 'gofmt' command #- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible - # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml settings: golines: # Target maximum line length. @@ -42,6 +43,7 @@ linters: - bidichk # checks for dangerous unicode character sequences - bodyclose # checks whether HTTP response body is closed successfully - canonicalheader # checks whether net/http.Header uses canonical header + - clickhouselint # detects common mistakes with the ClickHouse native Go driver API - copyloopvar # detects places where loop variables are copied (Go 1.22+) - cyclop # checks function and package cyclomatic complexity - depguard # checks if package imports are in a list of acceptable packages @@ -53,7 +55,7 @@ linters: - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 - exhaustive # checks exhaustiveness of enum switch statements - - exhaustruct # [highly recommend to enable] checks if all structure fields are initialized + - exhaustruct_v5 # [highly recommend to enable] checks if all structure fields are initialized - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions - fatcontext # detects nested contexts in loops - forbidigo # forbids identifiers @@ -92,6 +94,7 @@ linters: - nolintlint # reports ill-formed or insufficient nolint directives - nonamedreturns # reports all named returns - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL + - paralleltest # detects missing usage of t.Parallel() method in your Go test - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative - predeclared # finds code that shadows one of Go's predeclared identifiers - promlinter # checks Prometheus metrics naming via promlint @@ -117,6 +120,7 @@ linters: - wastedassign # finds wasted assignment statements - whitespace # detects leading and trailing whitespace - wrapcheck # checks that errors returned from external packages are wrapped + ## you may want to enable #- arangolint # opinionated best practices for arangodb client #- decorder # checks declaration order and count of types, constants, variables and functions @@ -138,8 +142,9 @@ linters: #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) #- dupword # [useless without config] checks for duplicate words in the source code #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- exhaustruct # [use exhaustruct_v5 instead] checks if all structure fields are initialized #- forcetypeassert # [replaced by errcheck] finds forced type assertions - #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies + #- gomodguard_v2: [use more powerful depguard] allow and blocklist linter for direct Go module dependencies #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase #- grouper # analyzes expression groups #- importas # enforces consistent import aliases @@ -147,13 +152,11 @@ linters: #- maintidx # measures the maintainability index of each function #- misspell # [useless] finds commonly misspelled English words in comments #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity - #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test #- tagliatelle # checks the struct tags #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers - #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines #- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines - # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml + # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml settings: cyclop: # The maximal code complexity to report. @@ -194,8 +197,7 @@ linters: # Default: [] deny: - pkg: github.com/golang/protobuf - desc: Use google.golang.org/protobuf instead, see - https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules - pkg: github.com/satori/go.uuid desc: Use github.com/google/uuid instead, satori's package is not maintained - pkg: github.com/gofrs/uuid$ @@ -229,17 +231,17 @@ linters: # Default: [ switch ] check: - switch - - map + - mapliteral - exhaustruct: - # List of regular expressions to match type names that should be excluded from processing. + exhaustruct_v5: + # List of regular expressions to match type names that should be skipped from checking. # Anonymous structs can be matched by '' alias. # Has precedence over `include`. # Each regular expression must match the full type name, including package path. # For example, to match type `net/http.Cookie` regular expression should be `.*/http\.Cookie`, # but not `http\.Cookie`. # Default: [] - exclude: + ignore-patterns: # std libs - ^net.ListenConfig$ - ^net/http.Client$ @@ -254,6 +256,7 @@ linters: # public libs - ^firebase.google.com/go/v4/messaging.AndroidConfig$ - ^firebase.google.com/go/v4/messaging.Message$ + - ^github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager.+Input$ - ^github.com/aws/aws-sdk-go-v2/service/s3.+Input$ - ^github.com/aws/aws-sdk-go-v2/service/s3/types.ObjectIdentifier$ - ^github.com/go-telegram-bot-api/telegram-bot-api/.+Config$ @@ -262,9 +265,14 @@ linters: - ^github.com/go-webauthn/webauthn/webauthn.+(Config|Credential)$ - ^github.com/gofiber/.+Config$ - ^github.com/golang-jwt/jwt/v5.+Claims$ + - ^github.com/invopop/jsonschema.Reflector$ - ^github.com/minio/minio-go/.+Options$ - ^github.com/mitchellh/mapstructure.DecoderConfig$ - - ^github.com/mymmrac/telego.+Params$ + - ^github.com/mymmrac/telego.+$ + - ^github.com/openai/openai-go/v3/shared\.ResponseFormatJSONSchemaJSONSchemaParam$ + - ^github.com/openai/openai-go/v3/shared\.ResponseFormatJSONSchemaParam$ + - ^github.com/openai/openai-go/v3\.ChatCompletionNewParams$ + - ^github.com/openai/openai-go/v3\.ChatCompletionNewParamsResponseFormatUnion$ - ^github.com/prometheus/client_golang/.+Opts$ - ^github.com/revrost/go-openrouter.+$ - ^github.com/secsy/goftp.Config$ @@ -279,6 +287,7 @@ linters: - ^github.com/urfave/cli.v3.ArgumentBase$ - ^github.com/urfave/cli.v3.Command$ - ^github.com/urfave/cli.v3.FlagBase$ + - ^github.com/urfave/cli.v3.+Flag$ - ^github.com/valyala/fasthttp.Client$ - ^golang.org/x/tools/go/analysis.Analyzer$ - ^google.golang.org/protobuf/.+Options$ @@ -290,6 +299,9 @@ linters: # Allows empty structures in return statements. # Default: false allow-empty-returns: true + # Allow empty literals the blank identifier receives. + # Default: false + allow-empty-blank-assignments: true funcorder: # Checks if the exported methods of a structure are placed before the non-exported ones. @@ -408,6 +420,9 @@ linters: # Optimizes into strings concatenation. # Default: true strconcat: false + # Enable/disable optimization of error formatting. + # Default: true + error-format: false # https://github.com/catenacyber/perfsprint/issues/62 reassign: # Patterns for global variable names that are checked for reassignment. @@ -423,21 +438,15 @@ linters: - github.com/jmoiron/sqlx sloglint: - # Enforce not using global loggers. - # Values: - # - "": disabled - # - "all": report all global loggers - # - "default": report only the default slog logger - # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global - # Default: "" + # Report the use of global loggers. + # https://github.com/go-simpler/sloglint#no-global-logger + # Values: "all", "default" + # Default: "" (disabled) no-global: all - # Enforce using methods that accept a context. - # Values: - # - "": disabled - # - "all": report all contextless calls - # - "scope": report only if a context exists in the scope of the outermost function - # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only - # Default: "" + # Report the use of functions without a context.Context. + # https://github.com/go-simpler/sloglint#context-only + # Values: "all", "scope" + # Default: "" (disabled) context: scope staticcheck: @@ -463,11 +472,18 @@ linters: wrapcheck: extra-ignore-sigs: + - .Exit( - .JSON( + - .NewError( + - .Next( + - .Send( - .SendStatus( exclusions: generated: lax + # Log a warning if an exclusion rule is unused. + # Default: false + warn-unused: true # Predefined exclusion rules. # Default: [] presets: @@ -481,23 +497,23 @@ linters: linters: [revive] - text: "avoid package names that conflict with Go standard library package names" linters: [revive] - - text: "exported \\S+ \\S+ should have comment( \\(or a comment on this - block\\))? or be unexported" + - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' linters: [revive] - text: 'package comment should be of the form ".+"' source: "// ?(nolint|TODO)" linters: [revive] - - text: "comment on exported \\S+ \\S+ should be of the form \".+\"" + - text: 'comment on exported \S+ \S+ should be of the form ".+"' source: "// ?(nolint|TODO)" linters: [revive, staticcheck] - - path: "_test\\.go" + - path: '_test\.go' linters: - bodyclose - dupl - err113 - errcheck - - exhaustruct + - exhaustruct_v5 - funlen + - gochecknoglobals - gocognit - goconst - gosec diff --git a/internal/commitref/parser_test.go b/internal/commitref/parser_test.go index 82d8d32..4915452 100644 --- a/internal/commitref/parser_test.go +++ b/internal/commitref/parser_test.go @@ -55,6 +55,8 @@ func equalViews(a, b []refView) bool { } func TestParseCommitMessage_Table(t *testing.T) { + t.Parallel() + kp := testParser(t) cases := []struct { @@ -163,6 +165,8 @@ func TestParseCommitMessage_Table(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := toViews(kp.ParseCommitMessage(tc.msg)) if !equalViews(got, tc.want) { t.Fatalf("ParseCommitMessage(%q):\n got %+v\nwant %+v", tc.msg, got, tc.want) @@ -174,6 +178,8 @@ func TestParseCommitMessage_Table(t *testing.T) { // TestParseCommitMessage_FieldRouting verifies CommitHash/CommitMessage routing // and that Action is nil for bare references and non-nil for keyword references. func TestParseCommitMessage_FieldRouting(t *testing.T) { + t.Parallel() + kp := testParser(t) refs := kp.ParseCommitMessage("resto-web#13") diff --git a/internal/db/pagination.go b/internal/db/pagination.go index 7fd1ae5..b814b3e 100644 --- a/internal/db/pagination.go +++ b/internal/db/pagination.go @@ -15,8 +15,9 @@ type Pagination[T DefaultPagination] struct { } func NewPagination[T DefaultPagination](limit, offset int) *Pagination[T] { - //nolint:exhaustruct // zero value + var def T return &Pagination[T]{ + def: def, limit: limit, offset: offset, } diff --git a/internal/oauth/crypto_test.go b/internal/oauth/crypto_test.go index 0024478..7493ce7 100644 --- a/internal/oauth/crypto_test.go +++ b/internal/oauth/crypto_test.go @@ -18,6 +18,8 @@ func testEncryptor(t *testing.T) *oauth.Encryptor { } func TestEncryptor_RoundTrip(t *testing.T) { + t.Parallel() + enc := testEncryptor(t) cases := []string{ @@ -44,6 +46,8 @@ func TestEncryptor_RoundTrip(t *testing.T) { } func TestEncryptor_Empty(t *testing.T) { + t.Parallel() + enc := testEncryptor(t) ct, err := enc.Encrypt("") if err != nil || ct != "" { @@ -56,6 +60,8 @@ func TestEncryptor_Empty(t *testing.T) { } func TestEncryptor_TamperFails(t *testing.T) { + t.Parallel() + enc := testEncryptor(t) ct, err := enc.Encrypt("top-secret") if err != nil { @@ -73,6 +79,8 @@ func TestEncryptor_TamperFails(t *testing.T) { } func TestNewEncryptorFromConfig_Errors(t *testing.T) { + t.Parallel() + if _, err := oauth.NewEncryptorFromConfig(""); err == nil { t.Fatalf("expected error for empty key") } @@ -90,6 +98,8 @@ func TestNewEncryptorFromConfig_Errors(t *testing.T) { } func TestNewEncryptorFromConfig_HexKeyPrecedence(t *testing.T) { + t.Parallel() + // A 32-char hex string is also valid base64; ensure it is interpreted as // raw hex bytes (16 bytes -> AES-128), not as 24 base64-decoded bytes. hexKey := "000102030405060708090a0b0c0d0e0f" diff --git a/internal/oauth/states_test.go b/internal/oauth/states_test.go index af01519..b833b3f 100644 --- a/internal/oauth/states_test.go +++ b/internal/oauth/states_test.go @@ -7,6 +7,8 @@ import ( ) func TestGenerateStateUnique(t *testing.T) { + t.Parallel() + seen := make(map[string]struct{}, 100) for range 100 { s, err := oauth.GenerateState() diff --git a/internal/server/auth/handler.go b/internal/server/auth/handler.go index 3180b0e..3a926a8 100644 --- a/internal/server/auth/handler.go +++ b/internal/server/auth/handler.go @@ -216,6 +216,6 @@ func (h *Handler) errorsHandler(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusUnauthorized, err.Error()) default: - return err //nolint:wrapcheck // err is already wrapped + return err } } diff --git a/internal/server/middlewares/jwtauth/jwtauth.go b/internal/server/middlewares/jwtauth/jwtauth.go index 1da645a..9356158 100644 --- a/internal/server/middlewares/jwtauth/jwtauth.go +++ b/internal/server/middlewares/jwtauth/jwtauth.go @@ -63,7 +63,7 @@ func ErrorsHandler() fiber.Handler { return fiber.NewError(fiber.StatusUnauthorized, err.Error()) default: - return err //nolint:wrapcheck // err is already wrapped + return err } } } diff --git a/internal/server/oauth/handler.go b/internal/server/oauth/handler.go index 69fd5a3..531b6aa 100644 --- a/internal/server/oauth/handler.go +++ b/internal/server/oauth/handler.go @@ -193,6 +193,6 @@ func (h *Handler) errorHandler(c *fiber.Ctx) error { case errors.Is(err, oauth.ErrDisabled): return fiber.NewError(fiber.StatusNotImplemented, err.Error()) default: - return err //nolint:wrapcheck // err is already wrapped + return err } } diff --git a/internal/server/passkey/handler.go b/internal/server/passkey/handler.go index 93922be..211e899 100644 --- a/internal/server/passkey/handler.go +++ b/internal/server/passkey/handler.go @@ -279,6 +279,6 @@ func (h *Handler) errorsHandler(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusUnauthorized, err.Error()) default: - return err //nolint:wrapcheck // err is already wrapped + return err } } diff --git a/internal/server/projects/handler.go b/internal/server/projects/handler.go index 146d007..82402d2 100644 --- a/internal/server/projects/handler.go +++ b/internal/server/projects/handler.go @@ -236,6 +236,6 @@ func (h *Handler) errorsHandler(c *fiber.Ctx) error { case errors.Is(err, projects.ErrInvalidURL): return fiber.NewError(fiber.StatusBadRequest, err.Error()) default: - return err //nolint:wrapcheck // err is already wrapped + return err } } diff --git a/internal/server/tasks/handler.go b/internal/server/tasks/handler.go index a9a70a8..4edc4a8 100644 --- a/internal/server/tasks/handler.go +++ b/internal/server/tasks/handler.go @@ -492,6 +492,6 @@ func (h *Handler) errorsHandler(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, err.Error()) default: - return err //nolint:wrapcheck // err is already wrapped + return err } } diff --git a/internal/server/users/handler.go b/internal/server/users/handler.go index b9a161a..8d0110c 100644 --- a/internal/server/users/handler.go +++ b/internal/server/users/handler.go @@ -188,6 +188,6 @@ func (h *Handler) errorsHandler(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, err.Error()) default: - return err //nolint:wrapcheck // err is already wrapped + return err } } diff --git a/internal/webauthn/sessions.go b/internal/webauthn/sessions.go index 83db153..952002d 100644 --- a/internal/webauthn/sessions.go +++ b/internal/webauthn/sessions.go @@ -26,8 +26,9 @@ func (e *sessionEntry) Marshal() ([]byte, error) { } func (e *sessionEntry) Unmarshal(data []byte) error { - e.SessionData = &webauthn.SessionData{} //nolint:exhaustruct // populated by Unmarshal - if err := json.Unmarshal(data, e.SessionData); err != nil { + var sd webauthn.SessionData + e.SessionData = &sd + if err := json.Unmarshal(data, &sd); err != nil { return fmt.Errorf("unmarshal session data: %w", err) } return nil diff --git a/internal/webhooks/service_test.go b/internal/webhooks/service_test.go index 43eab73..3f71a49 100644 --- a/internal/webhooks/service_test.go +++ b/internal/webhooks/service_test.go @@ -19,6 +19,8 @@ import ( // - task lookup, status transition, and bot comment for the resolved project func TestNormalizeSlug(t *testing.T) { + t.Parallel() + cases := []struct { name string in string @@ -33,6 +35,8 @@ func TestNormalizeSlug(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := webhooks.NormalizeSlugForTest(tc.in); got != tc.want { t.Fatalf("NormalizeSlugForTest(%q) = %q, want %q", tc.in, got, tc.want) } @@ -41,6 +45,8 @@ func TestNormalizeSlug(t *testing.T) { } func TestDedupResolvedRefs(t *testing.T) { + t.Parallel() + cases := []struct { name string in []webhooks.ResolvedRefView @@ -99,6 +105,8 @@ func TestDedupResolvedRefs(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := webhooks.DedupResolvedRefsForTest(tc.in) if !reflect.DeepEqual(got, tc.want) { t.Fatalf("DedupResolvedRefsForTest(%+v) = %+v, want %+v", tc.in, got, tc.want) From 69b04b5b455b4e4cf0d483ca9375124ab6d58cf6 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 22 Oct 2026 10:05:23 +0700 Subject: [PATCH 3/3] [commitref] promote to DI service with own config --- .env.example | 11 +- README.md | 1 + internal/commands/serve/serve.go | 2 + internal/commitref/bare_parser.go | 41 +++ internal/commitref/config.go | 8 + internal/commitref/doc.go | 4 +- internal/commitref/errors.go | 5 +- internal/commitref/keyword_parser.go | 125 +++++++++ internal/commitref/module.go | 14 + internal/commitref/parser.go | 247 ++---------------- internal/commitref/prefixed_parser.go | 46 ++++ internal/commitref/service.go | 41 +++ .../{parser_test.go => service_test.go} | 162 +++++++++++- internal/commitref/types.go | 9 + internal/config/config.go | 10 +- internal/config/module.go | 11 +- internal/webhooks/config.go | 7 +- internal/webhooks/export_test.go | 4 +- internal/webhooks/service.go | 58 ++-- internal/webhooks/service_test.go | 10 +- 20 files changed, 538 insertions(+), 278 deletions(-) create mode 100644 internal/commitref/bare_parser.go create mode 100644 internal/commitref/config.go create mode 100644 internal/commitref/keyword_parser.go create mode 100644 internal/commitref/module.go create mode 100644 internal/commitref/prefixed_parser.go create mode 100644 internal/commitref/service.go rename internal/commitref/{parser_test.go => service_test.go} (52%) diff --git a/.env.example b/.env.example index 278751a..b3c4c7d 100644 --- a/.env.example +++ b/.env.example @@ -262,18 +262,25 @@ WEBHOOKS__SECRET= # Example: bot@bitissues.local WEBHOOKS__BOT_USER_EMAIL=bot@bitissues.local -# Webhook Action Keywords +# ============================================================================= +# COMMITREF CONFIGURATION +# ============================================================================= + +# Commit Reference 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. +# Set to '{}' to disable action keywords entirely: commits are parsed as +# mentions only (bare #N and slug#N references add a mention comment; no +# keyword ever triggers a status transition). # 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='{"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"}}' +COMMITREF__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"}}' # ============================================================================= # BITBUCKET CONFIGURATION diff --git a/README.md b/README.md index 88e42ac..08b691e 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ Configuration is loaded from environment variables with optional YAML override v | `WEBAUTHN__RP_ORIGINS` | `["http://localhost:5173"]` | Allowed origins JSON array | | `WEBHOOKS__SECRET` | — | HMAC-SHA256 secret for webhook payload verification | | `WEBHOOKS__BOT_USER_EMAIL` | `bot@bitissues.local` | Email of the bot that posts comments | +| `COMMITREF__ACTION_KEYWORDS` | see `.env.example` | Commit keyword → task status transition map (JSON) | | `OAUTH__CLIENT_ID` | `empty (disabled)` | Bitbucket OAuth consumer key | | `OAUTH__CLIENT_SECRET` | `empty (disabled)` | Bitbucket OAuth consumer secret | | `OAUTH__TOKEN_ENCRYPTION_KEY` | `empty (disabled)` | AES-256 key for encrypting OAuth tokens at rest | diff --git a/internal/commands/serve/serve.go b/internal/commands/serve/serve.go index a3c8cab..1353e7a 100644 --- a/internal/commands/serve/serve.go +++ b/internal/commands/serve/serve.go @@ -6,6 +6,7 @@ import ( "github.com/bit-issues/backend/internal/attachments" "github.com/bit-issues/backend/internal/comments" + "github.com/bit-issues/backend/internal/commitref" "github.com/bit-issues/backend/internal/config" "github.com/bit-issues/backend/internal/db" "github.com/bit-issues/backend/internal/jwt" @@ -78,6 +79,7 @@ func run(ctx context.Context, version healthfx.Version) error { fx.Supply(version), attachments.Module(), comments.Module(), + commitref.Module(), jwt.Module(), oauth.Module(), projects.Module(), diff --git a/internal/commitref/bare_parser.go b/internal/commitref/bare_parser.go new file mode 100644 index 0000000..6559d18 --- /dev/null +++ b/internal/commitref/bare_parser.go @@ -0,0 +1,41 @@ +package commitref + +import ( + "regexp" + "strconv" +) + +// hashRefPattern matches "#NUMBER" preceded by start-of-string or a non-word, +// non-slash character. The slash exclusion keeps URL fragments like +// "https://x/#13" from being parsed as task references; "/" is a \W character, +// so a plain (?:^|\W) boundary would consume it. +var hashRefPattern = regexp.MustCompile(`(?:^|[^\w/])#(\d+)\b`) + +// bareParser matches #NUMBER references referring to the current project; the +// resulting references carry no status transition. +type bareParser struct{} + +func (bareParser) parse(message string, st *parseState) []ParsedReference { + matches := hashRefPattern.FindAllStringSubmatchIndex(message, -1) + + var refs []ParsedReference + + for _, m := range matches { + number, _ := strconv.Atoi(message[m[2]:m[3]]) + + key := refKey{slug: "", number: number} + if !st.claim(key, matchRange{start: m[0], end: m[1]}) { + continue + } + + refs = append(refs, ParsedReference{ + TaskNumber: number, + ProjectSlug: "", + Action: nil, + CommitHash: "", + CommitMessage: message, + }) + } + + return refs +} diff --git a/internal/commitref/config.go b/internal/commitref/config.go new file mode 100644 index 0000000..fb25b8b --- /dev/null +++ b/internal/commitref/config.go @@ -0,0 +1,8 @@ +package commitref + +// Config holds the settings for parsing commit messages. +type Config struct { + // ActionKeywords maps a commit message keyword to the task status + // transition it triggers. + ActionKeywords map[string]KeywordEntry +} diff --git a/internal/commitref/doc.go b/internal/commitref/doc.go index 3750cde..42b8b2b 100644 --- a/internal/commitref/doc.go +++ b/internal/commitref/doc.go @@ -1,4 +1,6 @@ // Package commitref parses commit messages for task references (bare #N, // keyword refs, and slug#N cross-project refs) and maps action keywords to -// task statuses. +// task statuses. Parsing is split into a chain of internal parsers sharing +// per-message dedup state; the package is provided as a service so every +// consumer shares the same configured keyword table. package commitref diff --git a/internal/commitref/errors.go b/internal/commitref/errors.go index 41bc2c1..3840f46 100644 --- a/internal/commitref/errors.go +++ b/internal/commitref/errors.go @@ -3,6 +3,7 @@ package commitref import "errors" var ( - ErrEmptyKeyword = errors.New("must not be empty") - ErrInvalidStatus = errors.New("invalid keyword status") + ErrEmptyKeyword = errors.New("must not be empty") + ErrInvalidStatus = errors.New("invalid keyword status") + ErrDuplicateKeyword = errors.New("duplicate keyword") ) diff --git a/internal/commitref/keyword_parser.go b/internal/commitref/keyword_parser.go new file mode 100644 index 0000000..cccd0a3 --- /dev/null +++ b/internal/commitref/keyword_parser.go @@ -0,0 +1,125 @@ +package commitref + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/bit-issues/backend/internal/tasks" +) + +// keywordParser matches configured action keywords and attaches status +// transitions to the references they carry. +type keywordParser struct { + actions map[string]KeywordAction + pattern *regexp.Regexp +} + +// newKeywordParser validates cfg.ActionKeywords and compiles the keyword +// matcher from the sorted keyword list. An empty map yields a parser with no +// matcher that claims nothing, so the chain runs in mentions-only mode; an +// invalid configuration is returned as an error so the app fails at startup +// instead of silently ignoring commit references. +func newKeywordParser(cfg Config) (*keywordParser, error) { + actions := make(map[string]KeywordAction, len(cfg.ActionKeywords)) + keys := make([]string, 0, len(cfg.ActionKeywords)) + + for keyword, entry := range cfg.ActionKeywords { + if keyword == "" { + return nil, fmt.Errorf("%w: keyword", ErrEmptyKeyword) + } + kw := strings.ToLower(keyword) + if _, exists := actions[kw]; exists { + return nil, fmt.Errorf("%w: %q", ErrDuplicateKeyword, kw) + } + + 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)) + } + + if len(keys) == 0 { + return &keywordParser{actions: actions, pattern: nil}, nil + } + + sort.Strings(keys) + + return &keywordParser{ + actions: actions, + pattern: regexp.MustCompile( + `(?i)\b(` + strings.Join(keys, "|") + `)\s+(?:([a-z0-9][a-z0-9_-]*?)#|#)(\d+)\b`, + ), + }, nil +} + +// parse scans keyword matches, claiming each unique (slug, number) pair. A +// claimed reference carries the keyword's status transition; the keyword wins +// over later parsers for the same pair because it runs first. A parser built +// from an empty keyword map has no matcher and claims nothing. +func (p *keywordParser) parse(message string, st *parseState) []ParsedReference { + if p.pattern == nil { + return nil + } + + matches := p.pattern.FindAllStringSubmatchIndex(message, -1) + + var refs []ParsedReference + + for _, m := range matches { + keyword := strings.ToLower(message[m[2]:m[3]]) + + // The (?i) flag folds some characters that strings.ToLower leaves + // unchanged (e.g. U+017F long s), so a match may not have a configured + // action. Skip it before claiming, leaving the pair to later parsers. + action, ok := p.actions[keyword] + if !ok { + continue + } + + slug := "" + if m[4] >= 0 { + slug = message[m[4]:m[5]] + } + number, _ := strconv.Atoi(message[m[6]:m[7]]) + + key := refKey{slug: strings.ToLower(slug), number: number} + if !st.claim(key, matchRange{start: m[0], end: m[1]}) { + continue + } + + refs = append(refs, ParsedReference{ + TaskNumber: number, + ProjectSlug: slug, + Action: &KeywordAction{Status: action.Status, Verb: action.Verb}, + CommitHash: "", + CommitMessage: message, + }) + } + + return refs +} + +// 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/commitref/module.go b/internal/commitref/module.go new file mode 100644 index 0000000..209d338 --- /dev/null +++ b/internal/commitref/module.go @@ -0,0 +1,14 @@ +package commitref + +import "go.uber.org/fx" + +// Module creates and returns an FX module for the commitref package. +// +// The module provides: +// - commitref.Service (public) - for use by other modules that scan commit messages +func Module() fx.Option { + return fx.Module( + "commitref", + fx.Provide(NewService), + ) +} diff --git a/internal/commitref/parser.go b/internal/commitref/parser.go index 65b8e5c..b7fd34d 100644 --- a/internal/commitref/parser.go +++ b/internal/commitref/parser.go @@ -1,129 +1,37 @@ package commitref -import ( - "fmt" - "regexp" - "slices" - "sort" - "strconv" - "strings" - "unicode" - - "github.com/bit-issues/backend/internal/tasks" -) - -// hashRefPattern matches "#NUMBER" preceded by start-of-string or a non-word character. -var hashRefPattern = regexp.MustCompile(`(?:^|\W)#(\d+)\b`) - -// prefixedRefPattern matches "slug#NUMBER" where the slug is preceded by -// start-of-string or a single opening boundary character (whitespace or open -// punctuation). RE2 has no lookbehind, so the boundary character is consumed -// by a non-capturing group and the slug is captured in submatch 1. -var prefixedRefPattern = regexp.MustCompile(`(?i)(?:^|[\s\[("'])([a-z0-9][a-z0-9_-]*?)#(\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, fmt.Errorf("%w: action_keywords", ErrEmptyKeyword) +import "slices" + +// parser extracts task references from a commit message. Parsers run as an +// ordered chain inside Service; each parser claims its matches through +// parseState, which centralizes dedup and overlap precedence across the chain. +// Precedence is chain order: a later parser never shadows an earlier one. +type parser interface { + parse(message string, st *parseState) []ParsedReference +} + +// parseState carries the shared dedup and precedence state for a single +// ParseCommitMessage call across the parser chain. +type parseState struct { + seen map[refKey]bool + ranges []matchRange +} + +// claim records the match for key unless the key was already claimed or the +// match overlaps a range claimed by an earlier parser. A failed claim leaves +// the state untouched: the key stays available to later parsers, mirroring the +// original per-parser skip semantics. +func (st *parseState) claim(key refKey, r matchRange) bool { + if st.seen[key] { + return false } - - actions := make(map[string]KeywordAction, len(entries)) - keys := make([]string, 0, len(entries)) - - for keyword, entry := range entries { - if keyword == "" { - return nil, fmt.Errorf("%w: keyword", ErrEmptyKeyword) - } - 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+(?:([a-z0-9][a-z0-9_-]*?)#|#)(\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 (slug, number) pair found. -// A reference may carry an optional project-slug prefix (e.g. "resto-web#13"); -// a bare "#NUMBER" keeps ProjectSlug == "" and refers to the current project. -// If both a keyword and bare reference exist for the same (slug, number), the -// keyword wins. -func (kp *KeywordParser) ParseCommitMessage(message string) []ParsedReference { - seen := make(map[refKey]bool) - - keywordRefs, keywordRanges := kp.parseKeywordRefs(message, seen) - prefixedRefs, prefixedRanges := parsePrefixedRefs(message, seen, keywordRanges) - bareRefs := parseBareRefs(message, seen, keywordRanges, prefixedRanges) - - var refs []ParsedReference - refs = append(refs, keywordRefs...) - refs = append(refs, prefixedRefs...) - refs = append(refs, bareRefs...) - - return refs -} - -// parseKeywordRefs scans keyword matches and records each unique reference, -// returning the references and the message ranges they occupy. -func (kp *KeywordParser) parseKeywordRefs(message string, seen map[refKey]bool) ([]ParsedReference, []matchRange) { - keywordMatches := kp.pattern.FindAllStringSubmatchIndex(message, -1) - - var refs []ParsedReference - var ranges []matchRange - - for _, m := range keywordMatches { - keyword := strings.ToLower(message[m[2]:m[3]]) - - slug := "" - if m[4] >= 0 { - slug = message[m[4]:m[5]] - } - number, _ := strconv.Atoi(message[m[6]:m[7]]) - - key := refKey{slug: strings.ToLower(slug), number: number} - if seen[key] { - continue - } - seen[key] = true - - ranges = append(ranges, matchRange{start: m[0], end: m[1]}) - - action := kp.actions[keyword] - refs = append(refs, ParsedReference{ - TaskNumber: number, - ProjectSlug: slug, - Action: &KeywordAction{Status: action.Status, Verb: action.Verb}, - CommitHash: "", - CommitMessage: message, - }) + if overlapsAny(r, st.ranges) { + return false } - return refs, ranges + st.seen[key] = true + st.ranges = append(st.ranges, r) + return true } // refKey identifies a reference by its lowercased project slug and task number. @@ -143,100 +51,3 @@ func (r matchRange) overlaps(other matchRange) bool { func overlapsAny(r matchRange, ranges []matchRange) bool { return slices.ContainsFunc(ranges, r.overlaps) } - -// parsePrefixedRefs scans slug#NUMBER references not already seen and not -// overlapping a keyword match, returning the references and their ranges. -func parsePrefixedRefs( - message string, - seen map[refKey]bool, - keywordRanges []matchRange, -) ([]ParsedReference, []matchRange) { - prefixedMatches := prefixedRefPattern.FindAllStringSubmatchIndex(message, -1) - - var refs []ParsedReference - var ranges []matchRange - - for _, m := range prefixedMatches { - slug := message[m[2]:m[3]] - number, _ := strconv.Atoi(message[m[4]:m[5]]) - - key := refKey{slug: strings.ToLower(slug), number: number} - if seen[key] { - continue - } - - pr := matchRange{start: m[0], end: m[1]} - if overlapsAny(pr, keywordRanges) { - continue - } - - seen[key] = true - ranges = append(ranges, pr) - - refs = append(refs, ParsedReference{ - TaskNumber: number, - ProjectSlug: slug, - Action: nil, - CommitHash: "", - CommitMessage: message, - }) - } - - return refs, ranges -} - -// parseBareRefs scans #NUMBER references not already seen and not overlapping a -// keyword or prefixed match. -func parseBareRefs(message string, seen map[refKey]bool, keywordRanges, prefixedRanges []matchRange) []ParsedReference { - hashMatches := hashRefPattern.FindAllStringSubmatchIndex(message, -1) - - var refs []ParsedReference - - for _, m := range hashMatches { - number, _ := strconv.Atoi(message[m[2]:m[3]]) - - key := refKey{slug: "", number: number} - if seen[key] { - continue - } - - hr := matchRange{start: m[0], end: m[1]} - if overlapsAny(hr, keywordRanges) || overlapsAny(hr, prefixedRanges) { - continue - } - - seen[key] = true - - refs = append(refs, ParsedReference{ - TaskNumber: number, - ProjectSlug: "", - Action: nil, - CommitHash: "", - CommitMessage: message, - }) - } - - return refs -} - -// ParsedReference represents a single task reference found in a commit message. -type ParsedReference struct { - TaskNumber int - ProjectSlug string // empty means the current project - 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/commitref/prefixed_parser.go b/internal/commitref/prefixed_parser.go new file mode 100644 index 0000000..61795a1 --- /dev/null +++ b/internal/commitref/prefixed_parser.go @@ -0,0 +1,46 @@ +package commitref + +import ( + "regexp" + "strconv" + "strings" +) + +// prefixedRefPattern matches "slug#NUMBER" where the slug is preceded by +// start-of-string or a single opening boundary character (whitespace, open +// punctuation, or the ":" and "," delimiters used in commit subjects such as +// "chore:resto-web#13"). Slashes are deliberately not boundaries, so URL paths +// ("https://x/foo#1") do not match. RE2 has no lookbehind, so the boundary +// character is consumed by a non-capturing group and the slug is captured in +// submatch 1. +var prefixedRefPattern = regexp.MustCompile(`(?i)(?:^|[\s\[("':,])([a-z0-9][a-z0-9_-]*?)#(\d+)\b`) + +// prefixedParser matches slug#NUMBER references without keyword context; the +// resulting references carry no status transition. +type prefixedParser struct{} + +func (prefixedParser) parse(message string, st *parseState) []ParsedReference { + matches := prefixedRefPattern.FindAllStringSubmatchIndex(message, -1) + + var refs []ParsedReference + + for _, m := range matches { + slug := message[m[2]:m[3]] + number, _ := strconv.Atoi(message[m[4]:m[5]]) + + key := refKey{slug: strings.ToLower(slug), number: number} + if !st.claim(key, matchRange{start: m[0], end: m[1]}) { + continue + } + + refs = append(refs, ParsedReference{ + TaskNumber: number, + ProjectSlug: slug, + Action: nil, + CommitHash: "", + CommitMessage: message, + }) + } + + return refs +} diff --git a/internal/commitref/service.go b/internal/commitref/service.go new file mode 100644 index 0000000..cac7c95 --- /dev/null +++ b/internal/commitref/service.go @@ -0,0 +1,41 @@ +package commitref + +// Service parses commit messages for task references by running an ordered +// chain of parsers over shared per-message state. +type Service struct { + parsers []parser +} + +// NewService builds a Service from cfg. It validates every keyword status, +// rejects keywords that collide after case normalization, and compiles the +// keyword matcher from the sorted keyword list. An invalid configuration is +// returned as an error so the app fails at startup instead of silently +// ignoring commit references. An empty ActionKeywords map leaves the keyword +// matcher uncompiled: the chain then runs in mentions-only mode, so bare #N +// and slug#N references still parse but no keyword carries a status +// transition. +func NewService(cfg Config) (*Service, error) { + kwParser, err := newKeywordParser(cfg) + if err != nil { + return nil, err + } + + return &Service{parsers: []parser{kwParser, prefixedParser{}, bareParser{}}}, nil +} + +// ParseCommitMessage scans a commit message for task references. +// Returns a list of ParsedReference, one per unique (slug, number) pair found. +// A reference may carry an optional project-slug prefix (e.g. "resto-web#13"); +// a bare "#NUMBER" keeps ProjectSlug == "" and refers to the current project. +// Parsers run in precedence order (keyword, prefixed, bare); if both a keyword +// and bare reference exist for the same (slug, number), the keyword wins. +func (s *Service) ParseCommitMessage(message string) []ParsedReference { + st := &parseState{seen: make(map[refKey]bool), ranges: nil} + + var refs []ParsedReference + for _, p := range s.parsers { + refs = append(refs, p.parse(message, st)...) + } + + return refs +} diff --git a/internal/commitref/parser_test.go b/internal/commitref/service_test.go similarity index 52% rename from internal/commitref/parser_test.go rename to internal/commitref/service_test.go index 4915452..62b9fdf 100644 --- a/internal/commitref/parser_test.go +++ b/internal/commitref/service_test.go @@ -1,22 +1,25 @@ package commitref_test import ( + "errors" "testing" "github.com/bit-issues/backend/internal/commitref" ) -func testParser(t *testing.T) *commitref.KeywordParser { +func testService(t *testing.T) *commitref.Service { t.Helper() - kp, err := commitref.NewKeywordParser(map[string]commitref.KeywordEntry{ - "fixes": {Status: "Resolved", Verb: "Resolved"}, - "closes": {Status: "Closed", Verb: "Closed"}, - "resolves": {Status: "Resolved", Verb: "Resolved"}, + svc, err := commitref.NewService(commitref.Config{ + ActionKeywords: map[string]commitref.KeywordEntry{ + "fixes": {Status: "Resolved", Verb: "Resolved"}, + "closes": {Status: "Closed", Verb: "Closed"}, + "resolves": {Status: "Resolved", Verb: "Resolved"}, + }, }) if err != nil { - t.Fatalf("failed to build parser: %v", err) + t.Fatalf("failed to build service: %v", err) } - return kp + return svc } // refView is a comparable projection of ParsedReference for table assertions. @@ -57,7 +60,7 @@ func equalViews(a, b []refView) bool { func TestParseCommitMessage_Table(t *testing.T) { t.Parallel() - kp := testParser(t) + svc := testService(t) cases := []struct { name string @@ -136,11 +139,41 @@ func TestParseCommitMessage_Table(t *testing.T) { msg: "https://x/page#1", want: nil, }, + { + name: "negative url fragment after slash", + msg: "see https://x/#13 for details", + want: nil, + }, + { + name: "negative url path prefixed", + msg: "https://x/foo/resto-web#13", + want: nil, + }, + { + name: "prefixed after colon delimiter", + msg: "chore:resto-web#13", + want: []refView{{slug: "resto-web", number: 13}}, + }, + { + name: "prefixed after comma delimiter", + msg: "update,resto-web#13 and more", + want: []refView{{slug: "resto-web", number: 13}}, + }, { name: "negative hash without digits", msg: "resto-web#", want: nil, }, + { + name: "long-s keyword folds to match but has no action, bare falls back", + msg: "fixeſ #13", + want: []refView{{slug: "", number: 13}}, + }, + { + name: "long-s keyword falls back to prefixed mention", + msg: "fixeſ resto-web#13", + want: []refView{{slug: "resto-web", number: 13}}, + }, { name: "boundary open bracket prefixed", msg: "see [resto-web#13] please", @@ -167,7 +200,7 @@ func TestParseCommitMessage_Table(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := toViews(kp.ParseCommitMessage(tc.msg)) + got := toViews(svc.ParseCommitMessage(tc.msg)) if !equalViews(got, tc.want) { t.Fatalf("ParseCommitMessage(%q):\n got %+v\nwant %+v", tc.msg, got, tc.want) } @@ -180,9 +213,9 @@ func TestParseCommitMessage_Table(t *testing.T) { func TestParseCommitMessage_FieldRouting(t *testing.T) { t.Parallel() - kp := testParser(t) + svc := testService(t) - refs := kp.ParseCommitMessage("resto-web#13") + refs := svc.ParseCommitMessage("resto-web#13") if len(refs) != 1 { t.Fatalf("want 1 ref, got %d", len(refs)) } @@ -196,8 +229,113 @@ func TestParseCommitMessage_FieldRouting(t *testing.T) { t.Fatalf("CommitHash should be empty from parser: %q", refs[0].CommitHash) } - refs = kp.ParseCommitMessage("fixes #13") + refs = svc.ParseCommitMessage("fixes #13") if len(refs) != 1 || refs[0].Action == nil { t.Fatalf("keyword ref should have non-nil Action, got %+v", refs) } } + +// TestNewService_InvalidConfig verifies that an invalid keyword configuration +// is rejected so the app fails at startup instead of ignoring references. +func TestNewService_InvalidConfig(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + cfg commitref.Config + wantErr error + }{ + { + name: "empty keyword key", + cfg: commitref.Config{ActionKeywords: map[string]commitref.KeywordEntry{"": {Status: "Resolved"}}}, + wantErr: commitref.ErrEmptyKeyword, + }, + { + name: "invalid status", + cfg: commitref.Config{ActionKeywords: map[string]commitref.KeywordEntry{ + "fixes": {Status: "Bogus"}, + }}, + wantErr: commitref.ErrInvalidStatus, + }, + { + name: "duplicate keyword after case normalization", + cfg: commitref.Config{ActionKeywords: map[string]commitref.KeywordEntry{ + "Fixes": {Status: "Resolved"}, + "fixes": {Status: "Closed"}, + }}, + wantErr: commitref.ErrDuplicateKeyword, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + svc, err := commitref.NewService(tc.cfg) + if err == nil { + t.Fatalf("want error, got service %+v", svc) + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("want error %v, got %v", tc.wantErr, err) + } + }) + } +} + +// TestNewService_MentionsOnly verifies that an empty action-keyword map +// constructs successfully and parses every reference as a mention: Action must +// stay nil (never an empty status action), keyword words in the message are +// ignored, and bare/prefixed references keep parsing. +func TestNewService_MentionsOnly(t *testing.T) { + t.Parallel() + + svc, err := commitref.NewService(commitref.Config{ + ActionKeywords: map[string]commitref.KeywordEntry{}, + }) + if err != nil { + t.Fatalf("empty keyword map should yield a mentions-only service: %v", err) + } + + cases := []struct { + name string + msg string + want []refView + }{ + { + name: "keyword word becomes plain bare mention", + msg: "fixes #13", + want: []refView{{slug: "", number: 13}}, + }, + { + name: "keyword word before prefixed slug", + msg: "fixes resto-web#13", + want: []refView{{slug: "resto-web", number: 13}}, + }, + { + name: "bare prefixed", + msg: "resto-web#13", + want: []refView{{slug: "resto-web", number: 13}}, + }, + { + name: "bare hash", + msg: "#13", + want: []refView{{slug: "", number: 13}}, + }, + { + name: "text around hash", + msg: "some work refs #42 done", + want: []refView{{slug: "", number: 42}}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := toViews(svc.ParseCommitMessage(tc.msg)) + if !equalViews(got, tc.want) { + t.Fatalf("ParseCommitMessage(%q):\n got %+v\nwant %+v", tc.msg, got, tc.want) + } + }) + } +} diff --git a/internal/commitref/types.go b/internal/commitref/types.go index a1e3556..30e51d3 100644 --- a/internal/commitref/types.go +++ b/internal/commitref/types.go @@ -12,3 +12,12 @@ type KeywordAction struct { Status tasks.Status // target status Verb string // past-tense label for comment, e.g. "Resolved" } + +// ParsedReference represents a single task reference found in a commit message. +type ParsedReference struct { + TaskNumber int + ProjectSlug string // empty means the current project + Action *KeywordAction // nil if bare #N with no keyword + CommitHash string + CommitMessage string +} diff --git a/internal/config/config.go b/internal/config/config.go index c880f9d..e0501ed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,8 +58,11 @@ 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"` +} + +type commitrefConfig struct { ActionKeywords map[string]commitref.KeywordEntry `koanf:"action_keywords"` } @@ -78,6 +81,7 @@ type Config struct { WebAuthn webauthnConfig `koanf:"webauthn"` Cache cacheConfig `koanf:"cache"` Webhooks webhooksConfig `koanf:"webhooks"` + Commitref commitrefConfig `koanf:"commitref"` OAuth oauthConfig `koanf:"oauth"` } @@ -125,6 +129,8 @@ func Default() Config { Webhooks: webhooksConfig{ Secret: "", BotUserEmail: "bot@bitissues.local", + }, + Commitref: commitrefConfig{ ActionKeywords: map[string]commitref.KeywordEntry{ "fixes": {Status: "Resolved", Verb: "Resolved"}, "fixed": {Status: "Resolved", Verb: "Resolved"}, diff --git a/internal/config/module.go b/internal/config/module.go index b3b3b11..8818aeb 100644 --- a/internal/config/module.go +++ b/internal/config/module.go @@ -2,6 +2,7 @@ package config import ( "github.com/bit-issues/backend/internal/attachments" + "github.com/bit-issues/backend/internal/commitref" "github.com/bit-issues/backend/internal/jwt" "github.com/bit-issues/backend/internal/oauth" "github.com/bit-issues/backend/internal/storage" @@ -70,9 +71,13 @@ func Module() fx.Option { }, func(cfg Config) webhooks.Config { return webhooks.Config{ - Secret: cfg.Webhooks.Secret, - BotUserEmail: cfg.Webhooks.BotUserEmail, - ActionKeywords: cfg.Webhooks.ActionKeywords, + Secret: cfg.Webhooks.Secret, + BotUserEmail: cfg.Webhooks.BotUserEmail, + } + }, + func(cfg Config) commitref.Config { + return commitref.Config{ + ActionKeywords: cfg.Commitref.ActionKeywords, } }, func(cfg Config) webauthn.Config { diff --git a/internal/webhooks/config.go b/internal/webhooks/config.go index 7af4a4f..2c745bb 100644 --- a/internal/webhooks/config.go +++ b/internal/webhooks/config.go @@ -1,9 +1,6 @@ package webhooks -import "github.com/bit-issues/backend/internal/commitref" - type Config struct { - Secret string - BotUserEmail string - ActionKeywords map[string]commitref.KeywordEntry + Secret string + BotUserEmail string } diff --git a/internal/webhooks/export_test.go b/internal/webhooks/export_test.go index 67fbf21..bf3d81c 100644 --- a/internal/webhooks/export_test.go +++ b/internal/webhooks/export_test.go @@ -15,6 +15,7 @@ func NormalizeSlugForTest(slug string) string { type ResolvedRefView struct { ProjectID string TaskNumber int + CommitHash string } // DedupResolvedRefsForTest exposes dedupResolvedRefs to the external test @@ -24,7 +25,7 @@ func DedupResolvedRefsForTest(refs []ResolvedRefView) []ResolvedRefView { for _, r := range refs { in = append(in, resolvedRef{ projectID: r.ProjectID, - ref: commitref.ParsedReference{TaskNumber: r.TaskNumber}, + ref: commitref.ParsedReference{TaskNumber: r.TaskNumber, CommitHash: r.CommitHash}, }) } @@ -34,6 +35,7 @@ func DedupResolvedRefsForTest(refs []ResolvedRefView) []ResolvedRefView { views = append(views, ResolvedRefView{ ProjectID: r.projectID, TaskNumber: r.ref.TaskNumber, + CommitHash: r.ref.CommitHash, }) } diff --git a/internal/webhooks/service.go b/internal/webhooks/service.go index ca82cf5..810f9b2 100644 --- a/internal/webhooks/service.go +++ b/internal/webhooks/service.go @@ -18,20 +18,21 @@ import ( ) type Service struct { - config Config + config Config + botUserID int64 - projectsSvc *projects.Service - tasksSvc *tasks.Service - commentsSvc *comments.Service - usersSvc *users.Service - logger *zap.Logger + projectsSvc *projects.Service + tasksSvc *tasks.Service + commentsSvc *comments.Service + usersSvc *users.Service + commitrefSvc *commitref.Service - keywordParser *commitref.KeywordParser - botUserID int64 + logger *zap.Logger } func NewService( cfg Config, + commitrefSvc *commitref.Service, projectsSvc *projects.Service, tasksSvc *tasks.Service, commentsSvc *comments.Service, @@ -39,26 +40,20 @@ func NewService( logger *zap.Logger, ) (*Service, error) { return &Service{ - config: cfg, + config: cfg, + botUserID: -1, - projectsSvc: projectsSvc, - tasksSvc: tasksSvc, - commentsSvc: commentsSvc, - usersSvc: usersSvc, - logger: logger, + projectsSvc: projectsSvc, + tasksSvc: tasksSvc, + commentsSvc: commentsSvc, + usersSvc: usersSvc, + commitrefSvc: commitrefSvc, - keywordParser: nil, - botUserID: -1, + logger: logger, }, nil } func (s *Service) Init(ctx context.Context) error { - parser, err := commitref.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) @@ -151,7 +146,7 @@ func (s *Service) processCommit( commit PushCommit, result *ProcessResult, ) { - refs := s.keywordParser.ParseCommitMessage(commit.Message) + refs := s.commitrefSvc.ParseCommitMessage(commit.Message) if len(refs) == 0 { return } @@ -239,10 +234,19 @@ type resolvedRefKey struct { } // dedupResolvedRefs collapses references that resolve to the same -// (project, task number) pair, preserving first-occurrence order. The parser -// already dedups raw (slug, number) pairs; this is the final pass over resolved -// targets, so it also collapses a prefixed ref with a bare one when both point -// at the same project (e.g. "myproj#13" and "#13" while processing myproj). +// (project, task number) pair, keeping the first reference in slice order. +// The parser already dedups raw (slug, number) pairs; this is the final pass +// over resolved targets, so it also collapses a prefixed ref with a bare one +// when both point at the same project (e.g. "myproj#13" and "#13" while +// processing myproj). +// +// Slice order is reference-kind order (keyword, then prefixed, then bare), not +// message position, so a keyword reference is retained over a plain one for +// the same resolved task even when it appears later in the commit message +// (e.g. "resto-web#13; fixes #13"). This mirrors the parser's documented +// keyword-wins precedence from the original pre-commitref keyword parser and +// is deliberate: an explicit action keyword outranks a bare mention regardless +// of position. func dedupResolvedRefs(refs []resolvedRef) []resolvedRef { seen := make(map[resolvedRefKey]struct{}, len(refs)) out := make([]resolvedRef, 0, len(refs)) diff --git a/internal/webhooks/service_test.go b/internal/webhooks/service_test.go index 3f71a49..8877541 100644 --- a/internal/webhooks/service_test.go +++ b/internal/webhooks/service_test.go @@ -87,13 +87,13 @@ func TestDedupResolvedRefs(t *testing.T) { { name: "first occurrence wins and order preserved", in: []webhooks.ResolvedRefView{ - {ProjectID: "resto-web", TaskNumber: 13}, - {ProjectID: "api", TaskNumber: 7}, - {ProjectID: "resto-web", TaskNumber: 13}, + {ProjectID: "resto-web", TaskNumber: 13, CommitHash: "aaaaaaa"}, + {ProjectID: "api", TaskNumber: 7, CommitHash: "bbbbbbb"}, + {ProjectID: "resto-web", TaskNumber: 13, CommitHash: "ccccccc"}, }, want: []webhooks.ResolvedRefView{ - {ProjectID: "resto-web", TaskNumber: 13}, - {ProjectID: "api", TaskNumber: 7}, + {ProjectID: "resto-web", TaskNumber: 13, CommitHash: "aaaaaaa"}, + {ProjectID: "api", TaskNumber: 7, CommitHash: "bbbbbbb"}, }, }, {