mirror of
https://github.com/muety/wakapi.git
synced 2025-12-05 22:20:24 -08:00
feat: wildcard aliases (resolve #607)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
4
go.mod
4
go.mod
@@ -42,12 +42,11 @@ require (
|
||||
gorm.io/gorm v1.25.11
|
||||
)
|
||||
|
||||
require github.com/samber/slog-multi v1.2.1 // indirect
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/BurntSushi/toml v1.4.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/becheran/wildmatch-go v1.0.0
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -78,6 +77,7 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/samber/lo v1.44.0 // indirect
|
||||
github.com/samber/slog-common v0.17.0 // indirect
|
||||
github.com/samber/slog-multi v1.2.1
|
||||
github.com/samber/slog-sentry/v2 v2.8.0
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -40,6 +40,8 @@ github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHc
|
||||
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
||||
github.com/alitto/pond v1.9.1 h1:OfCpIrMyrWJpn34f647DcFmUxjK8+7Nu3eoVN/WTP+o=
|
||||
github.com/alitto/pond v1.9.1/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI=
|
||||
github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA=
|
||||
github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
2
main.go
2
main.go
@@ -180,7 +180,7 @@ func main() {
|
||||
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
|
||||
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
||||
durationService = services.NewDurationService(heartbeatService)
|
||||
summaryService = services.NewSummaryService(summaryRepository, durationService, aliasService, projectLabelService)
|
||||
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, durationService, aliasService, projectLabelService)
|
||||
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||
reportService = services.NewReportService(summaryService, userService, mailService)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
|
||||
// AliasResolver returns the alias of an Entity, given its original name. I.e., it returns Alias.Key, given an Alias.Value
|
||||
type AliasResolver func(t uint8, k string) string
|
||||
|
||||
@@ -16,7 +18,10 @@ type Alias struct {
|
||||
}
|
||||
|
||||
func (a *Alias) IsValid() bool {
|
||||
return a.Key != "" && a.Value != "" && a.validateType()
|
||||
return a.Key != "" &&
|
||||
a.Value != "" &&
|
||||
a.validateType() &&
|
||||
a.validateWildcard()
|
||||
}
|
||||
|
||||
func (a *Alias) validateType() bool {
|
||||
@@ -27,3 +32,13 @@ func (a *Alias) validateType() bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Alias) validateWildcard() bool {
|
||||
if !strings.Contains(a.Value, "*") && !strings.Contains(a.Value, "?") {
|
||||
return true
|
||||
}
|
||||
v := a.Value
|
||||
v = strings.ReplaceAll(v, "*", "")
|
||||
v = strings.ReplaceAll(v, "?", "")
|
||||
return len(v) >= 3 // don't allow "*" or "a*" or sth.
|
||||
}
|
||||
|
||||
@@ -356,15 +356,14 @@ func (s *Summary) MaxByToString(entityType uint8) string {
|
||||
}
|
||||
|
||||
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||
processAliases := func(origin []*SummaryItem) []*SummaryItem {
|
||||
if origin == nil {
|
||||
processAliases := func(items []*SummaryItem) []*SummaryItem {
|
||||
if items == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
target := make([]*SummaryItem, 0)
|
||||
itemsAliased := make([]*SummaryItem, 0)
|
||||
|
||||
findItem := func(key string) *SummaryItem {
|
||||
for _, item := range target {
|
||||
for _, item := range itemsAliased {
|
||||
if item.Key == key {
|
||||
return item
|
||||
}
|
||||
@@ -372,20 +371,20 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
for _, item := range items {
|
||||
// Add all "top-level" items, i.e. such without aliases
|
||||
if key := resolve(item.Type, item.Key); key == item.Key {
|
||||
target = append(target, item)
|
||||
itemsAliased = append(itemsAliased, item)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
for _, item := range items {
|
||||
// Add all remaining projects and merge with their alias
|
||||
if key := resolve(item.Type, item.Key); key != item.Key {
|
||||
if targetItem := findItem(key); targetItem != nil {
|
||||
targetItem.Total += item.Total
|
||||
} else {
|
||||
target = append(target, &SummaryItem{
|
||||
itemsAliased = append(itemsAliased, &SummaryItem{
|
||||
ID: item.ID,
|
||||
SummaryID: item.SummaryID,
|
||||
Type: item.Type,
|
||||
@@ -396,7 +395,7 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
return itemsAliased
|
||||
}
|
||||
|
||||
// Resolve aliases
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/becheran/wildmatch-go"
|
||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
@@ -75,9 +76,13 @@ func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, val
|
||||
srv.MayInitializeUser(userId)
|
||||
}
|
||||
|
||||
match := func(aliasValue string, itemKey string) bool {
|
||||
return wildmatch.NewWildMatch(aliasValue).IsMatch(itemKey)
|
||||
}
|
||||
|
||||
if aliases, ok := userAliases.Load(userId); ok {
|
||||
for _, a := range aliases.([]*models.Alias) {
|
||||
if a.Type == summaryType && a.Value == value {
|
||||
if a.Type == summaryType && match(a.Value, value) {
|
||||
return a.Key, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ func (suite *AliasServiceTestSuite) SetupSuite() {
|
||||
Key: "wakapi",
|
||||
Value: "wakapi-mobile",
|
||||
},
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
UserID: suite.TestUserId,
|
||||
Key: "telepush",
|
||||
Value: "telepush-*",
|
||||
},
|
||||
}
|
||||
|
||||
aliasRepoMock := new(mocks.AliasRepositoryMock)
|
||||
@@ -44,6 +50,8 @@ func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() {
|
||||
result1, err1 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi-mobile")
|
||||
result2, err2 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi")
|
||||
result3, err3 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "anchr")
|
||||
result4, err4 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "telepush-mobile")
|
||||
result5, err5 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryLanguage, "telepush-mobile")
|
||||
|
||||
assert.Equal(suite.T(), "wakapi", result1)
|
||||
assert.Nil(suite.T(), err1)
|
||||
@@ -51,4 +59,8 @@ func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() {
|
||||
assert.Nil(suite.T(), err2)
|
||||
assert.Equal(suite.T(), "anchr", result3)
|
||||
assert.Nil(suite.T(), err3)
|
||||
assert.Equal(suite.T(), "telepush", result4)
|
||||
assert.Nil(suite.T(), err4)
|
||||
assert.Equal(suite.T(), "telepush-mobile", result5)
|
||||
assert.Nil(suite.T(), err5)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
TestProject1 = "test-project-1"
|
||||
TestProject2 = "test-project-2"
|
||||
TestProject3 = "test-project-3"
|
||||
TestProject4 = "something-completely-different-4"
|
||||
TestLanguageGo = "Go"
|
||||
TestLanguageJava = "Java"
|
||||
TestLanguagePython = "Python"
|
||||
|
||||
@@ -2,7 +2,9 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/becheran/wildmatch-go"
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
@@ -20,17 +22,19 @@ type SummaryService struct {
|
||||
cache *cache.Cache
|
||||
eventBus *hub.Hub
|
||||
repository repositories.ISummaryRepository
|
||||
heartbeatService IHeartbeatService
|
||||
durationService IDurationService
|
||||
aliasService IAliasService
|
||||
projectLabelService IProjectLabelService
|
||||
}
|
||||
|
||||
func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationService IDurationService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
|
||||
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, durationService IDurationService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
|
||||
srv := &SummaryService{
|
||||
config: config.Get(),
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
eventBus: config.EventBus(),
|
||||
repository: summaryRepo,
|
||||
heartbeatService: heartbeatService,
|
||||
durationService: durationService,
|
||||
aliasService: aliasService,
|
||||
projectLabelService: projectLabelService,
|
||||
@@ -481,11 +485,36 @@ func (srv *SummaryService) getAliasReverseResolver(user *models.User) models.Ali
|
||||
return func(t uint8, k string) []string {
|
||||
aliases, err := srv.aliasService.GetByUserAndKeyAndType(user.ID, k, t)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to fetch aliases for user", "user", user.ID, "error", err)
|
||||
aliases = []*models.Alias{}
|
||||
}
|
||||
|
||||
projects, err := srv.heartbeatService.GetEntitySetByUser(models.SummaryProject, user.ID)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to fetch projects for alias resolution for user", "user", user.ID, "error", err)
|
||||
}
|
||||
|
||||
isWildcard := func(alias string) bool {
|
||||
return strings.Contains(alias, "*") || strings.Contains(alias, "?")
|
||||
}
|
||||
|
||||
// for wildcard patterns like "anchr-" (e.g. resolving to "anchr-mobile", "anchr-web", ...), we need to fetch all projects matching the pattern
|
||||
// this is mainly used for the filtering functionality
|
||||
// proper way would be to make the filters support wildcards as well instead
|
||||
matchProjects := func(aliasWildcard string) []string {
|
||||
pattern := wildmatch.NewWildMatch(aliasWildcard)
|
||||
return slice.Filter[string](projects, func(i int, project string) bool {
|
||||
return pattern.IsMatch(project)
|
||||
})
|
||||
}
|
||||
|
||||
aliasStrings := make([]string, 0, len(aliases))
|
||||
for _, a := range aliases {
|
||||
aliasStrings = append(aliasStrings, a.Value)
|
||||
if isWildcard(a.Value) {
|
||||
aliasStrings = append(aliasStrings, matchProjects(a.Value)...)
|
||||
} else {
|
||||
aliasStrings = append(aliasStrings, a.Value)
|
||||
}
|
||||
}
|
||||
return aliasStrings
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type SummaryServiceTestSuite struct {
|
||||
TestDurations []*models.Duration
|
||||
TestLabels []*models.ProjectLabel
|
||||
SummaryRepository *mocks.SummaryRepositoryMock
|
||||
HeartbeatService *mocks.HeartbeatServiceMock
|
||||
DurationService *mocks.DurationServiceMock
|
||||
AliasService *mocks.AliasServiceMock
|
||||
ProjectLabelService *mocks.ProjectLabelServiceMock
|
||||
@@ -96,6 +97,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
|
||||
|
||||
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
|
||||
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
|
||||
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
|
||||
suite.DurationService = new(mocks.DurationServiceMock)
|
||||
suite.AliasService = new(mocks.AliasServiceMock)
|
||||
suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock)
|
||||
@@ -106,7 +108,7 @@ func TestSummaryServiceTestSuite(t *testing.T) {
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
var (
|
||||
from time.Time
|
||||
@@ -172,7 +174,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
var (
|
||||
summaries []*models.Summary
|
||||
@@ -331,7 +333,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
|
||||
|
||||
@@ -379,7 +381,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
suite.AliasService.On("InitializeUser", suite.TestUser.ID).Return(nil)
|
||||
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
|
||||
@@ -423,7 +425,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
var (
|
||||
from time.Time
|
||||
@@ -462,8 +464,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels()
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Filters() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
suite.HeartbeatService.On("GetEntitySetByUser", models.SummaryProject, suite.TestUser.ID).Return([]string{TestProject1, TestProject2, TestProject3, TestProject4}, nil)
|
||||
suite.AliasService.On("InitializeUser", suite.TestUser.ID).Return(nil)
|
||||
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
|
||||
|
||||
@@ -478,6 +481,11 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Filters() {
|
||||
Key: TestProject1,
|
||||
Value: TestProject2,
|
||||
},
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
Key: TestProject1,
|
||||
Value: TestProject4[0:3] + "*", // wildcard alias
|
||||
},
|
||||
}, nil)
|
||||
suite.ProjectLabelService.On("GetByUserGroupedInverted", suite.TestUser.ID).Return(map[string][]*models.ProjectLabel{
|
||||
suite.TestLabels[0].Label: suite.TestLabels[0:1],
|
||||
@@ -492,11 +500,12 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Filters() {
|
||||
assert.Contains(suite.T(), effectiveFilters.Project, TestProject1) // because actually requested
|
||||
assert.Contains(suite.T(), effectiveFilters.Project, TestProject2) // because of alias
|
||||
assert.Contains(suite.T(), effectiveFilters.Project, TestProject3) // because of label
|
||||
assert.Contains(suite.T(), effectiveFilters.Project, TestProject4) // because of wildcard alias
|
||||
assert.Contains(suite.T(), effectiveFilters.Label, TestProjectLabel3)
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_getMissingIntervals() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
from1, _ := time.Parse(time.RFC822, "25 Mar 22 11:00 UTC")
|
||||
to1, _ := time.Parse(time.RFC822, "25 Mar 22 13:00 UTC")
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="" method="post">
|
||||
<form action="" method="post" class="mb-2">
|
||||
<h3 class="inline-block font-semibold text-gray-300">Add Rule</h3>
|
||||
|
||||
<input type="hidden" name="action" value="add_alias">
|
||||
@@ -301,6 +301,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="text-sm text-gray-600">Wildcard patterns are supported, see <a href="https://github.com/becheran/wildmatch-go" target="_blank" rel="noopener noreferrer" class="link">here</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user