Files
kandev/apps/backend/internal/backendapp/mentions.go
T

317 lines
9.7 KiB
Go

package backendapp
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/entityrefs"
"github.com/kandev/kandev/internal/gitlab"
"github.com/kandev/kandev/internal/mentions"
"github.com/kandev/kandev/internal/plugins"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
api "github.com/kandev/kandev/pkg/api/v1"
)
// MentionComponents owns the shared descriptor registry used by search and
// message-submission authorization.
type MentionComponents struct {
Registry *mentions.Registry
Search mentions.Searcher
Submission entityrefs.SubmissionValidator
Log *logger.Logger
}
type mentionWorkspaceResolver interface {
GetWorkspace(context.Context, string) (*models.Workspace, error)
}
type mentionConversationResolver interface {
ResolveWorkspace(context.Context, string, string) (string, error)
}
func newMentionComponents(
log *logger.Logger,
workspaces mentionWorkspaceResolver,
conversations mentionConversationResolver,
providers ...mentions.MentionProvider,
) (*MentionComponents, error) {
registry := mentions.NewRegistry()
for _, provider := range providers {
if sourceRegistrar, ok := provider.(mentions.SourceRegistrar); ok {
if err := sourceRegistrar.RegisterMentionSources(registry); err != nil {
return nil, fmt.Errorf("register mention provider: %w", err)
}
continue
}
if err := registry.Register(provider); err != nil {
return nil, fmt.Errorf("register mention provider: %w", err)
}
}
search := &workspaceValidatingMentionSearcher{
workspaces: workspaces,
searcher: mentions.NewService(registry, mentions.WithLogger(log)),
}
return &MentionComponents{
Registry: registry,
Search: search,
Submission: entityrefs.NewSubmissionService(conversations, registry),
Log: log,
}, nil
}
type workspaceValidatingMentionSearcher struct {
workspaces mentionWorkspaceResolver
searcher mentions.Searcher
}
func (s *workspaceValidatingMentionSearcher) Search(
ctx context.Context,
request mentions.SearchRequest,
) (*api.MentionSearchResponse, error) {
request.WorkspaceID = strings.TrimSpace(request.WorkspaceID)
if request.WorkspaceID == "" {
return nil, mentions.ErrInvalidRequest
}
if s == nil || s.workspaces == nil || s.searcher == nil {
return nil, mentions.NewSearchFailure(mentions.FailureStageSearch, mentions.FailureClassInternal, errors.New("mention search is unavailable"))
}
workspace, err := s.workspaces.GetWorkspace(ctx, request.WorkspaceID)
if err != nil {
if errors.Is(err, repository.ErrWorkspaceNotFound) {
return nil, mentions.ErrWorkspaceNotFound
}
return nil, mentions.NewSearchFailure(mentions.FailureStageWorkspaceValidation, mentions.FailureClassWorkspaceLookup, err)
}
if workspace == nil || workspace.ID != request.WorkspaceID {
return nil, mentions.ErrWorkspaceNotFound
}
return s.searcher.Search(ctx, request)
}
func builtinMentionProviders(
services *Services,
gitLabRepositories gitLabMentionRepositoryResolver,
) []mentions.MentionProvider {
if services == nil {
services = &Services{}
}
var jiraService mentions.JiraMentionService
if services.Jira != nil {
jiraService = services.Jira
}
providers := []mentions.MentionProvider{mentions.NewJiraProvider(jiraService)}
var linearService mentions.LinearMentionService
if services.Linear != nil {
linearService = services.Linear
}
providers = append(providers, mentions.NewLinearProvider(linearService))
var githubService mentions.GitHubMentionService
if services.GitHub != nil {
githubService = services.GitHub
}
providers = append(providers,
mentions.NewGitHubIssueProvider(githubService),
mentions.NewGitHubPullRequestProvider(githubService),
)
var gitlabService mentions.GitLabMentionService
if services.GitLab != nil {
gitlabService = newWorkspaceScopedGitLabMentionService(services.GitLab, gitLabRepositories)
}
providers = append(providers,
mentions.NewGitLabIssueProvider(gitlabService),
mentions.NewGitLabMergeRequestProvider(gitlabService),
)
var azureService mentions.AzureMentionService
if services.AzureDevOps != nil {
azureService = services.AzureDevOps
}
providers = append(providers,
mentions.NewAzureWorkItemProvider(azureService),
mentions.NewAzurePullRequestProvider(azureService),
)
var sentryService mentions.SentryMentionService
if services.Sentry != nil {
sentryService = services.Sentry
}
providers = append(providers, mentions.NewSentryIssueProvider(sentryService))
if services.Plugins != nil {
providers = append(providers, plugins.NewMentionSourceBridge(services.Plugins))
}
return providers
}
type gitLabMentionRepositoryResolver interface {
ListRepositories(context.Context, string) ([]*models.Repository, error)
}
type gitLabMentionScopeService interface {
mentions.GitLabMentionService
ConfigureMentionScopeForWorkspace(
context.Context,
string,
string,
[]gitlab.MentionProjectScope,
) error
Host() string
}
type workspaceScopedGitLabMentionService struct {
service gitLabMentionScopeService
repositories gitLabMentionRepositoryResolver
}
func newWorkspaceScopedGitLabMentionService(
service gitLabMentionScopeService,
repositories gitLabMentionRepositoryResolver,
) mentions.GitLabMentionService {
if service == nil {
return nil
}
return &workspaceScopedGitLabMentionService{service: service, repositories: repositories}
}
func (s *workspaceScopedGitLabMentionService) SearchMentionIssuesForWorkspace(
ctx context.Context,
workspaceID, query string,
limit int,
) ([]gitlab.MentionItem, error) {
if err := s.ensureScope(ctx, workspaceID); err != nil {
return nil, err
}
return s.service.SearchMentionIssuesForWorkspace(ctx, workspaceID, query, limit)
}
func (s *workspaceScopedGitLabMentionService) SearchMentionMRsForWorkspace(
ctx context.Context,
workspaceID, query string,
limit int,
) ([]gitlab.MentionItem, error) {
if err := s.ensureScope(ctx, workspaceID); err != nil {
return nil, err
}
return s.service.SearchMentionMRsForWorkspace(ctx, workspaceID, query, limit)
}
func (s *workspaceScopedGitLabMentionService) MentionScopeForWorkspace(
ctx context.Context,
workspaceID string,
) (*gitlab.MentionScope, error) {
if err := s.ensureScope(ctx, workspaceID); err != nil {
return nil, err
}
return s.service.MentionScopeForWorkspace(ctx, workspaceID)
}
func (s *workspaceScopedGitLabMentionService) ensureScope(ctx context.Context, workspaceID string) error {
if s == nil || s.service == nil || s.repositories == nil {
return gitlab.ErrMentionUnsupportedScope
}
repositories, err := s.repositories.ListRepositories(ctx, workspaceID)
if err != nil {
return fmt.Errorf("list workspace GitLab mention repositories: %w", err)
}
host := s.service.Host()
projects, err := gitLabMentionProjects(workspaceID, host, repositories)
if err != nil {
return err
}
current, currentErr := s.service.MentionScopeForWorkspace(ctx, workspaceID)
if currentErr == nil && sameGitLabMentionScope(current, workspaceID, host, projects) {
return nil
}
if currentErr != nil && !errors.Is(currentErr, gitlab.ErrMentionUnsupportedScope) {
return currentErr
}
return s.service.ConfigureMentionScopeForWorkspace(ctx, workspaceID, host, projects)
}
func gitLabMentionProjects(
workspaceID, host string,
repositories []*models.Repository,
) ([]gitlab.MentionProjectScope, error) {
byID := make(map[int64]string)
byPath := make(map[string]int64)
for _, repository := range repositories {
if !isGitLabRepository(repository) {
continue
}
if repository.WorkspaceID != workspaceID {
return nil, gitlab.ErrMentionInvalidScope
}
if !gitlab.SameMentionHost(repository.ProviderHost, host) {
continue
}
projectIDText := strings.TrimSpace(repository.ProviderRepoID)
projectID, err := strconv.ParseInt(projectIDText, 10, 64)
projectPath := strings.Trim(strings.TrimSpace(repository.ProviderOwner), "/") + "/" +
strings.Trim(strings.TrimSpace(repository.ProviderName), "/")
if err != nil || projectID <= 0 || strconv.FormatInt(projectID, 10) != projectIDText {
return nil, gitlab.ErrMentionInvalidScope
}
if existingPath, exists := byID[projectID]; exists && existingPath != projectPath {
return nil, gitlab.ErrMentionInvalidScope
}
if existingID, exists := byPath[projectPath]; exists && existingID != projectID {
return nil, gitlab.ErrMentionInvalidScope
}
byID[projectID] = projectPath
byPath[projectPath] = projectID
}
if len(byID) == 0 {
return nil, gitlab.ErrMentionUnsupportedScope
}
projects := make([]gitlab.MentionProjectScope, 0, len(byID))
for projectID, projectPath := range byID {
projects = append(projects, gitlab.MentionProjectScope{ID: projectID, Path: projectPath})
}
sort.Slice(projects, func(i, j int) bool {
if projects[i].ID == projects[j].ID {
return projects[i].Path < projects[j].Path
}
return projects[i].ID < projects[j].ID
})
return projects, nil
}
func isGitLabRepository(repository *models.Repository) bool {
return repository != nil && strings.EqualFold(strings.TrimSpace(repository.Provider), "gitlab")
}
func sameGitLabMentionScope(
current *gitlab.MentionScope,
workspaceID, host string,
projects []gitlab.MentionProjectScope,
) bool {
if current == nil || current.WorkspaceID != workspaceID || current.Host != strings.TrimRight(host, "/") ||
len(current.Projects) != len(projects) {
return false
}
for index := range projects {
if current.Projects[index] != projects[index] {
return false
}
}
return true
}
func registerMentionRoutes(router gin.IRoutes, components *MentionComponents) {
if router == nil || components == nil || components.Search == nil {
return
}
mentions.NewHandler(components.Search, components.Log).RegisterRoutes(router)
}