From 7de788ceedbf42b72f2d30db54d8a722da0f3971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:03:56 +0100 Subject: [PATCH] feat: add cross-integration entity references (#1862) * feat: add cross-integration entity references * fix(web): anchor entity reference menu to composer * fix: keep Kandev tasks under @ mentions * fix: address entity reference review findings * test(e2e): use option role for prompt menu * test(e2e): update passthrough mention roles * fix: preserve queued references and paginate Azure PRs --- .../internal/azuredevops/mock_client.go | 13 +- .../internal/azuredevops/mock_client_test.go | 18 + .../internal/azuredevops/rest_client.go | 7 +- .../internal/azuredevops/rest_client_test.go | 6 + .../internal/azuredevops/service_mentions.go | 310 +++++++++ .../azuredevops/service_mentions_test.go | 198 ++++++ apps/backend/internal/backendapp/gateway.go | 10 +- apps/backend/internal/backendapp/helpers.go | 10 +- apps/backend/internal/backendapp/main.go | 6 + apps/backend/internal/backendapp/mentions.go | 302 +++++++++ .../backendapp/mentions_gitlab_test.go | 108 +++ .../internal/backendapp/mentions_test.go | 262 +++++++ apps/backend/internal/backendapp/services.go | 14 +- apps/backend/internal/backendapp/types.go | 2 + apps/backend/internal/entityrefs/reference.go | 144 ++++ .../internal/entityrefs/reference_test.go | 116 ++++ .../backend/internal/entityrefs/submission.go | 67 ++ .../internal/entityrefs/submission_test.go | 116 ++++ .../backend/internal/github/client_helpers.go | 8 +- .../internal/github/client_helpers_test.go | 5 + apps/backend/internal/github/gh_client.go | 3 + .../backend/internal/github/gh_client_test.go | 18 + apps/backend/internal/github/models.go | 4 + apps/backend/internal/github/pat_client.go | 4 + .../internal/github/pat_client_test.go | 21 + .../internal/github/service_mentions.go | 99 +++ .../internal/github/service_mentions_test.go | 87 +++ .../internal/gitlab/service_mentions.go | 463 +++++++++++++ .../internal/gitlab/service_mentions_test.go | 389 +++++++++++ apps/backend/internal/gitlab/store.go | 71 ++ .../internal/gitlab/store_mentions_test.go | 50 ++ apps/backend/internal/jira/cloud_client.go | 2 + .../internal/jira/cloud_client_test.go | 24 + apps/backend/internal/jira/models.go | 1 + .../backend/internal/jira/service_mentions.go | 141 ++++ .../internal/jira/service_mentions_test.go | 175 +++++ .../internal/linear/service_mentions.go | 89 +++ .../internal/linear/service_mentions_test.go | 194 ++++++ apps/backend/internal/mentions/handler.go | 80 +++ .../backend/internal/mentions/handler_test.go | 128 ++++ .../internal/mentions/provider_azure.go | 346 ++++++++++ .../internal/mentions/provider_azure_test.go | 150 ++++ .../internal/mentions/provider_github.go | 322 +++++++++ .../internal/mentions/provider_github_test.go | 152 +++++ .../internal/mentions/provider_gitlab.go | 276 ++++++++ .../internal/mentions/provider_gitlab_test.go | 341 ++++++++++ .../internal/mentions/provider_jira.go | 166 +++++ .../internal/mentions/provider_jira_test.go | 247 +++++++ .../internal/mentions/provider_linear.go | 212 ++++++ .../internal/mentions/provider_linear_test.go | 364 ++++++++++ .../internal/mentions/provider_sentry.go | 265 ++++++++ .../internal/mentions/provider_sentry_test.go | 324 +++++++++ apps/backend/internal/mentions/service.go | 479 +++++++++++++ .../backend/internal/mentions/service_test.go | 639 ++++++++++++++++++ apps/backend/internal/mentions/types.go | 120 ++++ .../orchestrator/event_handlers_agent.go | 25 +- .../orchestrator/event_handlers_queue_test.go | 193 ++++++ .../orchestrator/event_handlers_workflow.go | 53 +- .../event_handlers_workflow_moved_test.go | 2 +- .../orchestrator/handlers/queue_handlers.go | 103 ++- .../handlers/queue_handlers_test.go | 255 +++++++ .../internal/orchestrator/message_meta.go | 41 +- .../orchestrator/message_meta_test.go | 28 + .../orchestrator/messagequeue/repository.go | 23 + .../messagequeue/repository_memory.go | 7 +- .../messagequeue/repository_sqlite.go | 48 +- .../messagequeue/repository_sqlite_test.go | 63 ++ .../orchestrator/messagequeue/service.go | 18 +- .../orchestrator/messagequeue/service_test.go | 55 ++ .../orchestrator/messagequeue/types.go | 10 +- apps/backend/internal/sentry/rest_client.go | 66 +- .../sentry/rest_client_mentions_test.go | 57 ++ .../internal/sentry/service_mentions.go | 375 ++++++++++ .../internal/sentry/service_mentions_test.go | 486 +++++++++++++ .../task/handlers/message_handlers.go | 57 +- .../task/handlers/message_handlers_test.go | 102 ++- .../task/models/message_shell_output.go | 25 +- .../task/models/message_shell_output_test.go | 28 + .../internal/task/service/service_mentions.go | 36 + .../task/service/service_mentions_test.go | 53 ++ apps/backend/pkg/api/v1/mentions.go | 46 ++ .../quick-chat/quick-chat-content.tsx | 2 +- .../task/chat/chat-input-area.test.tsx | 10 +- .../components/task/chat/chat-input-area.tsx | 69 +- .../task/chat/chat-input-body.test.tsx | 20 +- .../components/task/chat/chat-input-body.tsx | 4 + .../task/chat/chat-input-container.tsx | 25 +- .../task/chat/composer-workspace.test.ts | 48 ++ .../task/chat/composer-workspace.ts | 26 + .../task/chat/entity-reference-groups.ts | 21 + .../task/chat/entity-reference-menu.test.tsx | 181 +++++ .../task/chat/entity-reference-menu.tsx | 145 ++++ .../task/chat/message-history-search.tsx | 6 +- .../task/chat/message-history.test.ts | 102 +++ .../components/task/chat/message-history.ts | 103 ++- .../task/chat/messages/chat-message.test.tsx | 86 +++ .../task/chat/messages/chat-message.tsx | 24 +- .../chat/messages/entity-reference-chip.tsx | 70 ++ .../components/task/chat/popup-menu.test.tsx | 154 +++++ apps/web/components/task/chat/popup-menu.tsx | 124 +++- .../task/chat/queued-ghost-list.test.tsx | 43 +- .../task/chat/queued-ghost-list.tsx | 16 +- .../task/chat/queued-ghost-message.test.tsx | 115 +++- .../task/chat/queued-ghost-message.tsx | 45 +- .../task/chat/task-mention-items.test.ts | 94 +-- .../task/chat/task-mention-items.ts | 37 +- .../task/chat/tiptap-dynamic-placeholder.ts | 60 ++ .../task/chat/tiptap-editor-history.test.ts | 37 + .../task/chat/tiptap-editor-history.ts | 58 +- .../tiptap-entity-reference-extension.test.ts | 24 + .../tiptap-entity-reference-extension.tsx | 73 ++ .../tiptap-entity-reference-suggestion.ts | 98 +++ .../task/chat/tiptap-helpers.test.ts | 143 ++++ .../components/task/chat/tiptap-helpers.ts | 91 ++- .../web/components/task/chat/tiptap-input.tsx | 51 +- .../task/chat/tiptap-suggestion.test.ts | 212 ++++++ .../task/chat/tiptap-suggestion.tsx | 5 +- .../task/chat/use-chat-input-container.ts | 10 +- .../task/chat/use-chat-input-state.test.ts | 46 +- .../task/chat/use-chat-input-state.ts | 38 +- .../use-entity-reference-composer.test.ts | 42 ++ .../chat/use-entity-reference-composer.ts | 98 +++ .../task/chat/use-tiptap-editor.test.ts | 46 +- .../components/task/chat/use-tiptap-editor.ts | 92 +-- .../task/passthrough-chat-composer.tsx | 25 +- .../task/passthrough-toolbar.test.tsx | 6 +- .../components/task/passthrough-toolbar.tsx | 2 +- .../task/preview-session-tabs.test.tsx | 45 +- .../components/task/preview-session-tabs.tsx | 28 +- apps/web/components/task/task-chat-panel.tsx | 8 +- .../chat/entity-reference-composer.spec.ts | 441 ++++++++++++ .../mobile-entity-reference-composer.spec.ts | 225 ++++++ .../cli-mode/passthrough-toolbar.spec.ts | 6 +- ...task-create-prompt-autocomplete-qa.spec.ts | 8 +- .../task-create-prompt-autocomplete.spec.ts | 2 +- .../hooks/domains/session/use-queue.test.ts | 55 ++ apps/web/hooks/domains/session/use-queue.ts | 34 +- .../hooks/use-entity-reference-search.test.ts | 218 ++++++ apps/web/hooks/use-entity-reference-search.ts | 87 +++ apps/web/hooks/use-message-handler.test.ts | 38 ++ apps/web/hooks/use-message-handler.ts | 39 +- apps/web/lib/api/domains/mentions-api.test.ts | 82 +++ apps/web/lib/api/domains/mentions-api.ts | 21 + apps/web/lib/api/domains/queue-api.test.ts | 92 ++- apps/web/lib/api/domains/queue-api.ts | 8 +- .../message-references.test.ts | 130 ++++ .../entity-references/message-references.ts | 289 ++++++++ apps/web/lib/state/slices/session/types.ts | 2 + apps/web/lib/types/entity-reference.ts | 35 + apps/web/lib/types/http.ts | 7 +- .../2026-07-21-work-item-reference-search.md | 50 ++ docs/decisions/INDEX.md | 1 + docs/plans/entity-reference-composer/plan.md | 206 ++++++ .../task-01-core-and-task-search.md | 46 ++ .../task-02-jira-provider.md | 46 ++ .../task-03-linear-provider.md | 43 ++ .../task-04-github-provider.md | 47 ++ .../task-05-gitlab-provider.md | 44 ++ .../task-06-azure-provider.md | 47 ++ .../task-07-sentry-provider.md | 46 ++ .../task-08-message-reference-metadata.md | 51 ++ .../task-09-backend-composition.md | 50 ++ .../task-10-frontend-search-client.md | 43 ++ .../task-11-composer-reference-ui.md | 58 ++ .../task-12-message-reference-ui.md | 58 ++ .../task-13-e2e-and-verification.md | 44 ++ docs/public/developer-tools.md | 8 + docs/public/feature-status.md | 1 + docs/public/websocket-api.md | 12 +- docs/specs/INDEX.md | 1 + docs/specs/ui/entity-reference-composer.md | 174 +++++ 171 files changed, 15719 insertions(+), 507 deletions(-) create mode 100644 apps/backend/internal/azuredevops/service_mentions.go create mode 100644 apps/backend/internal/azuredevops/service_mentions_test.go create mode 100644 apps/backend/internal/backendapp/mentions.go create mode 100644 apps/backend/internal/backendapp/mentions_gitlab_test.go create mode 100644 apps/backend/internal/backendapp/mentions_test.go create mode 100644 apps/backend/internal/entityrefs/reference.go create mode 100644 apps/backend/internal/entityrefs/reference_test.go create mode 100644 apps/backend/internal/entityrefs/submission.go create mode 100644 apps/backend/internal/entityrefs/submission_test.go create mode 100644 apps/backend/internal/github/service_mentions.go create mode 100644 apps/backend/internal/github/service_mentions_test.go create mode 100644 apps/backend/internal/gitlab/service_mentions.go create mode 100644 apps/backend/internal/gitlab/service_mentions_test.go create mode 100644 apps/backend/internal/gitlab/store_mentions_test.go create mode 100644 apps/backend/internal/jira/service_mentions.go create mode 100644 apps/backend/internal/jira/service_mentions_test.go create mode 100644 apps/backend/internal/linear/service_mentions.go create mode 100644 apps/backend/internal/linear/service_mentions_test.go create mode 100644 apps/backend/internal/mentions/handler.go create mode 100644 apps/backend/internal/mentions/handler_test.go create mode 100644 apps/backend/internal/mentions/provider_azure.go create mode 100644 apps/backend/internal/mentions/provider_azure_test.go create mode 100644 apps/backend/internal/mentions/provider_github.go create mode 100644 apps/backend/internal/mentions/provider_github_test.go create mode 100644 apps/backend/internal/mentions/provider_gitlab.go create mode 100644 apps/backend/internal/mentions/provider_gitlab_test.go create mode 100644 apps/backend/internal/mentions/provider_jira.go create mode 100644 apps/backend/internal/mentions/provider_jira_test.go create mode 100644 apps/backend/internal/mentions/provider_linear.go create mode 100644 apps/backend/internal/mentions/provider_linear_test.go create mode 100644 apps/backend/internal/mentions/provider_sentry.go create mode 100644 apps/backend/internal/mentions/provider_sentry_test.go create mode 100644 apps/backend/internal/mentions/service.go create mode 100644 apps/backend/internal/mentions/service_test.go create mode 100644 apps/backend/internal/mentions/types.go create mode 100644 apps/backend/internal/sentry/rest_client_mentions_test.go create mode 100644 apps/backend/internal/sentry/service_mentions.go create mode 100644 apps/backend/internal/sentry/service_mentions_test.go create mode 100644 apps/backend/internal/task/service/service_mentions.go create mode 100644 apps/backend/internal/task/service/service_mentions_test.go create mode 100644 apps/backend/pkg/api/v1/mentions.go create mode 100644 apps/web/components/task/chat/composer-workspace.test.ts create mode 100644 apps/web/components/task/chat/composer-workspace.ts create mode 100644 apps/web/components/task/chat/entity-reference-groups.ts create mode 100644 apps/web/components/task/chat/entity-reference-menu.test.tsx create mode 100644 apps/web/components/task/chat/entity-reference-menu.tsx create mode 100644 apps/web/components/task/chat/messages/entity-reference-chip.tsx create mode 100644 apps/web/components/task/chat/popup-menu.test.tsx create mode 100644 apps/web/components/task/chat/tiptap-dynamic-placeholder.ts create mode 100644 apps/web/components/task/chat/tiptap-editor-history.test.ts create mode 100644 apps/web/components/task/chat/tiptap-entity-reference-extension.test.ts create mode 100644 apps/web/components/task/chat/tiptap-entity-reference-extension.tsx create mode 100644 apps/web/components/task/chat/tiptap-entity-reference-suggestion.ts create mode 100644 apps/web/components/task/chat/tiptap-suggestion.test.ts create mode 100644 apps/web/components/task/chat/use-entity-reference-composer.test.ts create mode 100644 apps/web/components/task/chat/use-entity-reference-composer.ts create mode 100644 apps/web/e2e/tests/chat/entity-reference-composer.spec.ts create mode 100644 apps/web/e2e/tests/chat/mobile-entity-reference-composer.spec.ts create mode 100644 apps/web/hooks/use-entity-reference-search.test.ts create mode 100644 apps/web/hooks/use-entity-reference-search.ts create mode 100644 apps/web/lib/api/domains/mentions-api.test.ts create mode 100644 apps/web/lib/api/domains/mentions-api.ts create mode 100644 apps/web/lib/entity-references/message-references.test.ts create mode 100644 apps/web/lib/entity-references/message-references.ts create mode 100644 apps/web/lib/types/entity-reference.ts create mode 100644 docs/decisions/2026-07-21-work-item-reference-search.md create mode 100644 docs/plans/entity-reference-composer/plan.md create mode 100644 docs/plans/entity-reference-composer/task-01-core-and-task-search.md create mode 100644 docs/plans/entity-reference-composer/task-02-jira-provider.md create mode 100644 docs/plans/entity-reference-composer/task-03-linear-provider.md create mode 100644 docs/plans/entity-reference-composer/task-04-github-provider.md create mode 100644 docs/plans/entity-reference-composer/task-05-gitlab-provider.md create mode 100644 docs/plans/entity-reference-composer/task-06-azure-provider.md create mode 100644 docs/plans/entity-reference-composer/task-07-sentry-provider.md create mode 100644 docs/plans/entity-reference-composer/task-08-message-reference-metadata.md create mode 100644 docs/plans/entity-reference-composer/task-09-backend-composition.md create mode 100644 docs/plans/entity-reference-composer/task-10-frontend-search-client.md create mode 100644 docs/plans/entity-reference-composer/task-11-composer-reference-ui.md create mode 100644 docs/plans/entity-reference-composer/task-12-message-reference-ui.md create mode 100644 docs/plans/entity-reference-composer/task-13-e2e-and-verification.md create mode 100644 docs/specs/ui/entity-reference-composer.md diff --git a/apps/backend/internal/azuredevops/mock_client.go b/apps/backend/internal/azuredevops/mock_client.go index 60a93b8ed9..1d5492df6f 100644 --- a/apps/backend/internal/azuredevops/mock_client.go +++ b/apps/backend/internal/azuredevops/mock_client.go @@ -113,7 +113,18 @@ func (c *MockClient) ListPullRequests(_ context.Context, filter PullRequestFilte items = append(items, pr) } } - return &PullRequestPage{Items: items, Count: len(items), Skip: filter.Skip, Top: filter.Top}, nil + top := filter.Top + if top <= 0 || top > 100 { + top = defaultPRPageSize + } + if filter.Skip >= len(items) { + return &PullRequestPage{Skip: filter.Skip, Top: top}, nil + } + items = items[filter.Skip:] + if len(items) > top { + items = items[:top] + } + return &PullRequestPage{Items: items, Count: len(items), Skip: filter.Skip, Top: top}, nil } func (c *MockClient) GetPullRequest(_ context.Context, _, _ string, id int) (*PullRequest, error) { diff --git a/apps/backend/internal/azuredevops/mock_client_test.go b/apps/backend/internal/azuredevops/mock_client_test.go index 7a84f074c0..cf25c8a6b2 100644 --- a/apps/backend/internal/azuredevops/mock_client_test.go +++ b/apps/backend/internal/azuredevops/mock_client_test.go @@ -28,3 +28,21 @@ func TestMockClientSeedsWorkspaceReadState(t *testing.T) { t.Fatalf("mock state projects=%+v work=%+v prs=%+v feedback=%+v", projects, work, prs, feedback) } } + +func TestMockClientPaginatesPullRequests(t *testing.T) { + mock := NewMockClient() + mock.Seed(MockState{PullRequests: []PullRequest{ + {ID: 1, ProjectID: "p1", Status: activePullRequestState}, + {ID: 2, ProjectID: "p1", Status: activePullRequestState}, + }}) + + page, err := mock.ListPullRequests(t.Context(), PullRequestFilter{ + ProjectID: "p1", Status: activePullRequestState, Skip: 1, Top: 1, + }) + if err != nil { + t.Fatalf("list pull requests: %v", err) + } + if len(page.Items) != 1 || page.Items[0].ID != 2 { + t.Fatalf("items = %#v, want second pull request only", page.Items) + } +} diff --git a/apps/backend/internal/azuredevops/rest_client.go b/apps/backend/internal/azuredevops/rest_client.go index 2fe1b55954..e9464435ad 100644 --- a/apps/backend/internal/azuredevops/rest_client.go +++ b/apps/backend/internal/azuredevops/rest_client.go @@ -179,8 +179,11 @@ func (c *RESTClient) ListPullRequests(ctx context.Context, filter PullRequestFil top = defaultPRPageSize } values.Set("$top", strconv.Itoa(top)) - endpoint := fmt.Sprintf("/%s/_apis/git/repositories/%s/pullrequests?%s", - pathPart(filter.ProjectID), pathPart(filter.RepositoryID), values.Encode()) + endpoint := fmt.Sprintf("/%s/_apis/git/pullrequests?%s", pathPart(filter.ProjectID), values.Encode()) + if strings.TrimSpace(filter.RepositoryID) != "" { + endpoint = fmt.Sprintf("/%s/_apis/git/repositories/%s/pullrequests?%s", + pathPart(filter.ProjectID), pathPart(filter.RepositoryID), values.Encode()) + } var response struct { Count int `json:"count"` Value []rawPullRequest `json:"value"` diff --git a/apps/backend/internal/azuredevops/rest_client_test.go b/apps/backend/internal/azuredevops/rest_client_test.go index 95190899c7..168cd706e5 100644 --- a/apps/backend/internal/azuredevops/rest_client_test.go +++ b/apps/backend/internal/azuredevops/rest_client_test.go @@ -121,6 +121,8 @@ func TestRESTClientPullRequestReads(t *testing.T) { switch r.URL.Path { case "/acme/project-1/_apis/git/repositories/repo-1/pullrequests": _, _ = w.Write([]byte(`{"count":1,"value":[{"pullRequestId":42,"title":"Ship it","status":"active","isDraft":true,"sourceRefName":"refs/heads/feature","targetRefName":"refs/heads/main","url":"https://api/pr/42","createdBy":{"id":"u1","displayName":"Ada"},"repository":{"id":"repo-1","name":"widgets","webUrl":"https://dev.azure.com/acme/Platform/_git/widgets","project":{"id":"project-1","name":"Platform"}}}]}`)) + case "/acme/project-1/_apis/git/pullrequests": + _, _ = w.Write([]byte(`{"count":1,"value":[{"pullRequestId":42,"title":"Ship it","status":"active","repository":{"id":"repo-1","name":"widgets","webUrl":"https://dev.azure.com/acme/Platform/_git/widgets","project":{"id":"project-1","name":"Platform"}}}]}`)) case "/acme/project-1/_apis/git/repositories/repo-1/pullrequests/42": _, _ = w.Write([]byte(`{"pullRequestId":42,"title":"Ship it","status":"active","sourceRefName":"refs/heads/feature","targetRefName":"refs/heads/main","createdBy":{"id":"u1","displayName":"Ada"},"repository":{"id":"repo-1","name":"widgets","webUrl":"https://dev.azure.com/acme/Platform/_git/widgets","project":{"id":"project-1","name":"Platform"}}}`)) case "/acme/project-1/_apis/git/repositories/repo-1/pullrequests/42/reviewers": @@ -142,6 +144,10 @@ func TestRESTClientPullRequestReads(t *testing.T) { if err != nil || len(prs.Items) != 1 || prs.Items[0].SourceBranch != "feature" || prs.Items[0].WebURL == "" { t.Fatalf("ListPullRequests = %+v, %v", prs, err) } + projectPRs, err := client.ListPullRequests(context.Background(), PullRequestFilter{ProjectID: "project-1", Status: "active", Top: 10}) + if err != nil || len(projectPRs.Items) != 1 || projectPRs.Items[0].RepositoryID != "repo-1" { + t.Fatalf("project ListPullRequests = %+v, %v", projectPRs, err) + } pr, err := client.GetPullRequest(context.Background(), "project-1", "repo-1", 42) if err != nil || pr.ID != 42 { t.Fatalf("GetPullRequest = %+v, %v", pr, err) diff --git a/apps/backend/internal/azuredevops/service_mentions.go b/apps/backend/internal/azuredevops/service_mentions.go new file mode 100644 index 0000000000..d878a49043 --- /dev/null +++ b/apps/backend/internal/azuredevops/service_mentions.go @@ -0,0 +1,310 @@ +package azuredevops + +import ( + "context" + "fmt" + "sort" + "strings" +) + +const ( + defaultMentionLimit = 5 + maxMentionLimit = 10 + maxMentionProjects = 5 + mentionPRFetchLimit = 100 + activePullRequestState = "active" +) + +// MentionProject is the stable workspace-owned project scope attached to an +// Azure entity reference. +type MentionProject struct { + OrganizationURL string + ProjectID string + ProjectName string +} + +// MentionRepository extends a project scope with one Azure Repos repository. +type MentionRepository struct { + OrganizationURL string + ProjectID string + ProjectName string + RepositoryID string + RepositoryName string +} + +// MentionWorkItem is the minimal provider-neutral projection needed by the +// mention adapter. +type MentionWorkItem struct { + ID int + Title string + OrganizationURL string + ProjectID string + ProjectName string +} + +// MentionPullRequest is the corresponding Azure pull-request projection. +type MentionPullRequest struct { + ID int + Title string + OrganizationURL string + ProjectID string + ProjectName string + RepositoryID string + RepositoryName string +} + +// SearchMentionWorkItemsForWorkspace translates plain composer text into +// server-owned WIQL and searches a bounded set of workspace projects. +func (s *Service) SearchMentionWorkItemsForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]MentionWorkItem, error) { + workspaceID, query, limit, err := normalizeMentionRequest(workspaceID, query, limit) + if err != nil { + return nil, err + } + cfg, client, projects, err := s.mentionSearchContext(ctx, workspaceID) + if err != nil { + return nil, err + } + wiql := buildMentionWIQL(query) + items := make([]MentionWorkItem, 0, limit) + for _, project := range projects { + remaining := limit - len(items) + if remaining == 0 { + break + } + result, searchErr := client.QueryWIQL(ctx, project.ID, wiql, remaining) + if searchErr != nil { + return nil, searchErr + } + if result == nil { + continue + } + for _, item := range result.Items { + if item.ID <= 0 || strings.TrimSpace(item.Title) == "" { + continue + } + items = append(items, MentionWorkItem{ + ID: item.ID, Title: item.Title, OrganizationURL: cfg.OrganizationURL, + ProjectID: project.ID, ProjectName: project.Name, + }) + if len(items) == limit { + break + } + } + } + return items, nil +} + +// SearchMentionPullRequestsForWorkspace uses Azure's project-level PR list, +// then performs bounded case-insensitive title filtering in Kandev. +func (s *Service) SearchMentionPullRequestsForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]MentionPullRequest, error) { + workspaceID, query, limit, err := normalizeMentionRequest(workspaceID, query, limit) + if err != nil { + return nil, err + } + cfg, client, projects, err := s.mentionSearchContext(ctx, workspaceID) + if err != nil { + return nil, err + } + query = strings.ToLower(query) + items := make([]MentionPullRequest, 0, limit) + for _, project := range projects { + for skip := 0; ; skip += mentionPRFetchLimit { + result, searchErr := client.ListPullRequests(ctx, PullRequestFilter{ + ProjectID: project.ID, + Status: activePullRequestState, + Skip: skip, + Top: mentionPRFetchLimit, + }) + if searchErr != nil { + return nil, searchErr + } + if result == nil { + break + } + for _, item := range result.Items { + if item.ID <= 0 || strings.TrimSpace(item.RepositoryID) == "" || + strings.TrimSpace(item.RepositoryName) == "" || + !strings.Contains(strings.ToLower(item.Title), query) { + continue + } + items = append(items, MentionPullRequest{ + ID: item.ID, Title: item.Title, OrganizationURL: cfg.OrganizationURL, + ProjectID: project.ID, ProjectName: project.Name, + RepositoryID: item.RepositoryID, RepositoryName: item.RepositoryName, + }) + if len(items) == limit { + return items, nil + } + } + if len(result.Items) < mentionPRFetchLimit { + break + } + } + } + return items, nil +} + +// ResolveMentionProjectForWorkspace revalidates the project encoded in a +// submitted reference against current workspace configuration. +func (s *Service) ResolveMentionProjectForWorkspace( + ctx context.Context, + workspaceID, projectID string, +) (*MentionProject, error) { + if strings.TrimSpace(projectID) == "" { + return nil, fmt.Errorf("%w: project required", ErrInvalidConfig) + } + cfg, client, err := s.configuredMentionClient(ctx, workspaceID) + if err != nil { + return nil, err + } + project, err := resolveMentionProject(ctx, client, cfg, projectID) + if err != nil { + return nil, err + } + return &MentionProject{ + OrganizationURL: cfg.OrganizationURL, + ProjectID: project.ID, + ProjectName: project.Name, + }, nil +} + +// ResolveMentionRepositoryForWorkspace revalidates the repository encoded in +// a submitted pull-request reference. +func (s *Service) ResolveMentionRepositoryForWorkspace( + ctx context.Context, + workspaceID, projectID, repositoryID string, +) (*MentionRepository, error) { + if strings.TrimSpace(projectID) == "" || strings.TrimSpace(repositoryID) == "" { + return nil, fmt.Errorf("%w: project and repository required", ErrInvalidConfig) + } + cfg, client, err := s.configuredMentionClient(ctx, workspaceID) + if err != nil { + return nil, err + } + project, err := resolveMentionProject(ctx, client, cfg, projectID) + if err != nil { + return nil, err + } + repositories, err := client.ListRepositories(ctx, project.ID) + if err != nil { + return nil, err + } + for _, repository := range repositories { + if strings.EqualFold(strings.TrimSpace(repository.ID), repositoryID) { + return &MentionRepository{ + OrganizationURL: cfg.OrganizationURL, + ProjectID: project.ID, ProjectName: project.Name, + RepositoryID: repository.ID, RepositoryName: repository.Name, + }, nil + } + } + return nil, fmt.Errorf("%w: repository is outside workspace scope", ErrInvalidConfig) +} + +func (s *Service) mentionSearchContext( + ctx context.Context, + workspaceID string, +) (*Config, Client, []Project, error) { + cfg, client, err := s.configuredMentionClient(ctx, workspaceID) + if err != nil { + return nil, nil, nil, err + } + projects, err := mentionProjects(ctx, client, cfg) + if err != nil { + return nil, nil, nil, err + } + return cfg, client, projects, nil +} + +func (s *Service) configuredMentionClient(ctx context.Context, workspaceID string) (*Config, Client, error) { + cfg, pat, err := s.resolveCredentials(ctx, workspaceID, &SetConfigRequest{}) + if err != nil { + return nil, nil, err + } + client := s.clientFn(cfg, pat) + if client == nil { + return nil, nil, ErrNotConfigured + } + return cfg, client, nil +} + +func mentionProjects(ctx context.Context, client Client, cfg *Config) ([]Project, error) { + if projectID := strings.TrimSpace(cfg.DefaultProjectID); projectID != "" { + name := strings.TrimSpace(cfg.DefaultProjectName) + if name == "" { + name = projectID + } + return []Project{{ID: projectID, Name: name}}, nil + } + projects, err := client.ListProjects(ctx) + if err != nil { + return nil, err + } + projects = append([]Project(nil), projects...) + sort.SliceStable(projects, func(left, right int) bool { + leftName := strings.ToLower(strings.TrimSpace(projects[left].Name)) + rightName := strings.ToLower(strings.TrimSpace(projects[right].Name)) + if leftName != rightName { + return leftName < rightName + } + return strings.ToLower(projects[left].ID) < strings.ToLower(projects[right].ID) + }) + filtered := projects[:0] + for _, project := range projects { + if strings.TrimSpace(project.ID) == "" || strings.TrimSpace(project.Name) == "" { + continue + } + filtered = append(filtered, project) + if len(filtered) == maxMentionProjects { + break + } + } + return filtered, nil +} + +func resolveMentionProject(ctx context.Context, client Client, cfg *Config, projectID string) (Project, error) { + projects, err := mentionProjects(ctx, client, cfg) + if err != nil { + return Project{}, err + } + for _, project := range projects { + if strings.EqualFold(strings.TrimSpace(project.ID), strings.TrimSpace(projectID)) { + return project, nil + } + } + return Project{}, fmt.Errorf("%w: project is outside workspace scope", ErrInvalidConfig) +} + +func normalizeMentionRequest(workspaceID, query string, limit int) (string, string, int, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return "", "", 0, ErrInvalidWorkspaceID + } + query = strings.TrimSpace(query) + if query == "" { + return "", "", 0, fmt.Errorf("%w: mention query is required", ErrInvalidConfig) + } + switch { + case limit <= 0: + limit = defaultMentionLimit + case limit > maxMentionLimit: + limit = maxMentionLimit + } + return workspaceID, query, limit, nil +} + +func buildMentionWIQL(query string) string { + query = strings.NewReplacer("\r", " ", "\n", " ", "\t", " ").Replace(query) + query = strings.ReplaceAll(query, "'", "''") + return "SELECT [System.Id] FROM WorkItems " + + "WHERE [System.Title] CONTAINS '" + query + "' " + + "ORDER BY [System.ChangedDate] DESC" +} diff --git a/apps/backend/internal/azuredevops/service_mentions_test.go b/apps/backend/internal/azuredevops/service_mentions_test.go new file mode 100644 index 0000000000..ca09adfaf9 --- /dev/null +++ b/apps/backend/internal/azuredevops/service_mentions_test.go @@ -0,0 +1,198 @@ +package azuredevops + +import ( + "context" + "fmt" + "strings" + "testing" +) + +type mentionClient struct { + *invalidClient + projects []Project + repositories map[string][]Repository + workItems map[string][]WorkItem + pullRequests map[string][]PullRequest + wiqlCalls []mentionWIQLCall + prCalls []PullRequestFilter + projectCalls int + repositoryCalls int +} + +type mentionWIQLCall struct { + projectID string + wiql string + top int +} + +func (c *mentionClient) ListProjects(context.Context) ([]Project, error) { + c.projectCalls++ + return append([]Project(nil), c.projects...), nil +} + +func (c *mentionClient) ListRepositories(_ context.Context, projectID string) ([]Repository, error) { + c.repositoryCalls++ + return append([]Repository(nil), c.repositories[projectID]...), nil +} + +func (c *mentionClient) QueryWIQL(_ context.Context, projectID, wiql string, top int) (*WorkItemSearchResult, error) { + c.wiqlCalls = append(c.wiqlCalls, mentionWIQLCall{projectID: projectID, wiql: wiql, top: top}) + items := append([]WorkItem(nil), c.workItems[projectID]...) + if len(items) > top { + items = items[:top] + } + return &WorkItemSearchResult{Items: items, Count: len(items)}, nil +} + +func (c *mentionClient) ListPullRequests(_ context.Context, filter PullRequestFilter) (*PullRequestPage, error) { + c.prCalls = append(c.prCalls, filter) + items := append([]PullRequest(nil), c.pullRequests[filter.ProjectID]...) + if filter.Skip >= len(items) { + return &PullRequestPage{Skip: filter.Skip, Top: filter.Top}, nil + } + items = items[filter.Skip:] + if filter.Top > 0 && len(items) > filter.Top { + items = items[:filter.Top] + } + return &PullRequestPage{Items: items, Count: len(items), Skip: filter.Skip, Top: filter.Top}, nil +} + +func TestServiceMentionSearchBuildsSafeWIQLAndProjectLevelPRQuery(t *testing.T) { + client := &mentionClient{ + invalidClient: &invalidClient{}, + workItems: map[string][]WorkItem{ + "project-1": {{ID: 101, Title: "Fix auth", Project: "Platform"}}, + }, + pullRequests: map[string][]PullRequest{ + "project-1": { + {ID: 42, Title: "Fix auth flow", ProjectID: "project-1", ProjectName: "Platform", RepositoryID: "repo-1", RepositoryName: "widgets"}, + {ID: 43, Title: "Unrelated", ProjectID: "project-1", ProjectName: "Platform", RepositoryID: "repo-1", RepositoryName: "widgets"}, + }, + }, + } + service, _, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := service.SetConfigForWorkspace(t.Context(), "workspace-1", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", DefaultProjectID: "project-1", + DefaultProjectName: "Platform", PAT: "secret", + }); err != nil { + t.Fatalf("set config: %v", err) + } + query := `auth' OR [System.State] <> ''` + workItems, err := service.SearchMentionWorkItemsForWorkspace(t.Context(), "workspace-1", query, 5) + if err != nil { + t.Fatalf("search work items: %v", err) + } + prs, err := service.SearchMentionPullRequestsForWorkspace(t.Context(), "workspace-1", "AUTH", 5) + if err != nil { + t.Fatalf("search pull requests: %v", err) + } + if len(client.wiqlCalls) != 1 || client.wiqlCalls[0].projectID != "project-1" { + t.Fatalf("WIQL calls = %#v", client.wiqlCalls) + } + wiql := client.wiqlCalls[0].wiql + if !strings.Contains(wiql, `CONTAINS 'auth'' OR [System.State] <> '''`) || + strings.Contains(wiql, `CONTAINS 'auth' OR`) { + t.Fatalf("unsafe WIQL = %q", wiql) + } + if len(client.prCalls) != 1 || client.prCalls[0].RepositoryID != "" || + client.prCalls[0].ProjectID != "project-1" || client.prCalls[0].Status != "active" { + t.Fatalf("PR calls = %#v", client.prCalls) + } + if len(workItems) != 1 || workItems[0].ID != 101 || workItems[0].OrganizationURL != "https://dev.azure.com/acme" { + t.Fatalf("work items = %#v", workItems) + } + if len(prs) != 1 || prs[0].ID != 42 || prs[0].RepositoryID != "repo-1" { + t.Fatalf("PRs = %#v", prs) + } +} + +func TestServiceMentionSearchBoundsProjectDiscovery(t *testing.T) { + client := &mentionClient{ + invalidClient: &invalidClient{}, + workItems: make(map[string][]WorkItem), + } + for index := 7; index >= 1; index-- { + id := fmt.Sprintf("project-%d", index) + client.projects = append(client.projects, Project{ID: id, Name: fmt.Sprintf("Project %d", index)}) + client.workItems[id] = []WorkItem{{ID: index, Title: "Match", Project: id}} + } + service, _, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := service.SetConfigForWorkspace(t.Context(), "workspace-1", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "secret", + }); err != nil { + t.Fatalf("set config: %v", err) + } + + items, err := service.SearchMentionWorkItemsForWorkspace(t.Context(), "workspace-1", "match", 10) + if err != nil { + t.Fatalf("search: %v", err) + } + if client.projectCalls != 1 || len(client.wiqlCalls) != maxMentionProjects { + t.Fatalf("project calls = %d, WIQL calls = %d", client.projectCalls, len(client.wiqlCalls)) + } + if len(items) != maxMentionProjects || items[0].ProjectID != "project-1" { + t.Fatalf("items = %#v", items) + } +} + +func TestServiceMentionPullRequestSearchPaginatesUntilMatch(t *testing.T) { + pullRequests := make([]PullRequest, mentionPRFetchLimit+1) + for index := range pullRequests { + pullRequests[index] = PullRequest{ + ID: index + 1, Title: "Unrelated", Status: activePullRequestState, + ProjectID: "project-1", ProjectName: "Platform", + RepositoryID: "repo-1", RepositoryName: "widgets", + } + } + pullRequests[mentionPRFetchLimit].Title = "Needle after first page" + client := &mentionClient{ + invalidClient: &invalidClient{}, + pullRequests: map[string][]PullRequest{"project-1": pullRequests}, + } + service, _, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := service.SetConfigForWorkspace(t.Context(), "workspace-1", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", DefaultProjectID: "project-1", + DefaultProjectName: "Platform", PAT: "secret", + }); err != nil { + t.Fatalf("set config: %v", err) + } + + items, err := service.SearchMentionPullRequestsForWorkspace(t.Context(), "workspace-1", "needle", 1) + if err != nil { + t.Fatalf("search pull requests: %v", err) + } + if len(items) != 1 || items[0].ID != mentionPRFetchLimit+1 { + t.Fatalf("items = %#v, want matching pull request after first page", items) + } + if len(client.prCalls) != 2 || client.prCalls[0].Skip != 0 || client.prCalls[0].Top != mentionPRFetchLimit || + client.prCalls[1].Skip != mentionPRFetchLimit || client.prCalls[1].Top != mentionPRFetchLimit { + t.Fatalf("PR calls = %#v", client.prCalls) + } +} + +func TestServiceResolveMentionScopeRejectsForeignWorkspaceProject(t *testing.T) { + client := &mentionClient{ + invalidClient: &invalidClient{}, + projects: []Project{{ID: "project-1", Name: "Platform"}}, + repositories: map[string][]Repository{ + "project-1": {{ID: "repo-1", Name: "widgets", ProjectID: "project-1", ProjectName: "Platform"}}, + }, + } + service, _, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := service.SetConfigForWorkspace(t.Context(), "workspace-1", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "secret", + }); err != nil { + t.Fatalf("set config: %v", err) + } + project, err := service.ResolveMentionProjectForWorkspace(t.Context(), "workspace-1", "project-1") + if err != nil || project.ProjectName != "Platform" { + t.Fatalf("resolve project = %#v, %v", project, err) + } + repository, err := service.ResolveMentionRepositoryForWorkspace(t.Context(), "workspace-1", "project-1", "repo-1") + if err != nil || repository.RepositoryName != "widgets" { + t.Fatalf("resolve repository = %#v, %v", repository, err) + } + if _, err := service.ResolveMentionProjectForWorkspace(t.Context(), "workspace-2", "project-1"); err == nil { + t.Fatal("foreign workspace project resolved") + } +} diff --git a/apps/backend/internal/backendapp/gateway.go b/apps/backend/internal/backendapp/gateway.go index 91773558b2..7d97aed349 100644 --- a/apps/backend/internal/backendapp/gateway.go +++ b/apps/backend/internal/backendapp/gateway.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/scripts" + "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" @@ -111,6 +112,7 @@ func provideGateway( terminalRepo *terminalrepo.Repository, githubSvc *github.Service, gitlabSvc *gitlab.Service, + referenceValidator entityrefs.SubmissionValidator, dataDir string, ) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, *terminalservice.Service, error) { gateway, err := gateways.Provide(log) @@ -146,7 +148,13 @@ func provideGateway( orchestratorHandlers.RegisterHandlers(gateway.Dispatcher) // Register message queue handlers - queueHandlers := orchestratorhandlers.NewQueueHandlers(orchestratorSvc.GetMessageQueue(), orchestratorSvc.GetEventBus(), log, orchestratorSvc) + queueHandlers := orchestratorhandlers.NewQueueHandlers( + orchestratorSvc.GetMessageQueue(), + orchestratorSvc.GetEventBus(), + log, + orchestratorSvc, + referenceValidator, + ) queueHandlers.RegisterHandlers(gateway.Dispatcher) if lifecycleMgr != nil && agentRegistry != nil { diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 2643a72689..d75331cceb 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -39,6 +39,7 @@ import ( debughandlers "github.com/kandev/kandev/internal/debug" editorcontroller "github.com/kandev/kandev/internal/editors/controller" editorhandlers "github.com/kandev/kandev/internal/editors/handlers" + "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" "github.com/kandev/kandev/internal/github" @@ -855,6 +856,9 @@ func resolveRepositoryIDForSessionSubpath(ctx context.Context, taskRepo *sqliter // registerTaskRoutes registers all task-related HTTP and WebSocket routes. func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, handoffSvc *taskservice.HandoffService) { taskhandlers.RegisterWorkspaceRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) + if p.services != nil { + registerMentionRoutes(p.router, p.services.Mentions) + } taskhandlers.RegisterWorkflowRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.services.Workflow, p.log) taskH := taskhandlers.RegisterTaskRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.orchestratorSvc, p.taskRepo, planService, p.log) if p.services != nil && p.services.User != nil { @@ -880,9 +884,13 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, han taskhandlers.RegisterExecutorRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) taskhandlers.RegisterExecutorProfileRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.agentList, p.log) taskhandlers.RegisterEnvironmentRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) + var referenceValidators []entityrefs.SubmissionValidator + if p.services != nil && p.services.Mentions != nil && p.services.Mentions.Submission != nil { + referenceValidators = append(referenceValidators, p.services.Mentions.Submission) + } taskhandlers.RegisterMessageRoutes( p.router, p.gateway.Dispatcher, p.taskSvc, - &orchestratorWrapper{svc: p.orchestratorSvc}, p.log, + &orchestratorWrapper{svc: p.orchestratorSvc}, p.log, referenceValidators..., ) taskhandlers.RegisterProcessRoutes(p.router, p.taskSvc, p.lifecycleMgr, p.log) analyticshandlers.RegisterStatsRoutes(p.router, p.analyticsRepo, p.log) diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 70eb8939d9..877c8383b1 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -19,6 +19,7 @@ import ( "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/common/httpmw" + "github.com/kandev/kandev/internal/entityrefs" "go.uber.org/zap" // Common packages @@ -645,10 +646,15 @@ func startGatewayAndServe( // WEBSOCKET GATEWAY // ============================================ log.Info("Initializing WebSocket Gateway...") + var referenceValidator entityrefs.SubmissionValidator + if services.Mentions != nil { + referenceValidator = services.Mentions.Submission + } gateway, _, notificationCtrl, terminalSvc, err := provideGateway( ctx, log, eventBus, services.Task, services.User, orchestratorSvc, lifecycleMgr, agentRegistry, repos.Notification, repos.Task, repos.Terminal, services.GitHub, services.GitLab, + referenceValidator, cfg.ResolvedHomeDir(), ) if terminalSvc != nil { diff --git a/apps/backend/internal/backendapp/mentions.go b/apps/backend/internal/backendapp/mentions.go new file mode 100644 index 0000000000..305c02829f --- /dev/null +++ b/apps/backend/internal/backendapp/mentions.go @@ -0,0 +1,302 @@ +package backendapp + +import ( + "context" + "errors" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/entityrefs" + "github.com/kandev/kandev/internal/gitlab" + "github.com/kandev/kandev/internal/mentions" + "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 +} + +type mentionWorkspaceResolver interface { + GetWorkspace(context.Context, string) (*models.Workspace, error) +} + +type mentionConversationResolver interface { + ResolveWorkspace(context.Context, string, string) (string, error) +} + +func newMentionComponents( + workspaces mentionWorkspaceResolver, + conversations mentionConversationResolver, + providers ...mentions.MentionProvider, +) (*MentionComponents, error) { + registry := mentions.NewRegistry() + for _, provider := range providers { + if err := registry.Register(provider); err != nil { + return nil, fmt.Errorf("register mention provider: %w", err) + } + } + search := &workspaceValidatingMentionSearcher{ + workspaces: workspaces, + searcher: mentions.NewService(registry), + } + return &MentionComponents{ + Registry: registry, + Search: search, + Submission: entityrefs.NewSubmissionService(conversations, registry), + }, 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, 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, fmt.Errorf("validate mention workspace: %w", 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)) + 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).RegisterRoutes(router) +} diff --git a/apps/backend/internal/backendapp/mentions_gitlab_test.go b/apps/backend/internal/backendapp/mentions_gitlab_test.go new file mode 100644 index 0000000000..4f75f7b492 --- /dev/null +++ b/apps/backend/internal/backendapp/mentions_gitlab_test.go @@ -0,0 +1,108 @@ +package backendapp + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/gitlab" + "github.com/kandev/kandev/internal/mentions" + "github.com/kandev/kandev/internal/task/models" +) + +type gitLabMentionRepositoryResolverStub struct { + workspaceID string + repositories []*models.Repository + err error +} + +func (r *gitLabMentionRepositoryResolverStub) ListRepositories( + _ context.Context, + workspaceID string, +) ([]*models.Repository, error) { + r.workspaceID = workspaceID + return r.repositories, r.err +} + +func TestBuiltinGitLabMentionsSyncWorkspaceRepositoryScopeBeforeSearch(t *testing.T) { + const host = "https://gitlab.example.test" + pool := newGitLabServiceTestPool(t) + store, err := gitlab.NewStore(pool.Writer(), pool.Reader()) + if err != nil { + t.Fatalf("new GitLab store: %v", err) + } + client := gitlab.NewMockClient(host) + client.SeedIssue("group/api", &gitlab.Issue{ + ID: 7001, IID: 7, ProjectID: 101, Title: "Fix auth", + WebURL: host + "/group/api/-/issues/7", + }) + client.SeedIssue("other/private", &gitlab.Issue{ + ID: 9001, IID: 9, ProjectID: 202, Title: "Other auth", + WebURL: host + "/other/private/-/issues/9", + }) + service := gitlab.NewService(host, client, "mock", nil, newTestLogger()) + service.SetStore(store) + repositories := &gitLabMentionRepositoryResolverStub{repositories: []*models.Repository{ + { + ID: "repo-gitlab", WorkspaceID: "workspace-1", Provider: "gitlab", + ProviderRepoID: "101", ProviderHost: "HTTPS://GITLAB.EXAMPLE.TEST/", + ProviderOwner: "group", ProviderName: "api", + RemoteURL: host + "/group/api.git", + }, + { + ID: "repo-foreign-gitlab", WorkspaceID: "workspace-1", Provider: "gitlab", + ProviderRepoID: "202", ProviderHost: "https://gitlab.other.test", + ProviderOwner: "other", ProviderName: "private", + RemoteURL: "https://gitlab.other.test/other/private.git", + }, + { + ID: "repo-github", WorkspaceID: "workspace-1", Provider: "github", + ProviderRepoID: "other/private", ProviderOwner: "other", ProviderName: "private", + }, + }} + providers := builtinMentionProviders(&Services{GitLab: service}, repositories) + var issueProvider mentions.MentionProvider + for _, provider := range providers { + if provider.Descriptor().Source == "gitlab_issues" { + issueProvider = provider + break + } + } + if issueProvider == nil { + t.Fatal("GitLab issue provider not registered") + } + + candidates, err := issueProvider.Search(context.Background(), mentions.SearchRequest{ + WorkspaceID: "workspace-1", Query: "auth", Limit: 5, + }) + if err != nil { + t.Fatalf("search GitLab mentions: %v", err) + } + if repositories.workspaceID != "workspace-1" { + t.Fatalf("repository workspace = %q", repositories.workspaceID) + } + if len(candidates) != 1 || candidates[0].ID != "7001" || + candidates[0].Scope != host+"|project:101" { + t.Fatalf("candidates = %#v, want only workspace-bound project", candidates) + } + scope, err := service.MentionScopeForWorkspace(context.Background(), "workspace-1") + if err != nil { + t.Fatalf("load synchronized scope: %v", err) + } + if len(scope.Projects) != 1 || scope.Projects[0].ID != 101 || scope.Projects[0].Path != "group/api" { + t.Fatalf("scope = %#v", scope) + } + + repositories.repositories = []*models.Repository{{ + ID: "repo-unknown-gitlab", WorkspaceID: "workspace-1", Provider: "gitlab", + ProviderRepoID: "101", ProviderOwner: "group", ProviderName: "api", + RemoteURL: host + "/group/api.git", + }} + _, err = issueProvider.Search(context.Background(), mentions.SearchRequest{ + WorkspaceID: "workspace-1", Query: "auth", Limit: 5, + }) + var providerErr *mentions.ProviderError + if !errors.As(err, &providerErr) || providerErr.Status != mentions.StatusUnsupportedScope { + t.Fatalf("search after scope removal error = %v, want unsupported scope", err) + } +} diff --git a/apps/backend/internal/backendapp/mentions_test.go b/apps/backend/internal/backendapp/mentions_test.go new file mode 100644 index 0000000000..fff8ce0bb4 --- /dev/null +++ b/apps/backend/internal/backendapp/mentions_test.go @@ -0,0 +1,262 @@ +package backendapp + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/mentions" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeMentionScopeResolver struct { + workspaces map[string]*models.Workspace + err error +} + +func (r *fakeMentionScopeResolver) GetWorkspace(_ context.Context, id string) (*models.Workspace, error) { + if r.err != nil { + return nil, r.err + } + workspace, ok := r.workspaces[id] + if !ok { + return nil, repository.ErrWorkspaceNotFound + } + return workspace, nil +} + +func (r *fakeMentionScopeResolver) ResolveWorkspace(_ context.Context, sessionID, _ string) (string, error) { + if sessionID == "session-1" { + return "workspace-1", nil + } + return "", errors.New("unknown conversation") +} + +type fakeComposedMentionProvider struct { + descriptor mentions.ProviderDescriptor + candidates []mentions.Candidate + err error + calls int + workspace string + waitCancel bool + canceled bool + started chan struct{} + cancelSeen chan struct{} +} + +func (p *fakeComposedMentionProvider) Descriptor() mentions.ProviderDescriptor { + return p.descriptor +} + +func (p *fakeComposedMentionProvider) Search( + ctx context.Context, + request mentions.SearchRequest, +) ([]mentions.Candidate, error) { + p.calls++ + p.workspace = request.WorkspaceID + if p.started != nil { + close(p.started) + } + if p.waitCancel { + <-ctx.Done() + p.canceled = errors.Is(ctx.Err(), context.Canceled) + if p.cancelSeen != nil { + close(p.cancelSeen) + } + return nil, ctx.Err() + } + return p.candidates, p.err +} + +func (p *fakeComposedMentionProvider) AuthorizeReference( + context.Context, + mentions.ReferenceAuthorizationRequest, +) error { + return nil +} + +func TestMentionHTTPCompositionValidatesWorkspaceBeforeMixedProviderFanout(t *testing.T) { + gin.SetMode(gin.TestMode) + success := &fakeComposedMentionProvider{ + descriptor: mentions.ProviderDescriptor{ + Source: "plugin:test:issues", Provider: "plugin:test:tracker", Kind: "issue", + DisplayName: "Test tracker", KindLabel: "Issue", Order: 90, + }, + candidates: []mentions.Candidate{ + {ID: "1", Key: "T-1", Title: "First", URL: "https://tracker.example/items/1", Scope: "workspace-1"}, + {ID: "2", Key: "T-2", Title: "Second", URL: "https://tracker.example/items/2", Scope: "workspace-1"}, + {ID: "3", Key: "T-3", Title: "Third", URL: "https://tracker.example/items/3", Scope: "workspace-1"}, + }, + } + failure := &fakeComposedMentionProvider{ + descriptor: mentions.ProviderDescriptor{ + Source: "plugin:test:pulls", Provider: "plugin:test:tracker", Kind: "pull_request", + DisplayName: "Test tracker", KindLabel: "Pull request", Order: 91, + }, + err: mentions.NewProviderError(mentions.StatusUnauthorized, errors.New("secret upstream detail")), + } + timeout := &fakeComposedMentionProvider{ + descriptor: mentions.ProviderDescriptor{ + Source: "plugin:test:alerts", Provider: "plugin:test:alerts", Kind: "issue", + DisplayName: "Test alerts", KindLabel: "Issue", Order: 92, + }, + err: context.DeadlineExceeded, + } + resolver := &fakeMentionScopeResolver{workspaces: map[string]*models.Workspace{ + "workspace-1": {ID: "workspace-1", Name: "One"}, + }} + components, err := newMentionComponents(resolver, resolver, failure, timeout, success) + if err != nil { + t.Fatalf("compose mentions: %v", err) + } + router := gin.New() + registerMentionRoutes(router, components) + + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest( + http.MethodGet, + "/api/v1/workspaces/workspace-1/mentions/search?q=auth&limit=2", + nil, + )) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var body apiv1.MentionSearchResponse + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(body.Groups) != 3 || len(body.Groups[0].Results) != 2 || + body.Groups[1].Status != mentions.StatusUnauthorized || + body.Groups[2].Status != mentions.StatusTimeout { + t.Fatalf("groups = %#v", body.Groups) + } + if success.workspace != "workspace-1" || failure.workspace != "workspace-1" { + t.Fatalf("provider workspaces = %q, %q", success.workspace, failure.workspace) + } + + missing := httptest.NewRecorder() + router.ServeHTTP(missing, httptest.NewRequest( + http.MethodGet, + "/api/v1/workspaces/workspace-2/mentions/search?q=auth", + nil, + )) + if missing.Code != http.StatusNotFound { + t.Fatalf("missing workspace status = %d, body = %s", missing.Code, missing.Body.String()) + } + if success.calls != 1 || failure.calls != 1 || timeout.calls != 1 { + t.Fatalf( + "providers called after missing workspace: success=%d failure=%d timeout=%d", + success.calls, failure.calls, timeout.calls, + ) + } + + resolver.err = errors.New("database unavailable") + infrastructureFailure := httptest.NewRecorder() + router.ServeHTTP(infrastructureFailure, httptest.NewRequest( + http.MethodGet, + "/api/v1/workspaces/workspace-1/mentions/search?q=auth", + nil, + )) + if infrastructureFailure.Code != http.StatusInternalServerError { + t.Fatalf( + "workspace failure status = %d, body = %s", + infrastructureFailure.Code, + infrastructureFailure.Body.String(), + ) + } + if success.calls != 1 || failure.calls != 1 || timeout.calls != 1 { + t.Fatal("providers called after workspace infrastructure failure") + } +} + +func TestMentionHTTPCompositionPropagatesRequestCancellation(t *testing.T) { + gin.SetMode(gin.TestMode) + provider := &fakeComposedMentionProvider{ + descriptor: mentions.ProviderDescriptor{ + Source: "plugin:test:cancel", Provider: "plugin:test:cancel", Kind: "issue", + DisplayName: "Cancellation test", KindLabel: "Issue", Order: 90, + }, + waitCancel: true, + started: make(chan struct{}), + cancelSeen: make(chan struct{}), + } + resolver := &fakeMentionScopeResolver{workspaces: map[string]*models.Workspace{ + "workspace-1": {ID: "workspace-1", Name: "One"}, + }} + components, err := newMentionComponents(resolver, resolver, provider) + if err != nil { + t.Fatalf("compose mentions: %v", err) + } + router := gin.New() + registerMentionRoutes(router, components) + + ctx, cancel := context.WithCancel(context.Background()) + request := httptest.NewRequest( + http.MethodGet, + "/api/v1/workspaces/workspace-1/mentions/search?q=cancel", + nil, + ).WithContext(ctx) + response := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + router.ServeHTTP(response, request) + close(done) + }() + select { + case <-provider.started: + case <-time.After(time.Second): + t.Fatal("provider did not start") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("request did not stop after cancellation") + } + select { + case <-provider.cancelSeen: + case <-time.After(time.Second): + t.Fatal("provider did not observe request cancellation") + } + + if response.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if !provider.canceled { + t.Fatal("provider did not observe request cancellation") + } +} + +func TestBuiltinMentionProvidersRemainDescriptorDrivenWhenIntegrationsAreNil(t *testing.T) { + providers := builtinMentionProviders(&Services{}, nil) + want := []struct { + source string + order int + }{ + {"jira_issues", 20}, + {"linear_issues", 30}, + {"github_issues", 40}, + {"github_pull_requests", 41}, + {"gitlab_issues", 50}, + {"gitlab_merge_requests", 51}, + {"azure_work_items", 60}, + {"azure_pull_requests", 61}, + {"sentry_issues", 70}, + } + if len(providers) != len(want) { + t.Fatalf("provider count = %d, want %d", len(providers), len(want)) + } + for index, expected := range want { + descriptor := providers[index].Descriptor() + if descriptor.Source != expected.source || descriptor.Order != expected.order { + t.Fatalf("provider %d descriptor = %#v, want source=%q order=%d", index, descriptor, expected.source, expected.order) + } + } +} diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 65cffc5b48..9d764765ed 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -141,7 +141,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories automationComponents.Service.SetTaskDeleter(&automationTaskDeleterAdapter{svc: taskSvc}) } - return &Services{ + services := &Services{ Task: taskSvc, User: userSvc, Editor: editorSvc, @@ -164,7 +164,17 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Office: nil, // Notification service is initialized after gateway is available. Notification: nil, - }, agentSettingsController, nil + } + mentionComponents, err := newMentionComponents( + taskSvc, + taskSvc, + builtinMentionProviders(services, repos.Task)..., + ) + if err != nil { + return nil, nil, err + } + services.Mentions = mentionComponents + return services, agentSettingsController, nil } // loadCustomTUIAgents loads user-defined TUI agents from the database into the registry. diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index cca31f5039..75218b3601 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -97,4 +97,6 @@ type Services struct { // but always constructed (non-nil) when initialization succeeds so // tests/tools can exercise it directly. Plugins *plugins.Service + // Mentions owns the provider registry shared by # search and submission authorization. + Mentions *MentionComponents } diff --git a/apps/backend/internal/entityrefs/reference.go b/apps/backend/internal/entityrefs/reference.go new file mode 100644 index 0000000000..beb3a60ad5 --- /dev/null +++ b/apps/backend/internal/entityrefs/reference.go @@ -0,0 +1,144 @@ +// Package entityrefs owns the provider-neutral structural reference contract. +package entityrefs + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +var ErrInvalidReference = errors.New("invalid entity reference") + +var identityNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._:-]{0,127}$`) + +const ( + maxReferencesPerMessage = 100 + kandevProviderID = "kandev" + kandevTaskKind = "task" +) + +// CanonicalRef constructs the opaque stable identity owned by the registry. +func CanonicalRef(provider, kind, scope, id string) string { + parts := []string{ + strings.TrimSpace(provider), + strings.TrimSpace(kind), + strings.TrimSpace(scope), + strings.TrimSpace(id), + } + for index := range parts { + parts[index] = url.QueryEscape(parts[index]) + } + return "mention:v1:" + strings.Join(parts, ":") +} + +// NormalizeForSubmission rejects malformed known references and omits unknown versions. +func NormalizeForSubmission(references []apiv1.EntityReference) ([]apiv1.EntityReference, error) { + return normalize(references, true) +} + +// NormalizePersisted decodes JSON-roundtripped metadata and omits malformed or unknown entries. +func NormalizePersisted(raw any) []apiv1.EntityReference { + if raw == nil { + return nil + } + references, ok := raw.([]apiv1.EntityReference) + if !ok { + encoded, err := json.Marshal(raw) + if err != nil || json.Unmarshal(encoded, &references) != nil { + return nil + } + } + normalized, _ := normalize(references, false) + return normalized +} + +func normalize(references []apiv1.EntityReference, strict bool) ([]apiv1.EntityReference, error) { + if len(references) > maxReferencesPerMessage { + if strict { + return nil, fmt.Errorf("%w: at most %d references are allowed", ErrInvalidReference, maxReferencesPerMessage) + } + references = references[:maxReferencesPerMessage] + } + result := make([]apiv1.EntityReference, 0, len(references)) + seen := make(map[string]struct{}, len(references)) + for index, reference := range references { + if reference.Version != apiv1.EntityReferenceVersion { + continue + } + normalized, ok := normalizeOne(reference) + if !ok { + if strict { + return nil, fmt.Errorf("%w at index %d", ErrInvalidReference, index) + } + continue + } + if _, duplicate := seen[normalized.Ref]; duplicate { + continue + } + seen[normalized.Ref] = struct{}{} + result = append(result, normalized) + } + return result, nil +} + +func normalizeOne(reference apiv1.EntityReference) (apiv1.EntityReference, bool) { + reference.Ref = strings.TrimSpace(reference.Ref) + reference.Provider = strings.TrimSpace(reference.Provider) + reference.Kind = strings.TrimSpace(reference.Kind) + reference.ID = strings.TrimSpace(reference.ID) + reference.Scope = strings.TrimSpace(reference.Scope) + reference.Key = normalizeDisplayText(reference.Key) + reference.Title = normalizeDisplayText(reference.Title) + reference.URL = strings.TrimSpace(reference.URL) + + if !identityNamePattern.MatchString(reference.Provider) || !identityNamePattern.MatchString(reference.Kind) || + !validIdentity(reference.ID) || !validIdentity(reference.Scope) || reference.Title == "" || + utf8.RuneCountInString(reference.Title) > 500 || utf8.RuneCountInString(reference.Key) > 200 { + return apiv1.EntityReference{}, false + } + if reference.Ref != CanonicalRef(reference.Provider, reference.Kind, reference.Scope, reference.ID) || + !safeURL(reference) { + return apiv1.EntityReference{}, false + } + return reference, true +} + +func validIdentity(value string) bool { + if value == "" || !utf8.ValidString(value) || utf8.RuneCountInString(value) > 512 { + return false + } + for _, character := range value { + if unicode.IsControl(character) { + return false + } + } + return true +} + +func normalizeDisplayText(value string) string { + if !utf8.ValidString(value) { + return "" + } + return strings.Join(strings.Fields(value), " ") +} + +func safeURL(reference apiv1.EntityReference) bool { + if reference.URL == "" || !utf8.ValidString(reference.URL) || utf8.RuneCountInString(reference.URL) > 2048 { + return false + } + parsed, err := url.Parse(reference.URL) + if err != nil || parsed.User != nil { + return false + } + if reference.Provider == kandevProviderID && reference.Kind == kandevTaskKind { + return reference.URL == "/t/"+url.PathEscape(reference.ID) + } + return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" +} diff --git a/apps/backend/internal/entityrefs/reference_test.go b/apps/backend/internal/entityrefs/reference_test.go new file mode 100644 index 0000000000..bdbeabe8b3 --- /dev/null +++ b/apps/backend/internal/entityrefs/reference_test.go @@ -0,0 +1,116 @@ +package entityrefs + +import ( + "encoding/json" + "errors" + "testing" + + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +func TestNormalizeForSubmissionCanonicalizesAndDeduplicates(t *testing.T) { + ref := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: CanonicalRef("jira", "issue", " site-a ", " 10001 "), + Provider: " jira ", + Kind: " issue ", + ID: " 10001 ", + Key: " ENG-7\n", + Title: " Fix\n authentication ", + URL: " https://jira.example.test/browse/ENG-7 ", + Scope: " site-a ", + } + + got, err := NormalizeForSubmission([]apiv1.EntityReference{ref, ref}) + if err != nil { + t.Fatalf("normalize: %v", err) + } + if len(got) != 1 { + t.Fatalf("references = %#v, want one deduplicated reference", got) + } + want := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: CanonicalRef("jira", "issue", "site-a", "10001"), + Provider: "jira", + Kind: "issue", + ID: "10001", + Key: "ENG-7", + Title: "Fix authentication", + URL: "https://jira.example.test/browse/ENG-7", + Scope: "site-a", + } + if got[0] != want { + t.Fatalf("reference = %#v, want %#v", got[0], want) + } +} + +func TestNormalizeForSubmissionRejectsMalformedKnownVersion(t *testing.T) { + valid := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: CanonicalRef("kandev", "task", "workspace-1", "task-1"), + Provider: "kandev", Kind: "task", ID: "task-1", + Title: "Task", URL: "/t/task-1", Scope: "workspace-1", + } + tests := []struct { + name string + edit func(*apiv1.EntityReference) + }{ + {name: "spoofed ref", edit: func(ref *apiv1.EntityReference) { ref.Ref = "mention:v1:kandev:task:workspace-1:other" }}, + {name: "unsafe scheme", edit: func(ref *apiv1.EntityReference) { ref.URL = "javascript:alert(1)" }}, + {name: "external internal route", edit: func(ref *apiv1.EntityReference) { + ref.Provider = "jira" + ref.Ref = CanonicalRef("jira", "task", ref.Scope, ref.ID) + }}, + {name: "wrong task route", edit: func(ref *apiv1.EntityReference) { ref.URL = "/t/other" }}, + {name: "empty title", edit: func(ref *apiv1.EntityReference) { ref.Title = " \n " }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ref := valid + test.edit(&ref) + _, err := NormalizeForSubmission([]apiv1.EntityReference{ref}) + if !errors.Is(err, ErrInvalidReference) { + t.Fatalf("error = %v, want invalid reference", err) + } + }) + } +} + +func TestNormalizeForSubmissionOmitsUnknownVersion(t *testing.T) { + got, err := NormalizeForSubmission([]apiv1.EntityReference{{ + Version: 99, Ref: "future", Provider: "future", Kind: "thing", ID: "1", + Title: "Future", URL: "https://future.test/1", Scope: "tenant", + }}) + if err != nil { + t.Fatalf("normalize: %v", err) + } + if len(got) != 0 { + t.Fatalf("references = %#v, want unknown version omitted", got) + } +} + +func TestNormalizePersistedDecodesJSONRoundTripAndOmitsMalformed(t *testing.T) { + valid := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: CanonicalRef("linear", "issue", "acme", "issue-1"), + Provider: "linear", Kind: "issue", ID: "issue-1", Key: "ENG-1", + Title: "Fix auth", URL: "https://linear.app/acme/issue/ENG-1", Scope: "acme", + } + rawJSON, err := json.Marshal([]apiv1.EntityReference{valid, { + Version: apiv1.EntityReferenceVersion, + Ref: "bad", Provider: "linear", Kind: "issue", ID: "bad", + Title: "Bad", URL: "https://linear.app/bad", Scope: "acme", + }}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var roundTripped any + if err := json.Unmarshal(rawJSON, &roundTripped); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + got := NormalizePersisted(roundTripped) + if len(got) != 1 || got[0] != valid { + t.Fatalf("references = %#v, want only valid typed reference", got) + } +} diff --git a/apps/backend/internal/entityrefs/submission.go b/apps/backend/internal/entityrefs/submission.go new file mode 100644 index 0000000000..21b2402367 --- /dev/null +++ b/apps/backend/internal/entityrefs/submission.go @@ -0,0 +1,67 @@ +package entityrefs + +import ( + "context" + "errors" + "strings" + + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +var ErrUnauthorizedReference = errors.New("entity reference is not authorized") + +// ConversationResolver derives trusted workspace scope from a persisted session/task. +type ConversationResolver interface { + ResolveWorkspace(ctx context.Context, sessionID, assertedTaskID string) (string, error) +} + +// WorkspaceAuthorizer dispatches provider-owned authorization in trusted workspace scope. +type WorkspaceAuthorizer interface { + AuthorizeForWorkspace(ctx context.Context, workspaceID string, reference apiv1.EntityReference) error +} + +// SubmissionValidator is the handler-facing reference validation boundary. +type SubmissionValidator interface { + ValidateForSubmission( + ctx context.Context, + sessionID, assertedTaskID string, + references []apiv1.EntityReference, + ) ([]apiv1.EntityReference, error) +} + +// SubmissionService combines structural validation with conversation and provider authorization. +type SubmissionService struct { + resolver ConversationResolver + authorizer WorkspaceAuthorizer +} + +func NewSubmissionService(resolver ConversationResolver, authorizer WorkspaceAuthorizer) *SubmissionService { + return &SubmissionService{resolver: resolver, authorizer: authorizer} +} + +func (s *SubmissionService) ValidateForSubmission( + ctx context.Context, + sessionID, assertedTaskID string, + references []apiv1.EntityReference, +) ([]apiv1.EntityReference, error) { + normalized, err := NormalizeForSubmission(references) + if err != nil { + return nil, err + } + if len(normalized) == 0 { + return normalized, nil + } + if s == nil || s.resolver == nil || s.authorizer == nil { + return nil, ErrUnauthorizedReference + } + workspaceID, err := s.resolver.ResolveWorkspace(ctx, sessionID, assertedTaskID) + if err != nil || strings.TrimSpace(workspaceID) == "" { + return nil, ErrUnauthorizedReference + } + for _, reference := range normalized { + if err := s.authorizer.AuthorizeForWorkspace(ctx, workspaceID, reference); err != nil { + return nil, ErrUnauthorizedReference + } + } + return normalized, nil +} diff --git a/apps/backend/internal/entityrefs/submission_test.go b/apps/backend/internal/entityrefs/submission_test.go new file mode 100644 index 0000000000..d52afae5d9 --- /dev/null +++ b/apps/backend/internal/entityrefs/submission_test.go @@ -0,0 +1,116 @@ +package entityrefs + +import ( + "context" + "errors" + "testing" + + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeConversationResolver struct { + sessionID string + assertedTaskID string + workspaceID string + err error +} + +func (r *fakeConversationResolver) ResolveWorkspace( + _ context.Context, + sessionID, assertedTaskID string, +) (string, error) { + r.sessionID = sessionID + r.assertedTaskID = assertedTaskID + return r.workspaceID, r.err +} + +type fakeWorkspaceAuthorizer struct { + workspaceID string + references []apiv1.EntityReference + err error +} + +func (a *fakeWorkspaceAuthorizer) AuthorizeForWorkspace( + _ context.Context, + workspaceID string, + reference apiv1.EntityReference, +) error { + a.workspaceID = workspaceID + a.references = append(a.references, reference) + return a.err +} + +func TestSubmissionServiceNormalizesAndAuthorizesConversationReferences(t *testing.T) { + resolver := &fakeConversationResolver{workspaceID: "workspace-1"} + authorizer := &fakeWorkspaceAuthorizer{} + validator := NewSubmissionService(resolver, authorizer) + reference := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: CanonicalRef("kandev", "task", "workspace-1", "task-1"), + Provider: "kandev", Kind: "task", ID: "task-1", + Title: " Task ", URL: "/t/task-1", Scope: "workspace-1", + } + + got, err := validator.ValidateForSubmission( + context.Background(), "session-1", "task-current", []apiv1.EntityReference{reference, reference}, + ) + if err != nil { + t.Fatalf("validate: %v", err) + } + if resolver.sessionID != "session-1" || resolver.assertedTaskID != "task-current" { + t.Fatalf("resolver request = session %q task %q", resolver.sessionID, resolver.assertedTaskID) + } + if authorizer.workspaceID != "workspace-1" || len(authorizer.references) != 1 { + t.Fatalf("authorization = workspace %q refs %#v", authorizer.workspaceID, authorizer.references) + } + if len(got) != 1 || got[0].Title != "Task" { + t.Fatalf("normalized = %#v", got) + } +} + +func TestSubmissionServiceFailsClosed(t *testing.T) { + valid := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: CanonicalRef("jira", "issue", "site", "100"), + Provider: "jira", Kind: "issue", ID: "100", Title: "Issue", + URL: "https://jira.test/browse/ENG-1", Scope: "site", + } + tests := []struct { + name string + resolver ConversationResolver + authorizer WorkspaceAuthorizer + reference apiv1.EntityReference + }{ + {name: "missing resolver", authorizer: &fakeWorkspaceAuthorizer{}, reference: valid}, + {name: "missing authorizer", resolver: &fakeConversationResolver{workspaceID: "workspace-1"}, reference: valid}, + {name: "resolver rejects", resolver: &fakeConversationResolver{err: errors.New("task mismatch")}, authorizer: &fakeWorkspaceAuthorizer{}, reference: valid}, + {name: "provider rejects", resolver: &fakeConversationResolver{workspaceID: "workspace-1"}, authorizer: &fakeWorkspaceAuthorizer{err: errors.New("origin mismatch")}, reference: valid}, + {name: "malformed", resolver: &fakeConversationResolver{workspaceID: "workspace-1"}, authorizer: &fakeWorkspaceAuthorizer{}, reference: apiv1.EntityReference{Version: 1, Ref: "bad"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + validator := NewSubmissionService(test.resolver, test.authorizer) + _, err := validator.ValidateForSubmission(context.Background(), "session", "task", []apiv1.EntityReference{test.reference}) + if err == nil { + t.Fatal("validation succeeded") + } + }) + } +} + +func TestSubmissionServiceRejectsOverLimitBeforeScopeOrProviderWork(t *testing.T) { + resolver := &fakeConversationResolver{workspaceID: "workspace-1"} + authorizer := &fakeWorkspaceAuthorizer{} + validator := NewSubmissionService(resolver, authorizer) + references := make([]apiv1.EntityReference, maxReferencesPerMessage+1) + + _, err := validator.ValidateForSubmission( + context.Background(), "session-1", "task-1", references, + ) + if !errors.Is(err, ErrInvalidReference) { + t.Fatalf("error = %v, want invalid reference", err) + } + if resolver.sessionID != "" || len(authorizer.references) != 0 { + t.Fatalf("scope/provider work occurred: resolver=%q refs=%d", resolver.sessionID, len(authorizer.references)) + } +} diff --git a/apps/backend/internal/github/client_helpers.go b/apps/backend/internal/github/client_helpers.go index 2ac9b07938..fa791838c3 100644 --- a/apps/backend/internal/github/client_helpers.go +++ b/apps/backend/internal/github/client_helpers.go @@ -403,7 +403,7 @@ func isNewerCheck(a, b CheckRun) bool { // non-empty pull_request.merged_at; promote those to the "merged" state so the // UI renders the purple merged icon instead of the red closed one. func convertSearchItemToPR( - number int, title, htmlURL, state, authorLogin, repositoryURL, mergedAt string, + id int64, nodeID string, number int, title, htmlURL, state, authorLogin, repositoryURL, mergedAt string, draft bool, createdAt, updatedAt time.Time, ) *PR { owner, repo := parseRepoURL(repositoryURL) @@ -411,6 +411,8 @@ func convertSearchItemToPR( state = prStateMerged } return &PR{ + ID: id, + NodeID: nodeID, Number: number, Title: title, HTMLURL: htmlURL, @@ -482,6 +484,8 @@ func buildSearchQuery(typeQualifier, filter, customQuery string) string { // issueSearchItem is an issue item from the GitHub search API. type issueSearchItem struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` Number int `json:"number"` Title string `json:"title"` Body string `json:"body"` @@ -517,6 +521,8 @@ func convertSearchItemToIssue(item issueSearchItem) *Issue { assignees[i] = a.Login } issue := &Issue{ + ID: item.ID, + NodeID: item.NodeID, Number: item.Number, Title: item.Title, Body: item.Body, diff --git a/apps/backend/internal/github/client_helpers_test.go b/apps/backend/internal/github/client_helpers_test.go index f95da72c2c..4e84c0c20b 100644 --- a/apps/backend/internal/github/client_helpers_test.go +++ b/apps/backend/internal/github/client_helpers_test.go @@ -321,6 +321,7 @@ func TestIsNewerCheck(t *testing.T) { func TestConvertSearchItemToPR(t *testing.T) { now := time.Now() pr := convertSearchItemToPR( + 101, "PR_kwDOA", 42, "Fix bug", "https://github.com/owner/repo/pull/42", "open", "alice", "https://api.github.com/repos/myorg/myrepo", "", false, now, now, @@ -329,6 +330,9 @@ func TestConvertSearchItemToPR(t *testing.T) { if pr.Number != 42 { t.Errorf("expected number 42, got %d", pr.Number) } + if pr.ID != 101 || pr.NodeID != "PR_kwDOA" { + t.Errorf("identity = (%d, %q), want (101, PR_kwDOA)", pr.ID, pr.NodeID) + } if pr.Title != "Fix bug" { t.Errorf("expected title 'Fix bug', got %s", pr.Title) } @@ -350,6 +354,7 @@ func TestConvertSearchItemToPRMerged(t *testing.T) { now := time.Now() mergedAt := "2025-01-02T03:04:05Z" pr := convertSearchItemToPR( + 102, "PR_kwDOB", 7, "Land feature", "https://github.com/owner/repo/pull/7", "closed", "bob", "https://api.github.com/repos/myorg/myrepo", mergedAt, false, now, now, diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index e5167f60aa..395f9ca218 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -1130,6 +1130,8 @@ func (c *GHClient) parsePRList(data string, owner, repo string) ([]*PR, error) { // ghSearchItem is a PR item from the GitHub search API. type ghSearchItem struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` Number int `json:"number"` Title string `json:"title"` HTMLURL string `json:"html_url"` @@ -1155,6 +1157,7 @@ func (c *GHClient) parseSearchResults(data string) ([]*PR, error) { prs := make([]*PR, len(items)) for i, item := range items { prs[i] = convertSearchItemToPR( + item.ID, item.NodeID, item.Number, item.Title, item.HTMLURL, item.State, item.User.Login, item.RepositoryURL, item.PullRequest.MergedAt, item.Draft, item.CreatedAt, item.UpdatedAt, diff --git a/apps/backend/internal/github/gh_client_test.go b/apps/backend/internal/github/gh_client_test.go index 378d3eadbc..4b093c40dc 100644 --- a/apps/backend/internal/github/gh_client_test.go +++ b/apps/backend/internal/github/gh_client_test.go @@ -127,6 +127,24 @@ func TestParseTimePtrValue(t *testing.T) { } } +func TestGHSearchParsingPreservesImmutableIdentity(t *testing.T) { + prs, err := (&GHClient{}).parseSearchResults(`[{"id":202,"node_id":"PR_kwDOB","number":8,"title":"Fix auth","html_url":"https://github.com/acme/web/pull/8","state":"open","repository_url":"https://api.github.com/repos/acme/web","pull_request":{}}]`) + if err != nil { + t.Fatalf("parse search results: %v", err) + } + if len(prs) != 1 || prs[0].ID != 202 || prs[0].NodeID != "PR_kwDOB" { + t.Fatalf("PRs = %#v", prs) + } + + issues := parseIssueSearchResults([]issueSearchItem{{ + ID: 303, NodeID: "I_kwDOC", Number: 9, Title: "Broken login", + RepositoryURL: "https://api.github.com/repos/acme/web", + }}) + if len(issues) != 1 || issues[0].ID != 303 || issues[0].NodeID != "I_kwDOC" { + t.Fatalf("issues = %#v", issues) + } +} + func TestGHClient_ListCheckRuns_PaginatesCheckRuns(t *testing.T) { binDir := t.TempDir() logPath := filepath.Join(t.TempDir(), "gh-args.log") diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index 7cfaa5bb3a..3fa637d508 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -12,6 +12,8 @@ const TaskCIAutoFixMaxRounds = 10 // PR represents a GitHub Pull Request. type PR struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` Number int `json:"number"` Title string `json:"title"` URL string `json:"url"` @@ -549,6 +551,8 @@ type DailyCount struct { // Issue represents a GitHub Issue (not a PR). type Issue struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` Number int `json:"number"` Title string `json:"title"` Body string `json:"body"` diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index 4ae68abc60..d04b4710aa 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -222,6 +222,7 @@ func (c *PATClient) ListReviewRequestedPRs(ctx context.Context, scope, filter, c prs := make([]*PR, len(result.Items)) for i, item := range result.Items { prs[i] = convertSearchItemToPR( + item.ID, item.NodeID, item.Number, item.Title, item.HTMLURL, item.State, item.User.Login, item.RepositoryURL, item.PullRequest.MergedAt, item.Draft, item.CreatedAt, item.UpdatedAt, @@ -253,6 +254,7 @@ func (c *PATClient) SearchPRsPaged(ctx context.Context, filter, customQuery stri prs := make([]*PR, len(result.Items)) for i, item := range result.Items { prs[i] = convertSearchItemToPR( + item.ID, item.NodeID, item.Number, item.Title, item.HTMLURL, item.State, item.User.Login, item.RepositoryURL, item.PullRequest.MergedAt, item.Draft, item.CreatedAt, item.UpdatedAt, @@ -981,6 +983,8 @@ type patIssue struct { } type patSearchItem struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` Number int `json:"number"` Title string `json:"title"` HTMLURL string `json:"html_url"` diff --git a/apps/backend/internal/github/pat_client_test.go b/apps/backend/internal/github/pat_client_test.go index ade84789a9..d92be6d866 100644 --- a/apps/backend/internal/github/pat_client_test.go +++ b/apps/backend/internal/github/pat_client_test.go @@ -108,6 +108,27 @@ func TestConvertPatPR_Merged(t *testing.T) { } } +func TestPATClientSearchPreservesImmutableIdentity(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/search/issues" { + t.Errorf("path = %q, want /search/issues", r.URL.Path) + http.Error(w, "unexpected path", http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"total_count":1,"items":[{"id":101,"node_id":"PR_kwDOA","number":7,"title":"Fix auth","html_url":"https://github.com/acme/web/pull/7","state":"open","repository_url":"https://api.github.com/repos/acme/web","pull_request":{}}]}`)) + })) + t.Cleanup(srv.Close) + + client := newPATClientPointingAt(t, srv.URL) + page, err := client.SearchPRsPaged(context.Background(), "", "auth", 1, 5) + if err != nil { + t.Fatalf("search PRs: %v", err) + } + if len(page.PRs) != 1 || page.PRs[0].ID != 101 || page.PRs[0].NodeID != "PR_kwDOA" { + t.Fatalf("PRs = %#v", page.PRs) + } +} + func TestPATClient_ListCheckRunsPaginatesCheckRuns(t *testing.T) { var requestedPages []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/apps/backend/internal/github/service_mentions.go b/apps/backend/internal/github/service_mentions.go new file mode 100644 index 0000000000..b2910b6e47 --- /dev/null +++ b/apps/backend/internal/github/service_mentions.go @@ -0,0 +1,99 @@ +package github + +import ( + "context" + "errors" + "strings" +) + +const ( + defaultMentionSearchLimit = 5 + maxMentionSearchLimit = 10 +) + +var ( + ErrMentionWorkspaceRequired = errors.New("GitHub mention workspace is required") + ErrMentionQueryRequired = errors.New("GitHub mention query is required") +) + +// SearchMentionIssuesForWorkspace translates plain composer text into a +// server-owned, title-only GitHub issue query within one workspace's scope. +func (s *Service) SearchMentionIssuesForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]*Issue, error) { + workspaceID, query, limit, err := normalizeMentionSearch(workspaceID, query, limit) + if err != nil { + return nil, err + } + result, err := s.SearchUserIssuesPagedForWorkspace( + ctx, + workspaceID, + "", + buildMentionTitleQuery("type:issue", query), + 1, + limit, + ) + if err != nil { + return nil, err + } + if result == nil || result.Issues == nil { + return []*Issue{}, nil + } + return result.Issues, nil +} + +// SearchMentionPullRequestsForWorkspace is the pull-request counterpart to +// SearchMentionIssuesForWorkspace. +func (s *Service) SearchMentionPullRequestsForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]*PR, error) { + workspaceID, query, limit, err := normalizeMentionSearch(workspaceID, query, limit) + if err != nil { + return nil, err + } + result, err := s.SearchUserPRsPagedForWorkspace( + ctx, + workspaceID, + "", + buildMentionTitleQuery("type:pr", query), + 1, + limit, + ) + if err != nil { + return nil, err + } + if result == nil || result.PRs == nil { + return []*PR{}, nil + } + return result.PRs, nil +} + +func normalizeMentionSearch(workspaceID, query string, limit int) (string, string, int, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return "", "", 0, ErrMentionWorkspaceRequired + } + query = strings.TrimSpace(query) + if query == "" { + return "", "", 0, ErrMentionQueryRequired + } + switch { + case limit <= 0: + limit = defaultMentionSearchLimit + case limit > maxMentionSearchLimit: + limit = maxMentionSearchLimit + } + return workspaceID, query, limit, nil +} + +// buildMentionTitleQuery keeps the server-owned type qualifier outside the +// quoted user input so qualifier-like title text cannot change result types. +func buildMentionTitleQuery(typeQualifier, query string) string { + query = strings.ReplaceAll(query, `\`, `\\`) + query = strings.ReplaceAll(query, `"`, `\"`) + return typeQualifier + ` in:title "` + query + `"` +} diff --git a/apps/backend/internal/github/service_mentions_test.go b/apps/backend/internal/github/service_mentions_test.go new file mode 100644 index 0000000000..44210a5cdf --- /dev/null +++ b/apps/backend/internal/github/service_mentions_test.go @@ -0,0 +1,87 @@ +package github + +import ( + "context" + "testing" +) + +type mentionSearchClient struct { + *MockClient + issueQuery string + prQuery string + issues []*Issue + prs []*PR +} + +func (c *mentionSearchClient) ListIssuesPaged( + _ context.Context, + _, customQuery string, + page, perPage int, +) (*IssueSearchPage, error) { + c.issueQuery = customQuery + return &IssueSearchPage{Issues: c.issues, TotalCount: len(c.issues), Page: page, PerPage: perPage}, nil +} + +func (c *mentionSearchClient) SearchPRsPaged( + _ context.Context, + _, customQuery string, + page, perPage int, +) (*PRSearchPage, error) { + c.prQuery = customQuery + return &PRSearchPage{PRs: c.prs, TotalCount: len(c.prs), Page: page, PerPage: perPage}, nil +} + +func TestServiceSearchMentionsUsesServerBuiltTitleOnlyQuery(t *testing.T) { + client := &mentionSearchClient{ + MockClient: NewMockClient(), + issues: []*Issue{{ID: 101, NodeID: "I_kwDOA", Number: 7, Title: "Fix auth", RepoOwner: "acme", RepoName: "web"}}, + prs: []*PR{{ID: 202, NodeID: "PR_kwDOB", Number: 8, Title: "Fix auth flow", RepoOwner: "acme", RepoName: "web"}}, + } + service := &Service{client: client, searchCache: newTTLCache()} + + issues, err := service.SearchMentionIssuesForWorkspace( + context.Background(), + "workspace-1", + `fix type:pr is:issue repo:foreign/private "auth" \\ flow`, + 3, + ) + if err != nil { + t.Fatalf("search issue mentions: %v", err) + } + prs, err := service.SearchMentionPullRequestsForWorkspace( + context.Background(), + "workspace-1", + `fix type:pr is:issue repo:foreign/private "auth" \\ flow`, + 4, + ) + if err != nil { + t.Fatalf("search PR mentions: %v", err) + } + wantIssueQuery := `type:issue in:title "fix type:pr is:issue repo:foreign/private \"auth\" \\\\ flow"` + wantPRQuery := `type:pr in:title "fix type:pr is:issue repo:foreign/private \"auth\" \\\\ flow"` + if client.issueQuery != wantIssueQuery || client.prQuery != wantPRQuery { + t.Fatalf( + "queries = issue %q PR %q, want issue %q PR %q", + client.issueQuery, + client.prQuery, + wantIssueQuery, + wantPRQuery, + ) + } + if len(issues) != 1 || issues[0].NodeID != "I_kwDOA" { + t.Fatalf("issues = %#v", issues) + } + if len(prs) != 1 || prs[0].NodeID != "PR_kwDOB" { + t.Fatalf("PRs = %#v", prs) + } +} + +func TestServiceSearchMentionsRequiresExplicitWorkspaceAndQuery(t *testing.T) { + service := &Service{client: NewMockClient(), searchCache: newTTLCache()} + if _, err := service.SearchMentionIssuesForWorkspace(context.Background(), "", "auth", 5); err == nil { + t.Fatal("blank workspace was accepted") + } + if _, err := service.SearchMentionPullRequestsForWorkspace(context.Background(), "workspace-1", " ", 5); err == nil { + t.Fatal("blank query was accepted") + } +} diff --git a/apps/backend/internal/gitlab/service_mentions.go b/apps/backend/internal/gitlab/service_mentions.go new file mode 100644 index 0000000000..d394027569 --- /dev/null +++ b/apps/backend/internal/gitlab/service_mentions.go @@ -0,0 +1,463 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + "net/url" + "path" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + defaultMentionLimit = 5 + maxMentionLimit = 10 + mentionPageSize = 10 + maxMentionPages = 10 + maxMentionProjects = 50 + mentionHTTPScheme = "http" + mentionHTTPSScheme = "https" +) + +var ( + ErrMentionWorkspaceRequired = errors.New("gitlab mention search: workspace ID required") + ErrMentionQueryRequired = errors.New("gitlab mention search: query required") + ErrMentionUnsupportedScope = errors.New("gitlab mention search: workspace scope unsupported") + ErrMentionInvalidScope = errors.New("gitlab mention search: invalid workspace scope") + ErrMentionMalformedResponse = errors.New("gitlab mention search: malformed response") +) + +// MentionItem preserves GitLab's immutable object ID and project-local display +// identity without exposing native query/filter details to the registry. +type MentionItem struct { + ID int64 + IID int + ProjectID int64 + ProjectPath string + Title string + URL string + Host string +} + +type mentionSearchRecord struct { + id int64 + iid int + projectID int64 + projectPath string + title string + url string +} + +type mentionSearchPage struct { + records []mentionSearchRecord + resultCount int + totalCount int +} + +type mentionSearchPageLoader func(context.Context, int, int) (mentionSearchPage, error) + +var mentionProjectSegmentPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + +// ConfigureMentionScopeForWorkspace explicitly binds the legacy global client +// to one workspace's host and project allowlist. No binding is inferred. +func (s *Service) ConfigureMentionScopeForWorkspace( + ctx context.Context, + workspaceID, host string, + projects []MentionProjectScope, +) error { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return ErrMentionWorkspaceRequired + } + if strings.TrimSpace(host) == "" { + return ErrMentionUnsupportedScope + } + normalizedHost, err := normalizeMentionHost(host) + if err != nil { + return err + } + serviceHost, err := normalizeMentionHost(s.Host()) + if err != nil || normalizedHost != serviceHost { + return ErrMentionUnsupportedScope + } + normalizedProjects, err := normalizeMentionProjects(projects) + if err != nil { + return err + } + store := s.requireStore() + if store == nil { + return ErrMentionUnsupportedScope + } + return store.UpsertMentionScope(ctx, &MentionScope{ + WorkspaceID: workspaceID, + Host: normalizedHost, + Projects: normalizedProjects, + }) +} + +// MentionScopeForWorkspace loads only an explicit binding and verifies it +// still matches the installed client's host. +func (s *Service) MentionScopeForWorkspace(ctx context.Context, workspaceID string) (*MentionScope, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return nil, ErrMentionWorkspaceRequired + } + store := s.requireStore() + if store == nil { + return nil, ErrMentionUnsupportedScope + } + scope, err := store.GetMentionScope(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("load GitLab mention scope: %w", err) + } + if scope == nil || scope.WorkspaceID != workspaceID { + return nil, ErrMentionUnsupportedScope + } + normalizedHost, err := normalizeMentionHost(scope.Host) + if err != nil { + return nil, err + } + serviceHost, err := normalizeMentionHost(s.Host()) + if err != nil || normalizedHost != serviceHost { + return nil, ErrMentionUnsupportedScope + } + projects, err := normalizeMentionProjects(scope.Projects) + if err != nil { + return nil, err + } + return &MentionScope{WorkspaceID: workspaceID, Host: normalizedHost, Projects: projects}, nil +} + +// SearchMentionIssuesForWorkspace searches titles through a fixed query shape +// and filters every hit against the workspace project binding. +func (s *Service) SearchMentionIssuesForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]MentionItem, error) { + scope, client, query, limit, err := s.prepareMentionSearch(ctx, workspaceID, query, limit) + if err != nil { + return nil, err + } + titleQuery := buildMentionTitleQuery(query) + return collectMentionSearchPages(ctx, scope, limit, MentionIssueURL, func( + ctx context.Context, pageNumber, pageSize int, + ) (mentionSearchPage, error) { + page, err := client.ListIssuesPaged(ctx, "", titleQuery, pageNumber, pageSize) + if err != nil { + return mentionSearchPage{}, fmt.Errorf("search GitLab issue mentions: %w", err) + } + if page == nil { + return mentionSearchPage{}, ErrMentionMalformedResponse + } + return mentionSearchPage{ + records: issueMentionSearchRecords(page.Issues), resultCount: len(page.Issues), totalCount: page.TotalCount, + }, nil + }) +} + +// SearchMentionMRsForWorkspace is the merge-request counterpart to issue +// mention search. +func (s *Service) SearchMentionMRsForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]MentionItem, error) { + scope, client, query, limit, err := s.prepareMentionSearch(ctx, workspaceID, query, limit) + if err != nil { + return nil, err + } + titleQuery := buildMentionTitleQuery(query) + return collectMentionSearchPages(ctx, scope, limit, MentionMRURL, func( + ctx context.Context, pageNumber, pageSize int, + ) (mentionSearchPage, error) { + page, err := client.SearchMRsPaged(ctx, "", titleQuery, pageNumber, pageSize) + if err != nil { + return mentionSearchPage{}, fmt.Errorf("search GitLab MR mentions: %w", err) + } + if page == nil { + return mentionSearchPage{}, ErrMentionMalformedResponse + } + return mentionSearchPage{ + records: mrMentionSearchRecords(page.MRs), resultCount: len(page.MRs), totalCount: page.TotalCount, + }, nil + }) +} + +func collectMentionSearchPages( + ctx context.Context, + scope *MentionScope, + limit int, + canonicalURL func(string, string, int) string, + load mentionSearchPageLoader, +) ([]MentionItem, error) { + items := make([]MentionItem, 0, limit) + for pageNumber := 1; pageNumber <= maxMentionPages; pageNumber++ { + if err := ctx.Err(); err != nil { + return nil, err + } + page, err := load(ctx, pageNumber, mentionPageSize) + if err != nil { + return nil, err + } + items = append(items, filterMentionSearchRecords( + scope, page.records, limit-len(items), canonicalURL, + )...) + if len(items) == limit || !hasNextMentionPage( + page.resultCount, page.totalCount, pageNumber, mentionPageSize, + ) { + break + } + } + return items, nil +} + +func (s *Service) prepareMentionSearch( + ctx context.Context, + workspaceID, query string, + limit int, +) (*MentionScope, Client, string, int, error) { + query, err := normalizeMentionQuery(query) + if err != nil { + return nil, nil, "", 0, err + } + scope, client, limit, err := s.mentionSearchBoundary(ctx, workspaceID, limit) + return scope, client, query, limit, err +} + +func issueMentionSearchRecords(issues []*Issue) []mentionSearchRecord { + records := make([]mentionSearchRecord, 0, len(issues)) + for _, issue := range issues { + if issue == nil { + continue + } + records = append(records, mentionSearchRecord{ + id: issue.ID, iid: issue.IID, projectID: issue.ProjectID, + projectPath: issue.ProjectPath, title: issue.Title, url: firstMentionURL(issue.WebURL, issue.URL), + }) + } + return records +} + +func mrMentionSearchRecords(mrs []*MR) []mentionSearchRecord { + records := make([]mentionSearchRecord, 0, len(mrs)) + for _, mr := range mrs { + if mr == nil { + continue + } + records = append(records, mentionSearchRecord{ + id: mr.ID, iid: mr.IID, projectID: mr.ProjectID, + projectPath: mr.ProjectPath, title: mr.Title, url: firstMentionURL(mr.WebURL, mr.URL), + }) + } + return records +} + +func firstMentionURL(webURL, fallback string) string { + if webURL != "" { + return webURL + } + return fallback +} + +func hasNextMentionPage(resultCount, totalCount, page, perPage int) bool { + if resultCount < perPage { + return false + } + return totalCount <= 0 || page*perPage < totalCount +} + +func filterMentionSearchRecords( + scope *MentionScope, + records []mentionSearchRecord, + limit int, + canonicalURL func(string, string, int) string, +) []MentionItem { + allowed := mentionProjectsByID(scope.Projects) + items := make([]MentionItem, 0, min(len(records), limit)) + for _, record := range records { + if len(items) == limit { + break + } + if !allowedMentionProject(allowed, record.projectID, record.projectPath) || + record.id <= 0 || record.iid <= 0 || record.url != canonicalURL(scope.Host, record.projectPath, record.iid) { + continue + } + items = append(items, MentionItem{ + ID: record.id, IID: record.iid, ProjectID: record.projectID, + ProjectPath: record.projectPath, Title: record.title, URL: record.url, Host: scope.Host, + }) + } + return items +} + +func (s *Service) mentionSearchBoundary( + ctx context.Context, + workspaceID string, + limit int, +) (*MentionScope, Client, int, error) { + scope, err := s.MentionScopeForWorkspace(ctx, workspaceID) + if err != nil { + return nil, nil, 0, err + } + client := s.Client() + if client == nil { + return nil, nil, 0, ErrNoClient + } + clientHost, err := normalizeMentionHost(client.Host()) + if err != nil || clientHost != scope.Host { + return nil, nil, 0, ErrMentionUnsupportedScope + } + return scope, client, normalizeMentionLimit(limit), nil +} + +func normalizeMentionQuery(query string) (string, error) { + query = strings.TrimSpace(query) + if query == "" || !utf8.ValidString(query) || utf8.RuneCountInString(query) > 200 { + return "", ErrMentionQueryRequired + } + return query, nil +} + +func buildMentionTitleQuery(query string) string { + values := url.Values{} + values.Set("in", "title") + values.Set("order_by", "updated_at") + values.Set("scope", "all") + values.Set("search", query) + values.Set("sort", "desc") + values.Set("state", gitlabStateOpened) + return values.Encode() +} + +func normalizeMentionLimit(limit int) int { + if limit <= 0 { + return defaultMentionLimit + } + if limit > maxMentionLimit { + return maxMentionLimit + } + return limit +} + +func normalizeMentionHost(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", ErrMentionInvalidScope + } + parsed, err := url.Parse(raw) + if err != nil || parsed.User != nil || parsed.Opaque != "" || parsed.Host == "" || + (parsed.Scheme != mentionHTTPScheme && parsed.Scheme != mentionHTTPSScheme) || parsed.RawQuery != "" || + parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(raw, "|") { + return "", ErrMentionInvalidScope + } + trimmedPath := strings.TrimRight(parsed.Path, "/") + if trimmedPath == "." || (trimmedPath != "" && path.Clean(trimmedPath) != trimmedPath) { + return "", ErrMentionInvalidScope + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + parsed.Host = strings.ToLower(parsed.Host) + parsed.Path = trimmedPath + parsed.RawPath = "" + return parsed.String(), nil +} + +// SameMentionHost reports whether two GitLab origins normalize to the same +// mention-search host. Invalid or missing hosts never match. +func SameMentionHost(left, right string) bool { + left, leftErr := normalizeMentionHost(left) + right, rightErr := normalizeMentionHost(right) + return leftErr == nil && rightErr == nil && left == right +} + +func normalizeMentionProjects(projects []MentionProjectScope) ([]MentionProjectScope, error) { + if len(projects) == 0 || len(projects) > maxMentionProjects { + return nil, ErrMentionInvalidScope + } + byID := make(map[int64]struct{}, len(projects)) + byPath := make(map[string]struct{}, len(projects)) + normalized := make([]MentionProjectScope, 0, len(projects)) + for _, project := range projects { + project.Path = strings.TrimSpace(project.Path) + if project.ID <= 0 || !validMentionProjectPath(project.Path) { + return nil, ErrMentionInvalidScope + } + if _, exists := byID[project.ID]; exists { + return nil, ErrMentionInvalidScope + } + if _, exists := byPath[project.Path]; exists { + return nil, ErrMentionInvalidScope + } + byID[project.ID] = struct{}{} + byPath[project.Path] = struct{}{} + normalized = append(normalized, project) + } + sort.Slice(normalized, func(i, j int) bool { + if normalized[i].ID == normalized[j].ID { + return normalized[i].Path < normalized[j].Path + } + return normalized[i].ID < normalized[j].ID + }) + return normalized, nil +} + +func validMentionProjectPath(projectPath string) bool { + if projectPath == "" || !utf8.ValidString(projectPath) || utf8.RuneCountInString(projectPath) > 300 { + return false + } + for _, r := range projectPath { + if unicode.IsControl(r) { + return false + } + } + segments := strings.Split(projectPath, "/") + for _, segment := range segments { + if segment == "" || segment == "." || segment == ".." || !mentionProjectSegmentPattern.MatchString(segment) { + return false + } + } + return true +} + +func mentionProjectsByID(projects []MentionProjectScope) map[int64]string { + allowed := make(map[int64]string, len(projects)) + for _, project := range projects { + allowed[project.ID] = project.Path + } + return allowed +} + +func allowedMentionProject(allowed map[int64]string, projectID int64, projectPath string) bool { + return allowed[projectID] == projectPath && projectPath != "" +} + +// MentionProjectScopeKey combines the configured host and immutable project ID. +func MentionProjectScopeKey(host string, projectID int64) string { + return host + "|project:" + strconv.FormatInt(projectID, 10) +} + +// MentionIssueURL returns the one canonical web destination for an issue. +func MentionIssueURL(host, projectPath string, iid int) string { + return mentionProjectURL(host, projectPath, "issues", iid) +} + +// MentionMRURL returns the one canonical web destination for a merge request. +func MentionMRURL(host, projectPath string, iid int) string { + return mentionProjectURL(host, projectPath, "merge_requests", iid) +} + +func mentionProjectURL(host, projectPath, kind string, iid int) string { + segments := strings.Split(projectPath, "/") + for index := range segments { + segments[index] = url.PathEscape(segments[index]) + } + return host + "/" + strings.Join(segments, "/") + "/-/" + kind + "/" + strconv.Itoa(iid) +} diff --git a/apps/backend/internal/gitlab/service_mentions_test.go b/apps/backend/internal/gitlab/service_mentions_test.go new file mode 100644 index 0000000000..d2f34401eb --- /dev/null +++ b/apps/backend/internal/gitlab/service_mentions_test.go @@ -0,0 +1,389 @@ +package gitlab + +import ( + "context" + "errors" + "net/url" + "strconv" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +type mentionRecordingClient struct { + *MockClient + issues func(context.Context, string, string, int, int) (*IssueSearchPage, error) + mrs func(context.Context, string, string, int, int) (*MRSearchPage, error) +} + +func (c *mentionRecordingClient) ListIssuesPaged( + ctx context.Context, + filter, customQuery string, + page, perPage int, +) (*IssueSearchPage, error) { + return c.issues(ctx, filter, customQuery, page, perPage) +} + +func (c *mentionRecordingClient) SearchMRsPaged( + ctx context.Context, + filter, customQuery string, + page, perPage int, +) (*MRSearchPage, error) { + return c.mrs(ctx, filter, customQuery, page, perPage) +} + +func newMentionTestService(t *testing.T, host string, client Client) (*Service, *Store) { + t.Helper() + service := NewService(host, client, "mock", nil, logger.Default()) + store := newTestStore(t) + service.SetStore(store) + return service, store +} + +func assertLiteralMentionQuery(t *testing.T, filter, customQuery, wantSearch string, page, perPage int) { + t.Helper() + if filter != "" { + t.Fatalf("raw filter = %q, want empty", filter) + } + if page != 1 || perPage != 10 { + t.Fatalf("pagination = page %d perPage %d, want 1/10", page, perPage) + } + values, err := url.ParseQuery(customQuery) + if err != nil { + t.Fatalf("parse generated query: %v", err) + } + if values.Get("search") != wantSearch || values.Get("in") != "title" || + values.Get("scope") != "all" || values.Get("state") != "opened" || + values.Get("order_by") != "updated_at" || values.Get("sort") != "desc" { + t.Fatalf("generated query = %q (%v)", customQuery, values) + } +} + +func TestServiceMentionSearch_BuildsLiteralQueryAndFiltersWorkspaceProjects(t *testing.T) { + const host = "https://gitlab.example.test/base" + const query = `auth&scope=created_by_me#fragment` + client := &mentionRecordingClient{MockClient: NewMockClient(host)} + client.issues = func(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + assertLiteralMentionQuery(t, filter, customQuery, query, page, perPage) + return &IssueSearchPage{Issues: []*Issue{ + {ID: 1001, IID: 42, ProjectID: 101, ProjectPath: "group/api", Title: "Fix auth", WebURL: host + "/group/api/-/issues/42"}, + {ID: 9001, IID: 9, ProjectID: 999, ProjectPath: "other/secret", Title: "Leak", WebURL: host + "/other/secret/-/issues/9"}, + nil, + }}, nil + } + client.mrs = func(ctx context.Context, filter, customQuery string, page, perPage int) (*MRSearchPage, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + assertLiteralMentionQuery(t, filter, customQuery, query, page, perPage) + return &MRSearchPage{MRs: []*MR{ + {ID: 2001, IID: 7, ProjectID: 202, ProjectPath: "group/web", Title: "Auth MR", WebURL: host + "/group/web/-/merge_requests/7"}, + {ID: 9002, IID: 8, ProjectID: 999, ProjectPath: "other/secret", Title: "Leak", WebURL: host + "/other/secret/-/merge_requests/8"}, + nil, + }}, nil + } + service, _ := newMentionTestService(t, host, client) + if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host+"/", []MentionProjectScope{ + {ID: 101, Path: "group/api"}, + {ID: 202, Path: "group/web"}, + }); err != nil { + t.Fatalf("configure mention scope: %v", err) + } + + issues, err := service.SearchMentionIssuesForWorkspace(context.Background(), "workspace-1", query, 99) + if err != nil { + t.Fatalf("search issues: %v", err) + } + if len(issues) != 1 || issues[0].ID != 1001 || issues[0].IID != 42 || + issues[0].ProjectID != 101 || issues[0].ProjectPath != "group/api" || + issues[0].Host != host || issues[0].URL != host+"/group/api/-/issues/42" { + t.Fatalf("issues = %#v", issues) + } + mrs, err := service.SearchMentionMRsForWorkspace(context.Background(), "workspace-1", query, 99) + if err != nil { + t.Fatalf("search MRs: %v", err) + } + if len(mrs) != 1 || mrs[0].ID != 2001 || mrs[0].IID != 7 || + mrs[0].ProjectID != 202 || mrs[0].ProjectPath != "group/web" || + mrs[0].Host != host || mrs[0].URL != host+"/group/web/-/merge_requests/7" { + t.Fatalf("MRs = %#v", mrs) + } +} + +func TestServiceMentionIssueSearch_ScansPastForeignFirstPage(t *testing.T) { + const host = "https://gitlab.example.test" + issueCalls := 0 + client := &mentionRecordingClient{MockClient: NewMockClient(host)} + client.issues = func(_ context.Context, _, _ string, page, perPage int) (*IssueSearchPage, error) { + issueCalls++ + if perPage != 10 { + t.Fatalf("perPage = %d, want 10", perPage) + } + switch page { + case 1: + foreign := make([]*Issue, 10) + for index := range foreign { + foreign[index] = &Issue{ + ID: int64(9001 + index), IID: 9 + index, ProjectID: 999, ProjectPath: "other/secret", + Title: "Foreign auth", WebURL: host + "/other/secret/-/issues/" + strconv.Itoa(9+index), + } + } + return &IssueSearchPage{ + Issues: foreign, TotalCount: 11, Page: 1, PerPage: 10, + }, nil + case 2: + return &IssueSearchPage{ + Issues: []*Issue{{ + ID: 1001, IID: 42, ProjectID: 101, ProjectPath: "group/api", + Title: "Allowed auth", WebURL: host + "/group/api/-/issues/42", + }}, + TotalCount: 11, Page: 2, PerPage: 10, + }, nil + default: + t.Fatalf("unexpected page %d", page) + return nil, nil + } + } + service, _ := newMentionTestService(t, host, client) + if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host, []MentionProjectScope{ + {ID: 101, Path: "group/api"}, + }); err != nil { + t.Fatalf("configure mention scope: %v", err) + } + + issues, err := service.SearchMentionIssuesForWorkspace(context.Background(), "workspace-1", "auth", 1) + if err != nil { + t.Fatalf("search issues: %v", err) + } + if len(issues) != 1 || issues[0].ID != 1001 { + t.Fatalf("issues = %#v, want allowed second-page issue", issues) + } + if issueCalls != 2 { + t.Fatalf("issue calls = %d, want 2", issueCalls) + } +} + +func TestServiceMentionIssueSearch_StopsPaginationWhenContextCanceled(t *testing.T) { + const host = "https://gitlab.example.test" + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + issueCalls := 0 + client := &mentionRecordingClient{MockClient: NewMockClient(host)} + client.issues = func(_ context.Context, _, _ string, page, perPage int) (*IssueSearchPage, error) { + issueCalls++ + if page == 1 { + cancel() + } + foreign := make([]*Issue, perPage) + for index := range foreign { + foreign[index] = &Issue{ + ID: int64(9001 + index), IID: 9 + index, ProjectID: 999, ProjectPath: "other/secret", + Title: "Foreign auth", WebURL: host + "/other/secret/-/issues/" + strconv.Itoa(9+index), + } + } + return &IssueSearchPage{ + Issues: foreign, TotalCount: 1000, Page: page, PerPage: perPage, + }, nil + } + service, _ := newMentionTestService(t, host, client) + if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host, []MentionProjectScope{ + {ID: 101, Path: "group/api"}, + }); err != nil { + t.Fatalf("configure mention scope: %v", err) + } + + _, err := service.SearchMentionIssuesForWorkspace(ctx, "workspace-1", "auth", 1) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context canceled", err) + } + if issueCalls != 1 { + t.Fatalf("issue calls = %d, want 1", issueCalls) + } +} + +func TestServiceMentionMRSearch_ScansPastForeignFirstPage(t *testing.T) { + const host = "https://gitlab.example.test" + mrCalls := 0 + client := &mentionRecordingClient{MockClient: NewMockClient(host)} + client.mrs = func(_ context.Context, _, _ string, page, perPage int) (*MRSearchPage, error) { + mrCalls++ + if perPage != 10 { + t.Fatalf("perPage = %d, want 10", perPage) + } + switch page { + case 1: + foreign := make([]*MR, 10) + for index := range foreign { + foreign[index] = &MR{ + ID: int64(9001 + index), IID: 9 + index, ProjectID: 999, ProjectPath: "other/secret", + Title: "Foreign auth", WebURL: host + "/other/secret/-/merge_requests/" + strconv.Itoa(9+index), + } + } + return &MRSearchPage{ + MRs: foreign, TotalCount: 11, Page: 1, PerPage: 10, + }, nil + case 2: + return &MRSearchPage{ + MRs: []*MR{{ + ID: 2001, IID: 42, ProjectID: 101, ProjectPath: "group/api", + Title: "Allowed auth", WebURL: host + "/group/api/-/merge_requests/42", + }}, + TotalCount: 11, Page: 2, PerPage: 10, + }, nil + default: + t.Fatalf("unexpected page %d", page) + return nil, nil + } + } + service, _ := newMentionTestService(t, host, client) + if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host, []MentionProjectScope{ + {ID: 101, Path: "group/api"}, + }); err != nil { + t.Fatalf("configure mention scope: %v", err) + } + + mrs, err := service.SearchMentionMRsForWorkspace(context.Background(), "workspace-1", "auth", 1) + if err != nil { + t.Fatalf("search MRs: %v", err) + } + if len(mrs) != 1 || mrs[0].ID != 2001 { + t.Fatalf("MRs = %#v, want allowed second-page MR", mrs) + } + if mrCalls != 2 { + t.Fatalf("MR calls = %d, want 2", mrCalls) + } +} + +func TestServiceMentionMRSearch_StopsPaginationWhenContextCanceled(t *testing.T) { + const host = "https://gitlab.example.test" + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + mrCalls := 0 + client := &mentionRecordingClient{MockClient: NewMockClient(host)} + client.mrs = func(_ context.Context, _, _ string, page, perPage int) (*MRSearchPage, error) { + mrCalls++ + if page == 1 { + cancel() + } + foreign := make([]*MR, perPage) + for index := range foreign { + foreign[index] = &MR{ + ID: int64(9001 + index), IID: 9 + index, ProjectID: 999, ProjectPath: "other/secret", + Title: "Foreign auth", WebURL: host + "/other/secret/-/merge_requests/" + strconv.Itoa(9+index), + } + } + return &MRSearchPage{ + MRs: foreign, TotalCount: 1000, Page: page, PerPage: perPage, + }, nil + } + service, _ := newMentionTestService(t, host, client) + if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host, []MentionProjectScope{ + {ID: 101, Path: "group/api"}, + }); err != nil { + t.Fatalf("configure mention scope: %v", err) + } + + _, err := service.SearchMentionMRsForWorkspace(ctx, "workspace-1", "auth", 1) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context canceled", err) + } + if mrCalls != 1 { + t.Fatalf("MR calls = %d, want 1", mrCalls) + } +} + +func TestServiceMentionSearch_RequiresExplicitMatchingWorkspaceBinding(t *testing.T) { + called := false + host := "https://gitlab.example.test" + client := &mentionRecordingClient{MockClient: NewMockClient(host)} + client.issues = func(context.Context, string, string, int, int) (*IssueSearchPage, error) { + called = true + return &IssueSearchPage{}, nil + } + client.mrs = func(context.Context, string, string, int, int) (*MRSearchPage, error) { + called = true + return &MRSearchPage{}, nil + } + service, store := newMentionTestService(t, host, client) + + if _, err := service.SearchMentionIssuesForWorkspace(context.Background(), "", "auth", 5); !errors.Is(err, ErrMentionWorkspaceRequired) { + t.Fatalf("blank workspace error = %v", err) + } + if _, err := service.SearchMentionMRsForWorkspace(context.Background(), "workspace-1", "auth", 5); !errors.Is(err, ErrMentionUnsupportedScope) { + t.Fatalf("unbound workspace error = %v", err) + } + if err := store.UpsertMentionScope(context.Background(), &MentionScope{ + WorkspaceID: "workspace-1", + Host: "https://other-gitlab.example.test", + Projects: []MentionProjectScope{{ID: 101, Path: "group/api"}}, + }); err != nil { + t.Fatalf("seed mismatched scope: %v", err) + } + if _, err := service.SearchMentionIssuesForWorkspace(context.Background(), "workspace-1", "auth", 5); !errors.Is(err, ErrMentionUnsupportedScope) { + t.Fatalf("host-mismatched workspace error = %v", err) + } + if called { + t.Fatal("global GitLab client searched without exact workspace binding") + } +} + +func TestServiceConfigureMentionScope_RejectsUnsafeOrEmptyScope(t *testing.T) { + host := "https://gitlab.example.test" + client := &mentionRecordingClient{MockClient: NewMockClient(host)} + client.issues = func(context.Context, string, string, int, int) (*IssueSearchPage, error) { + return &IssueSearchPage{}, nil + } + client.mrs = func(context.Context, string, string, int, int) (*MRSearchPage, error) { return &MRSearchPage{}, nil } + service, _ := newMentionTestService(t, host, client) + + tests := []struct { + name string + workspace string + host string + projects []MentionProjectScope + wantTarget error + }{ + {name: "blank workspace", host: host, projects: []MentionProjectScope{{ID: 1, Path: "group/api"}}, wantTarget: ErrMentionWorkspaceRequired}, + {name: "global default forbidden", workspace: "workspace-1", host: "", projects: []MentionProjectScope{{ID: 1, Path: "group/api"}}, wantTarget: ErrMentionUnsupportedScope}, + {name: "foreign host", workspace: "workspace-1", host: "https://other.example.test", projects: []MentionProjectScope{{ID: 1, Path: "group/api"}}, wantTarget: ErrMentionUnsupportedScope}, + {name: "userinfo", workspace: "workspace-1", host: "https://user:secret@gitlab.example.test", projects: []MentionProjectScope{{ID: 1, Path: "group/api"}}, wantTarget: ErrMentionInvalidScope}, + {name: "empty projects", workspace: "workspace-1", host: host, wantTarget: ErrMentionInvalidScope}, + {name: "invalid project", workspace: "workspace-1", host: host, projects: []MentionProjectScope{{ID: 0, Path: "../secret"}}, wantTarget: ErrMentionInvalidScope}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := service.ConfigureMentionScopeForWorkspace( + context.Background(), test.workspace, test.host, test.projects, + ) + if !errors.Is(err, test.wantTarget) { + t.Fatalf("error = %v, want %v", err, test.wantTarget) + } + }) + } +} + +func TestServiceMentionSearch_PropagatesCancellation(t *testing.T) { + host := "https://gitlab.example.test" + client := &mentionRecordingClient{MockClient: NewMockClient(host)} + client.issues = func(ctx context.Context, _ string, _ string, _ int, _ int) (*IssueSearchPage, error) { + return nil, ctx.Err() + } + client.mrs = func(ctx context.Context, _ string, _ string, _ int, _ int) (*MRSearchPage, error) { + return nil, ctx.Err() + } + service, _ := newMentionTestService(t, host, client) + if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host, []MentionProjectScope{{ID: 101, Path: "group/api"}}); err != nil { + t.Fatalf("configure scope: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := service.SearchMentionIssuesForWorkspace(ctx, "workspace-1", "auth", 5); !errors.Is(err, context.Canceled) { + t.Fatalf("issue cancellation = %v", err) + } + if _, err := service.SearchMentionMRsForWorkspace(ctx, "workspace-1", "auth", 5); !errors.Is(err, context.Canceled) { + t.Fatalf("MR cancellation = %v", err) + } +} diff --git a/apps/backend/internal/gitlab/store.go b/apps/backend/internal/gitlab/store.go index a899505123..a7e5fe2f84 100644 --- a/apps/backend/internal/gitlab/store.go +++ b/apps/backend/internal/gitlab/store.go @@ -3,8 +3,10 @@ package gitlab import ( "context" "database/sql" + "encoding/json" "errors" "fmt" + "strings" "time" "github.com/google/uuid" @@ -20,6 +22,20 @@ type Store struct { ro *sqlx.DB } +// MentionProjectScope is one explicitly workspace-bound GitLab project. +type MentionProjectScope struct { + ID int64 `json:"id"` + Path string `json:"path"` +} + +// MentionScope is the operational host/project boundary for mention search. +// It is deliberately separate from the legacy install-wide GitLab auth state. +type MentionScope struct { + WorkspaceID string `json:"workspace_id"` + Host string `json:"host"` + Projects []MentionProjectScope `json:"projects"` +} + // NewStore initialises the gitlab Store and creates its tables. db is the // writer pool; ro is the read-only pool (same SQLite file, separate handle // to avoid serialising reads behind writes). Both must be non-nil. @@ -183,6 +199,14 @@ const createTablesSQL = ` issue_presets TEXT NOT NULL DEFAULT '[]', updated_at DATETIME NOT NULL ); + + CREATE TABLE IF NOT EXISTS gitlab_mention_scopes ( + workspace_id TEXT PRIMARY KEY, + host TEXT NOT NULL, + projects_json TEXT NOT NULL DEFAULT '[]', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); ` func (s *Store) createTables() error { @@ -269,6 +293,53 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) { return columns, rows.Err() } +// UpsertMentionScope explicitly binds one workspace to a GitLab host and +// project allowlist. No global/default workspace fallback is used. +func (s *Store) UpsertMentionScope(ctx context.Context, scope *MentionScope) error { + if scope == nil || strings.TrimSpace(scope.WorkspaceID) == "" { + return errors.New("gitlab mention scope: workspace ID required") + } + projects, err := json.Marshal(scope.Projects) + if err != nil { + return fmt.Errorf("marshal gitlab mention projects: %w", err) + } + now := time.Now().UTC() + _, err = s.db.ExecContext(ctx, ` + INSERT INTO gitlab_mention_scopes (workspace_id, host, projects_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + host = excluded.host, + projects_json = excluded.projects_json, + updated_at = excluded.updated_at`, + scope.WorkspaceID, scope.Host, string(projects), now, now) + return err +} + +// GetMentionScope returns only the exact workspace binding, or nil when none +// exists. It never consults install-wide host/default settings. +func (s *Store) GetMentionScope(ctx context.Context, workspaceID string) (*MentionScope, error) { + var row struct { + WorkspaceID string `db:"workspace_id"` + Host string `db:"host"` + Projects string `db:"projects_json"` + } + err := s.ro.GetContext(ctx, &row, ` + SELECT workspace_id, host, projects_json + FROM gitlab_mention_scopes + WHERE workspace_id = ?`, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + scope := &MentionScope{WorkspaceID: row.WorkspaceID, Host: row.Host} + if err := json.Unmarshal([]byte(row.Projects), &scope.Projects); err != nil { + return nil, fmt.Errorf("decode gitlab mention projects: %w", err) + } + return scope, nil +} + // taskMRSelectCols is the projection used by every SELECT so dev DBs that // still have dropped columns (older revisions defined `unresolved_threads` // and `discussion_count` here) don't break sqlx struct binding. diff --git a/apps/backend/internal/gitlab/store_mentions_test.go b/apps/backend/internal/gitlab/store_mentions_test.go new file mode 100644 index 0000000000..35d58a293a --- /dev/null +++ b/apps/backend/internal/gitlab/store_mentions_test.go @@ -0,0 +1,50 @@ +package gitlab + +import ( + "context" + "reflect" + "testing" +) + +func TestStoreMentionScope_RoundTripsAndIsolatesWorkspaces(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + want := &MentionScope{ + WorkspaceID: "workspace-1", + Host: "https://gitlab.example.test/base", + Projects: []MentionProjectScope{ + {ID: 101, Path: "group/api"}, + {ID: 202, Path: "group/web"}, + }, + } + if err := store.UpsertMentionScope(ctx, want); err != nil { + t.Fatalf("upsert mention scope: %v", err) + } + + got, err := store.GetMentionScope(ctx, "workspace-1") + if err != nil { + t.Fatalf("get mention scope: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("scope = %#v, want %#v", got, want) + } + other, err := store.GetMentionScope(ctx, "workspace-2") + if err != nil { + t.Fatalf("get other workspace scope: %v", err) + } + if other != nil { + t.Fatalf("other workspace scope = %#v, want nil", other) + } + + want.Projects = []MentionProjectScope{{ID: 303, Path: "other/mobile"}} + if err := store.UpsertMentionScope(ctx, want); err != nil { + t.Fatalf("update mention scope: %v", err) + } + updated, err := store.GetMentionScope(ctx, "workspace-1") + if err != nil { + t.Fatalf("get updated scope: %v", err) + } + if !reflect.DeepEqual(updated, want) { + t.Fatalf("updated scope = %#v, want %#v", updated, want) + } +} diff --git a/apps/backend/internal/jira/cloud_client.go b/apps/backend/internal/jira/cloud_client.go index 823ddfb623..bddea11629 100644 --- a/apps/backend/internal/jira/cloud_client.go +++ b/apps/backend/internal/jira/cloud_client.go @@ -243,6 +243,7 @@ func (c *CloudClient) TestAuth(ctx context.Context) (*TestConnectionResult, erro // issueResponse mirrors the subset of the Atlassian issue payload we consume. type issueResponse struct { + ID string `json:"id"` Key string `json:"key"` Fields struct { Summary string `json:"summary"` @@ -547,6 +548,7 @@ func (c *CloudClient) searchTicketsServer(ctx context.Context, jql, pageToken st // Factored out so GetTicket and SearchTickets stay consistent. func issueToTicket(issue *issueResponse, siteURL string) JiraTicket { return JiraTicket{ + ID: issue.ID, Key: issue.Key, Summary: issue.Fields.Summary, StatusID: issue.Fields.Status.ID, diff --git a/apps/backend/internal/jira/cloud_client_test.go b/apps/backend/internal/jira/cloud_client_test.go index 23d8fbfe7b..0bf9193279 100644 --- a/apps/backend/internal/jira/cloud_client_test.go +++ b/apps/backend/internal/jira/cloud_client_test.go @@ -396,6 +396,30 @@ func TestCloudClient_ServerMode_SearchTickets_StartAtPagination(t *testing.T) { } } +func TestCloudClient_SearchTickets_PreservesImmutableIssueID(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issues": [{ + "id": "10042", + "key": "ENG-42", + "fields": {"summary": "Fix auth", "status": {}, "project": {}, "issuetype": {}} + }], + "isLast": true + }`)) + }) + + result, err := clientTo(ts, AuthMethodAPIToken, "token").SearchTickets( + context.Background(), "summary ~ auth", "", 5, + ) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(result.Tickets) != 1 || result.Tickets[0].ID != "10042" { + t.Fatalf("tickets = %#v, want immutable issue ID", result.Tickets) + } +} + // Jira Server can return fewer issues than maxResults on a non-terminal page // (rate limits, filtered results). The pager must still advance — IsLast is // driven by total, not by len(issues) < maxResults. diff --git a/apps/backend/internal/jira/models.go b/apps/backend/internal/jira/models.go index 69f14129f3..dd79b73cb7 100644 --- a/apps/backend/internal/jira/models.go +++ b/apps/backend/internal/jira/models.go @@ -87,6 +87,7 @@ type TestConnectionResult struct { // and surface a few familiar fields (assignee, reporter, priority) in the // popover so users don't have to switch tabs to Jira. type JiraTicket struct { + ID string `json:"id"` Key string `json:"key"` Summary string `json:"summary"` Description string `json:"description"` diff --git a/apps/backend/internal/jira/service_mentions.go b/apps/backend/internal/jira/service_mentions.go new file mode 100644 index 0000000000..4604c753ad --- /dev/null +++ b/apps/backend/internal/jira/service_mentions.go @@ -0,0 +1,141 @@ +package jira + +import ( + "context" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "unicode/utf8" +) + +const ( + defaultMentionSearchLimit = 5 + maxMentionSearchLimit = 10 +) + +var ( + ErrMentionWorkspaceRequired = errors.New("jira: mention workspace ID required") + ErrMentionInvalidQuery = errors.New("jira: invalid mention query") + ErrMentionInvalidSiteURL = errors.New("jira: invalid mention site URL") + ErrMentionMalformedResponse = errors.New("jira: malformed mention search response") +) + +// MentionTicket is Jira's provider-local projection for normalized references. +type MentionTicket struct { + ID string + Key string + Title string + URL string + SiteURL string +} + +var mentionIssueKeyPattern = regexp.MustCompile(`(?i)^[a-z][a-z0-9]+-[0-9]+$`) + +// buildMentionJQL translates plain user text into one fixed Jira query shape. +func buildMentionJQL(rawQuery string) (string, error) { + query := strings.TrimSpace(rawQuery) + if query == "" || !utf8.ValidString(query) || utf8.RuneCountInString(query) > 200 { + return "", ErrMentionInvalidQuery + } + if mentionIssueKeyPattern.MatchString(query) { + return "key = " + quoteJQLString(strings.ToUpper(query)) + " ORDER BY updated DESC", nil + } + return "summary ~ " + quoteJQLString(query) + " ORDER BY updated DESC", nil +} + +func quoteJQLString(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `"`, `\"`) + return `"` + value + `"` +} + +// MentionSiteURLForWorkspace returns only the explicitly configured Jira site. +func (s *Service) MentionSiteURLForWorkspace(ctx context.Context, workspaceID string) (string, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return "", ErrMentionWorkspaceRequired + } + if s == nil || s.store == nil { + return "", ErrNotConfigured + } + cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) + if err != nil { + return "", fmt.Errorf("read Jira mention config: %w", err) + } + if cfg == nil { + return "", ErrNotConfigured + } + siteURL, err := normalizeMentionSiteURL(cfg.SiteURL) + if err != nil { + return "", err + } + return siteURL, nil +} + +func normalizeMentionSiteURL(raw string) (string, error) { + siteURL := normalizeSiteURL(raw) + parsed, err := url.Parse(siteURL) + if err != nil || parsed.User != nil || parsed.Host == "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") || + parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Opaque != "" { + return "", ErrMentionInvalidSiteURL + } + return strings.TrimRight(siteURL, "/"), nil +} + +// SearchMentionTicketsForWorkspace accepts only plain text and always starts at +// the first page with a small per-source cap. +func (s *Service) SearchMentionTicketsForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]MentionTicket, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return nil, ErrMentionWorkspaceRequired + } + jql, err := buildMentionJQL(query) + if err != nil { + return nil, err + } + limit = normalizeMentionSearchLimit(limit) + siteURL, err := s.MentionSiteURLForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + result, err := s.SearchTicketsForWorkspace(ctx, workspaceID, jql, "", limit) + if err != nil { + return nil, err + } + if result == nil { + return nil, ErrMentionMalformedResponse + } + count := min(len(result.Tickets), limit) + tickets := make([]MentionTicket, 0, count) + for i := 0; i < count; i++ { + ticket := result.Tickets[i] + tickets = append(tickets, MentionTicket{ + ID: ticket.ID, + Key: ticket.Key, + Title: ticket.Summary, + URL: ticket.URL, + SiteURL: siteURL, + }) + } + return tickets, nil +} + +func normalizeMentionSearchLimit(limit int) int { + switch { + case limit == 0: + return defaultMentionSearchLimit + case limit < 1: + return 1 + case limit > maxMentionSearchLimit: + return maxMentionSearchLimit + default: + return limit + } +} diff --git a/apps/backend/internal/jira/service_mentions_test.go b/apps/backend/internal/jira/service_mentions_test.go new file mode 100644 index 0000000000..0ee5022588 --- /dev/null +++ b/apps/backend/internal/jira/service_mentions_test.go @@ -0,0 +1,175 @@ +package jira + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +type mentionRecordingClient struct { + *fakeClient + search func(context.Context, string, int) (*SearchResult, error) +} + +func (c *mentionRecordingClient) SearchTickets( + ctx context.Context, + jql, _ string, + maxResults int, +) (*SearchResult, error) { + return c.search(ctx, jql, maxResults) +} + +func newMentionService( + t *testing.T, + workspaceID, siteURL string, + client Client, +) *Service { + t.Helper() + store := newTestStore(t) + secrets := newFakeSecretStore() + ctx := context.Background() + if err := store.UpsertConfigForWorkspace(ctx, workspaceID, &JiraConfig{ + SiteURL: siteURL, + Email: "user@example.test", + AuthMethod: AuthMethodAPIToken, + InstanceType: InstanceTypeCloud, + }); err != nil { + t.Fatalf("save config: %v", err) + } + if err := secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), "Jira token", "token"); err != nil { + t.Fatalf("save secret: %v", err) + } + return NewService(store, secrets, func(*JiraConfig, string) Client { + return client + }, logger.Default()) +} + +func TestBuildMentionJQL_EscapesPlainKeyOrTitle(t *testing.T) { + tests := []struct { + name string + query string + want string + }{ + {name: "key", query: "eng-42", want: `key = "ENG-42" ORDER BY updated DESC`}, + { + name: "title cannot inject raw JQL", + query: ` auth" OR status = Done \ path `, + want: `summary ~ "auth\" OR status = Done \\ path" ORDER BY updated DESC`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := buildMentionJQL(test.query) + if err != nil { + t.Fatalf("build JQL: %v", err) + } + if got != test.want { + t.Fatalf("JQL = %q, want %q", got, test.want) + } + }) + } + + if _, err := buildMentionJQL(" \n\t "); !errors.Is(err, ErrMentionInvalidQuery) { + t.Fatalf("blank query error = %v, want ErrMentionInvalidQuery", err) + } +} + +func TestService_SearchMentionTicketsForWorkspace_ProjectsAndCaps(t *testing.T) { + client := &mentionRecordingClient{fakeClient: &fakeClient{}} + client.search = func(ctx context.Context, jql string, maxResults int) (*SearchResult, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if jql != `summary ~ "auth" ORDER BY updated DESC` { + t.Fatalf("JQL = %q", jql) + } + if maxResults != 10 { + t.Fatalf("maxResults = %d, want 10", maxResults) + } + tickets := make([]JiraTicket, 0, 12) + for i := 0; i < 12; i++ { + tickets = append(tickets, JiraTicket{ + ID: fmt.Sprintf("100%d", i), + Key: fmt.Sprintf("ENG-%d", i), + Summary: fmt.Sprintf("Auth %d", i), + URL: fmt.Sprintf("https://jira.example.test/base/browse/ENG-%d", i), + }) + } + return &SearchResult{Tickets: tickets}, nil + } + service := newMentionService(t, "workspace-1", "https://jira.example.test/base/", client) + + tickets, err := service.SearchMentionTicketsForWorkspace( + context.Background(), "workspace-1", "auth", 99, + ) + if err != nil { + t.Fatalf("search mentions: %v", err) + } + if len(tickets) != 10 { + t.Fatalf("tickets = %d, want capped 10", len(tickets)) + } + first := tickets[0] + if first.ID != "1000" || first.Key != "ENG-0" || first.Title != "Auth 0" || + first.URL != "https://jira.example.test/base/browse/ENG-0" || + first.SiteURL != "https://jira.example.test/base" { + t.Fatalf("first projection = %#v", first) + } +} + +func TestService_MentionSearchRequiresExplicitConfiguredWorkspace(t *testing.T) { + clientCalled := false + client := &mentionRecordingClient{fakeClient: &fakeClient{}} + client.search = func(context.Context, string, int) (*SearchResult, error) { + clientCalled = true + return &SearchResult{}, nil + } + service := newMentionService(t, "default", "https://default.example.test", client) + + if _, err := service.SearchMentionTicketsForWorkspace(context.Background(), "", "auth", 5); !errors.Is(err, ErrMentionWorkspaceRequired) { + t.Fatalf("blank workspace error = %v, want ErrMentionWorkspaceRequired", err) + } + if clientCalled { + t.Fatal("blank workspace fell back to default Jira config") + } + if _, err := service.SearchMentionTicketsForWorkspace(context.Background(), "workspace-2", "auth", 5); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("unconfigured workspace error = %v, want ErrNotConfigured", err) + } +} + +func TestService_MentionSiteURLForWorkspace_NormalizesAndRejectsUnsafeConfig(t *testing.T) { + client := &mentionRecordingClient{fakeClient: &fakeClient{}, search: func(context.Context, string, int) (*SearchResult, error) { + return &SearchResult{}, nil + }} + service := newMentionService(t, "workspace-1", "https://jira.example.test/base/", client) + + siteURL, err := service.MentionSiteURLForWorkspace(context.Background(), "workspace-1") + if err != nil { + t.Fatalf("mention site URL: %v", err) + } + if siteURL != "https://jira.example.test/base" { + t.Fatalf("site URL = %q", siteURL) + } + + unsafe := newMentionService(t, "workspace-1", "https://jira.example.test/base?token=secret", client) + if _, err := unsafe.MentionSiteURLForWorkspace(context.Background(), "workspace-1"); !errors.Is(err, ErrMentionInvalidSiteURL) { + t.Fatalf("unsafe config error = %v, want ErrMentionInvalidSiteURL", err) + } +} + +func TestService_SearchMentionTicketsForWorkspace_PropagatesCancellation(t *testing.T) { + client := &mentionRecordingClient{fakeClient: &fakeClient{}} + client.search = func(ctx context.Context, _ string, _ int) (*SearchResult, error) { + return nil, ctx.Err() + } + service := newMentionService(t, "workspace-1", "https://jira.example.test", client) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := service.SearchMentionTicketsForWorkspace(ctx, "workspace-1", "auth", 5) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context cancellation", err) + } +} diff --git a/apps/backend/internal/linear/service_mentions.go b/apps/backend/internal/linear/service_mentions.go new file mode 100644 index 0000000000..350323f468 --- /dev/null +++ b/apps/backend/internal/linear/service_mentions.go @@ -0,0 +1,89 @@ +package linear + +import ( + "context" + "errors" + "fmt" + "strings" +) + +const ( + defaultMentionSearchLimit = 5 + maxMentionSearchLimit = 10 +) + +var ( + // ErrMentionWorkspaceRequired prevents mention search from falling back to + // whichever workspace happens to be active for the current process. + ErrMentionWorkspaceRequired = errors.New("linear mention search: workspace ID is required") + // ErrMentionQueryRequired prevents an empty typeahead query from becoming an + // unbounded issue browse request. + ErrMentionQueryRequired = errors.New("linear mention search: query is required") + // ErrMentionScopeUnavailable means the configured connection has not yet + // captured the non-secret organization slug needed to scope references. + ErrMentionScopeUnavailable = errors.New("linear mention search: organization scope is unavailable") +) + +// SearchMentionIssues searches the first bounded issue page for one explicit +// workspace and returns the configured non-secret organization scope. +func (s *Service) SearchMentionIssues( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]LinearIssue, string, error) { + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return nil, "", ErrMentionWorkspaceRequired + } + query = strings.TrimSpace(query) + if query == "" { + return nil, "", ErrMentionQueryRequired + } + if s == nil || s.store == nil { + return nil, "", ErrNotConfigured + } + + cfg, err := s.store.GetConfigForWorkspace(ctx, workspaceID) + if err != nil { + return nil, "", fmt.Errorf("load Linear mention configuration: %w", err) + } + if cfg == nil { + return nil, "", ErrNotConfigured + } + orgSlug := strings.TrimSpace(cfg.OrgSlug) + if orgSlug == "" { + return nil, "", ErrMentionScopeUnavailable + } + + client, err := s.clientFor(ctx, workspaceID) + if err != nil { + return nil, "", fmt.Errorf("create Linear mention client: %w", err) + } + limit = normalizeMentionSearchLimit(limit) + result, err := client.SearchIssues( + ctx, + SearchFilter{Query: query}, + "", + limit, + ) + if err != nil { + return nil, "", fmt.Errorf("search Linear mention issues: %w", err) + } + if result == nil { + return nil, "", errors.New("search Linear mention issues: empty response") + } + return result.Issues[:min(len(result.Issues), limit)], orgSlug, nil +} + +func normalizeMentionSearchLimit(limit int) int { + if limit == 0 { + return defaultMentionSearchLimit + } + if limit < 1 { + return 1 + } + if limit > maxMentionSearchLimit { + return maxMentionSearchLimit + } + return limit +} diff --git a/apps/backend/internal/linear/service_mentions_test.go b/apps/backend/internal/linear/service_mentions_test.go new file mode 100644 index 0000000000..e25a5461af --- /dev/null +++ b/apps/backend/internal/linear/service_mentions_test.go @@ -0,0 +1,194 @@ +package linear + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" +) + +func configureMentionWorkspace(t *testing.T, fixture *svcFixture, workspaceID, orgSlug string) { + t.Helper() + ctx := context.Background() + if err := fixture.store.UpsertConfigForWorkspace(ctx, workspaceID, &LinearConfig{ + AuthMethod: AuthMethodAPIKey, + }); err != nil { + t.Fatalf("upsert config: %v", err) + } + if err := fixture.store.UpdateAuthHealthForWorkspace( + ctx, + workspaceID, + true, + "", + orgSlug, + time.Now().UTC(), + ); err != nil { + t.Fatalf("update auth health: %v", err) + } + if err := fixture.secrets.Set( + ctx, + SecretKeyForWorkspace(workspaceID), + "Linear API key", + "lin_api_test", + ); err != nil { + t.Fatalf("store secret: %v", err) + } +} + +func TestServiceSearchMentionIssuesUsesStructuredQueryAndExplicitWorkspace(t *testing.T) { + fixture := newSvcFixture(t) + configureMentionWorkspace(t, fixture, "workspace-1", "acme") + configureMentionWorkspace(t, fixture, "workspace-2", "other") + + wantIssues := []LinearIssue{{ + ID: "issue-uuid", + Identifier: "ENG-123", + Title: "Fix authentication", + URL: "https://linear.app/acme/issue/ENG-123/fix-authentication", + }} + var gotFilter SearchFilter + var gotPageToken string + var gotLimit int + fixture.client.searchIssuesFn = func(filter SearchFilter, pageToken string, limit int) (*SearchResult, error) { + gotFilter = filter + gotPageToken = pageToken + gotLimit = limit + return &SearchResult{Issues: wantIssues}, nil + } + + issues, orgSlug, err := fixture.svc.SearchMentionIssues( + context.Background(), + "workspace-1", + " auth ", + 99, + ) + if err != nil { + t.Fatalf("search mention issues: %v", err) + } + if !reflect.DeepEqual(gotFilter, SearchFilter{Query: "auth"}) { + t.Fatalf("filter = %#v, want structured query only", gotFilter) + } + if gotPageToken != "" || gotLimit != 10 { + t.Fatalf("pagination = token %q limit %d, want first page capped at 10", gotPageToken, gotLimit) + } + if orgSlug != "acme" { + t.Fatalf("org scope = %q, want acme", orgSlug) + } + if len(issues) != 1 || !reflect.DeepEqual(issues[0], wantIssues[0]) { + t.Fatalf("issues = %#v, want immutable identity fields preserved", issues) + } +} + +func TestServiceSearchMentionIssuesRejectsBlankWorkspaceWithoutDefaultFallback(t *testing.T) { + fixture := newSvcFixture(t) + configureMentionWorkspace(t, fixture, "default", "default-org") + + _, _, err := fixture.svc.SearchMentionIssues( + context.Background(), + " \t ", + "auth", + 5, + ) + if err == nil || !strings.Contains(err.Error(), "workspace") { + t.Fatalf("error = %v, want explicit workspace rejection", err) + } + if got := fixture.factoryHit.Load(); got != 0 { + t.Fatalf("client factory called %d times; blank workspace must not use default config", got) + } +} + +func TestServiceSearchMentionIssuesRequiresOrganizationScope(t *testing.T) { + fixture := newSvcFixture(t) + configureMentionWorkspace(t, fixture, "workspace-1", "") + + _, _, err := fixture.svc.SearchMentionIssues( + context.Background(), + "workspace-1", + "auth", + 5, + ) + if err == nil || !strings.Contains(err.Error(), "organization") { + t.Fatalf("error = %v, want missing organization scope rejection", err) + } + if got := fixture.factoryHit.Load(); got != 0 { + t.Fatalf("client factory called %d times; incomplete scope must fail before search", got) + } +} + +func TestServiceSearchMentionIssuesWithoutConfigIsNotConfigured(t *testing.T) { + fixture := newSvcFixture(t) + + _, _, err := fixture.svc.SearchMentionIssues( + context.Background(), + "workspace-1", + "auth", + 5, + ) + if !errors.Is(err, ErrNotConfigured) { + t.Fatalf("error = %v, want not configured", err) + } + if got := fixture.factoryHit.Load(); got != 0 { + t.Fatalf("client factory called %d times without config", got) + } +} + +func TestServiceSearchMentionIssuesPreservesCancellation(t *testing.T) { + fixture := newSvcFixture(t) + configureMentionWorkspace(t, fixture, "workspace-1", "acme") + fixture.client.searchIssuesFn = func(SearchFilter, string, int) (*SearchResult, error) { + return nil, context.Canceled + } + + _, _, err := fixture.svc.SearchMentionIssues( + context.Background(), + "workspace-1", + "auth", + 5, + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want wrapped context cancellation", err) + } +} + +func TestServiceSearchMentionIssuesCapsUpstreamResults(t *testing.T) { + tests := []struct { + name string + limit int + wantLimit int + }{ + {name: "default", limit: 0, wantLimit: 5}, + {name: "minimum", limit: -1, wantLimit: 1}, + {name: "maximum", limit: 99, wantLimit: 10}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newSvcFixture(t) + configureMentionWorkspace(t, fixture, "workspace-1", "acme") + upstreamIssues := make([]LinearIssue, 12) + for index := range upstreamIssues { + upstreamIssues[index] = LinearIssue{ID: string(rune('a' + index))} + } + gotLimit := 0 + fixture.client.searchIssuesFn = func(_ SearchFilter, _ string, intLimit int) (*SearchResult, error) { + gotLimit = intLimit + return &SearchResult{Issues: upstreamIssues}, nil + } + + issues, _, err := fixture.svc.SearchMentionIssues( + context.Background(), + "workspace-1", + "auth", + test.limit, + ) + if err != nil { + t.Fatalf("search mention issues: %v", err) + } + if gotLimit != test.wantLimit || len(issues) != test.wantLimit { + t.Fatalf("client limit = %d, returned issues = %d, want cap %d", + gotLimit, len(issues), test.wantLimit) + } + }) + } +} diff --git a/apps/backend/internal/mentions/handler.go b/apps/backend/internal/mentions/handler.go new file mode 100644 index 0000000000..6134e6335f --- /dev/null +++ b/apps/backend/internal/mentions/handler.go @@ -0,0 +1,80 @@ +package mentions + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +const errorResponseField = "error" + +// Searcher is the HTTP handler's provider-neutral search boundary. +type Searcher interface { + Search(context.Context, SearchRequest) (*apiv1.MentionSearchResponse, error) +} + +// Handler exposes mention search over HTTP. +type Handler struct { + searcher Searcher +} + +func NewHandler(searcher Searcher) *Handler { + return &Handler{searcher: searcher} +} + +func (h *Handler) RegisterRoutes(router gin.IRoutes) { + router.GET("/api/v1/workspaces/:id/mentions/search", h.search) +} + +func (h *Handler) search(c *gin.Context) { + limit, err := parseLimit(c.Query("limit")) + if err != nil { + writeSearchError(c, ErrInvalidRequest) + return + } + response, err := h.searcher.Search(c.Request.Context(), SearchRequest{ + WorkspaceID: c.Param("id"), + Query: c.Query("q"), + Limit: limit, + }) + if err != nil { + writeSearchError(c, err) + return + } + c.JSON(http.StatusOK, response) +} + +func parseLimit(rawLimit string) (int, error) { + rawLimit = strings.TrimSpace(rawLimit) + if rawLimit == "" { + return DefaultLimit, nil + } + limit, err := strconv.Atoi(rawLimit) + if err != nil { + return 0, ErrInvalidRequest + } + if limit < 1 { + return 1, nil + } + if limit > MaxLimit { + return MaxLimit, nil + } + return limit, nil +} + +func writeSearchError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrInvalidRequest): + c.JSON(http.StatusBadRequest, gin.H{errorResponseField: "invalid mention search request"}) + case errors.Is(err, ErrWorkspaceNotFound): + c.JSON(http.StatusNotFound, gin.H{errorResponseField: "mention search workspace not found"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{errorResponseField: "mention search failed"}) + } +} diff --git a/apps/backend/internal/mentions/handler_test.go b/apps/backend/internal/mentions/handler_test.go new file mode 100644 index 0000000000..b167afa283 --- /dev/null +++ b/apps/backend/internal/mentions/handler_test.go @@ -0,0 +1,128 @@ +package mentions + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeSearcher struct { + search func(context.Context, SearchRequest) (*apiv1.MentionSearchResponse, error) +} + +func TestHandlerRegisterRoutesUsesExistingWorkspaceWildcardName(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/api/v1/workspaces/:id", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + handler := NewHandler(fakeSearcher{search: func(_ context.Context, request SearchRequest) (*apiv1.MentionSearchResponse, error) { + return &apiv1.MentionSearchResponse{Query: request.Query}, nil + }}) + + var recovered any + func() { + defer func() { recovered = recover() }() + handler.RegisterRoutes(router) + }() + if recovered != nil { + t.Fatalf("register mention route alongside workspace routes: %v", recovered) + } +} + +func (s fakeSearcher) Search(ctx context.Context, request SearchRequest) (*apiv1.MentionSearchResponse, error) { + return s.search(ctx, request) +} + +func TestHandlerSearch_ReturnsNormalizedResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(fakeSearcher{search: func(_ context.Context, request SearchRequest) (*apiv1.MentionSearchResponse, error) { + if request.WorkspaceID != "workspace-1" || request.Query != "auth" || request.Limit != MaxLimit { + t.Fatalf("request = %+v", request) + } + return &apiv1.MentionSearchResponse{ + Query: "auth", + Groups: []apiv1.MentionGroup{{ + Source: "tasks", Provider: "kandev", Kind: "task", + DisplayName: "Kandev tasks", KindLabel: "Task", Status: StatusOK, + Results: []apiv1.EntityReference{}, + }}, + }, nil + }}).RegisterRoutes(router) + + request := httptest.NewRequest(http.MethodGet, + "/api/v1/workspaces/workspace-1/mentions/search?q=auth&limit=99", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var body apiv1.MentionSearchResponse + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Query != "auth" || len(body.Groups) != 1 || body.Groups[0].Source != "tasks" { + t.Fatalf("body = %#v", body) + } +} + +func TestHandlerSearch_RejectsInvalidLimitWithoutSearching(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(fakeSearcher{search: func(_ context.Context, _ SearchRequest) (*apiv1.MentionSearchResponse, error) { + t.Fatal("search called for invalid limit") + return nil, nil + }}).RegisterRoutes(router) + + request := httptest.NewRequest(http.MethodGet, + "/api/v1/workspaces/workspace-1/mentions/search?q=auth&limit=not-a-number", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusBadRequest, response.Body.String()) + } +} + +func TestHandlerSearch_MapsSafeErrorsWithoutLeaking(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + }{ + {name: "invalid request", err: fmt.Errorf("query detail: %w", ErrInvalidRequest), wantStatus: http.StatusBadRequest}, + {name: "workspace missing", err: fmt.Errorf("lookup detail: %w", ErrWorkspaceNotFound), wantStatus: http.StatusNotFound}, + {name: "internal", err: errors.New("secret database address"), wantStatus: http.StatusInternalServerError}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(fakeSearcher{search: func(_ context.Context, _ SearchRequest) (*apiv1.MentionSearchResponse, error) { + return nil, test.err + }}).RegisterRoutes(router) + + request := httptest.NewRequest(http.MethodGet, + "/api/v1/workspaces/workspace-1/mentions/search?q=auth", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != test.wantStatus { + t.Fatalf("status = %d, want %d; body = %s", response.Code, test.wantStatus, response.Body.String()) + } + if strings.Contains(response.Body.String(), test.err.Error()) { + t.Fatalf("response leaked raw error: %s", response.Body.String()) + } + }) + } +} diff --git a/apps/backend/internal/mentions/provider_azure.go b/apps/backend/internal/mentions/provider_azure.go new file mode 100644 index 0000000000..97cfca2901 --- /dev/null +++ b/apps/backend/internal/mentions/provider_azure.go @@ -0,0 +1,346 @@ +package mentions + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/kandev/kandev/internal/azuredevops" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +const ( + azureProviderID = "azure_devops" + azureWorkItemKind = "work_item" + azurePullRequestKind = "pull_request" +) + +// AzureMentionService is Azure DevOps' workspace-explicit mention boundary. +type AzureMentionService interface { + SearchMentionWorkItemsForWorkspace( + context.Context, string, string, int, + ) ([]azuredevops.MentionWorkItem, error) + SearchMentionPullRequestsForWorkspace( + context.Context, string, string, int, + ) ([]azuredevops.MentionPullRequest, error) + ResolveMentionProjectForWorkspace( + context.Context, string, string, + ) (*azuredevops.MentionProject, error) + ResolveMentionRepositoryForWorkspace( + context.Context, string, string, string, + ) (*azuredevops.MentionRepository, error) +} + +type azureProvider struct { + service AzureMentionService + kind string +} + +// NewAzureWorkItemProvider creates Azure Boards' work-item mention source. +func NewAzureWorkItemProvider(service AzureMentionService) MentionProvider { + return &azureProvider{service: service, kind: azureWorkItemKind} +} + +// NewAzurePullRequestProvider creates Azure Repos' pull-request mention source. +func NewAzurePullRequestProvider(service AzureMentionService) MentionProvider { + return &azureProvider{service: service, kind: azurePullRequestKind} +} + +func (p *azureProvider) Descriptor() ProviderDescriptor { + if p.kind == azurePullRequestKind { + return ProviderDescriptor{ + Source: "azure_pull_requests", Provider: azureProviderID, Kind: azurePullRequestKind, + DisplayName: "Azure DevOps", KindLabel: "Pull request", Order: 61, + } + } + return ProviderDescriptor{ + Source: "azure_work_items", Provider: azureProviderID, Kind: azureWorkItemKind, + DisplayName: "Azure DevOps", KindLabel: mentionLabelWorkItem, Order: 60, + } +} + +func (p *azureProvider) Search(ctx context.Context, request SearchRequest) ([]Candidate, error) { + if p == nil || p.service == nil { + return nil, NewProviderError(StatusNotConfigured) + } + if strings.TrimSpace(request.WorkspaceID) == "" { + return nil, NewProviderError(StatusUnsupportedScope, azuredevops.ErrInvalidWorkspaceID) + } + limit := clampAzureMentionLimit(request.Limit) + if p.kind == azurePullRequestKind { + items, err := p.service.SearchMentionPullRequestsForWorkspace( + ctx, request.WorkspaceID, request.Query, limit, + ) + if err != nil { + return nil, classifyAzureMentionError(err) + } + return azurePullRequestCandidates(items, limit), nil + } + items, err := p.service.SearchMentionWorkItemsForWorkspace( + ctx, request.WorkspaceID, request.Query, limit, + ) + if err != nil { + return nil, classifyAzureMentionError(err) + } + return azureWorkItemCandidates(items, limit), nil +} + +func azureWorkItemCandidates(items []azuredevops.MentionWorkItem, limit int) []Candidate { + candidates := make([]Candidate, 0, min(len(items), limit)) + for _, item := range items { + if item.ID <= 0 || strings.TrimSpace(item.Title) == "" || + strings.TrimSpace(item.ProjectName) == "" { + continue + } + scope, ok := azureScope(item.OrganizationURL, item.ProjectID) + if !ok { + continue + } + id := strconv.Itoa(item.ID) + candidates = append(candidates, Candidate{ + ID: id, Key: item.ProjectName + "#" + id, Title: item.Title, + URL: strings.TrimRight(item.OrganizationURL, "/") + "/" + + url.PathEscape(item.ProjectName) + "/_workitems/edit/" + id, + Scope: scope, + }) + if len(candidates) == limit { + break + } + } + return candidates +} + +func azurePullRequestCandidates(items []azuredevops.MentionPullRequest, limit int) []Candidate { + candidates := make([]Candidate, 0, min(len(items), limit)) + for _, item := range items { + if item.ID <= 0 || strings.TrimSpace(item.Title) == "" || + strings.TrimSpace(item.ProjectName) == "" || strings.TrimSpace(item.RepositoryName) == "" { + continue + } + scope, ok := azureScope(item.OrganizationURL, item.ProjectID, item.RepositoryID) + if !ok { + continue + } + id := strconv.Itoa(item.ID) + candidates = append(candidates, Candidate{ + ID: id, Key: item.ProjectName + "/" + item.RepositoryName + "!" + id, Title: item.Title, + URL: strings.TrimRight(item.OrganizationURL, "/") + "/" + + url.PathEscape(item.ProjectName) + "/_git/" + url.PathEscape(item.RepositoryName) + + "/pullrequest/" + id, + Scope: scope, + }) + if len(candidates) == limit { + break + } + } + return candidates +} + +func clampAzureMentionLimit(limit int) int { + if limit <= 0 { + return DefaultLimit + } + if limit > MaxLimit { + return MaxLimit + } + return limit +} + +func classifyAzureMentionError(err error) error { + status := StatusUpstreamError + switch { + case errors.Is(err, azuredevops.ErrNotConfigured): + status = StatusNotConfigured + case errors.Is(err, azuredevops.ErrInvalidWorkspaceID), errors.Is(err, azuredevops.ErrInvalidConfig): + status = StatusUnsupportedScope + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + status = StatusTimeout + default: + var apiErr *azuredevops.APIError + var timeoutErr net.Error + switch { + case errors.As(err, &apiErr): + status = azureAPIStatus(apiErr.StatusCode) + case errors.As(err, &timeoutErr) && timeoutErr.Timeout(): + status = StatusTimeout + } + } + return NewProviderError(status, err) +} + +func azureAPIStatus(statusCode int) Status { + switch statusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return StatusUnauthorized + case http.StatusTooManyRequests: + return StatusRateLimited + case http.StatusRequestTimeout, http.StatusGatewayTimeout: + return StatusTimeout + default: + return StatusUpstreamError + } +} + +func (p *azureProvider) AuthorizeReference(ctx context.Context, request ReferenceAuthorizationRequest) error { + if err := ctx.Err(); err != nil { + return err + } + if p == nil || p.service == nil || strings.TrimSpace(request.WorkspaceID) == "" || + (request.Purpose != ReferencePurposeSearch && request.Purpose != ReferencePurposeSubmission) { + return ErrReferenceUnauthorized + } + parts, ok := validAzureReference(request.Reference, p.kind) + if !ok { + return ErrReferenceUnauthorized + } + if request.Purpose == ReferencePurposeSearch { + return nil + } + if p.kind == azurePullRequestKind { + return p.authorizePullRequest(ctx, request.WorkspaceID, request.Reference, parts) + } + return p.authorizeWorkItem(ctx, request.WorkspaceID, request.Reference, parts) +} + +type azureScopeParts struct { + organization string + projectID string + repositoryID string +} + +func validAzureReference(reference apiv1.EntityReference, kind string) (azureScopeParts, bool) { + if !validAzureReferenceIdentity(reference, kind) { + return azureScopeParts{}, false + } + return parseAzureReferenceScope(reference.Scope, kind) +} + +func validAzureReferenceIdentity(reference apiv1.EntityReference, kind string) bool { + if reference.Version != apiv1.EntityReferenceVersion || reference.Provider != azureProviderID || + reference.Kind != kind || reference.ID == "" || reference.ID != strings.TrimSpace(reference.ID) || + reference.Ref != canonicalRef(azureProviderID, kind, reference.Scope, reference.ID) { + return false + } + id, err := strconv.Atoi(reference.ID) + return err == nil && id > 0 && strconv.Itoa(id) == reference.ID +} + +func parseAzureReferenceScope(scope, kind string) (azureScopeParts, bool) { + wantParts := 3 + if kind == azurePullRequestKind { + wantParts = 4 + } + parts := strings.Split(scope, "/") + if len(parts) != wantParts || parts[0] != "dev.azure.com" || strings.ToLower(scope) != scope { + return azureScopeParts{}, false + } + for _, part := range parts[1:] { + if !validAzureScopeSegment(part) { + return azureScopeParts{}, false + } + } + result := azureScopeParts{organization: parts[1], projectID: parts[2]} + if len(parts) == 4 { + result.repositoryID = parts[3] + } + return result, true +} + +func (p *azureProvider) authorizeWorkItem( + ctx context.Context, + workspaceID string, + reference apiv1.EntityReference, + parts azureScopeParts, +) error { + project, err := p.service.ResolveMentionProjectForWorkspace(ctx, workspaceID, parts.projectID) + if err != nil || project == nil || !azureResolvedScopeMatches( + project.OrganizationURL, project.ProjectID, "", parts, + ) { + return ErrReferenceUnauthorized + } + wantKey := project.ProjectName + "#" + reference.ID + wantURL := strings.TrimRight(project.OrganizationURL, "/") + "/" + + url.PathEscape(project.ProjectName) + "/_workitems/edit/" + reference.ID + if reference.Key != wantKey || reference.URL != wantURL { + return ErrReferenceUnauthorized + } + return nil +} + +func (p *azureProvider) authorizePullRequest( + ctx context.Context, + workspaceID string, + reference apiv1.EntityReference, + parts azureScopeParts, +) error { + repository, err := p.service.ResolveMentionRepositoryForWorkspace( + ctx, workspaceID, parts.projectID, parts.repositoryID, + ) + if err != nil || repository == nil || !azureResolvedScopeMatches( + repository.OrganizationURL, repository.ProjectID, repository.RepositoryID, parts, + ) { + return ErrReferenceUnauthorized + } + wantKey := repository.ProjectName + "/" + repository.RepositoryName + "!" + reference.ID + wantURL := strings.TrimRight(repository.OrganizationURL, "/") + "/" + + url.PathEscape(repository.ProjectName) + "/_git/" + url.PathEscape(repository.RepositoryName) + + "/pullrequest/" + reference.ID + if reference.Key != wantKey || reference.URL != wantURL { + return ErrReferenceUnauthorized + } + return nil +} + +func azureResolvedScopeMatches( + organizationURL, projectID, repositoryID string, + parts azureScopeParts, +) bool { + scope, ok := azureScope(organizationURL, projectID, repositoryID) + if !ok { + return false + } + want := "dev.azure.com/" + parts.organization + "/" + parts.projectID + if parts.repositoryID != "" { + want += "/" + parts.repositoryID + } + return scope == want +} + +func azureScope(organizationURL, projectID string, repositoryIDs ...string) (string, bool) { + canonicalURL, err := azuredevops.ValidateOrganizationURL(organizationURL) + if err != nil || canonicalURL != strings.TrimRight(organizationURL, "/") { + return "", false + } + organization := strings.TrimPrefix(strings.ToLower(canonicalURL), "https://dev.azure.com/") + projectID = strings.ToLower(strings.TrimSpace(projectID)) + if !validAzureScopeSegment(organization) || !validAzureScopeSegment(projectID) { + return "", false + } + scope := "dev.azure.com/" + organization + "/" + projectID + if len(repositoryIDs) > 0 && strings.TrimSpace(repositoryIDs[0]) != "" { + repositoryID := strings.ToLower(strings.TrimSpace(repositoryIDs[0])) + if !validAzureScopeSegment(repositoryID) { + return "", false + } + scope += "/" + repositoryID + } + return scope, true +} + +func validAzureScopeSegment(value string) bool { + if value == "" || value == "." || value == ".." { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || + char == '-' || char == '_' || char == '.' { + continue + } + return false + } + return true +} diff --git a/apps/backend/internal/mentions/provider_azure_test.go b/apps/backend/internal/mentions/provider_azure_test.go new file mode 100644 index 0000000000..428eb46628 --- /dev/null +++ b/apps/backend/internal/mentions/provider_azure_test.go @@ -0,0 +1,150 @@ +package mentions + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/azuredevops" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeAzureMentionService struct { + workItems []azuredevops.MentionWorkItem + prs []azuredevops.MentionPullRequest + project *azuredevops.MentionProject + repository *azuredevops.MentionRepository + err error +} + +func (s *fakeAzureMentionService) SearchMentionWorkItemsForWorkspace( + context.Context, string, string, int, +) ([]azuredevops.MentionWorkItem, error) { + return s.workItems, s.err +} + +func (s *fakeAzureMentionService) SearchMentionPullRequestsForWorkspace( + context.Context, string, string, int, +) ([]azuredevops.MentionPullRequest, error) { + return s.prs, s.err +} + +func (s *fakeAzureMentionService) ResolveMentionProjectForWorkspace( + context.Context, string, string, +) (*azuredevops.MentionProject, error) { + return s.project, s.err +} + +func (s *fakeAzureMentionService) ResolveMentionRepositoryForWorkspace( + context.Context, string, string, string, +) (*azuredevops.MentionRepository, error) { + return s.repository, s.err +} + +func TestAzureProvidersMapWorkItemsAndPullRequests(t *testing.T) { + service := &fakeAzureMentionService{ + workItems: []azuredevops.MentionWorkItem{{ + ID: 101, Title: "Fix auth", OrganizationURL: "https://dev.azure.com/acme", + ProjectID: "project-1", ProjectName: "Platform", + }}, + prs: []azuredevops.MentionPullRequest{{ + ID: 42, Title: "Land auth fix", OrganizationURL: "https://dev.azure.com/acme", + ProjectID: "project-1", ProjectName: "Platform", RepositoryID: "repo-1", RepositoryName: "widgets", + }}, + } + workProvider := NewAzureWorkItemProvider(service) + prProvider := NewAzurePullRequestProvider(service) + if descriptor := workProvider.Descriptor(); descriptor.Provider != "azure_devops" || descriptor.Kind != "work_item" || descriptor.Order != 60 { + t.Fatalf("work descriptor = %#v", descriptor) + } + if descriptor := prProvider.Descriptor(); descriptor.Provider != "azure_devops" || descriptor.Kind != "pull_request" || descriptor.Order != 61 { + t.Fatalf("PR descriptor = %#v", descriptor) + } + work, err := workProvider.Search(t.Context(), SearchRequest{WorkspaceID: "workspace-1", Query: "auth", Limit: 5}) + if err != nil { + t.Fatalf("search work: %v", err) + } + prs, err := prProvider.Search(t.Context(), SearchRequest{WorkspaceID: "workspace-1", Query: "auth", Limit: 5}) + if err != nil { + t.Fatalf("search PRs: %v", err) + } + wantWork := Candidate{ + ID: "101", Key: "Platform#101", Title: "Fix auth", + URL: "https://dev.azure.com/acme/Platform/_workitems/edit/101", + Scope: "dev.azure.com/acme/project-1", + } + wantPR := Candidate{ + ID: "42", Key: "Platform/widgets!42", Title: "Land auth fix", + URL: "https://dev.azure.com/acme/Platform/_git/widgets/pullrequest/42", + Scope: "dev.azure.com/acme/project-1/repo-1", + } + if len(work) != 1 || work[0] != wantWork { + t.Fatalf("work = %#v, want %#v", work, wantWork) + } + if len(prs) != 1 || prs[0] != wantPR { + t.Fatalf("PRs = %#v, want %#v", prs, wantPR) + } +} + +func TestAzureProviderAuthorizesConfiguredProjectAndCanonicalRoute(t *testing.T) { + service := &fakeAzureMentionService{ + project: &azuredevops.MentionProject{ + OrganizationURL: "https://dev.azure.com/acme", ProjectID: "project-1", ProjectName: "Platform", + }, + repository: &azuredevops.MentionRepository{ + OrganizationURL: "https://dev.azure.com/acme", ProjectID: "project-1", ProjectName: "Platform", + RepositoryID: "repo-1", RepositoryName: "widgets", + }, + } + workProvider := NewAzureWorkItemProvider(service) + prProvider := NewAzurePullRequestProvider(service) + workRef := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: canonicalRef("azure_devops", "work_item", "dev.azure.com/acme/project-1", "101"), + Provider: "azure_devops", Kind: "work_item", ID: "101", Key: "Platform#101", Title: "Fix auth", + URL: "https://dev.azure.com/acme/Platform/_workitems/edit/101", Scope: "dev.azure.com/acme/project-1", + } + prRef := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: canonicalRef("azure_devops", "pull_request", "dev.azure.com/acme/project-1/repo-1", "42"), + Provider: "azure_devops", Kind: "pull_request", ID: "42", Key: "Platform/widgets!42", Title: "Land auth", + URL: "https://dev.azure.com/acme/Platform/_git/widgets/pullrequest/42", Scope: "dev.azure.com/acme/project-1/repo-1", + } + for _, test := range []struct { + provider MentionProvider + ref apiv1.EntityReference + }{ + {workProvider, workRef}, {prProvider, prRef}, + } { + authorizer := test.provider.(ReferenceAuthorizer) + if err := authorizer.AuthorizeReference(t.Context(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", Purpose: ReferencePurposeSubmission, Reference: test.ref, + }); err != nil { + t.Fatalf("authorize %s: %v", test.ref.Kind, err) + } + invalid := test.ref + invalid.URL += "?redirect=1" + if err := authorizer.AuthorizeReference(t.Context(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", Purpose: ReferencePurposeSubmission, Reference: invalid, + }); err == nil { + t.Fatalf("unsafe %s URL authorized", test.ref.Kind) + } + } + foreign := workRef + foreign.Scope = "dev.azure.com/foreign/project-1" + foreign.Ref = canonicalRef("azure_devops", "work_item", foreign.Scope, foreign.ID) + if err := workProvider.(ReferenceAuthorizer).AuthorizeReference(t.Context(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", Purpose: ReferencePurposeSubmission, Reference: foreign, + }); err == nil { + t.Fatal("foreign organization scope authorized") + } +} + +func TestAzureProviderClassifiesMissingConfig(t *testing.T) { + provider := NewAzureWorkItemProvider(&fakeAzureMentionService{err: azuredevops.ErrNotConfigured}) + _, err := provider.Search(context.Background(), SearchRequest{WorkspaceID: "workspace-1", Query: "auth", Limit: 5}) + var providerErr *ProviderError + if !errors.As(err, &providerErr) || providerErr.Status != StatusNotConfigured { + t.Fatalf("error = %v, want not configured", err) + } +} diff --git a/apps/backend/internal/mentions/provider_github.go b/apps/backend/internal/mentions/provider_github.go new file mode 100644 index 0000000000..ee4f7205cc --- /dev/null +++ b/apps/backend/internal/mentions/provider_github.go @@ -0,0 +1,322 @@ +package mentions + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "strconv" + "strings" + + "github.com/kandev/kandev/internal/github" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +const ( + githubProviderID = "github" + githubIssueKind = mentionKindIssue + githubPullRequestKind = "pull_request" +) + +// GitHubMentionService is GitHub's workspace-explicit mention boundary. +type GitHubMentionService interface { + SearchMentionIssuesForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, + ) ([]*github.Issue, error) + SearchMentionPullRequestsForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, + ) ([]*github.PR, error) + GetWorkspaceSettings(ctx context.Context, workspaceID string) (*github.WorkspaceSettings, error) +} + +type githubProvider struct { + service GitHubMentionService + kind string +} + +// NewGitHubIssueProvider creates GitHub's issue mention source. +func NewGitHubIssueProvider(service GitHubMentionService) MentionProvider { + return &githubProvider{service: service, kind: githubIssueKind} +} + +// NewGitHubPullRequestProvider creates GitHub's pull-request mention source. +func NewGitHubPullRequestProvider(service GitHubMentionService) MentionProvider { + return &githubProvider{service: service, kind: githubPullRequestKind} +} + +func (p *githubProvider) Descriptor() ProviderDescriptor { + if p.kind == githubPullRequestKind { + return ProviderDescriptor{ + Source: "github_pull_requests", Provider: githubProviderID, Kind: githubPullRequestKind, + DisplayName: "GitHub", KindLabel: "Pull request", Order: 41, + } + } + return ProviderDescriptor{ + Source: "github_issues", Provider: githubProviderID, Kind: githubIssueKind, + DisplayName: "GitHub", KindLabel: mentionLabelIssue, Order: 40, + } +} + +func (p *githubProvider) Search(ctx context.Context, request SearchRequest) ([]Candidate, error) { + if p == nil || p.service == nil { + return nil, NewProviderError(StatusNotConfigured) + } + if strings.TrimSpace(request.WorkspaceID) == "" { + return nil, NewProviderError(StatusUnsupportedScope, github.ErrMentionWorkspaceRequired) + } + limit := clampGitHubMentionLimit(request.Limit) + if p.kind == githubPullRequestKind { + prs, err := p.service.SearchMentionPullRequestsForWorkspace( + ctx, request.WorkspaceID, request.Query, limit, + ) + if err != nil { + return nil, classifyGitHubMentionError(err) + } + return githubPullRequestCandidates(prs, limit), nil + } + issues, err := p.service.SearchMentionIssuesForWorkspace( + ctx, request.WorkspaceID, request.Query, limit, + ) + if err != nil { + return nil, classifyGitHubMentionError(err) + } + return githubIssueCandidates(issues, limit), nil +} + +func githubIssueCandidates(issues []*github.Issue, limit int) []Candidate { + candidates := make([]Candidate, 0, min(len(issues), limit)) + for _, issue := range issues { + if issue == nil || issue.Number <= 0 { + continue + } + candidate, ok := githubCandidate( + issue.ID, issue.NodeID, issue.Number, issue.Title, issue.RepoOwner, issue.RepoName, githubIssueKind, + ) + if ok { + candidates = append(candidates, candidate) + } + if len(candidates) == limit { + break + } + } + return candidates +} + +func githubPullRequestCandidates(prs []*github.PR, limit int) []Candidate { + candidates := make([]Candidate, 0, min(len(prs), limit)) + for _, pr := range prs { + if pr == nil || pr.Number <= 0 { + continue + } + candidate, ok := githubCandidate( + pr.ID, pr.NodeID, pr.Number, pr.Title, pr.RepoOwner, pr.RepoName, githubPullRequestKind, + ) + if ok { + candidates = append(candidates, candidate) + } + if len(candidates) == limit { + break + } + } + return candidates +} + +func githubCandidate( + restID int64, + nodeID string, + number int, + title, owner, repo, kind string, +) (Candidate, bool) { + owner, repo, ok := normalizeGitHubRepository(owner, repo) + if !ok { + return Candidate{}, false + } + id := strings.TrimSpace(nodeID) + if id == "" && restID > 0 { + id = strconv.FormatInt(restID, 10) + } + if id == "" || id != strings.TrimSpace(id) { + return Candidate{}, false + } + route := mentionIssuesPathSegment + if kind == githubPullRequestKind { + route = "pull" + } + repository := owner + "/" + repo + return Candidate{ + ID: id, + Key: repository + "#" + strconv.Itoa(number), + Title: title, + URL: "https://github.com/" + repository + "/" + route + "/" + strconv.Itoa(number), + Scope: "github.com/" + repository, + }, true +} + +func clampGitHubMentionLimit(limit int) int { + if limit <= 0 { + return DefaultLimit + } + if limit > MaxLimit { + return MaxLimit + } + return limit +} + +func classifyGitHubMentionError(err error) error { + status := StatusUpstreamError + switch { + case errors.Is(err, github.ErrNoClient): + status = StatusNotConfigured + case errors.Is(err, github.ErrMentionWorkspaceRequired): + status = StatusUnsupportedScope + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + status = StatusTimeout + default: + var apiErr *github.GitHubAPIError + var timeoutErr net.Error + switch { + case errors.As(err, &apiErr): + status = githubAPIStatus(apiErr.StatusCode) + case errors.As(err, &timeoutErr) && timeoutErr.Timeout(): + status = StatusTimeout + case strings.Contains(strings.ToLower(err.Error()), "rate limit"): + status = StatusRateLimited + } + } + return NewProviderError(status, err) +} + +func githubAPIStatus(statusCode int) Status { + switch statusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return StatusUnauthorized + case http.StatusTooManyRequests: + return StatusRateLimited + case http.StatusRequestTimeout, http.StatusGatewayTimeout: + return StatusTimeout + default: + return StatusUpstreamError + } +} + +func (p *githubProvider) AuthorizeReference(ctx context.Context, request ReferenceAuthorizationRequest) error { + if err := ctx.Err(); err != nil { + return err + } + if p == nil || p.service == nil || request.WorkspaceID == "" || + (request.Purpose != ReferencePurposeSearch && request.Purpose != ReferencePurposeSubmission) || + !validGitHubReference(request.Reference, p.kind) { + return ErrReferenceUnauthorized + } + settings, err := p.service.GetWorkspaceSettings(ctx, request.WorkspaceID) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + return ErrReferenceUnauthorized + } + owner, repo, ok := repositoryFromGitHubScope(request.Reference.Scope) + if !ok || !githubSettingsAllowRepository(settings, request.WorkspaceID, owner, repo) { + return ErrReferenceUnauthorized + } + return nil +} + +func validGitHubReference(reference apiv1.EntityReference, kind string) bool { + if reference.Version != apiv1.EntityReferenceVersion || reference.Provider != githubProviderID || + reference.Kind != kind || reference.ID == "" || reference.ID != strings.TrimSpace(reference.ID) || + reference.Ref != canonicalRef(githubProviderID, kind, reference.Scope, reference.ID) { + return false + } + owner, repo, ok := repositoryFromGitHubScope(reference.Scope) + if !ok { + return false + } + repository := owner + "/" + repo + keyPrefix := repository + "#" + if !strings.HasPrefix(reference.Key, keyPrefix) { + return false + } + numberText := strings.TrimPrefix(reference.Key, keyPrefix) + number, err := strconv.Atoi(numberText) + if err != nil || number <= 0 || strconv.Itoa(number) != numberText { + return false + } + route := mentionIssuesPathSegment + if kind == githubPullRequestKind { + route = "pull" + } + wantURL := fmt.Sprintf("https://github.com/%s/%s/%d", repository, route, number) + return reference.URL == wantURL +} + +func repositoryFromGitHubScope(scope string) (string, string, bool) { + const prefix = "github.com/" + if !strings.HasPrefix(scope, prefix) || strings.ToLower(scope) != scope { + return "", "", false + } + parts := strings.Split(strings.TrimPrefix(scope, prefix), "/") + if len(parts) != 2 { + return "", "", false + } + owner, repo, ok := normalizeGitHubRepository(parts[0], parts[1]) + if !ok || scope != prefix+owner+"/"+repo { + return "", "", false + } + return owner, repo, true +} + +func normalizeGitHubRepository(owner, repo string) (string, string, bool) { + owner = strings.ToLower(strings.TrimSpace(owner)) + repo = strings.ToLower(strings.TrimSpace(repo)) + if !validGitHubPathSegment(owner) || !validGitHubPathSegment(repo) { + return "", "", false + } + return owner, repo, true +} + +func validGitHubPathSegment(value string) bool { + if value == "" || value == "." || value == ".." { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || + char == '-' || char == '_' || char == '.' { + continue + } + return false + } + return true +} + +func githubSettingsAllowRepository( + settings *github.WorkspaceSettings, + workspaceID, owner, repo string, +) bool { + if settings == nil || strings.TrimSpace(settings.WorkspaceID) != workspaceID { + return false + } + switch strings.ToLower(strings.TrimSpace(settings.RepoScopeMode)) { + case github.RepoScopeModeAll: + return true + case github.RepoScopeModeOrgs: + for _, org := range settings.RepoScopeOrgs { + if strings.EqualFold(strings.TrimSpace(org), owner) { + return true + } + } + case github.RepoScopeModeRepos: + for _, candidate := range settings.RepoScopeRepos { + if strings.EqualFold(strings.TrimSpace(candidate.Owner), owner) && + strings.EqualFold(strings.TrimSpace(candidate.Name), repo) { + return true + } + } + } + return false +} diff --git a/apps/backend/internal/mentions/provider_github_test.go b/apps/backend/internal/mentions/provider_github_test.go new file mode 100644 index 0000000000..8ca75c00b1 --- /dev/null +++ b/apps/backend/internal/mentions/provider_github_test.go @@ -0,0 +1,152 @@ +package mentions + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/github" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeGitHubMentionService struct { + issues []*github.Issue + prs []*github.PR + settings *github.WorkspaceSettings + err error + workspaceID string + query string + limit int +} + +func (s *fakeGitHubMentionService) SearchMentionIssuesForWorkspace( + _ context.Context, + workspaceID, query string, + limit int, +) ([]*github.Issue, error) { + s.workspaceID, s.query, s.limit = workspaceID, query, limit + return s.issues, s.err +} + +func (s *fakeGitHubMentionService) SearchMentionPullRequestsForWorkspace( + _ context.Context, + workspaceID, query string, + limit int, +) ([]*github.PR, error) { + s.workspaceID, s.query, s.limit = workspaceID, query, limit + return s.prs, s.err +} + +func (s *fakeGitHubMentionService) GetWorkspaceSettings( + _ context.Context, + workspaceID string, +) (*github.WorkspaceSettings, error) { + s.workspaceID = workspaceID + if s.err != nil { + return nil, s.err + } + return s.settings, nil +} + +func TestGitHubProvidersMapIssueAndPullRequestIdentity(t *testing.T) { + service := &fakeGitHubMentionService{ + issues: []*github.Issue{{ID: 101, NodeID: "I_kwDOA", Number: 17, Title: "Fix auth", RepoOwner: "Acme", RepoName: "Widgets"}}, + prs: []*github.PR{{ID: 202, NodeID: "PR_kwDOB", Number: 18, Title: "Land auth fix", RepoOwner: "Acme", RepoName: "Widgets"}}, + } + + issueProvider := NewGitHubIssueProvider(service) + prProvider := NewGitHubPullRequestProvider(service) + issueDescriptor := issueProvider.Descriptor() + prDescriptor := prProvider.Descriptor() + if issueDescriptor.Source != "github_issues" || issueDescriptor.Kind != "issue" || issueDescriptor.Order != 40 { + t.Fatalf("issue descriptor = %#v", issueDescriptor) + } + if prDescriptor.Source != "github_pull_requests" || prDescriptor.Kind != "pull_request" || prDescriptor.Order != 41 { + t.Fatalf("PR descriptor = %#v", prDescriptor) + } + + issues, err := issueProvider.Search(context.Background(), SearchRequest{WorkspaceID: "workspace-1", Query: "auth", Limit: 5}) + if err != nil { + t.Fatalf("search issues: %v", err) + } + prs, err := prProvider.Search(context.Background(), SearchRequest{WorkspaceID: "workspace-1", Query: "auth", Limit: 5}) + if err != nil { + t.Fatalf("search PRs: %v", err) + } + wantIssue := Candidate{ + ID: "I_kwDOA", Key: "acme/widgets#17", Title: "Fix auth", + URL: "https://github.com/acme/widgets/issues/17", Scope: "github.com/acme/widgets", + } + wantPR := Candidate{ + ID: "PR_kwDOB", Key: "acme/widgets#18", Title: "Land auth fix", + URL: "https://github.com/acme/widgets/pull/18", Scope: "github.com/acme/widgets", + } + if len(issues) != 1 || issues[0] != wantIssue { + t.Fatalf("issue candidates = %#v, want %#v", issues, wantIssue) + } + if len(prs) != 1 || prs[0] != wantPR { + t.Fatalf("PR candidates = %#v, want %#v", prs, wantPR) + } +} + +func TestGitHubProviderAuthorizesOnlyWorkspaceScopedCanonicalDestination(t *testing.T) { + service := &fakeGitHubMentionService{settings: &github.WorkspaceSettings{ + WorkspaceID: "workspace-1", + RepoScopeMode: github.RepoScopeModeRepos, + RepoScopeRepos: []github.RepoFilter{{Owner: "acme", Name: "widgets"}}, + }} + provider := NewGitHubIssueProvider(service) + authorizer, ok := provider.(ReferenceAuthorizer) + if !ok { + t.Fatal("GitHub issue provider must authorize references") + } + reference := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: canonicalRef("github", "issue", "github.com/acme/widgets", "I_kwDOA"), + Provider: "github", Kind: "issue", ID: "I_kwDOA", Key: "acme/widgets#17", + Title: "Fix auth", URL: "https://github.com/acme/widgets/issues/17", Scope: "github.com/acme/widgets", + } + for _, purpose := range []ReferencePurpose{ReferencePurposeSearch, ReferencePurposeSubmission} { + if err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", Purpose: purpose, Reference: reference, + }); err != nil { + t.Fatalf("authorize %s: %v", purpose, err) + } + } + + tests := []struct { + name string + mutate func(*apiv1.EntityReference) + }{ + {"foreign repo", func(ref *apiv1.EntityReference) { + ref.Scope = "github.com/foreign/private" + ref.URL = "https://github.com/foreign/private/issues/17" + ref.Key = "foreign/private#17" + ref.Ref = canonicalRef("github", "issue", ref.Scope, ref.ID) + }}, + {"foreign host", func(ref *apiv1.EntityReference) { ref.URL = "https://github.example.com/acme/widgets/issues/17" }}, + {"query", func(ref *apiv1.EntityReference) { ref.URL += "?redirect=1" }}, + {"fragment", func(ref *apiv1.EntityReference) { ref.URL += "#comment" }}, + {"wrong route", func(ref *apiv1.EntityReference) { ref.URL = "https://github.com/acme/widgets/pull/17" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + invalid := reference + test.mutate(&invalid) + if err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", Purpose: ReferencePurposeSubmission, Reference: invalid, + }); err == nil { + t.Fatal("invalid reference was authorized") + } + }) + } +} + +func TestGitHubProviderClassifiesUnavailableClient(t *testing.T) { + provider := NewGitHubIssueProvider(&fakeGitHubMentionService{err: github.ErrNoClient}) + _, err := provider.Search(context.Background(), SearchRequest{WorkspaceID: "workspace-1", Query: "auth", Limit: 5}) + var providerErr *ProviderError + if !errors.As(err, &providerErr) || providerErr.Status != StatusNotConfigured { + t.Fatalf("error = %v, want not configured", err) + } +} diff --git a/apps/backend/internal/mentions/provider_gitlab.go b/apps/backend/internal/mentions/provider_gitlab.go new file mode 100644 index 0000000000..2017239724 --- /dev/null +++ b/apps/backend/internal/mentions/provider_gitlab.go @@ -0,0 +1,276 @@ +package mentions + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "path" + "strconv" + "strings" + + "github.com/kandev/kandev/internal/gitlab" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +const ( + gitLabProviderID = "gitlab" + gitLabIssueKind = "issue" + gitLabMergeRequestKind = "merge_request" + gitLabDisplayName = "GitLab" + gitLabIssueLabel = "Issue" + gitLabMergeRequestLabel = "Merge request" + gitLabHTTPScheme = "http" + gitLabHTTPSScheme = "https" +) + +// GitLabMentionService is GitLab's explicit workspace/project mention boundary. +type GitLabMentionService interface { + SearchMentionIssuesForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, + ) ([]gitlab.MentionItem, error) + SearchMentionMRsForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, + ) ([]gitlab.MentionItem, error) + MentionScopeForWorkspace(ctx context.Context, workspaceID string) (*gitlab.MentionScope, error) +} + +type gitLabProvider struct { + service GitLabMentionService + kind string +} + +// NewGitLabIssueProvider creates GitLab's issue mention source. +func NewGitLabIssueProvider(service GitLabMentionService) MentionProvider { + return &gitLabProvider{service: service, kind: gitLabIssueKind} +} + +// NewGitLabMergeRequestProvider creates GitLab's merge-request mention source. +func NewGitLabMergeRequestProvider(service GitLabMentionService) MentionProvider { + return &gitLabProvider{service: service, kind: gitLabMergeRequestKind} +} + +func (p *gitLabProvider) Descriptor() ProviderDescriptor { + if p.kind == gitLabMergeRequestKind { + return ProviderDescriptor{ + Source: "gitlab_merge_requests", Provider: gitLabProviderID, Kind: p.kind, + DisplayName: gitLabDisplayName, KindLabel: gitLabMergeRequestLabel, Order: 51, + } + } + return ProviderDescriptor{ + Source: "gitlab_issues", Provider: gitLabProviderID, Kind: gitLabIssueKind, + DisplayName: gitLabDisplayName, KindLabel: gitLabIssueLabel, Order: 50, + } +} + +func (p *gitLabProvider) Search(ctx context.Context, request SearchRequest) ([]Candidate, error) { + if p == nil || p.service == nil { + return nil, NewProviderError(StatusNotConfigured) + } + request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) + if request.WorkspaceID == "" { + return nil, NewProviderError(StatusUnsupportedScope, gitlab.ErrMentionWorkspaceRequired) + } + request.Query = strings.TrimSpace(request.Query) + if request.Query == "" { + return nil, NewProviderError(StatusUpstreamError, gitlab.ErrMentionQueryRequired) + } + limit := clampGitLabMentionLimit(request.Limit) + var ( + items []gitlab.MentionItem + err error + ) + if p.kind == gitLabMergeRequestKind { + items, err = p.service.SearchMentionMRsForWorkspace(ctx, request.WorkspaceID, request.Query, limit) + } else { + items, err = p.service.SearchMentionIssuesForWorkspace(ctx, request.WorkspaceID, request.Query, limit) + } + if err != nil { + return nil, classifyGitLabMentionError(err) + } + count := min(len(items), limit) + candidates := make([]Candidate, 0, count) + for index := 0; index < count; index++ { + item := items[index] + candidates = append(candidates, Candidate{ + ID: strconv.FormatInt(item.ID, 10), + Key: gitLabMentionKey(p.kind, item.ProjectPath, item.IID), + Title: item.Title, + URL: item.URL, + Scope: gitlab.MentionProjectScopeKey(item.Host, item.ProjectID), + }) + } + return candidates, nil +} + +func clampGitLabMentionLimit(limit int) int { + if limit <= 0 { + return DefaultLimit + } + if limit > MaxLimit { + return MaxLimit + } + return limit +} + +func gitLabMentionKey(kind, projectPath string, iid int) string { + separator := "#" + if kind == gitLabMergeRequestKind { + separator = "!" + } + return projectPath + separator + strconv.Itoa(iid) +} + +func classifyGitLabMentionError(err error) error { + status := StatusUpstreamError + switch { + case errors.Is(err, gitlab.ErrNoClient): + status = StatusNotConfigured + case errors.Is(err, gitlab.ErrMentionWorkspaceRequired), + errors.Is(err, gitlab.ErrMentionUnsupportedScope), + errors.Is(err, gitlab.ErrMentionInvalidScope): + status = StatusUnsupportedScope + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), isGitLabTimeoutError(err): + status = StatusTimeout + default: + var apiErr *gitlab.APIError + if errors.As(err, &apiErr) { + status = gitLabAPIErrorStatus(apiErr.StatusCode) + } + } + return NewProviderError(status, err) +} + +func isGitLabTimeoutError(err error) bool { + var timeoutErr net.Error + return errors.As(err, &timeoutErr) && timeoutErr.Timeout() +} + +func gitLabAPIErrorStatus(statusCode int) Status { + switch { + case statusCode >= http.StatusMultipleChoices && statusCode < http.StatusBadRequest: + return StatusUnauthorized + case statusCode == http.StatusUnauthorized, statusCode == http.StatusForbidden: + return StatusUnauthorized + case statusCode == http.StatusTooManyRequests: + return StatusRateLimited + case statusCode == http.StatusRequestTimeout, statusCode == http.StatusGatewayTimeout: + return StatusTimeout + default: + return StatusUpstreamError + } +} + +func (p *gitLabProvider) AuthorizeReference(ctx context.Context, request ReferenceAuthorizationRequest) error { + if err := ctx.Err(); err != nil { + return err + } + if p == nil || p.service == nil { + return ErrReferenceUnauthorized + } + workspaceID, ok := gitLabAuthorizationWorkspace(request) + if !ok { + return ErrReferenceUnauthorized + } + projectPath, iid, ok := validGitLabReference(request.Reference, p.kind) + if !ok { + return ErrReferenceUnauthorized + } + scope, err := p.service.MentionScopeForWorkspace(ctx, workspaceID) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + return ErrReferenceUnauthorized + } + if !gitLabScopeAuthorizesReference(scope, workspaceID, projectPath, iid, p.kind, request.Reference) { + return ErrReferenceUnauthorized + } + return nil +} + +func gitLabAuthorizationWorkspace(request ReferenceAuthorizationRequest) (string, bool) { + workspaceID := strings.TrimSpace(request.WorkspaceID) + validPurpose := request.Purpose == ReferencePurposeSearch || request.Purpose == ReferencePurposeSubmission + return workspaceID, workspaceID != "" && workspaceID == request.WorkspaceID && validPurpose +} + +func validGitLabReference(reference apiv1.EntityReference, kind string) (string, int, bool) { + projectPath, iid, validKey := parseGitLabMentionKey(kind, reference.Key) + if !validKey || reference.Version != apiv1.EntityReferenceVersion || + reference.Provider != gitLabProviderID || reference.Kind != kind || + !validPositiveCanonicalInt(reference.ID) || + reference.Ref != canonicalRef(gitLabProviderID, kind, reference.Scope, reference.ID) { + return "", 0, false + } + return projectPath, iid, true +} + +func gitLabScopeAuthorizesReference( + scope *gitlab.MentionScope, + workspaceID, projectPath string, + iid int, + kind string, + reference apiv1.EntityReference, +) bool { + if scope == nil || scope.WorkspaceID != workspaceID || !validGitLabMentionHost(scope.Host) { + return false + } + for _, project := range scope.Projects { + if project.ID <= 0 || project.Path != projectPath { + continue + } + if reference.Scope != gitlab.MentionProjectScopeKey(scope.Host, project.ID) { + continue + } + expectedURL := gitlab.MentionIssueURL(scope.Host, project.Path, iid) + if kind == gitLabMergeRequestKind { + expectedURL = gitlab.MentionMRURL(scope.Host, project.Path, iid) + } + if reference.URL == expectedURL { + return true + } + } + return false +} + +func parseGitLabMentionKey(kind, key string) (string, int, bool) { + separator := "#" + if kind == gitLabMergeRequestKind { + separator = "!" + } + index := strings.LastIndex(key, separator) + if index <= 0 || index == len(key)-1 { + return "", 0, false + } + projectPath := key[:index] + rawIID := key[index+1:] + iid, err := strconv.Atoi(rawIID) + if err != nil || iid <= 0 || strconv.Itoa(iid) != rawIID || strings.TrimSpace(projectPath) != projectPath { + return "", 0, false + } + return projectPath, iid, true +} + +func validPositiveCanonicalInt(raw string) bool { + value, err := strconv.ParseInt(raw, 10, 64) + return err == nil && value > 0 && strconv.FormatInt(value, 10) == raw +} + +func validGitLabMentionHost(host string) bool { + if host == "" || strings.TrimRight(host, "/") != host || strings.Contains(host, "|") { + return false + } + parsed, err := url.Parse(host) + if err != nil || parsed.User != nil || parsed.Opaque != "" || parsed.Host == "" || + (parsed.Scheme != gitLabHTTPScheme && parsed.Scheme != gitLabHTTPSScheme) || parsed.RawQuery != "" || + parsed.ForceQuery || parsed.Fragment != "" { + return false + } + return parsed.Path == "" || path.Clean(parsed.Path) == parsed.Path +} diff --git a/apps/backend/internal/mentions/provider_gitlab_test.go b/apps/backend/internal/mentions/provider_gitlab_test.go new file mode 100644 index 0000000000..91980c36b8 --- /dev/null +++ b/apps/backend/internal/mentions/provider_gitlab_test.go @@ -0,0 +1,341 @@ +package mentions + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/kandev/kandev/internal/gitlab" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeGitLabMentionService struct { + issues func(context.Context, string, string, int) ([]gitlab.MentionItem, error) + mrs func(context.Context, string, string, int) ([]gitlab.MentionItem, error) + scope func(context.Context, string) (*gitlab.MentionScope, error) +} + +func (s *fakeGitLabMentionService) SearchMentionIssuesForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]gitlab.MentionItem, error) { + return s.issues(ctx, workspaceID, query, limit) +} + +func (s *fakeGitLabMentionService) SearchMentionMRsForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]gitlab.MentionItem, error) { + return s.mrs(ctx, workspaceID, query, limit) +} + +func (s *fakeGitLabMentionService) MentionScopeForWorkspace( + ctx context.Context, + workspaceID string, +) (*gitlab.MentionScope, error) { + return s.scope(ctx, workspaceID) +} + +func gitLabTestService() *fakeGitLabMentionService { + const host = "https://gitlab.example.test/base" + return &fakeGitLabMentionService{ + issues: func(_ context.Context, workspaceID, query string, limit int) ([]gitlab.MentionItem, error) { + if workspaceID != "workspace-1" || query != "auth" || limit != 4 { + return nil, errors.New("unexpected issue search request") + } + return []gitlab.MentionItem{{ + ID: 1001, IID: 42, ProjectID: 101, ProjectPath: "group/api", + Title: "Fix auth", URL: host + "/group/api/-/issues/42", Host: host, + }}, nil + }, + mrs: func(_ context.Context, workspaceID, query string, limit int) ([]gitlab.MentionItem, error) { + if workspaceID != "workspace-1" || query != "auth" || limit != 4 { + return nil, errors.New("unexpected MR search request") + } + return []gitlab.MentionItem{{ + ID: 2001, IID: 7, ProjectID: 202, ProjectPath: "group/web", + Title: "Auth MR", URL: host + "/group/web/-/merge_requests/7", Host: host, + }}, nil + }, + scope: func(_ context.Context, workspaceID string) (*gitlab.MentionScope, error) { + if workspaceID != "workspace-1" { + return nil, gitlab.ErrMentionUnsupportedScope + } + return &gitlab.MentionScope{ + WorkspaceID: workspaceID, + Host: host, + Projects: []gitlab.MentionProjectScope{ + {ID: 101, Path: "group/api"}, + {ID: 202, Path: "group/web"}, + }, + }, nil + }, + } +} + +func TestGitLabProvidersMapImmutableIssueAndMRIdentity(t *testing.T) { + service := gitLabTestService() + tests := []struct { + name string + provider MentionProvider + wantSource string + wantKind string + wantOrder int + wantID string + wantKey string + wantTitle string + wantURL string + wantScope string + }{ + { + name: "issue", provider: NewGitLabIssueProvider(service), + wantSource: "gitlab_issues", wantKind: "issue", wantOrder: 50, + wantID: "1001", wantKey: "group/api#42", wantTitle: "Fix auth", + wantURL: "https://gitlab.example.test/base/group/api/-/issues/42", + wantScope: gitlab.MentionProjectScopeKey("https://gitlab.example.test/base", 101), + }, + { + name: "merge request", provider: NewGitLabMergeRequestProvider(service), + wantSource: "gitlab_merge_requests", wantKind: "merge_request", wantOrder: 51, + wantID: "2001", wantKey: "group/web!7", wantTitle: "Auth MR", + wantURL: "https://gitlab.example.test/base/group/web/-/merge_requests/7", + wantScope: gitlab.MentionProjectScopeKey("https://gitlab.example.test/base", 202), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + descriptor := test.provider.Descriptor() + if descriptor.Source != test.wantSource || descriptor.Provider != "gitlab" || + descriptor.Kind != test.wantKind || descriptor.Order != test.wantOrder || + descriptor.DisplayName != "GitLab" { + t.Fatalf("descriptor = %#v", descriptor) + } + registry := NewRegistry() + if err := registry.Register(test.provider); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", Query: "auth", Limit: 4, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(response.Groups) != 1 || len(response.Groups[0].Results) != 1 { + t.Fatalf("groups = %#v", response.Groups) + } + result := response.Groups[0].Results[0] + if result.ID != test.wantID || result.Key != test.wantKey || result.Title != test.wantTitle || + result.URL != test.wantURL || result.Scope != test.wantScope || + result.Ref != canonicalRef("gitlab", test.wantKind, test.wantScope, test.wantID) { + t.Fatalf("result = %#v", result) + } + }) + } +} + +func TestGitLabProviderSearchRejectsUnboundProjectHit(t *testing.T) { + const host = "https://gitlab.example.test" + service := &fakeGitLabMentionService{ + issues: func(context.Context, string, string, int) ([]gitlab.MentionItem, error) { + return []gitlab.MentionItem{{ + ID: 9001, IID: 9, ProjectID: 999, ProjectPath: "secret/project", + Title: "Leak", URL: host + "/secret/project/-/issues/9", Host: host, + }}, nil + }, + mrs: func(context.Context, string, string, int) ([]gitlab.MentionItem, error) { return nil, nil }, + scope: func(context.Context, string) (*gitlab.MentionScope, error) { + return &gitlab.MentionScope{ + WorkspaceID: "workspace-1", Host: host, + Projects: []gitlab.MentionProjectScope{{ID: 101, Path: "group/api"}}, + }, nil + }, + } + registry := NewRegistry() + if err := registry.Register(NewGitLabIssueProvider(service)); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", Query: "auth", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if got := len(response.Groups[0].Results); got != 0 { + t.Fatalf("unbound project results = %d, want 0", got) + } +} + +func TestGitLabProviderMapsSafePartialStatuses(t *testing.T) { + tests := []struct { + name string + err error + want Status + }{ + {name: "not configured", err: gitlab.ErrNoClient, want: StatusNotConfigured}, + {name: "unsupported workspace", err: gitlab.ErrMentionUnsupportedScope, want: StatusUnsupportedScope}, + {name: "unauthorized", err: &gitlab.APIError{StatusCode: http.StatusUnauthorized, Body: "secret"}, want: StatusUnauthorized}, + {name: "login redirect", err: &gitlab.APIError{StatusCode: http.StatusFound, Body: "secret"}, want: StatusUnauthorized}, + {name: "rate limited", err: &gitlab.APIError{StatusCode: http.StatusTooManyRequests, Body: "secret"}, want: StatusRateLimited}, + {name: "timeout", err: &gitlab.APIError{StatusCode: http.StatusGatewayTimeout, Body: "secret"}, want: StatusTimeout}, + {name: "context timeout", err: context.DeadlineExceeded, want: StatusTimeout}, + {name: "upstream", err: errors.New("secret upstream response"), want: StatusUpstreamError}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + service := &fakeGitLabMentionService{ + issues: func(context.Context, string, string, int) ([]gitlab.MentionItem, error) { return nil, test.err }, + mrs: func(context.Context, string, string, int) ([]gitlab.MentionItem, error) { return nil, test.err }, + scope: func(context.Context, string) (*gitlab.MentionScope, error) { return nil, test.err }, + } + registry := NewRegistry() + if err := registry.Register(NewGitLabIssueProvider(service)); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", Query: "auth", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if response.Groups[0].Status != test.want { + t.Fatalf("status = %q, want %q", response.Groups[0].Status, test.want) + } + }) + } +} + +func TestGitLabProvidersWithoutServiceAreNotConfigured(t *testing.T) { + for _, provider := range []MentionProvider{NewGitLabIssueProvider(nil), NewGitLabMergeRequestProvider(nil)} { + registry := NewRegistry() + if err := registry.Register(provider); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", Query: "auth", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if response.Groups[0].Status != StatusNotConfigured { + t.Fatalf("status = %q", response.Groups[0].Status) + } + } +} + +func TestGitLabProviderAuthorizesExactWorkspaceHostAndProjectForBothPurposes(t *testing.T) { + const host = "https://gitlab.example.test/base" + scopeCalls := 0 + service := &fakeGitLabMentionService{ + issues: func(context.Context, string, string, int) ([]gitlab.MentionItem, error) { return nil, nil }, + mrs: func(context.Context, string, string, int) ([]gitlab.MentionItem, error) { return nil, nil }, + scope: func(_ context.Context, workspaceID string) (*gitlab.MentionScope, error) { + scopeCalls++ + if workspaceID == "" { + t.Fatal("blank workspace fell back to global GitLab config") + } + if workspaceID != "workspace-1" { + return nil, gitlab.ErrMentionUnsupportedScope + } + return &gitlab.MentionScope{ + WorkspaceID: workspaceID, Host: host, + Projects: []gitlab.MentionProjectScope{ + {ID: 101, Path: "group/api"}, + {ID: 202, Path: "group/web"}, + }, + }, nil + }, + } + tests := []struct { + name string + provider MentionProvider + reference apiv1.EntityReference + }{ + { + name: "issue", provider: NewGitLabIssueProvider(service), + reference: gitLabReference("issue", "1001", "group/api#42", host, 101, + host+"/group/api/-/issues/42"), + }, + { + name: "merge request", provider: NewGitLabMergeRequestProvider(service), + reference: gitLabReference("merge_request", "2001", "group/web!7", host, 202, + host+"/group/web/-/merge_requests/7"), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + authorizer, ok := test.provider.(ReferenceAuthorizer) + if !ok { + t.Fatal("GitLab provider must implement ReferenceAuthorizer") + } + for _, purpose := range []ReferencePurpose{ReferencePurposeSearch, ReferencePurposeSubmission} { + if err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", Purpose: purpose, Reference: test.reference, + }); err != nil { + t.Fatalf("authorize %s: %v", purpose, err) + } + } + + badCases := []struct { + name string + workspace string + mutate func(*apiv1.EntityReference) + }{ + {name: "blank workspace", workspace: ""}, + {name: "other workspace", workspace: "workspace-2"}, + {name: "wrong host scope", workspace: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Scope = gitlab.MentionProjectScopeKey("https://evil.example.test", 101) + }}, + {name: "wrong project scope", workspace: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Scope = gitlab.MentionProjectScopeKey(host, 999) + }}, + {name: "wrong URL", workspace: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://evil.example.test/secret" + }}, + {name: "URL query", workspace: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL += "?token=secret" + }}, + {name: "wrong key project", workspace: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + if ref.Kind == "issue" { + ref.Key = "other/api#42" + } else { + ref.Key = "other/web!7" + } + }}, + {name: "wrong canonical ref", workspace: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Ref = "mention:v1:gitlab:spoofed" + }}, + } + for _, bad := range badCases { + t.Run(bad.name, func(t *testing.T) { + ref := test.reference + if bad.mutate != nil { + bad.mutate(&ref) + } + err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: bad.workspace, Purpose: ReferencePurposeSubmission, Reference: ref, + }) + if !errors.Is(err, ErrReferenceUnauthorized) { + t.Fatalf("error = %v, want ErrReferenceUnauthorized", err) + } + }) + } + }) + } + if scopeCalls == 0 { + t.Fatal("workspace GitLab scope was never rechecked") + } +} + +func gitLabReference(kind, id, key, host string, projectID int64, destination string) apiv1.EntityReference { + scope := gitlab.MentionProjectScopeKey(host, projectID) + return apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: canonicalRef("gitlab", kind, scope, id), + Provider: "gitlab", Kind: kind, ID: id, Key: key, Title: "Title", + URL: destination, Scope: scope, + } +} diff --git a/apps/backend/internal/mentions/provider_jira.go b/apps/backend/internal/mentions/provider_jira.go new file mode 100644 index 0000000000..6245612597 --- /dev/null +++ b/apps/backend/internal/mentions/provider_jira.go @@ -0,0 +1,166 @@ +package mentions + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "strings" + + "github.com/kandev/kandev/internal/jira" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +const jiraProviderID = "jira" + +// JiraMentionService is Jira's workspace-explicit mention boundary. +type JiraMentionService interface { + SearchMentionTicketsForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, + ) ([]jira.MentionTicket, error) + MentionSiteURLForWorkspace(ctx context.Context, workspaceID string) (string, error) +} + +type jiraProvider struct { + service JiraMentionService +} + +// NewJiraProvider creates Jira's built-in issue mention source. +func NewJiraProvider(service JiraMentionService) MentionProvider { + return &jiraProvider{service: service} +} + +func (p *jiraProvider) Descriptor() ProviderDescriptor { + return ProviderDescriptor{ + Source: "jira_issues", + Provider: jiraProviderID, + Kind: mentionKindIssue, + DisplayName: "Jira", + KindLabel: mentionLabelIssue, + Order: 20, + } +} + +func (p *jiraProvider) Search(ctx context.Context, request SearchRequest) ([]Candidate, error) { + if p.service == nil { + return nil, NewProviderError(StatusNotConfigured) + } + if strings.TrimSpace(request.WorkspaceID) == "" { + return nil, NewProviderError(StatusUnsupportedScope, jira.ErrMentionWorkspaceRequired) + } + tickets, err := p.service.SearchMentionTicketsForWorkspace( + ctx, + request.WorkspaceID, + request.Query, + request.Limit, + ) + if err != nil { + return nil, mapJiraProviderError(err) + } + limit := normalizedJiraProviderLimit(request.Limit) + count := min(len(tickets), limit) + candidates := make([]Candidate, 0, count) + for i := 0; i < count; i++ { + ticket := tickets[i] + candidates = append(candidates, Candidate{ + ID: ticket.ID, + Key: ticket.Key, + Title: ticket.Title, + URL: ticket.URL, + Scope: ticket.SiteURL, + }) + } + return candidates, nil +} + +func normalizedJiraProviderLimit(limit int) int { + switch { + case limit == 0: + return DefaultLimit + case limit < 1: + return 1 + case limit > MaxLimit: + return MaxLimit + default: + return limit + } +} + +func mapJiraProviderError(err error) error { + status := StatusUpstreamError + switch { + case errors.Is(err, jira.ErrNotConfigured): + status = StatusNotConfigured + case errors.Is(err, jira.ErrMentionWorkspaceRequired), errors.Is(err, jira.ErrMentionInvalidSiteURL): + status = StatusUnsupportedScope + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled), isJiraTimeoutError(err): + status = StatusTimeout + default: + var apiErr *jira.APIError + if errors.As(err, &apiErr) { + status = jiraAPIErrorStatus(apiErr.StatusCode) + } + } + return NewProviderError(status, err) +} + +func isJiraTimeoutError(err error) bool { + var timeoutErr net.Error + return errors.As(err, &timeoutErr) && timeoutErr.Timeout() +} + +func jiraAPIErrorStatus(statusCode int) Status { + switch { + case statusCode >= http.StatusMultipleChoices && statusCode < http.StatusBadRequest: + return StatusUnauthorized + case statusCode == http.StatusUnauthorized, statusCode == http.StatusForbidden: + return StatusUnauthorized + case statusCode == http.StatusTooManyRequests: + return StatusRateLimited + case statusCode == http.StatusRequestTimeout, statusCode == http.StatusGatewayTimeout: + return StatusTimeout + default: + return StatusUpstreamError + } +} + +func (p *jiraProvider) AuthorizeReference(ctx context.Context, request ReferenceAuthorizationRequest) error { + reference := request.Reference + workspaceID := strings.TrimSpace(request.WorkspaceID) + if p.service == nil || workspaceID == "" || workspaceID != request.WorkspaceID || + (request.Purpose != ReferencePurposeSearch && request.Purpose != ReferencePurposeSubmission) || + !validJiraReferenceShape(reference) { + return ErrReferenceUnauthorized + } + siteURL, err := p.service.MentionSiteURLForWorkspace(ctx, workspaceID) + if err != nil || !jiraReferenceMatchesSite(reference, siteURL) { + return ErrReferenceUnauthorized + } + return nil +} + +func validJiraReferenceShape(reference apiv1.EntityReference) bool { + return reference.Version == apiv1.EntityReferenceVersion && reference.Provider == jiraProviderID && + reference.Kind == mentionKindIssue && reference.ID != "" && + strings.TrimSpace(reference.ID) == reference.ID && reference.Key != "" && + strings.TrimSpace(reference.Key) == reference.Key +} + +func jiraReferenceMatchesSite(reference apiv1.EntityReference, siteURL string) bool { + return validConfiguredJiraSiteURL(siteURL) && reference.Scope == siteURL && + reference.Ref == canonicalRef(jiraProviderID, mentionKindIssue, siteURL, reference.ID) && + reference.URL == siteURL+"/browse/"+url.PathEscape(reference.Key) +} + +func validConfiguredJiraSiteURL(siteURL string) bool { + if siteURL == "" || strings.TrimRight(siteURL, "/") != siteURL { + return false + } + parsed, err := url.Parse(siteURL) + return err == nil && parsed.User == nil && parsed.Host != "" && + (parsed.Scheme == "http" || parsed.Scheme == "https") && + parsed.RawQuery == "" && parsed.Fragment == "" && parsed.Opaque == "" +} diff --git a/apps/backend/internal/mentions/provider_jira_test.go b/apps/backend/internal/mentions/provider_jira_test.go new file mode 100644 index 0000000000..6259207d85 --- /dev/null +++ b/apps/backend/internal/mentions/provider_jira_test.go @@ -0,0 +1,247 @@ +package mentions + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/kandev/kandev/internal/jira" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeJiraMentionService struct { + search func(context.Context, string, string, int) ([]jira.MentionTicket, error) + site func(context.Context, string) (string, error) +} + +func (s *fakeJiraMentionService) SearchMentionTicketsForWorkspace( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]jira.MentionTicket, error) { + return s.search(ctx, workspaceID, query, limit) +} + +func (s *fakeJiraMentionService) MentionSiteURLForWorkspace( + ctx context.Context, + workspaceID string, +) (string, error) { + return s.site(ctx, workspaceID) +} + +func TestJiraProviderSearchMapsImmutableWorkspaceProjection(t *testing.T) { + backend := &fakeJiraMentionService{ + search: func(_ context.Context, workspaceID, query string, limit int) ([]jira.MentionTicket, error) { + if workspaceID != "workspace-1" || query != "auth" || limit != 4 { + t.Fatalf("delegated search = workspace %q query %q limit %d", workspaceID, query, limit) + } + return []jira.MentionTicket{{ + ID: "10042", + Key: "ENG-42", + Title: "Fix authentication", + URL: "https://jira.example.test/base/browse/ENG-42", + SiteURL: "https://jira.example.test/base", + }}, nil + }, + site: func(_ context.Context, workspaceID string) (string, error) { + if workspaceID != "workspace-1" { + t.Fatalf("authorization workspace = %q", workspaceID) + } + return "https://jira.example.test/base", nil + }, + } + provider := NewJiraProvider(backend) + descriptor := provider.Descriptor() + if descriptor.Source != "jira_issues" || descriptor.Provider != "jira" || descriptor.Kind != "issue" || + descriptor.DisplayName != "Jira" || descriptor.KindLabel != "Issue" { + t.Fatalf("descriptor = %#v", descriptor) + } + registry := NewRegistry() + if err := registry.Register(provider); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "auth", + Limit: 4, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(response.Groups) != 1 || len(response.Groups[0].Results) != 1 { + t.Fatalf("groups = %#v", response.Groups) + } + result := response.Groups[0].Results[0] + if result.ID != "10042" || result.Key != "ENG-42" || result.Title != "Fix authentication" || + result.URL != "https://jira.example.test/base/browse/ENG-42" || + result.Scope != "https://jira.example.test/base" || + result.Ref != canonicalRef("jira", "issue", "https://jira.example.test/base", "10042") { + t.Fatalf("result = %#v", result) + } +} + +func TestJiraProviderSearchFiltersForeignConfiguredOrigin(t *testing.T) { + backend := &fakeJiraMentionService{ + search: func(context.Context, string, string, int) ([]jira.MentionTicket, error) { + return []jira.MentionTicket{{ + ID: "10042", Key: "ENG-42", Title: "Spoofed", + URL: "https://evil.example.test/browse/ENG-42", SiteURL: "https://jira.example.test/base", + }}, nil + }, + site: func(context.Context, string) (string, error) { + return "https://jira.example.test/base", nil + }, + } + registry := NewRegistry() + if err := registry.Register(NewJiraProvider(backend)); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", Query: "auth", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if got := len(response.Groups[0].Results); got != 0 { + t.Fatalf("foreign-origin results = %d, want 0", got) + } +} + +func TestJiraProviderMapsSafePartialStatuses(t *testing.T) { + tests := []struct { + name string + err error + want Status + }{ + {name: "not configured", err: jira.ErrNotConfigured, want: StatusNotConfigured}, + {name: "unauthorized", err: &jira.APIError{StatusCode: http.StatusUnauthorized, Message: "secret body"}, want: StatusUnauthorized}, + {name: "login redirect", err: &jira.APIError{StatusCode: http.StatusFound, Message: "secret body"}, want: StatusUnauthorized}, + {name: "rate limited", err: &jira.APIError{StatusCode: http.StatusTooManyRequests, Message: "secret body"}, want: StatusRateLimited}, + {name: "gateway timeout", err: &jira.APIError{StatusCode: http.StatusGatewayTimeout, Message: "secret body"}, want: StatusTimeout}, + {name: "context timeout", err: context.DeadlineExceeded, want: StatusTimeout}, + {name: "upstream", err: errors.New("secret upstream response"), want: StatusUpstreamError}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + backend := &fakeJiraMentionService{ + search: func(context.Context, string, string, int) ([]jira.MentionTicket, error) { + return nil, test.err + }, + site: func(context.Context, string) (string, error) { + return "https://jira.example.test", nil + }, + } + registry := NewRegistry() + if err := registry.Register(NewJiraProvider(backend)); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", Query: "auth", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(response.Groups) != 1 || response.Groups[0].Status != test.want { + t.Fatalf("groups = %#v, want status %q", response.Groups, test.want) + } + }) + } +} + +func TestJiraProviderWithoutServiceIsNotConfigured(t *testing.T) { + registry := NewRegistry() + if err := registry.Register(NewJiraProvider(nil)); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", Query: "auth", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if response.Groups[0].Status != StatusNotConfigured { + t.Fatalf("status = %q", response.Groups[0].Status) + } +} + +func TestJiraProviderAuthorizesExactConfiguredSiteForSearchAndSubmission(t *testing.T) { + siteCalls := 0 + backend := &fakeJiraMentionService{ + search: func(context.Context, string, string, int) ([]jira.MentionTicket, error) { + return nil, nil + }, + site: func(_ context.Context, workspaceID string) (string, error) { + siteCalls++ + if workspaceID == "" { + t.Fatal("blank workspace fell back to Jira default config") + } + if workspaceID != "workspace-1" { + return "https://other.example.test", nil + } + return "https://jira.example.test/base", nil + }, + } + provider := NewJiraProvider(backend) + authorizer, ok := provider.(ReferenceAuthorizer) + if !ok { + t.Fatal("Jira provider must implement ReferenceAuthorizer") + } + reference := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: canonicalRef("jira", "issue", "https://jira.example.test/base", "10042"), + Provider: "jira", Kind: "issue", ID: "10042", Key: "ENG-42", Title: "Fix auth", + URL: "https://jira.example.test/base/browse/ENG-42", Scope: "https://jira.example.test/base", + } + for _, purpose := range []ReferencePurpose{ReferencePurposeSearch, ReferencePurposeSubmission} { + if err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", Purpose: purpose, Reference: reference, + }); err != nil { + t.Fatalf("authorize %s: %v", purpose, err) + } + } + + tests := []struct { + name string + workspaceID string + mutate func(*apiv1.EntityReference) + }{ + {name: "blank workspace", workspaceID: ""}, + {name: "other workspace", workspaceID: "workspace-2"}, + {name: "wrong scope", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Scope = "https://jira.example.test" + }}, + {name: "wrong origin", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://evil.example.test/base/browse/ENG-42" + }}, + {name: "wrong base path", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://jira.example.test/browse/ENG-42" + }}, + {name: "URL query", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL += "?token=secret" + }}, + {name: "wrong canonical ref", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Ref = "mention:v1:jira:issue:spoofed" + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ref := reference + if test.mutate != nil { + test.mutate(&ref) + } + err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: test.workspaceID, + Purpose: ReferencePurposeSubmission, + Reference: ref, + }) + if !errors.Is(err, ErrReferenceUnauthorized) { + t.Fatalf("error = %v, want ErrReferenceUnauthorized", err) + } + }) + } + if siteCalls == 0 { + t.Fatal("configured Jira site was never checked") + } +} diff --git a/apps/backend/internal/mentions/provider_linear.go b/apps/backend/internal/mentions/provider_linear.go new file mode 100644 index 0000000000..e6971338b6 --- /dev/null +++ b/apps/backend/internal/mentions/provider_linear.go @@ -0,0 +1,212 @@ +package mentions + +import ( + "context" + "errors" + "net/url" + "strings" + + "github.com/kandev/kandev/internal/linear" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +const ( + linearSource = "linear_issues" + linearProviderID = "linear" + linearIssueKind = "issue" + linearDisplayName = "Linear" + linearIssueLabel = "Issue" + linearScheme = "https" + linearAppHost = "linear.app" +) + +// LinearMentionService is the workspace-scoped Linear surface needed by the +// mention provider and its submission authorizer. +type LinearMentionService interface { + SearchMentionIssues( + ctx context.Context, + workspaceID, query string, + limit int, + ) ([]linear.LinearIssue, string, error) + GetConfigForWorkspace(ctx context.Context, workspaceID string) (*linear.LinearConfig, error) +} + +type linearProvider struct { + service LinearMentionService +} + +// NewLinearProvider creates the Linear issue mention source. +func NewLinearProvider(service LinearMentionService) MentionProvider { + return &linearProvider{service: service} +} + +func (p *linearProvider) Descriptor() ProviderDescriptor { + return ProviderDescriptor{ + Source: linearSource, + Provider: linearProviderID, + Kind: linearIssueKind, + DisplayName: linearDisplayName, + KindLabel: linearIssueLabel, + Order: 30, + } +} + +func (p *linearProvider) Search(ctx context.Context, request SearchRequest) ([]Candidate, error) { + if p == nil || p.service == nil { + return nil, NewProviderError(StatusNotConfigured) + } + request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) + if request.WorkspaceID == "" { + return nil, NewProviderError(StatusUnsupportedScope, linear.ErrMentionWorkspaceRequired) + } + request.Query = strings.TrimSpace(request.Query) + if request.Query == "" { + return nil, NewProviderError(StatusUpstreamError, linear.ErrMentionQueryRequired) + } + + limit := normalizedLinearProviderLimit(request.Limit) + issues, orgSlug, err := p.service.SearchMentionIssues( + ctx, + request.WorkspaceID, + request.Query, + limit, + ) + if err != nil { + return nil, classifyLinearError(err) + } + orgSlug = strings.TrimSpace(orgSlug) + if orgSlug == "" { + return nil, NewProviderError(StatusUnsupportedScope, linear.ErrMentionScopeUnavailable) + } + count := min(len(issues), limit) + candidates := make([]Candidate, 0, count) + for index := 0; index < count; index++ { + issue := issues[index] + candidates = append(candidates, Candidate{ + ID: issue.ID, + Key: issue.Identifier, + Title: issue.Title, + URL: issue.URL, + Scope: orgSlug, + }) + } + return candidates, nil +} + +func normalizedLinearProviderLimit(limit int) int { + if limit == 0 { + return DefaultLimit + } + if limit < 1 { + return 1 + } + if limit > MaxLimit { + return MaxLimit + } + return limit +} + +func classifyLinearError(err error) error { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return NewProviderError(StatusTimeout, err) + } + switch { + case errors.Is(err, linear.ErrNotConfigured): + return NewProviderError(StatusNotConfigured, err) + case errors.Is(err, linear.ErrMentionWorkspaceRequired), + errors.Is(err, linear.ErrMentionScopeUnavailable): + return NewProviderError(StatusUnsupportedScope, err) + } + var apiErr *linear.APIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case 401, 403: + return NewProviderError(StatusUnauthorized, err) + case 408, 504: + return NewProviderError(StatusTimeout, err) + case 429: + return NewProviderError(StatusRateLimited, err) + } + } + return NewProviderError(StatusUpstreamError, err) +} + +func (p *linearProvider) AuthorizeReference(ctx context.Context, request ReferenceAuthorizationRequest) error { + if err := ctx.Err(); err != nil { + return err + } + reference := request.Reference + workspaceID := strings.TrimSpace(request.WorkspaceID) + if workspaceID == "" || !validLinearReference(reference) { + return ErrReferenceUnauthorized + } + if request.Purpose == ReferencePurposeSearch { + return nil + } + if request.Purpose != ReferencePurposeSubmission || p == nil || p.service == nil { + return ErrReferenceUnauthorized + } + + config, err := p.service.GetConfigForWorkspace(ctx, workspaceID) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + return ErrReferenceUnauthorized + } + if config == nil || strings.TrimSpace(config.WorkspaceID) != workspaceID || + strings.TrimSpace(config.OrgSlug) != reference.Scope { + return ErrReferenceUnauthorized + } + return nil +} + +func validLinearReference(reference apiv1.EntityReference) bool { + if reference.Version != apiv1.EntityReferenceVersion || + reference.Provider != linearProviderID || reference.Kind != linearIssueKind || + reference.ID == "" || reference.ID != strings.TrimSpace(reference.ID) || + reference.Key == "" || reference.Key != strings.TrimSpace(reference.Key) || + reference.Scope == "" || reference.Scope != strings.TrimSpace(reference.Scope) || + reference.Ref != canonicalRef(linearProviderID, linearIssueKind, reference.Scope, reference.ID) { + return false + } + return validLinearDestination(reference.URL, reference.Scope, reference.Key) +} + +func validLinearDestination(rawURL, orgSlug, issueKey string) bool { + parsed, err := url.Parse(rawURL) + if err != nil || !validLinearURLBase(parsed) { + return false + } + escapedPath := parsed.EscapedPath() + if !strings.HasPrefix(escapedPath, "/") { + return false + } + segments := strings.Split(strings.TrimPrefix(escapedPath, "/"), "/") + if len(segments) != 3 && len(segments) != 4 { + return false + } + decoded := make([]string, len(segments)) + for index, segment := range segments { + decoded[index], err = decodeCanonicalLinearPathSegment(segment) + if err != nil { + return false + } + } + return decoded[0] == orgSlug && decoded[1] == linearIssueKind && decoded[2] == issueKey +} + +func validLinearURLBase(parsed *url.URL) bool { + return parsed.Scheme == linearScheme && parsed.Host == linearAppHost && + parsed.User == nil && parsed.Opaque == "" && parsed.RawQuery == "" && + !parsed.ForceQuery && parsed.Fragment == "" +} + +func decodeCanonicalLinearPathSegment(segment string) (string, error) { + decoded, err := url.PathUnescape(segment) + if err != nil || decoded == "" || decoded == "." || decoded == ".." || + strings.ContainsAny(decoded, "/\\") || url.PathEscape(decoded) != segment { + return "", errors.New("non-canonical Linear path segment") + } + return decoded, nil +} diff --git a/apps/backend/internal/mentions/provider_linear_test.go b/apps/backend/internal/mentions/provider_linear_test.go new file mode 100644 index 0000000000..00b83f44bd --- /dev/null +++ b/apps/backend/internal/mentions/provider_linear_test.go @@ -0,0 +1,364 @@ +package mentions + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/linear" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeLinearMentionService struct { + searchWorkspaceID string + searchQuery string + searchLimit int + issues []linear.LinearIssue + orgSlug string + searchErr error + configWorkspaceID string + configCalls int + configFn func(string) (*linear.LinearConfig, error) +} + +func (s *fakeLinearMentionService) SearchMentionIssues( + _ context.Context, + workspaceID, query string, + limit int, +) ([]linear.LinearIssue, string, error) { + s.searchWorkspaceID = workspaceID + s.searchQuery = query + s.searchLimit = limit + return s.issues, s.orgSlug, s.searchErr +} + +func (s *fakeLinearMentionService) GetConfigForWorkspace( + _ context.Context, + workspaceID string, +) (*linear.LinearConfig, error) { + s.configWorkspaceID = workspaceID + s.configCalls++ + if s.configFn != nil { + return s.configFn(workspaceID) + } + return &linear.LinearConfig{WorkspaceID: workspaceID, OrgSlug: s.orgSlug}, nil +} + +func TestLinearProviderSearchMapsImmutableIssueIdentityAndScope(t *testing.T) { + service := &fakeLinearMentionService{ + orgSlug: "acme", + issues: []linear.LinearIssue{{ + ID: "issue-uuid", + Identifier: "ENG-123", + Title: "Fix authentication", + URL: "https://linear.app/acme/issue/ENG-123/fix-authentication", + }}, + } + provider := NewLinearProvider(service) + + descriptor := provider.Descriptor() + if descriptor.Source != "linear_issues" || descriptor.Provider != "linear" || + descriptor.Kind != "issue" || descriptor.DisplayName != "Linear" || + descriptor.KindLabel != "Issue" || descriptor.Order != 30 { + t.Fatalf("descriptor = %#v", descriptor) + } + candidates, err := provider.Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "auth", + Limit: 7, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if service.searchWorkspaceID != "workspace-1" || service.searchQuery != "auth" || service.searchLimit != 7 { + t.Fatalf("delegated search = workspace %q query %q limit %d", + service.searchWorkspaceID, service.searchQuery, service.searchLimit) + } + want := Candidate{ + ID: "issue-uuid", + Key: "ENG-123", + Title: "Fix authentication", + URL: "https://linear.app/acme/issue/ENG-123/fix-authentication", + Scope: "acme", + } + if len(candidates) != 1 || candidates[0] != want { + t.Fatalf("candidates = %#v, want %#v", candidates, want) + } +} + +func TestLinearProviderSearchUsesAuthorizerToDropUnsafeDestinations(t *testing.T) { + service := &fakeLinearMentionService{ + orgSlug: "acme", + issues: []linear.LinearIssue{ + { + ID: "safe-id", Identifier: "ENG-1", Title: "Safe", + URL: "https://linear.app/acme/issue/ENG-1/safe", + }, + { + ID: "evil-id", Identifier: "ENG-2", Title: "Unsafe", + URL: "https://linear.app.evil.test/acme/issue/ENG-2/unsafe", + }, + }, + } + provider := NewLinearProvider(service) + registry := NewRegistry() + if err := registry.Register(provider); err != nil { + t.Fatalf("register: %v", err) + } + + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "issue", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(response.Groups) != 1 || len(response.Groups[0].Results) != 1 || + response.Groups[0].Results[0].ID != "safe-id" { + t.Fatalf("groups = %#v, want only canonical Linear destination", response.Groups) + } + if service.configCalls != 0 { + t.Fatalf("search authorization repeated config lookup %d times", service.configCalls) + } +} + +func TestLinearProviderMapsFailuresToSafeStatuses(t *testing.T) { + tests := []struct { + name string + err error + status Status + }{ + {name: "not configured", err: linear.ErrNotConfigured, status: StatusNotConfigured}, + {name: "workspace required", err: linear.ErrMentionWorkspaceRequired, status: StatusUnsupportedScope}, + {name: "organization unavailable", err: linear.ErrMentionScopeUnavailable, status: StatusUnsupportedScope}, + {name: "unauthorized", err: &linear.APIError{StatusCode: 401, Message: "secret upstream body"}, status: StatusUnauthorized}, + {name: "forbidden", err: &linear.APIError{StatusCode: 403, Message: "secret upstream body"}, status: StatusUnauthorized}, + {name: "rate limited", err: &linear.APIError{StatusCode: 429, Message: "secret upstream body"}, status: StatusRateLimited}, + {name: "request timeout", err: &linear.APIError{StatusCode: 408, Message: "secret upstream body"}, status: StatusTimeout}, + {name: "gateway timeout", err: &linear.APIError{StatusCode: 504, Message: "secret upstream body"}, status: StatusTimeout}, + {name: "deadline", err: context.DeadlineExceeded, status: StatusTimeout}, + {name: "canceled", err: context.Canceled, status: StatusTimeout}, + {name: "upstream", err: errors.New("secret upstream body"), status: StatusUpstreamError}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := NewRegistry() + if err := registry.Register(NewLinearProvider(&fakeLinearMentionService{searchErr: test.err})); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "auth", + }) + if err != nil { + t.Fatalf("aggregate search: %v", err) + } + if len(response.Groups) != 1 || response.Groups[0].Status != test.status { + t.Fatalf("groups = %#v, want status %q", response.Groups, test.status) + } + }) + } +} + +func TestLinearProviderPreservesCancellation(t *testing.T) { + provider := NewLinearProvider(&fakeLinearMentionService{searchErr: context.Canceled}) + _, err := provider.Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "auth", + Limit: DefaultLimit, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want wrapped cancellation", err) + } +} + +func TestLinearProviderCapsCandidates(t *testing.T) { + issues := make([]linear.LinearIssue, 12) + for index := range issues { + issues[index] = linear.LinearIssue{ + ID: string(rune('a' + index)), + Identifier: string(rune('A' + index)), + Title: "Issue", + URL: "https://linear.app/acme/issue/ENG-1", + } + } + service := &fakeLinearMentionService{issues: issues, orgSlug: "acme"} + provider := NewLinearProvider(service) + + candidates, err := provider.Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "auth", + Limit: -1, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if service.searchLimit != 1 || len(candidates) != 1 { + t.Fatalf("service limit = %d, candidates = %d, want minimum cap 1", + service.searchLimit, len(candidates)) + } +} + +func TestLinearProviderAuthorizesConfiguredWorkspaceOrganizationOnSubmission(t *testing.T) { + service := &fakeLinearMentionService{ + orgSlug: "acme", + issues: []linear.LinearIssue{{ + ID: "issue-uuid", Identifier: "ENG-123", Title: "Fix authentication", + URL: "https://linear.app/acme/issue/ENG-123/fix-authentication", + }}, + configFn: func(workspaceID string) (*linear.LinearConfig, error) { + orgSlug := "acme" + if workspaceID == "workspace-2" { + orgSlug = "other-org" + } + return &linear.LinearConfig{WorkspaceID: workspaceID, OrgSlug: orgSlug}, nil + }, + } + provider := NewLinearProvider(service) + authorizer, ok := provider.(ReferenceAuthorizer) + if !ok { + t.Fatal("Linear provider must authorize references") + } + registry := NewRegistry() + if err := registry.Register(provider); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "auth", + }) + if err != nil || len(response.Groups) != 1 || len(response.Groups[0].Results) != 1 { + t.Fatalf("search response = %#v, err = %v", response, err) + } + ref := response.Groups[0].Results[0] + + if err := registry.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", + Purpose: ReferencePurposeSubmission, + Reference: ref, + }); err != nil { + t.Fatalf("authorize valid submission: %v", err) + } + if service.configWorkspaceID != "workspace-1" || service.configCalls != 1 { + t.Fatalf("config lookup = workspace %q calls %d", service.configWorkspaceID, service.configCalls) + } + + tests := []struct { + name string + workspaceID string + mutate func(*apiv1.EntityReference) + }{ + {name: "blank workspace", workspaceID: " "}, + {name: "different configured organization", workspaceID: "workspace-2"}, + {name: "wrong scope", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Scope = "other-org" + ref.Ref = canonicalRef(ref.Provider, ref.Kind, ref.Scope, ref.ID) + }}, + {name: "wrong canonical ref", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Ref = "mention:v1:linear:issue:acme:other-id" + }}, + {name: "http destination", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "http://linear.app/acme/issue/ENG-123/fix-authentication" + }}, + {name: "lookalike host", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://linear.app.evil.test/acme/issue/ENG-123/fix-authentication" + }}, + {name: "host port", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://linear.app:443/acme/issue/ENG-123/fix-authentication" + }}, + {name: "wrong organization path", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://linear.app/other-org/issue/ENG-123/fix-authentication" + }}, + {name: "wrong issue key path", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://linear.app/acme/issue/ENG-999/fix-authentication" + }}, + {name: "encoded path separator", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://linear.app/acme%2Fevil/issue/ENG-123/fix-authentication" + }}, + {name: "query string", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL += "?token=secret" + }}, + {name: "fragment", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL += "#fragment" + }}, + {name: "wrong provider", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Provider = "github" + }}, + {name: "wrong kind", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Kind = "pull_request" + }}, + {name: "wrong version", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Version = apiv1.EntityReferenceVersion + 1 + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := ref + if test.mutate != nil { + test.mutate(&candidate) + } + if err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: test.workspaceID, + Purpose: ReferencePurposeSubmission, + Reference: candidate, + }); !errors.Is(err, ErrReferenceUnauthorized) { + t.Fatalf("error = %v, want unauthorized", err) + } + }) + } +} + +func TestLinearProviderSubmissionFailsClosedWhenConfigurationUnavailable(t *testing.T) { + service := &fakeLinearMentionService{ + orgSlug: "acme", + configFn: func(string) (*linear.LinearConfig, error) { + return nil, linear.ErrNotConfigured + }, + } + provider := NewLinearProvider(service) + authorizer, ok := provider.(ReferenceAuthorizer) + if !ok { + t.Fatal("Linear provider must authorize references") + } + ref := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: canonicalRef("linear", "issue", "acme", "issue-uuid"), + Provider: "linear", Kind: "issue", ID: "issue-uuid", Key: "ENG-123", + Title: "Fix authentication", URL: "https://linear.app/acme/issue/ENG-123", Scope: "acme", + } + if err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", + Purpose: ReferencePurposeSubmission, + Reference: ref, + }); !errors.Is(err, ErrReferenceUnauthorized) { + t.Fatalf("error = %v, want fail-closed unauthorized", err) + } +} + +func TestLinearProviderSubmissionRejectsConfigurationFromAnotherWorkspace(t *testing.T) { + service := &fakeLinearMentionService{ + orgSlug: "acme", + configFn: func(string) (*linear.LinearConfig, error) { + return &linear.LinearConfig{WorkspaceID: "workspace-2", OrgSlug: "acme"}, nil + }, + } + provider := NewLinearProvider(service) + authorizer, ok := provider.(ReferenceAuthorizer) + if !ok { + t.Fatal("Linear provider must authorize references") + } + ref := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: canonicalRef("linear", "issue", "acme", "issue-uuid"), + Provider: "linear", Kind: "issue", ID: "issue-uuid", Key: "ENG-123", + Title: "Fix authentication", URL: "https://linear.app/acme/issue/ENG-123", Scope: "acme", + } + if err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", + Purpose: ReferencePurposeSubmission, + Reference: ref, + }); !errors.Is(err, ErrReferenceUnauthorized) { + t.Fatalf("error = %v, want cross-workspace config rejected", err) + } +} diff --git a/apps/backend/internal/mentions/provider_sentry.go b/apps/backend/internal/mentions/provider_sentry.go new file mode 100644 index 0000000000..9395f628a2 --- /dev/null +++ b/apps/backend/internal/mentions/provider_sentry.go @@ -0,0 +1,265 @@ +package mentions + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "strings" + + "github.com/kandev/kandev/internal/sentry" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +const ( + sentrySource = "sentry_issues" + sentryProviderID = "sentry" + sentryIssueKind = "issue" + sentryDisplayName = "Sentry" + sentryIssueLabel = "Issue" + sentryScopeDelimiter = "|" + sentrySchemeHTTP = "http" + sentrySchemeHTTPS = "https" + sentryIssuesSegment = "issues" + sentryOrgsSegment = "organizations" +) + +// SentryMentionService is Sentry's workspace-explicit mention boundary. +type SentryMentionService interface { + SearchMentionIssues( + ctx context.Context, + workspaceID, query string, + limit int, + ) ([]sentry.MentionIssue, error) + MentionInstanceForWorkspace( + ctx context.Context, + workspaceID, instanceID string, + ) (*sentry.MentionInstance, error) +} + +type sentryIssueProvider struct { + service SentryMentionService +} + +// NewSentryIssueProvider creates Sentry's built-in issue mention source. +func NewSentryIssueProvider(service SentryMentionService) MentionProvider { + return &sentryIssueProvider{service: service} +} + +func (p *sentryIssueProvider) Descriptor() ProviderDescriptor { + return ProviderDescriptor{ + Source: sentrySource, + Provider: sentryProviderID, + Kind: sentryIssueKind, + DisplayName: sentryDisplayName, + KindLabel: sentryIssueLabel, + Order: 70, + } +} + +func (p *sentryIssueProvider) Search(ctx context.Context, request SearchRequest) ([]Candidate, error) { + if p == nil || p.service == nil { + return nil, NewProviderError(StatusNotConfigured) + } + request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) + if request.WorkspaceID == "" { + return nil, NewProviderError(StatusUnsupportedScope, sentry.ErrMentionWorkspaceRequired) + } + request.Query = strings.TrimSpace(request.Query) + if request.Query == "" { + return nil, NewProviderError(StatusUpstreamError, sentry.ErrMentionQueryRequired) + } + limit := normalizedSentryProviderLimit(request.Limit) + issues, err := p.service.SearchMentionIssues(ctx, request.WorkspaceID, request.Query, limit) + if err != nil { + return nil, mapSentryProviderError(err) + } + count := min(len(issues), limit) + candidates := make([]Candidate, 0, count) + for index := 0; index < count; index++ { + issue := issues[index] + candidates = append(candidates, Candidate{ + ID: issue.ID, + Key: issue.ShortID, + Title: issue.Title, + URL: issue.URL, + Scope: sentryReferenceScope(issue.InstanceID, issue.InstanceOrigin), + }) + } + return candidates, nil +} + +func normalizedSentryProviderLimit(limit int) int { + switch { + case limit == 0: + return DefaultLimit + case limit < 1: + return 1 + case limit > MaxLimit: + return MaxLimit + default: + return limit + } +} + +func mapSentryProviderError(err error) error { + status := StatusUpstreamError + switch { + case errors.Is(err, sentry.ErrNotConfigured): + status = StatusNotConfigured + case errors.Is(err, sentry.ErrMentionWorkspaceRequired), + errors.Is(err, sentry.ErrMentionScopeLimit), + errors.Is(err, sentry.ErrMentionInvalidOrigin), + errors.Is(err, sentry.ErrInstanceRequired), + errors.Is(err, sentry.ErrInstanceNotFound): + status = StatusUnsupportedScope + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), isSentryTimeout(err): + status = StatusTimeout + default: + var apiErr *sentry.APIError + if errors.As(err, &apiErr) { + status = sentryAPIErrorStatus(apiErr.StatusCode) + } + } + return NewProviderError(status, err) +} + +func isSentryTimeout(err error) bool { + var timeoutErr net.Error + return errors.As(err, &timeoutErr) && timeoutErr.Timeout() +} + +func sentryAPIErrorStatus(statusCode int) Status { + switch statusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return StatusUnauthorized + case http.StatusTooManyRequests: + return StatusRateLimited + case http.StatusRequestTimeout, http.StatusGatewayTimeout: + return StatusTimeout + default: + return StatusUpstreamError + } +} + +func (p *sentryIssueProvider) AuthorizeReference(ctx context.Context, request ReferenceAuthorizationRequest) error { + if err := ctx.Err(); err != nil { + return err + } + workspaceID := strings.TrimSpace(request.WorkspaceID) + instanceID, origin, ok := validSentryReference(request.Reference) + if workspaceID == "" || workspaceID != request.WorkspaceID || !ok { + return ErrReferenceUnauthorized + } + if request.Purpose == ReferencePurposeSearch { + return nil + } + if request.Purpose != ReferencePurposeSubmission || p == nil || p.service == nil { + return ErrReferenceUnauthorized + } + + instance, err := p.service.MentionInstanceForWorkspace(ctx, workspaceID, instanceID) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + return ErrReferenceUnauthorized + } + if !sentryInstanceMatchesReference(instance, workspaceID, instanceID, origin, request.Reference.Scope) { + return ErrReferenceUnauthorized + } + return nil +} + +func sentryInstanceMatchesReference( + instance *sentry.MentionInstance, + workspaceID, instanceID, origin, scope string, +) bool { + return instance != nil && instance.ID == instanceID && instance.WorkspaceID == workspaceID && + instance.Origin == origin && scope == sentryReferenceScope(instance.ID, instance.Origin) +} + +func validSentryReference(reference apiv1.EntityReference) (string, string, bool) { + instanceID, origin, scopeOK := parseSentryReferenceScope(reference.Scope) + if !scopeOK || reference.Version != apiv1.EntityReferenceVersion || + reference.Provider != sentryProviderID || reference.Kind != sentryIssueKind || + reference.ID == "" || reference.ID != strings.TrimSpace(reference.ID) || + reference.Key != strings.TrimSpace(reference.Key) || + reference.Ref != canonicalRef(sentryProviderID, sentryIssueKind, reference.Scope, reference.ID) || + !validSentryDestination(reference.URL, origin, reference.ID, reference.Key) { + return "", "", false + } + return instanceID, origin, true +} + +func sentryReferenceScope(instanceID, origin string) string { + return instanceID + sentryScopeDelimiter + origin +} + +func parseSentryReferenceScope(scope string) (string, string, bool) { + instanceID, origin, found := strings.Cut(scope, sentryScopeDelimiter) + if !found || instanceID == "" || instanceID != strings.TrimSpace(instanceID) || + strings.Contains(origin, sentryScopeDelimiter) || !validSentryOrigin(origin) || + scope != sentryReferenceScope(instanceID, origin) { + return "", "", false + } + return instanceID, origin, true +} + +func validSentryOrigin(origin string) bool { + parsed, err := url.Parse(origin) + if err != nil || parsed.User != nil || parsed.Opaque != "" || parsed.Host == "" || + (parsed.Scheme != sentrySchemeHTTP && parsed.Scheme != sentrySchemeHTTPS) || parsed.Path != "" || + parsed.RawQuery != "" || parsed.Fragment != "" { + return false + } + canonical := strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host) + return origin == canonical +} + +func validSentryDestination(rawURL, origin, issueID, issueKey string) bool { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.User != nil || parsed.Opaque != "" || parsed.RawQuery != "" || + parsed.ForceQuery || parsed.Fragment != "" || + strings.ToLower(parsed.Scheme)+"://"+strings.ToLower(parsed.Host) != origin { + return false + } + escapedPath := strings.TrimSuffix(parsed.EscapedPath(), "/") + if !strings.HasPrefix(escapedPath, "/") { + return false + } + rawSegments := strings.Split(strings.TrimPrefix(escapedPath, "/"), "/") + segments := make([]string, len(rawSegments)) + for index, segment := range rawSegments { + segments[index], err = decodeCanonicalSentryPathSegment(segment) + if err != nil { + return false + } + } + if !validSentryIssuePath(segments) { + return false + } + target := segments[len(segments)-1] + return target == issueID || issueKey != "" && target == issueKey +} + +func validSentryIssuePath(segments []string) bool { + switch len(segments) { + case 2: + return segments[0] == sentryIssuesSegment + case 4: + return segments[0] == sentryOrgsSegment && segments[1] != "" && segments[2] == sentryIssuesSegment + default: + return false + } +} + +func decodeCanonicalSentryPathSegment(segment string) (string, error) { + decoded, err := url.PathUnescape(segment) + if err != nil || decoded == "" || decoded == "." || decoded == ".." || + strings.ContainsAny(decoded, "/\\") || url.PathEscape(decoded) != segment { + return "", errors.New("non-canonical Sentry path segment") + } + return decoded, nil +} diff --git a/apps/backend/internal/mentions/provider_sentry_test.go b/apps/backend/internal/mentions/provider_sentry_test.go new file mode 100644 index 0000000000..e1a8240089 --- /dev/null +++ b/apps/backend/internal/mentions/provider_sentry_test.go @@ -0,0 +1,324 @@ +package mentions + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/sentry" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeSentryMentionService struct { + searchWorkspaceID string + searchQuery string + searchLimit int + issues []sentry.MentionIssue + searchErr error + lookupWorkspaceID string + lookupInstanceID string + lookupCalls int + lookupFn func(string, string) (*sentry.MentionInstance, error) +} + +func (s *fakeSentryMentionService) SearchMentionIssues( + _ context.Context, + workspaceID, query string, + limit int, +) ([]sentry.MentionIssue, error) { + s.searchWorkspaceID = workspaceID + s.searchQuery = query + s.searchLimit = limit + return s.issues, s.searchErr +} + +func (s *fakeSentryMentionService) MentionInstanceForWorkspace( + _ context.Context, + workspaceID, instanceID string, +) (*sentry.MentionInstance, error) { + s.lookupWorkspaceID = workspaceID + s.lookupInstanceID = instanceID + s.lookupCalls++ + if s.lookupFn != nil { + return s.lookupFn(workspaceID, instanceID) + } + return nil, sentry.ErrInstanceNotFound +} + +func TestSentryIssueProviderMapsStableInstanceOriginScope(t *testing.T) { + service := &fakeSentryMentionService{issues: []sentry.MentionIssue{ + { + InstanceID: "instance-a", InstanceOrigin: "https://sentry.example", OrganizationSlug: "acme", + ID: "42", ShortID: "APP-1", Title: "Panic", + URL: "https://sentry.example/organizations/acme/issues/42/", + }, + { + InstanceID: "instance-b", InstanceOrigin: "http://self-hosted.example:9000", OrganizationSlug: "internal", + ID: "42", ShortID: "OPS-1", Title: "Panic", + URL: "http://self-hosted.example:9000/issues/42/", + }, + }} + provider := NewSentryIssueProvider(service) + + descriptor := provider.Descriptor() + if descriptor.Source != "sentry_issues" || descriptor.Provider != "sentry" || + descriptor.Kind != "issue" || descriptor.DisplayName != "Sentry" || + descriptor.KindLabel != "Issue" || descriptor.Order != 70 { + t.Fatalf("descriptor = %#v", descriptor) + } + candidates, err := provider.Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "panic", + Limit: 2, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if service.searchWorkspaceID != "workspace-1" || service.searchQuery != "panic" || service.searchLimit != 2 { + t.Fatalf("delegated search = workspace %q query %q limit %d", + service.searchWorkspaceID, service.searchQuery, service.searchLimit) + } + if len(candidates) != 2 || candidates[0].ID != "42" || candidates[1].ID != "42" || + candidates[0].Scope != "instance-a|https://sentry.example" || + candidates[1].Scope != "instance-b|http://self-hosted.example:9000" || + candidates[0].Scope == candidates[1].Scope { + t.Fatalf("candidates = %#v, want collision-proof instance+origin scopes", candidates) + } +} + +func TestSentryIssueProviderSearchDropsUnsafeDestinationsThroughAuthorizer(t *testing.T) { + service := &fakeSentryMentionService{issues: []sentry.MentionIssue{ + { + InstanceID: "instance-a", InstanceOrigin: "https://sentry.example", OrganizationSlug: "acme", + ID: "42", ShortID: "APP-1", Title: "Safe", + URL: "https://sentry.example/organizations/acme/issues/42/", + }, + { + InstanceID: "instance-a", InstanceOrigin: "https://sentry.example", OrganizationSlug: "acme", + ID: "43", ShortID: "APP-2", Title: "Evil host", + URL: "https://sentry.example.evil.test/issues/43/", + }, + { + InstanceID: "instance-a", InstanceOrigin: "https://sentry.example", OrganizationSlug: "acme", + ID: "44", ShortID: "APP-3", Title: "Wrong path", + URL: "https://sentry.example/projects/acme/issues/44/", + }, + }} + registry := NewRegistry() + if err := registry.Register(NewSentryIssueProvider(service)); err != nil { + t.Fatalf("register: %v", err) + } + + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "panic", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(response.Groups) != 1 || len(response.Groups[0].Results) != 1 || response.Groups[0].Results[0].ID != "42" { + t.Fatalf("groups = %#v, want one exact Sentry destination", response.Groups) + } + if service.lookupCalls != 0 { + t.Fatalf("search authorization repeated instance lookup %d times", service.lookupCalls) + } +} + +func TestSentryIssueProviderMapsFailuresToSafeStatuses(t *testing.T) { + tests := []struct { + name string + err error + status Status + }{ + {name: "not configured", err: sentry.ErrNotConfigured, status: StatusNotConfigured}, + {name: "workspace required", err: sentry.ErrMentionWorkspaceRequired, status: StatusUnsupportedScope}, + {name: "scope overflow", err: sentry.ErrMentionScopeLimit, status: StatusUnsupportedScope}, + {name: "invalid origin", err: sentry.ErrMentionInvalidOrigin, status: StatusUnsupportedScope}, + {name: "instance missing", err: sentry.ErrInstanceNotFound, status: StatusUnsupportedScope}, + {name: "unauthorized", err: &sentry.APIError{StatusCode: 401, Message: "secret body"}, status: StatusUnauthorized}, + {name: "forbidden", err: &sentry.APIError{StatusCode: 403, Message: "secret body"}, status: StatusUnauthorized}, + {name: "rate limited", err: &sentry.APIError{StatusCode: 429, Message: "secret body"}, status: StatusRateLimited}, + {name: "request timeout", err: &sentry.APIError{StatusCode: 408, Message: "secret body"}, status: StatusTimeout}, + {name: "gateway timeout", err: &sentry.APIError{StatusCode: 504, Message: "secret body"}, status: StatusTimeout}, + {name: "deadline", err: context.DeadlineExceeded, status: StatusTimeout}, + {name: "canceled", err: context.Canceled, status: StatusTimeout}, + {name: "upstream", err: errors.New("secret body"), status: StatusUpstreamError}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := NewRegistry() + if err := registry.Register(NewSentryIssueProvider(&fakeSentryMentionService{searchErr: test.err})); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "panic", + }) + if err != nil { + t.Fatalf("aggregate search: %v", err) + } + if len(response.Groups) != 1 || response.Groups[0].Status != test.status { + t.Fatalf("groups = %#v, want status %q", response.Groups, test.status) + } + }) + } +} + +func TestSentryIssueProviderPreservesCancellationAndCapsCandidates(t *testing.T) { + issues := make([]sentry.MentionIssue, 12) + for index := range issues { + issues[index] = sentry.MentionIssue{ + InstanceID: "instance-a", InstanceOrigin: "https://sentry.example", + ID: string(rune('a' + index)), ShortID: "APP-1", Title: "Issue", + URL: "https://sentry.example/issues/APP-1/", + } + } + service := &fakeSentryMentionService{issues: issues} + provider := NewSentryIssueProvider(service) + candidates, err := provider.Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", Query: "panic", Limit: -1, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if service.searchLimit != 1 || len(candidates) != 1 { + t.Fatalf("service limit = %d, candidates = %d, want minimum cap 1", service.searchLimit, len(candidates)) + } + + service.searchErr = context.Canceled + if _, err := provider.Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", Query: "panic", Limit: 5, + }); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation error = %v, want preserved", err) + } +} + +func TestSentryIssueProviderAuthorizesOwnedInstanceAndExactDestination(t *testing.T) { + service := &fakeSentryMentionService{ + issues: []sentry.MentionIssue{{ + InstanceID: "instance-a", InstanceOrigin: "https://sentry.example", OrganizationSlug: "acme", + ID: "42", ShortID: "APP-1", Title: "Panic", + URL: "https://sentry.example/organizations/acme/issues/42/", + }}, + lookupFn: func(workspaceID, instanceID string) (*sentry.MentionInstance, error) { + if workspaceID == "workspace-2" { + return &sentry.MentionInstance{ID: instanceID, WorkspaceID: workspaceID, Origin: "https://other.example"}, nil + } + return &sentry.MentionInstance{ID: instanceID, WorkspaceID: workspaceID, Origin: "https://sentry.example"}, nil + }, + } + provider := NewSentryIssueProvider(service) + authorizer, ok := provider.(ReferenceAuthorizer) + if !ok { + t.Fatal("Sentry provider must authorize references") + } + registry := NewRegistry() + if err := registry.Register(provider); err != nil { + t.Fatalf("register: %v", err) + } + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", Query: "panic", + }) + if err != nil || len(response.Groups) != 1 || len(response.Groups[0].Results) != 1 { + t.Fatalf("search response = %#v, err = %v", response, err) + } + ref := response.Groups[0].Results[0] + if err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", Purpose: ReferencePurposeSubmission, Reference: ref, + }); err != nil { + t.Fatalf("authorize valid submission: %v", err) + } + if service.lookupWorkspaceID != "workspace-1" || service.lookupInstanceID != "instance-a" || service.lookupCalls != 1 { + t.Fatalf("lookup = workspace %q instance %q calls %d", + service.lookupWorkspaceID, service.lookupInstanceID, service.lookupCalls) + } + + tests := []struct { + name string + workspaceID string + mutate func(*apiv1.EntityReference) + }{ + {name: "blank workspace", workspaceID: " "}, + {name: "different workspace origin", workspaceID: "workspace-2"}, + {name: "wrong scope", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Scope = "instance-a|https://other.example" + ref.Ref = canonicalRef(ref.Provider, ref.Kind, ref.Scope, ref.ID) + }}, + {name: "wrong canonical ref", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Ref = "mention:v1:sentry:issue:spoofed" + }}, + {name: "wrong scheme", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "http://sentry.example/organizations/acme/issues/42/" + }}, + {name: "lookalike host", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://sentry.example.evil.test/organizations/acme/issues/42/" + }}, + {name: "userinfo", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://user@sentry.example/organizations/acme/issues/42/" + }}, + {name: "port", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://sentry.example:443/organizations/acme/issues/42/" + }}, + {name: "wrong path", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://sentry.example/projects/acme/issues/42/" + }}, + {name: "wrong issue identity", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://sentry.example/organizations/acme/issues/99/" + }}, + {name: "encoded path separator", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL = "https://sentry.example/organizations/acme%2Fevil/issues/42/" + }}, + {name: "query", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL += "?project=1" + }}, + {name: "fragment", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.URL += "#event" + }}, + {name: "wrong provider", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Provider = "linear" + }}, + {name: "wrong kind", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Kind = "event" + }}, + {name: "wrong version", workspaceID: "workspace-1", mutate: func(ref *apiv1.EntityReference) { + ref.Version = apiv1.EntityReferenceVersion + 1 + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := ref + if test.mutate != nil { + test.mutate(&candidate) + } + if err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: test.workspaceID, + Purpose: ReferencePurposeSubmission, + Reference: candidate, + }); !errors.Is(err, ErrReferenceUnauthorized) { + t.Fatalf("error = %v, want unauthorized", err) + } + }) + } +} + +func TestSentryIssueProviderSubmissionFailsClosedWhenInstanceUnavailable(t *testing.T) { + service := &fakeSentryMentionService{lookupFn: func(string, string) (*sentry.MentionInstance, error) { + return nil, sentry.ErrInstanceNotFound + }} + provider := NewSentryIssueProvider(service) + authorizer, ok := provider.(ReferenceAuthorizer) + if !ok { + t.Fatal("Sentry provider must authorize references") + } + ref := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: canonicalRef("sentry", "issue", "instance-a|https://sentry.example", "42"), + Provider: "sentry", Kind: "issue", ID: "42", Key: "APP-1", Title: "Panic", + URL: "https://sentry.example/issues/42/", Scope: "instance-a|https://sentry.example", + } + if err := authorizer.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", Purpose: ReferencePurposeSubmission, Reference: ref, + }); !errors.Is(err, ErrReferenceUnauthorized) { + t.Fatalf("error = %v, want fail-closed unauthorized", err) + } +} diff --git a/apps/backend/internal/mentions/service.go b/apps/backend/internal/mentions/service.go new file mode 100644 index 0000000000..69007f506f --- /dev/null +++ b/apps/backend/internal/mentions/service.go @@ -0,0 +1,479 @@ +package mentions + +import ( + "context" + "errors" + "fmt" + "net/url" + "regexp" + "sort" + "strings" + "sync" + "time" + "unicode" + "unicode/utf8" + + "github.com/kandev/kandev/internal/entityrefs" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type registeredProvider struct { + descriptor ProviderDescriptor + provider MentionProvider + authorizer ReferenceAuthorizer +} + +// Registry stores provider descriptors independently of their implementation type. +type Registry struct { + mu sync.RWMutex + providers []registeredProvider + sources map[string]struct{} + providerKinds map[string]struct{} + referenceAuthorizers map[string]ReferenceAuthorizer +} + +func NewRegistry() *Registry { + return &Registry{ + sources: make(map[string]struct{}), + providerKinds: make(map[string]struct{}), + referenceAuthorizers: make(map[string]ReferenceAuthorizer), + } +} + +func (r *Registry) Register(provider MentionProvider) error { + if provider == nil { + return fmt.Errorf("%w: provider is nil", ErrInvalidDescriptor) + } + descriptor, err := normalizeDescriptor(provider.Descriptor()) + if err != nil { + return err + } + authorizer, ok := provider.(ReferenceAuthorizer) + if !ok || authorizer == nil { + return ErrMissingAuthorizer + } + r.mu.Lock() + defer r.mu.Unlock() + if r.sources == nil { + r.sources = make(map[string]struct{}) + } + if r.providerKinds == nil { + r.providerKinds = make(map[string]struct{}) + } + if r.referenceAuthorizers == nil { + r.referenceAuthorizers = make(map[string]ReferenceAuthorizer) + } + if _, exists := r.sources[descriptor.Source]; exists { + return fmt.Errorf("%w: %s", ErrDuplicateSource, descriptor.Source) + } + providerKey := referenceProviderKey(descriptor.Provider, descriptor.Kind) + if _, exists := r.providerKinds[providerKey]; exists { + return fmt.Errorf("%w: %s/%s", ErrDuplicateProvider, descriptor.Provider, descriptor.Kind) + } + r.providers = append(r.providers, registeredProvider{ + descriptor: descriptor, + provider: provider, + authorizer: authorizer, + }) + r.sources[descriptor.Source] = struct{}{} + r.providerKinds[providerKey] = struct{}{} + r.referenceAuthorizers[providerKey] = authorizer + return nil +} + +func referenceProviderKey(provider, kind string) string { + return provider + "\x00" + kind +} + +// AuthorizeReference dispatches to the provider registered for the normalized identity. +func (r *Registry) AuthorizeReference(ctx context.Context, request ReferenceAuthorizationRequest) error { + key := referenceProviderKey(request.Reference.Provider, request.Reference.Kind) + r.mu.RLock() + authorizer, ok := r.referenceAuthorizers[key] + r.mu.RUnlock() + if !ok { + return ErrReferenceProviderUnavailable + } + return authorizer.AuthorizeReference(ctx, request) +} + +// AuthorizeForWorkspace satisfies entityrefs.WorkspaceAuthorizer for message submission. +func (r *Registry) AuthorizeForWorkspace( + ctx context.Context, + workspaceID string, + reference apiv1.EntityReference, +) error { + return r.AuthorizeReference(ctx, ReferenceAuthorizationRequest{ + WorkspaceID: workspaceID, + Purpose: ReferencePurposeSubmission, + Reference: reference, + }) +} + +var descriptorIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._:-]{0,127}$`) + +func normalizeDescriptor(descriptor ProviderDescriptor) (ProviderDescriptor, error) { + descriptor.Source = strings.TrimSpace(descriptor.Source) + descriptor.Provider = strings.TrimSpace(descriptor.Provider) + descriptor.Kind = strings.TrimSpace(descriptor.Kind) + if !descriptorIDPattern.MatchString(descriptor.Source) || + !descriptorIDPattern.MatchString(descriptor.Provider) || + !descriptorIDPattern.MatchString(descriptor.Kind) { + return ProviderDescriptor{}, fmt.Errorf("%w: source, provider, and kind must be namespaced lowercase identifiers", ErrInvalidDescriptor) + } + descriptor.DisplayName = strings.TrimSpace(descriptor.DisplayName) + descriptor.KindLabel = strings.TrimSpace(descriptor.KindLabel) + if !validDescriptorLabel(descriptor.DisplayName) || !validDescriptorLabel(descriptor.KindLabel) { + return ProviderDescriptor{}, fmt.Errorf("%w: labels must be valid UTF-8 and at most 100 characters", ErrInvalidDescriptor) + } + if descriptor.DisplayName == "" { + descriptor.DisplayName = descriptor.Provider + } + if descriptor.KindLabel == "" { + descriptor.KindLabel = mentionLabelWorkItem + } + return descriptor, nil +} + +func validDescriptorLabel(label string) bool { + return utf8.ValidString(label) && utf8.RuneCountInString(label) <= 100 +} + +func (r *Registry) snapshot() []registeredProvider { + r.mu.RLock() + defer r.mu.RUnlock() + providers := append([]registeredProvider(nil), r.providers...) + sort.SliceStable(providers, func(i, j int) bool { + if providers[i].descriptor.Order == providers[j].descriptor.Order { + return providers[i].descriptor.Source < providers[j].descriptor.Source + } + return providers[i].descriptor.Order < providers[j].descriptor.Order + }) + return providers +} + +// Service validates and aggregates every registered provider. +type Service struct { + registry *Registry + providerTimeout time.Duration + maxConcurrency int + providerSlots chan struct{} + providerSlotMu sync.Mutex + providerInFlight map[string]chan struct{} +} + +const ( + defaultProviderTimeout = 1500 * time.Millisecond + defaultMaxConcurrency = 4 +) + +// Option configures aggregate search execution. +type Option func(*Service) + +func WithProviderTimeout(timeout time.Duration) Option { + return func(service *Service) { + if timeout > 0 { + service.providerTimeout = timeout + } + } +} + +func WithMaxConcurrency(limit int) Option { + return func(service *Service) { + if limit > 0 { + service.maxConcurrency = limit + } + } +} + +func NewService(registry *Registry, options ...Option) *Service { + service := &Service{ + registry: registry, + providerTimeout: defaultProviderTimeout, + maxConcurrency: defaultMaxConcurrency, + } + for _, option := range options { + option(service) + } + if service.maxConcurrency <= 0 { + service.maxConcurrency = defaultMaxConcurrency + } + service.providerSlots = make(chan struct{}, service.maxConcurrency) + service.providerInFlight = make(map[string]chan struct{}) + return service +} + +func (s *Service) Search(ctx context.Context, request SearchRequest) (*apiv1.MentionSearchResponse, error) { + request, err := normalizeRequest(request) + if err != nil { + return nil, err + } + response := &apiv1.MentionSearchResponse{ + Query: request.Query, + Groups: []apiv1.MentionGroup{}, + } + providers := s.providers() + if len(providers) == 0 { + return response, nil + } + response.Groups = s.searchProviders(ctx, providers, request) + if err := ctx.Err(); err != nil { + return nil, err + } + return response, nil +} + +func (s *Service) providers() []registeredProvider { + if s.registry == nil { + return nil + } + return s.registry.snapshot() +} + +func (s *Service) searchProviders( + ctx context.Context, + providers []registeredProvider, + request SearchRequest, +) []apiv1.MentionGroup { + groups := make([]apiv1.MentionGroup, len(providers)) + var wg sync.WaitGroup + for index := range providers { + wg.Add(1) + go func() { + defer wg.Done() + groups[index] = s.searchProvider(ctx, providers[index], request) + }() + } + wg.Wait() + return groups +} + +func failedGroup(descriptor ProviderDescriptor, status Status) apiv1.MentionGroup { + group := groupFromDescriptor(descriptor) + group.Status = status + return group +} + +func (s *Service) searchProvider(ctx context.Context, registered registeredProvider, request SearchRequest) apiv1.MentionGroup { + waitCtx, cancelWait := context.WithTimeout(ctx, s.providerTimeout) + providerSlot := s.slotForProvider(registered.descriptor.Source) + select { + case providerSlot <- struct{}{}: + cancelWait() + case <-waitCtx.Done(): + cancelWait() + return failedGroup(registered.descriptor, StatusTimeout) + } + select { + case s.providerSlots <- struct{}{}: + case <-ctx.Done(): + <-providerSlot + return failedGroup(registered.descriptor, StatusTimeout) + } + providerCtx, cancelProvider := context.WithTimeout(ctx, s.providerTimeout) + defer cancelProvider() + result := make(chan apiv1.MentionGroup, 1) + go func() { + group := searchProviderWithinContext(providerCtx, registered, request) + // The provider slot belongs to the underlying call. A provider that ignores + // cancellation stays quarantined without consuming shared search capacity. + <-providerSlot + result <- group + }() + select { + case group := <-result: + <-s.providerSlots + return group + case <-providerCtx.Done(): + <-s.providerSlots + return failedGroup(registered.descriptor, StatusTimeout) + } +} + +func (s *Service) slotForProvider(source string) chan struct{} { + s.providerSlotMu.Lock() + defer s.providerSlotMu.Unlock() + if s.providerInFlight == nil { + s.providerInFlight = make(map[string]chan struct{}) + } + if slot, ok := s.providerInFlight[source]; ok { + return slot + } + slot := make(chan struct{}, 1) + s.providerInFlight[source] = slot + return slot +} + +func searchProviderWithinContext( + ctx context.Context, + registered registeredProvider, + request SearchRequest, +) apiv1.MentionGroup { + group := groupFromDescriptor(registered.descriptor) + candidates, err := registered.provider.Search(ctx, request) + if err != nil { + group.Status = classifyProviderError(err) + return group + } + if ctx.Err() != nil { + return failedGroup(registered.descriptor, StatusTimeout) + } + seen := make(map[string]struct{}) + for _, candidate := range candidates { + if len(group.Results) == request.Limit { + break + } + candidate, ok := normalizeCandidate(candidate) + if !ok { + continue + } + reference := referenceFromCandidate(registered.descriptor, candidate) + if registered.authorizer != nil { + if err := registered.authorizer.AuthorizeReference(ctx, ReferenceAuthorizationRequest{ + WorkspaceID: request.WorkspaceID, + Purpose: ReferencePurposeSearch, + Reference: reference, + }); err != nil { + continue + } + } + if _, duplicate := seen[reference.Ref]; duplicate { + continue + } + seen[reference.Ref] = struct{}{} + group.Results = append(group.Results, reference) + } + return group +} + +func classifyProviderError(err error) Status { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return StatusTimeout + } + var providerErr *ProviderError + if errors.As(err, &providerErr) && safeFailureStatus(providerErr.Status) { + return providerErr.Status + } + return StatusUpstreamError +} + +func safeFailureStatus(status Status) bool { + switch status { + case StatusNotConfigured, StatusUnauthorized, StatusRateLimited, StatusTimeout, + StatusUpstreamError, StatusUnsupportedScope: + return true + default: + return false + } +} + +func normalizeCandidate(candidate Candidate) (Candidate, bool) { + candidate.ID = strings.TrimSpace(candidate.ID) + candidate.Scope = strings.TrimSpace(candidate.Scope) + if !validIdentity(candidate.ID) || !validIdentity(candidate.Scope) { + return Candidate{}, false + } + candidate.Title = normalizeDisplayText(candidate.Title) + candidate.Key = normalizeDisplayText(candidate.Key) + if candidate.Title == "" || utf8.RuneCountInString(candidate.Title) > 500 || + utf8.RuneCountInString(candidate.Key) > 200 { + return Candidate{}, false + } + candidate.URL = strings.TrimSpace(candidate.URL) + if !safeMentionURL(candidate.URL) { + return Candidate{}, false + } + return candidate, true +} + +func validIdentity(value string) bool { + if value == "" || !utf8.ValidString(value) || utf8.RuneCountInString(value) > 512 { + return false + } + for _, r := range value { + if unicode.IsControl(r) { + return false + } + } + return true +} + +func normalizeDisplayText(value string) string { + if !utf8.ValidString(value) { + return "" + } + return strings.Join(strings.Fields(value), " ") +} + +func safeMentionURL(raw string) bool { + if raw == "" || !utf8.ValidString(raw) || utf8.RuneCountInString(raw) > 2048 { + return false + } + parsed, err := url.Parse(raw) + if err != nil || parsed.User != nil { + return false + } + if parsed.Scheme == "" && parsed.Host == "" { + return false + } + return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" +} + +func normalizeRequest(request SearchRequest) (SearchRequest, error) { + request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) + request.Query = strings.TrimSpace(request.Query) + if !validIdentity(request.WorkspaceID) { + return SearchRequest{}, fmt.Errorf("%w: workspace ID is required", ErrInvalidRequest) + } + if !utf8.ValidString(request.Query) { + return SearchRequest{}, fmt.Errorf("%w: query must be valid UTF-8", ErrInvalidRequest) + } + queryLength := utf8.RuneCountInString(request.Query) + if queryLength < 1 || queryLength > 200 { + return SearchRequest{}, fmt.Errorf("%w: query must contain 1 to 200 characters", ErrInvalidRequest) + } + for _, character := range request.Query { + if unicode.IsControl(character) { + return SearchRequest{}, fmt.Errorf("%w: query must not contain control characters", ErrInvalidRequest) + } + } + switch { + case request.Limit == 0: + request.Limit = DefaultLimit + case request.Limit < 1: + request.Limit = 1 + case request.Limit > MaxLimit: + request.Limit = MaxLimit + } + return request, nil +} + +func groupFromDescriptor(descriptor ProviderDescriptor) apiv1.MentionGroup { + return apiv1.MentionGroup{ + Source: descriptor.Source, + Provider: descriptor.Provider, + Kind: descriptor.Kind, + DisplayName: descriptor.DisplayName, + KindLabel: descriptor.KindLabel, + Status: apiv1.MentionStatusOK, + Results: []apiv1.EntityReference{}, + } +} + +func referenceFromCandidate(descriptor ProviderDescriptor, candidate Candidate) apiv1.EntityReference { + return apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: canonicalRef(descriptor.Provider, descriptor.Kind, candidate.Scope, candidate.ID), + Provider: descriptor.Provider, + Kind: descriptor.Kind, + ID: candidate.ID, + Key: candidate.Key, + Title: candidate.Title, + URL: candidate.URL, + Scope: candidate.Scope, + } +} + +func canonicalRef(provider, kind, scope, id string) string { + return entityrefs.CanonicalRef(provider, kind, scope, id) +} diff --git a/apps/backend/internal/mentions/service_test.go b/apps/backend/internal/mentions/service_test.go new file mode 100644 index 0000000000..14afb2a9fd --- /dev/null +++ b/apps/backend/internal/mentions/service_test.go @@ -0,0 +1,639 @@ +package mentions + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeProvider struct { + descriptor ProviderDescriptor + search func(context.Context, SearchRequest) ([]Candidate, error) + authorize func(context.Context, ReferenceAuthorizationRequest) error +} + +type searchOnlyProvider struct { + descriptor ProviderDescriptor +} + +func (p searchOnlyProvider) Descriptor() ProviderDescriptor { + return p.descriptor +} + +func (p searchOnlyProvider) Search(context.Context, SearchRequest) ([]Candidate, error) { + return nil, nil +} + +func (p fakeProvider) Descriptor() ProviderDescriptor { + return p.descriptor +} + +func (p fakeProvider) Search(ctx context.Context, request SearchRequest) ([]Candidate, error) { + return p.search(ctx, request) +} + +func (p fakeProvider) AuthorizeReference(ctx context.Context, request ReferenceAuthorizationRequest) error { + if p.authorize == nil { + return nil + } + return p.authorize(ctx, request) +} + +func TestServiceSearch_RegistryOwnsNamespacedProviderIdentity(t *testing.T) { + registry := NewRegistry() + err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{ + Source: "plugin:acme:incidents", + Provider: "plugin:acme:tracker", + Kind: "incident", + DisplayName: "Acme incidents", + KindLabel: "Incident", + }, + search: func(_ context.Context, request SearchRequest) ([]Candidate, error) { + if request.Query != "auth" { + t.Fatalf("query = %q, want trimmed query auth", request.Query) + } + if request.Limit != DefaultLimit { + t.Fatalf("limit = %d, want default %d", request.Limit, DefaultLimit) + } + return []Candidate{{ + ID: "incident-7", + Key: "INC-7", + Title: "Authentication outage", + URL: "https://incidents.example.test/7", + Scope: "tenant-a", + }}, nil + }, + }) + if err != nil { + t.Fatalf("register provider: %v", err) + } + + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: " auth ", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(response.Groups) != 1 || len(response.Groups[0].Results) != 1 { + t.Fatalf("groups = %#v, want one group with one result", response.Groups) + } + group := response.Groups[0] + result := group.Results[0] + if group.Source != "plugin:acme:incidents" || result.Provider != "plugin:acme:tracker" || result.Kind != "incident" { + t.Fatalf("registry identity = source %q provider %q kind %q", group.Source, result.Provider, result.Kind) + } + const wantRef = "mention:v1:plugin%3Aacme%3Atracker:incident:tenant-a:incident-7" + if result.Ref != wantRef { + t.Fatalf("ref = %q, want %q", result.Ref, wantRef) + } +} + +func TestRegistryRegister_RejectsUnsafeAndDuplicateSources(t *testing.T) { + provider := func(source string) fakeProvider { + return fakeProvider{ + descriptor: ProviderDescriptor{Source: source, Provider: "acme", Kind: "issue"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + return nil, nil + }, + } + } + + registry := NewRegistry() + if err := registry.Register(provider("GitHub Issues")); err == nil { + t.Fatal("register unsafe source: err = nil, want validation error") + } + if err := registry.Register(provider("plugin:acme:issues")); err != nil { + t.Fatalf("register valid source: %v", err) + } + if err := registry.Register(provider("plugin:acme:issues")); err == nil { + t.Fatal("register duplicate source: err = nil, want duplicate error") + } +} + +func TestRegistryAuthorizesReferenceThroughRegisteredProvider(t *testing.T) { + registry := NewRegistry() + called := false + provider := fakeProvider{ + descriptor: ProviderDescriptor{Source: "acme_issues", Provider: "acme", Kind: "issue"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + return nil, nil + }, + authorize: func(_ context.Context, request ReferenceAuthorizationRequest) error { + called = true + if request.WorkspaceID != "workspace-1" || request.Purpose != ReferencePurposeSubmission || + request.Reference.ID != "issue-7" { + t.Fatalf("authorization request = %#v", request) + } + return nil + }, + } + if err := registry.Register(provider); err != nil { + t.Fatalf("register: %v", err) + } + ref := apiv1.EntityReference{Provider: "acme", Kind: "issue", ID: "issue-7"} + if err := registry.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", + Purpose: ReferencePurposeSubmission, + Reference: ref, + }); err != nil { + t.Fatalf("authorize: %v", err) + } + if !called { + t.Fatal("registered provider authorizer was not called") + } + + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: "acme_issues_duplicate", Provider: "acme", Kind: "issue"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { return nil, nil }, + }); err == nil { + t.Fatal("duplicate provider/kind registration succeeded") + } + unknown := ref + unknown.Provider = "unknown" + if err := registry.AuthorizeReference(context.Background(), ReferenceAuthorizationRequest{ + WorkspaceID: "workspace-1", + Purpose: ReferencePurposeSubmission, + Reference: unknown, + }); !errors.Is(err, ErrReferenceProviderUnavailable) { + t.Fatalf("unknown provider error = %v, want unavailable", err) + } +} + +func TestRegistryRejectsProviderWithoutAuthorizer(t *testing.T) { + registry := NewRegistry() + err := registry.Register(searchOnlyProvider{descriptor: ProviderDescriptor{ + Source: "plain_issues", Provider: "plain", Kind: "issue", + }}) + if !errors.Is(err, ErrMissingAuthorizer) { + t.Fatalf("register error = %v, want missing authorizer", err) + } +} + +func TestServiceSearchDropsCandidateRejectedByProviderAuthorizer(t *testing.T) { + registry := NewRegistry() + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: "acme_issues", Provider: "acme", Kind: "issue"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + return []Candidate{ + {ID: "allowed", Title: "Allowed", URL: "https://acme.test/allowed", Scope: "tenant"}, + {ID: "blocked", Title: "Blocked", URL: "https://evil.test/blocked", Scope: "tenant"}, + }, nil + }, + authorize: func(_ context.Context, request ReferenceAuthorizationRequest) error { + if request.Purpose != ReferencePurposeSearch { + t.Fatalf("purpose = %q, want search", request.Purpose) + } + if request.Reference.ID == "blocked" { + return errors.New("origin mismatch") + } + return nil + }, + }); err != nil { + t.Fatalf("register: %v", err) + } + + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "issue", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(response.Groups[0].Results) != 1 || response.Groups[0].Results[0].ID != "allowed" { + t.Fatalf("results = %#v, want only provider-authorized candidate", response.Groups[0].Results) + } +} + +func TestServiceSearch_RejectsInvalidWorkspaceAndQuery(t *testing.T) { + registry := NewRegistry() + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: "test", Provider: "test", Kind: "issue"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + t.Fatal("provider called for invalid request") + return nil, nil + }, + }); err != nil { + t.Fatalf("register provider: %v", err) + } + service := NewService(registry) + + tests := []SearchRequest{ + {WorkspaceID: "", Query: "auth"}, + {WorkspaceID: "workspace\x00admin", Query: "auth"}, + {WorkspaceID: "workspace-1", Query: " "}, + {WorkspaceID: "workspace-1", Query: "auth\x00admin"}, + {WorkspaceID: "workspace-1", Query: strings.Repeat("界", 201)}, + } + for _, request := range tests { + if _, err := service.Search(context.Background(), request); err == nil { + t.Fatalf("Search(%+v): err = nil, want validation error", request) + } + } +} + +func TestServiceSearch_ClampsPerSourceLimitAndOutput(t *testing.T) { + registry := NewRegistry() + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: "issues", Provider: "acme", Kind: "issue"}, + search: func(_ context.Context, request SearchRequest) ([]Candidate, error) { + if request.Limit != MaxLimit { + t.Fatalf("provider limit = %d, want %d", request.Limit, MaxLimit) + } + candidates := make([]Candidate, 0, MaxLimit+2) + for i := 0; i < MaxLimit+2; i++ { + candidates = append(candidates, Candidate{ + ID: fmt.Sprintf("issue-%d", i), + Title: fmt.Sprintf("Issue %d", i), + URL: fmt.Sprintf("https://issues.example.test/%d", i), + Scope: "tenant-a", + }) + } + return candidates, nil + }, + }); err != nil { + t.Fatalf("register provider: %v", err) + } + + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "auth", + Limit: 99, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if got := len(response.Groups[0].Results); got != MaxLimit { + t.Fatalf("result count = %d, want capped %d", got, MaxLimit) + } +} + +func TestServiceSearch_DropsUnsafeCandidatesAndDeduplicatesRefs(t *testing.T) { + registry := NewRegistry() + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: "issues", Provider: "acme", Kind: "issue"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + return []Candidate{ + {ID: "", Title: "Missing ID", URL: "https://acme.test/issues/missing", Scope: "workspace-1"}, + {ID: "bad-scope", Title: "Missing scope", URL: "https://acme.test/issues/bad-scope"}, + {ID: "bad-title", URL: "https://acme.test/issues/bad-title", Scope: "workspace-1"}, + {ID: "bad-url", Title: "Bad URL", URL: "javascript:alert(1)", Scope: "workspace-1"}, + {ID: "issue-1", Key: " ACME-1\n", Title: " Login\n\t failure ", URL: "https://acme.test/issues/1", Scope: " workspace-1 "}, + {ID: "issue-1", Key: "ACME-1", Title: "Duplicate", URL: "https://acme.test/issues/1", Scope: "workspace-1"}, + }, nil + }, + }); err != nil { + t.Fatalf("register provider: %v", err) + } + + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "login", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + results := response.Groups[0].Results + if len(results) != 1 { + t.Fatalf("results = %#v, want one safe unique result", results) + } + if results[0].Title != "Login failure" || results[0].Key != "ACME-1" || results[0].Scope != "workspace-1" { + t.Fatalf("sanitized result = %#v", results[0]) + } +} + +func TestServiceSearch_DropsInternalRoutesFromEveryProvider(t *testing.T) { + registry := NewRegistry() + providers := []fakeProvider{ + { + descriptor: ProviderDescriptor{Source: "plugin_tasks", Provider: "plugin:acme:tracker", Kind: "task"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + return []Candidate{{ID: "spoofed", Title: "Spoofed task", URL: "/t/real-task", Scope: "tenant"}}, nil + }, + }, + { + descriptor: ProviderDescriptor{Source: "kandev_tasks", Provider: "kandev", Kind: "task"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + return []Candidate{{ID: "task-1", Title: "Task", URL: "/t/task-1", Scope: "workspace-1"}}, nil + }, + }, + } + for _, provider := range providers { + if err := registry.Register(provider); err != nil { + t.Fatalf("register %s: %v", provider.descriptor.Source, err) + } + } + + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "task", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + groups := make(map[string]apiv1.MentionGroup, len(response.Groups)) + for _, group := range response.Groups { + groups[group.Source] = group + } + if len(groups["plugin_tasks"].Results) != 0 { + t.Fatalf("plugin internal-route results = %#v, want none", groups["plugin_tasks"].Results) + } + if len(groups["kandev_tasks"].Results) != 0 { + t.Fatalf("Kandev internal-route results = %#v, want none", groups["kandev_tasks"].Results) + } +} + +func TestServiceSearch_ReturnsDeterministicPartialStatuses(t *testing.T) { + registry := NewRegistry() + providers := []fakeProvider{ + { + descriptor: ProviderDescriptor{Source: "success", Provider: "acme", Kind: "issue", Order: 30}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + return []Candidate{{ID: "1", Title: "Found", URL: "https://acme.test/1", Scope: "tenant"}}, nil + }, + }, + { + descriptor: ProviderDescriptor{Source: "raw-error", Provider: "broken", Kind: "issue", Order: 20}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + return nil, errors.New("secret upstream body") + }, + }, + { + descriptor: ProviderDescriptor{Source: "auth", Provider: "private", Kind: "ticket", Order: 10}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + return nil, NewProviderError(StatusUnauthorized, errors.New("token expired: secret-token")) + }, + }, + } + for _, provider := range providers { + if err := registry.Register(provider); err != nil { + t.Fatalf("register %s: %v", provider.descriptor.Source, err) + } + } + + response, err := NewService(registry).Search(context.Background(), SearchRequest{ + WorkspaceID: "workspace-1", + Query: "auth", + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if got := []string{response.Groups[0].Source, response.Groups[1].Source, response.Groups[2].Source}; fmt.Sprint(got) != "[auth raw-error success]" { + t.Fatalf("group order = %v", got) + } + if response.Groups[0].Status != StatusUnauthorized || response.Groups[1].Status != StatusUpstreamError || + response.Groups[2].Status != StatusOK { + t.Fatalf("statuses = %q, %q, %q", response.Groups[0].Status, response.Groups[1].Status, response.Groups[2].Status) + } + if response.Groups[0].DisplayName != "private" || response.Groups[0].KindLabel != "Work item" { + t.Fatalf("generic labels = %#v", response.Groups[0]) + } +} + +func TestServiceSearch_ProviderDeadlineBecomesPartialTimeout(t *testing.T) { + registry := NewRegistry() + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: "slow", Provider: "slow", Kind: "issue"}, + search: func(ctx context.Context, _ SearchRequest) ([]Candidate, error) { + if _, ok := ctx.Deadline(); !ok { + return nil, errors.New("provider context missing deadline") + } + <-ctx.Done() + return nil, ctx.Err() + }, + }); err != nil { + t.Fatalf("register provider: %v", err) + } + + response, err := NewService(registry, WithProviderTimeout(10*time.Millisecond)).Search( + context.Background(), + SearchRequest{WorkspaceID: "workspace-1", Query: "auth"}, + ) + if err != nil { + t.Fatalf("search: %v", err) + } + if response.Groups[0].Status != StatusTimeout { + t.Fatalf("status = %q, want %q", response.Groups[0].Status, StatusTimeout) + } +} + +func TestClassifyProviderErrorTreatsCancellationAsTimeout(t *testing.T) { + if got := classifyProviderError(context.Canceled); got != StatusTimeout { + t.Fatalf("status = %q, want %q", got, StatusTimeout) + } +} + +func TestServiceSearch_EnforcesTimeoutWhenProviderIgnoresContext(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + registry := NewRegistry() + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: "stuck", Provider: "stuck", Kind: "issue"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + <-release + return nil, nil + }, + }); err != nil { + t.Fatalf("register provider: %v", err) + } + + type outcome struct { + response *apiv1.MentionSearchResponse + err error + } + done := make(chan outcome, 1) + go func() { + response, err := NewService(registry, WithProviderTimeout(10*time.Millisecond)).Search( + context.Background(), + SearchRequest{WorkspaceID: "workspace-1", Query: "auth"}, + ) + done <- outcome{response: response, err: err} + }() + + select { + case result := <-done: + if result.err != nil { + t.Fatalf("search: %v", result.err) + } + if result.response.Groups[0].Status != StatusTimeout { + t.Fatalf("status = %q, want %q", result.response.Groups[0].Status, StatusTimeout) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("search remained blocked after provider timeout") + } +} + +func TestServiceSearch_BoundsAbandonedProviderCallsPerSource(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + started := make(chan struct{}, 8) + registry := NewRegistry() + for index := 0; index < 4; index++ { + providerID := fmt.Sprintf("stuck-%d", index) + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: providerID, Provider: providerID, Kind: "issue"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + started <- struct{}{} + <-release + return nil, nil + }, + }); err != nil { + t.Fatalf("register provider: %v", err) + } + } + service := NewService( + registry, + WithMaxConcurrency(1), + WithProviderTimeout(10*time.Millisecond), + ) + for attempt := 0; attempt < 4; attempt++ { + response, err := service.Search( + context.Background(), + SearchRequest{WorkspaceID: "workspace-1", Query: "auth"}, + ) + if err != nil { + t.Fatalf("search %d: %v", attempt, err) + } + for _, group := range response.Groups { + if group.Status != StatusTimeout { + t.Fatalf("search %d group status = %q", attempt, group.Status) + } + } + } + if got := len(started); got != 4 { + t.Fatalf("started provider calls = %d, want one quarantined call per source", got) + } +} + +func TestServiceSearch_QuarantinesRepeatedStuckProviderWithoutHidingHealthyResults(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + stuckStarted := make(chan struct{}, 4) + registry := NewRegistry() + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: "stuck", Provider: "stuck", Kind: "issue"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + stuckStarted <- struct{}{} + <-release + return nil, nil + }, + }); err != nil { + t.Fatalf("register stuck provider: %v", err) + } + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: "healthy", Provider: "healthy", Kind: "issue"}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + return []Candidate{{ + ID: "healthy-1", + Title: "Healthy result", + URL: "https://healthy.example.test/1", + Scope: "workspace-1", + }}, nil + }, + }); err != nil { + t.Fatalf("register healthy provider: %v", err) + } + + service := NewService( + registry, + WithMaxConcurrency(1), + WithProviderTimeout(10*time.Millisecond), + ) + for attempt := 0; attempt < 4; attempt++ { + response, err := service.Search( + context.Background(), + SearchRequest{WorkspaceID: "workspace-1", Query: "auth"}, + ) + if err != nil { + t.Fatalf("search %d: %v", attempt, err) + } + groups := make(map[string]apiv1.MentionGroup, len(response.Groups)) + for _, group := range response.Groups { + groups[group.Source] = group + } + if groups["stuck"].Status != StatusTimeout { + t.Fatalf("search %d stuck status = %q, want %q", attempt, groups["stuck"].Status, StatusTimeout) + } + if groups["healthy"].Status != StatusOK || len(groups["healthy"].Results) != 1 { + t.Fatalf("search %d healthy group = %#v, want successful result", attempt, groups["healthy"]) + } + } + if got := len(stuckStarted); got != 1 { + t.Fatalf("started stuck provider calls = %d, want quarantined at 1", got) + } +} + +func TestServiceSearch_BoundsConcurrentFanoutAndKeepsDescriptorOrder(t *testing.T) { + started := make(chan string, 3) + release := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + + registry := NewRegistry() + for index, source := range []string{"c", "a", "b"} { + source := source + if err := registry.Register(fakeProvider{ + descriptor: ProviderDescriptor{Source: source, Provider: source, Kind: "issue", Order: 30 - index*10}, + search: func(context.Context, SearchRequest) ([]Candidate, error) { + started <- source + <-release + return nil, nil + }, + }); err != nil { + t.Fatalf("register %s: %v", source, err) + } + } + + type searchOutcome struct { + responseSources []string + err error + } + done := make(chan searchOutcome, 1) + go func() { + response, err := NewService( + registry, + WithMaxConcurrency(2), + WithProviderTimeout(time.Second), + ).Search(context.Background(), SearchRequest{WorkspaceID: "workspace-1", Query: "auth"}) + outcome := searchOutcome{err: err} + if response != nil { + for _, group := range response.Groups { + outcome.responseSources = append(outcome.responseSources, group.Source) + } + } + done <- outcome + }() + + for i := 0; i < 2; i++ { + select { + case <-started: + case <-time.After(500 * time.Millisecond): + t.Fatal("fewer than two providers started concurrently") + } + } + select { + case source := <-started: + t.Fatalf("provider %q exceeded concurrency bound before release", source) + case <-time.After(50 * time.Millisecond): + } + + releaseOnce.Do(func() { close(release) }) + select { + case outcome := <-done: + if outcome.err != nil { + t.Fatalf("search: %v", outcome.err) + } + if got := fmt.Sprint(outcome.responseSources); got != "[b a c]" { + t.Fatalf("group order = %s, want [b a c]", got) + } + case <-time.After(time.Second): + t.Fatal("search did not finish after providers released") + } +} diff --git a/apps/backend/internal/mentions/types.go b/apps/backend/internal/mentions/types.go new file mode 100644 index 0000000000..2b237e4047 --- /dev/null +++ b/apps/backend/internal/mentions/types.go @@ -0,0 +1,120 @@ +// Package mentions provides provider-neutral, workspace-scoped work-item search. +package mentions + +import ( + "context" + "errors" + + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +const ( + DefaultLimit = 5 + MaxLimit = 10 + + mentionKindIssue = "issue" + mentionLabelIssue = "Issue" + mentionLabelWorkItem = "Work item" + mentionIssuesPathSegment = "issues" +) + +var ( + ErrInvalidDescriptor = errors.New("invalid mention provider descriptor") + ErrDuplicateSource = errors.New("mention provider source already registered") + ErrDuplicateProvider = errors.New("mention provider and kind already registered") + ErrMissingAuthorizer = errors.New("mention provider reference authorizer is required") + ErrInvalidRequest = errors.New("invalid mention search request") + ErrWorkspaceNotFound = errors.New("mention search workspace not found") + ErrReferenceProviderUnavailable = errors.New("reference provider unavailable") + ErrReferenceUnauthorized = errors.New("reference is not authorized") +) + +type Status = apiv1.MentionStatus + +const ( + StatusOK = apiv1.MentionStatusOK + StatusNotConfigured = apiv1.MentionStatusNotConfigured + StatusUnauthorized = apiv1.MentionStatusUnauthorized + StatusRateLimited = apiv1.MentionStatusRateLimited + StatusTimeout = apiv1.MentionStatusTimeout + StatusUpstreamError = apiv1.MentionStatusUpstreamError + StatusUnsupportedScope = apiv1.MentionStatusUnsupportedScope +) + +// ProviderError classifies a provider failure without exposing its cause to clients. +type ProviderError struct { + Status Status + cause error +} + +func (e *ProviderError) Error() string { + if e.cause != nil { + return e.cause.Error() + } + return string(e.Status) +} + +func (e *ProviderError) Unwrap() error { + return e.cause +} + +// NewProviderError wraps an optional diagnostic cause with a safe response status. +func NewProviderError(status Status, causes ...error) error { + var cause error + if len(causes) > 0 { + cause = causes[0] + } + return &ProviderError{Status: status, cause: cause} +} + +// ProviderDescriptor is immutable registry-owned identity and presentation metadata. +type ProviderDescriptor struct { + Source string + Provider string + Kind string + DisplayName string + KindLabel string + Order int +} + +// SearchRequest is the plain-text boundary shared by every provider. +type SearchRequest struct { + WorkspaceID string + Query string + Limit int +} + +// Candidate is untrusted provider-local data. Registry identity is intentionally absent. +type Candidate struct { + ID string + Key string + Title string + URL string + Scope string +} + +// ReferencePurpose lets one provider apply cheaper search checks and stronger submission checks. +type ReferencePurpose string + +const ( + ReferencePurposeSearch ReferencePurpose = "search" + ReferencePurposeSubmission ReferencePurpose = "submission" +) + +// ReferenceAuthorizationRequest contains trusted conversation scope and one normalized reference. +type ReferenceAuthorizationRequest struct { + WorkspaceID string + Purpose ReferencePurpose + Reference apiv1.EntityReference +} + +// ReferenceAuthorizer verifies provider-owned scope and destination rules. +type ReferenceAuthorizer interface { + AuthorizeReference(context.Context, ReferenceAuthorizationRequest) error +} + +// MentionProvider searches one provider source and kind. +type MentionProvider interface { + Descriptor() ProviderDescriptor + Search(context.Context, SearchRequest) ([]Candidate, error) +} diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index e59467f1c0..af5d0cb5c7 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/agent/runtime/routingerr" + "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/messagequeue" @@ -499,6 +500,8 @@ func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messag DeliveryMode: att.DeliveryMode, } } + references := entityrefs.NormalizePersisted(queuedMsg.Metadata[messagequeue.MetadataEntityReferences]) + promptContent := AppendEntityReferenceContext(queuedMsg.Content, references) // Create user message for the queued message (so it appears in chat history). // Skip when the queued metadata is tagged user_message_recorded — that means @@ -517,12 +520,13 @@ func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messag meta := NewUserMessageMeta(). WithPlanMode(queuedMsg.PlanMode). - WithAttachments(attachments) + WithAttachments(attachments). + WithEntityReferences(references) // Merge any extra metadata captured at queue time (e.g. sender_task_id // from message_task_kandev) so the resulting Message row carries the // full context. - metaMap := mergeMetadata(meta.ToMap(), queuedMsg.Metadata) - err := s.messageCreator.CreateUserMessage(promptCtx, queuedMsg.TaskID, queuedMsg.Content, queuedMsg.SessionID, turnID, metaMap) + metaMap := mergeMetadata(meta.ToMap(), metadataWithoutEntityReferences(queuedMsg.Metadata)) + err := s.messageCreator.CreateUserMessage(promptCtx, queuedMsg.TaskID, promptContent, queuedMsg.SessionID, turnID, metaMap) if err != nil { s.logger.Error("failed to create user message for queued message", zap.String("session_id", queuedMsg.SessionID), @@ -548,7 +552,7 @@ func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messag // comment for the guarded claim-then-mark-RUNNING step this enables, // and for why a bare, unguarded check-then-clear would not be enough. _, err := s.promptTask(promptCtx, queuedMsg.TaskID, queuedMsg.SessionID, - queuedMsg.Content, queuedMsg.Model, queuedMsg.PlanMode, attachments, false, + promptContent, queuedMsg.Model, queuedMsg.PlanMode, attachments, false, queuedMsg.ID) if errors.Is(err, errQueuedDispatchSuperseded) { // A newer dispatch for this same session (e.g. a second parent @@ -593,6 +597,19 @@ func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messag } } +func metadataWithoutEntityReferences(metadata map[string]interface{}) map[string]interface{} { + if len(metadata) == 0 { + return nil + } + copy := make(map[string]interface{}, len(metadata)) + for key, value := range metadata { + if key != messagequeue.MetadataEntityReferences { + copy[key] = value + } + } + return copy +} + // handleAgentCompleted handles agent completion events func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEventData) { if data.SessionID == "" { diff --git a/apps/backend/internal/orchestrator/event_handlers_queue_test.go b/apps/backend/internal/orchestrator/event_handlers_queue_test.go index 49b7b106cc..9ffcb72f1b 100644 --- a/apps/backend/internal/orchestrator/event_handlers_queue_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_queue_test.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "encoding/json" "fmt" "reflect" "strings" @@ -13,9 +14,24 @@ import ( "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) +func queuedReferenceFixture() v1.EntityReference { + return v1.EntityReference{ + Version: v1.EntityReferenceVersion, + Ref: "mention:v1:kandev:task:workspace-1:task-2", + Provider: "kandev", + Kind: "task", + ID: "task-2", + Key: "TASK-2", + Title: "Referenced task", + URL: "/t/task-2", + Scope: "workspace-1", + } +} + func TestExecuteQueuedMessage_RequeuesWhenResetInProgress(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) @@ -85,6 +101,9 @@ func TestExecuteQueuedMessage_RequeuesCancelReleaseFailure(t *testing.T) { TaskID: "t1", Content: "hello after cancel", QueuedBy: "test", + Metadata: map[string]interface{}{ + messagequeue.MetadataEntityReferences: []v1.EntityReference{queuedReferenceFixture()}, + }, } svc.markQueuedDispatchInFlight("s1", queuedMsg.ID) @@ -97,6 +116,9 @@ func TestExecuteQueuedMessage_RequeuesCancelReleaseFailure(t *testing.T) { if status.Entries[0].Content != "hello after cancel" { t.Fatalf("expected queued content to be preserved, got %q", status.Entries[0].Content) } + if !reflect.DeepEqual(status.Entries[0].Metadata, queuedMsg.Metadata) { + t.Fatalf("expected queued metadata to be preserved, got %#v", status.Entries[0].Metadata) + } } // TestExecuteQueuedMessage_SkipsUserMessageWhenAlreadyRecorded pins the @@ -278,3 +300,174 @@ func TestExecuteQueuedMessage_StoresAttachmentsInUserMessageMetadata(t *testing. t.Fatalf("attachments mismatch\n got: %+v\nwant: %+v", got, want) } } + +func TestExecuteQueuedMessage_NormalizesReferencesForMessageAndPrompt(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + session, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + session.State = models.TaskSessionStateWaitingForInput + session.AgentExecutionID = "exec-1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + + agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + seedExecutorRunning(t, repo, "s1", "t1", "exec-1") + messages := &mockMessageCreator{} + svc.messageCreator = messages + + reference := queuedReferenceFixture() + encoded, err := json.Marshal([]v1.EntityReference{reference}) + if err != nil { + t.Fatalf("marshal reference: %v", err) + } + var persisted interface{} + if err := json.Unmarshal(encoded, &persisted); err != nil { + t.Fatalf("round-trip reference: %v", err) + } + queued := &messagequeue.QueuedMessage{ + ID: "q-ref", + SessionID: "s1", + TaskID: "t1", + Content: "inspect referenced work", + QueuedBy: messagequeue.QueuedByUser, + Metadata: map[string]interface{}{ + "entity_references": persisted, + "origin": "quick-chat", + }, + } + + svc.markQueuedDispatchInFlight("s1", queued.ID) + svc.executeQueuedMessage("s1", queued) + + if len(messages.userMessages) != 1 { + t.Fatalf("expected one persisted user message, got %d", len(messages.userMessages)) + } + stored := messages.userMessages[0] + if !strings.Contains(stored.content, "inspect referenced work") || !strings.Contains(stored.content, "Validated work-item reference snapshots") { + t.Fatalf("stored content missing validated reference context: %q", stored.content) + } + gotReferences, ok := stored.metadata["entity_references"].([]v1.EntityReference) + if !ok || !reflect.DeepEqual(gotReferences, []v1.EntityReference{reference}) { + t.Fatalf("stored references = %#v, want typed normalized reference", stored.metadata["entity_references"]) + } + if stored.metadata["origin"] != "quick-chat" { + t.Fatalf("unrelated queue metadata lost: %+v", stored.metadata) + } + if len(agentMgr.capturedPrompts) != 1 || strings.Count(agentMgr.capturedPrompts[0], "Validated work-item reference snapshots") != 1 { + t.Fatalf("agent prompt missing one reference block: %+v", agentMgr.capturedPrompts) + } + if queued.Content != "inspect referenced work" { + t.Fatalf("queue retry content mutated: %q", queued.Content) + } + if !reflect.DeepEqual(queued.Metadata[messagequeue.MetadataEntityReferences], persisted) { + t.Fatalf("queue retry metadata mutated: %#v", queued.Metadata) + } +} + +func TestAutoStartStepPrompt_PreservesHandoffReferences(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + session, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + session.State = models.TaskSessionStateWaitingForInput + session.AgentExecutionID = "exec-1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + + agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + seedExecutorRunning(t, repo, "s1", "t1", "exec-1") + messages := &mockMessageCreator{} + svc.messageCreator = messages + + reference := queuedReferenceFixture() + handoff, err := svc.messageQueue.QueueMessageWithMetadata( + ctx, "s1", "t1", "handoff details", "", messagequeue.QueuedByUser, false, nil, + map[string]interface{}{messagequeue.MetadataEntityReferences: []v1.EntityReference{reference}}, + ) + if err != nil { + t.Fatalf("queue handoff: %v", err) + } + step := &wfmodels.WorkflowStep{ID: "step2", WorkflowID: "wf1", Name: "Review"} + + if err := svc.autoStartStepPrompt(ctx, "t1", session, step, "workflow start", false, false); err != nil { + t.Fatalf("auto-start: %v", err) + } + + if len(messages.userMessages) != 1 { + t.Fatalf("expected one workflow user message, got %d", len(messages.userMessages)) + } + stored := messages.userMessages[0] + if strings.Count(stored.content, "Validated work-item reference snapshots") != 1 { + t.Fatalf("stored workflow message missing one reference block: %q", stored.content) + } + if !strings.Contains(stored.content, "workflow start") || !strings.Contains(stored.content, "handoff details") { + t.Fatalf("stored workflow message missing merged visible content: %q", stored.content) + } + gotReferences, ok := stored.metadata[messagequeue.MetadataEntityReferences].([]v1.EntityReference) + if !ok || !reflect.DeepEqual(gotReferences, []v1.EntityReference{reference}) { + t.Fatalf("workflow message references = %#v", stored.metadata[messagequeue.MetadataEntityReferences]) + } + if len(agentMgr.capturedPrompts) != 1 || strings.Count(agentMgr.capturedPrompts[0], "Validated work-item reference snapshots") != 1 { + t.Fatalf("workflow agent prompt missing one reference block: %+v", agentMgr.capturedPrompts) + } + if handoff.Content != "handoff details" { + t.Fatalf("original queued payload mutated: %q", handoff.Content) + } +} + +func TestAutoStartStepPrompt_QueuesRawHandoffWithReferencesWhenBusy(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + session, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + session.State = models.TaskSessionStateRunning + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + reference := queuedReferenceFixture() + _, err = svc.messageQueue.QueueMessageWithMetadata( + ctx, "s1", "t1", "handoff details", "", messagequeue.QueuedByUser, false, nil, + map[string]interface{}{messagequeue.MetadataEntityReferences: []v1.EntityReference{reference}}, + ) + if err != nil { + t.Fatalf("queue handoff: %v", err) + } + + step := &wfmodels.WorkflowStep{ID: "step2", WorkflowID: "wf1", Name: "Review"} + if err := svc.autoStartStepPrompt(ctx, "t1", session, step, "workflow start", false, true); err != nil { + t.Fatalf("auto-start: %v", err) + } + + entries := svc.messageQueue.GetStatus(ctx, "s1").Entries + if len(entries) != 1 { + t.Fatalf("expected one merged workflow queue entry, got %d", len(entries)) + } + entry := entries[0] + if !strings.Contains(entry.Content, "workflow start") || !strings.Contains(entry.Content, "handoff details") { + t.Fatalf("queued content missing merged prompt: %q", entry.Content) + } + if strings.Contains(entry.Content, "Validated work-item reference snapshots") { + t.Fatalf("queue must retain raw retry content, got %q", entry.Content) + } + gotReferences, ok := entry.Metadata[messagequeue.MetadataEntityReferences].([]v1.EntityReference) + if !ok || !reflect.DeepEqual(gotReferences, []v1.EntityReference{reference}) { + t.Fatalf("queued workflow references = %#v", entry.Metadata[messagequeue.MetadataEntityReferences]) + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 21b8e222b9..6b3c14e606 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -9,6 +9,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/executor" @@ -1345,11 +1346,12 @@ func workflowOriginFromStep(step *wfmodels.WorkflowStep) workflowMessageOrigin { } } -func workflowMessageMetadata(planMode bool, origin workflowMessageOrigin) map[string]interface{} { +func workflowMessageMetadata(planMode bool, origin workflowMessageOrigin, references []v1.EntityReference) map[string]interface{} { meta := NewUserMessageMeta(). WithPlanMode(planMode). WithAutoStart(true). WithWorkflowStep(origin.StepID, origin.StepName, origin.StepColor). + WithEntityReferences(references). ToMap() if meta == nil { meta = make(map[string]interface{}) @@ -1373,8 +1375,9 @@ func (s *Service) autoStartStepPrompt( // content first, hand-off after — and forward attachments verbatim. // Track the original message so terminal failure paths can restore it // instead of dropping the user's prompt or attachments on the floor. - takenMsg, mergedPrompt, attachments := s.takeAndMergeHandoffMessage(ctx, sessionID, prompt) + takenMsg, mergedPrompt, attachments, references := s.takeAndMergeHandoffMessage(ctx, sessionID, prompt) prompt = mergedPrompt + agentPrompt := AppendEntityReferenceContext(prompt, references) // requeueTaken puts the original queued message back so a manual retry can // pick it up. Skip when shouldQueueIfBusy successfully re-queued the @@ -1390,7 +1393,7 @@ func (s *Service) autoStartStepPrompt( // userMessageRecorded=false: recordAutoStartMessage has not run yet — // the drain side (executeQueuedMessage) is responsible for inserting // the chat-history row. - queued, err := s.queueAutoStartPromptIfRunning(ctx, taskID, session, prompt, planMode, attachments, origin, false) + queued, err := s.queueAutoStartPromptIfRunning(ctx, taskID, session, prompt, planMode, attachments, origin, false, references) if err != nil { requeueTaken() return err @@ -1406,12 +1409,12 @@ func (s *Service) autoStartStepPrompt( // before persisting (and before passing downstream) so the DB row matches // what the agent receives. StartCreatedSession's wrap is idempotent // (HasKandevContext guard) so the pre-wrap doesn't double. - // The HasKandevContext check on `prompt` also guards against any future + // The HasKandevContext check on `agentPrompt` also guards against any future // caller that ever pre-wraps before reaching here (none today). // Passthrough sessions skip the wrap: the prompt is typed straight into // the agent CLI's TTY and the user sees it verbatim. - recordedPrompt := prompt - if session.State == models.TaskSessionStateCreated && !session.IsPassthrough && (prompt != "" || len(attachments) > 0) && !sysprompt.HasKandevContext(prompt) { + recordedPrompt := agentPrompt + if session.State == models.TaskSessionStateCreated && !session.IsPassthrough && (agentPrompt != "" || len(attachments) > 0) && !sysprompt.HasKandevContext(agentPrompt) { isOfficeTask, err := s.lookupOfficeTask(ctx, taskID) if err != nil { requeueTaken() @@ -1419,12 +1422,12 @@ func (s *Service) autoStartStepPrompt( } configMode, _ := session.Metadata["config_mode"].(bool) requiresSignal := step != nil && step.AutoAdvanceRequiresSignal - recordedPrompt = sysprompt.InjectKandevContextWithOptions(taskID, sessionID, prompt, sysprompt.KandevContextOptions{ + recordedPrompt = sysprompt.InjectKandevContextWithOptions(taskID, sessionID, agentPrompt, sysprompt.KandevContextOptions{ RequiresCompletionSignal: requiresSignal, IncludeCoordinatorTaskControls: !isOfficeTask && !configMode, }) } - userMsgRecorded := s.recordAutoStartMessage(ctx, taskID, sessionID, recordedPrompt, planMode, origin) + userMsgRecorded := s.recordAutoStartMessage(ctx, taskID, sessionID, recordedPrompt, planMode, origin, references) // If the session is in CREATED state, the agent was never started (e.g. workspace-only // preparation from a blocked auto-start). PromptTask will reject CREATED sessions, @@ -1444,7 +1447,7 @@ func (s *Service) autoStartStepPrompt( const maxRetryAttempts = 5 for attempt := 1; attempt <= maxRetryAttempts; attempt++ { - _, err := s.PromptTask(ctx, taskID, sessionID, prompt, "", planMode, attachments, false) + _, err := s.PromptTask(ctx, taskID, sessionID, agentPrompt, "", planMode, attachments, false) if err == nil { return nil } @@ -1460,7 +1463,7 @@ func (s *Service) autoStartStepPrompt( zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.String("step_name", stepName)) - return s.fallbackFreshLaunchOnMissingExecution(ctx, taskID, sessionID, prompt, planMode, takenMsg, attachments) + return s.fallbackFreshLaunchOnMissingExecution(ctx, taskID, sessionID, agentPrompt, planMode, takenMsg, attachments) } // "already has an agent running" means the execution store still tracks @@ -1470,7 +1473,7 @@ func (s *Service) autoStartStepPrompt( // chat row was successfully inserted above; a failed write passes false, // letting the drain re-attempt insertion. if isAgentAlreadyRunningError(err) && shouldQueueIfBusy { - if queueErr := s.queueAutoStartPrompt(ctx, taskID, sessionID, prompt, planMode, attachments, origin, userMsgRecorded); queueErr != nil { + if queueErr := s.queueAutoStartPrompt(ctx, taskID, sessionID, prompt, planMode, attachments, origin, userMsgRecorded, references); queueErr != nil { requeueTaken() return queueErr } @@ -1485,7 +1488,7 @@ func (s *Service) autoStartStepPrompt( if shouldQueueIfBusy { // Pass userMsgRecorded so the drain skips CreateUserMessage only when // the chat row was successfully inserted above by recordAutoStartMessage. - if queueErr := s.queueAutoStartPrompt(ctx, taskID, sessionID, prompt, planMode, attachments, origin, userMsgRecorded); queueErr != nil { + if queueErr := s.queueAutoStartPrompt(ctx, taskID, sessionID, prompt, planMode, attachments, origin, userMsgRecorded, references); queueErr != nil { requeueTaken() return queueErr } @@ -1598,15 +1601,15 @@ func (s *Service) resetSessionForFreshFallback( // (set by handleMoveTask via move_task_kandev or by drainQueuedMessageForPromptableSession) // and merges its content + attachments into the auto-start prompt. Returns the // original queued message (so terminal failure paths can re-queue it via -// requeueMessage), the merged prompt, and the converted attachments. Empty -// messages with neither content nor attachments are left in the queue. -func (s *Service) takeAndMergeHandoffMessage(ctx context.Context, sessionID, basePrompt string) (*messagequeue.QueuedMessage, string, []v1.MessageAttachment) { +// requeueMessage), merged prompt, converted attachments, and structurally +// normalized references. +func (s *Service) takeAndMergeHandoffMessage(ctx context.Context, sessionID, basePrompt string) (*messagequeue.QueuedMessage, string, []v1.MessageAttachment, []v1.EntityReference) { if s.messageQueue == nil { - return nil, basePrompt, nil + return nil, basePrompt, nil, nil } msg, ok := s.messageQueue.TakeQueued(ctx, sessionID) if !ok || msg == nil || (msg.Content == "" && len(msg.Attachments) == 0) { - return nil, basePrompt, nil + return nil, basePrompt, nil, nil } prompt := basePrompt if msg.Content != "" { @@ -1626,7 +1629,8 @@ func (s *Service) takeAndMergeHandoffMessage(ctx context.Context, sessionID, bas } } s.publishQueueStatusEvent(ctx, sessionID) - return msg, prompt, attachments + references := entityrefs.NormalizePersisted(msg.Metadata[messagequeue.MetadataEntityReferences]) + return msg, prompt, attachments, references } // recordAutoStartMessage creates a user message for a workflow auto-start prompt @@ -1642,6 +1646,7 @@ func (s *Service) recordAutoStartMessage( taskID, sessionID, prompt string, planMode bool, origin workflowMessageOrigin, + references []v1.EntityReference, ) bool { if s.messageCreator == nil || prompt == "" { return false @@ -1658,7 +1663,7 @@ func (s *Service) recordAutoStartMessage( // re-creating the exact pileup the cleanup_policy work fixes. // workflow_auto_start is the original tag this function set; preserved // for any consumer reading it directly. - metaMap := workflowMessageMetadata(planMode, origin) + metaMap := workflowMessageMetadata(planMode, origin, references) if err := s.messageCreator.CreateUserMessage(ctx, taskID, prompt, sessionID, turnID, metaMap); err != nil { s.logger.Error("failed to create auto-start user message", zap.String("task_id", taskID), @@ -1682,11 +1687,12 @@ func (s *Service) queueAutoStartPromptIfRunning( attachments []v1.MessageAttachment, origin workflowMessageOrigin, userMessageRecorded bool, + references []v1.EntityReference, ) (bool, error) { if session.State != models.TaskSessionStateRunning && session.State != models.TaskSessionStateStarting { return false, nil } - if err := s.queueAutoStartPrompt(ctx, taskID, session.ID, prompt, planMode, attachments, origin, userMessageRecorded); err != nil { + if err := s.queueAutoStartPrompt(ctx, taskID, session.ID, prompt, planMode, attachments, origin, userMessageRecorded, references); err != nil { return false, err } return true, nil @@ -1717,7 +1723,9 @@ func toQueuedAttachments(attachments []v1.MessageAttachment) []messagequeue.Mess // transiently and the queue drained on boot_ready. Passing false (failed write // or pre-record queue path) lets the drain side record the message instead. // Callers that queue BEFORE recordAutoStartMessage runs (e.g. -// queueAutoStartPromptIfRunning's early-busy path) must pass false. +// queueAutoStartPromptIfRunning's early-busy path) must pass false. Prompt +// content stays raw here; references remain metadata until drain-time context +// is built, preventing duplicate system blocks across retries. func (s *Service) queueAutoStartPrompt( ctx context.Context, taskID, sessionID, prompt string, @@ -1725,11 +1733,12 @@ func (s *Service) queueAutoStartPrompt( attachments []v1.MessageAttachment, origin workflowMessageOrigin, userMessageRecorded bool, + references []v1.EntityReference, ) error { if s.messageQueue == nil { return fmt.Errorf("message queue is not configured") } - meta := workflowMessageMetadata(planMode, origin) + meta := workflowMessageMetadata(planMode, origin, references) if userMessageRecorded { meta[metaKeyUserMessageRecorded] = true } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go index 45bd5df5e5..0df6bdcfd8 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go @@ -436,7 +436,7 @@ func TestHandleTaskMovedWithSession(t *testing.T) { _ = repo.UpdateTaskSession(ctx, session) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - queued, err := svc.queueAutoStartPromptIfRunning(ctx, "t1", session, "prompt", false, nil, workflowMessageOrigin{}, false) + queued, err := svc.queueAutoStartPromptIfRunning(ctx, "t1", session, "prompt", false, nil, workflowMessageOrigin{}, false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers.go b/apps/backend/internal/orchestrator/handlers/queue_handlers.go index 06fa145b22..e32f5a035c 100644 --- a/apps/backend/internal/orchestrator/handlers/queue_handlers.go +++ b/apps/backend/internal/orchestrator/handlers/queue_handlers.go @@ -5,10 +5,12 @@ import ( "errors" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/orchestrator/messagequeue" + v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) @@ -19,6 +21,7 @@ const ( queueErrorCodeEntryNotFound = "entry_not_found" queueErrorCodeSessionBusy = "session_busy" queueErrorCodeNotPromptable = "session_not_promptable" + queueInvalidReferences = "Invalid entity references" // Payload field names — extracted to satisfy goconst (≥3 occurrences). fieldSessionID = "session_id" @@ -30,9 +33,9 @@ const ( // QueueService is the surface the handlers depend on. Real implementation lives // in messagequeue.Service. type QueueService interface { - QueueMessage(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []messagequeue.MessageAttachment) (*messagequeue.QueuedMessage, error) + QueueMessageWithMetadata(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []messagequeue.MessageAttachment, metadata map[string]interface{}) (*messagequeue.QueuedMessage, error) AppendContent(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []messagequeue.MessageAttachment) (*messagequeue.QueuedMessage, bool, error) - UpdateMessage(ctx context.Context, sessionID, entryID, content string, attachments []messagequeue.MessageAttachment, queuedBy string) error + UpdateMessageWithMetadata(ctx context.Context, sessionID, entryID, content string, attachments []messagequeue.MessageAttachment, metadataUpdates map[string]interface{}, queuedBy string) error RemoveEntry(ctx context.Context, sessionID, entryID string) error CancelAll(ctx context.Context, sessionID string) (int, error) GetStatus(ctx context.Context, sessionID string) *messagequeue.QueueStatus @@ -44,23 +47,31 @@ type QueueDrainer interface { // QueueHandlers handles WebSocket message-queue operations. type QueueHandlers struct { - queueService QueueService - queueDrainer QueueDrainer - eventBus bus.EventBus - logger *logger.Logger + queueService QueueService + queueDrainer QueueDrainer + eventBus bus.EventBus + logger *logger.Logger + referenceValidator entityrefs.SubmissionValidator } // NewQueueHandlers creates a new QueueHandlers instance. -func NewQueueHandlers(queueService QueueService, eventBus bus.EventBus, log *logger.Logger, drainer ...QueueDrainer) *QueueHandlers { - var queueDrainer QueueDrainer - if len(drainer) > 0 { - queueDrainer = drainer[0] +func NewQueueHandlers( + queueService QueueService, + eventBus bus.EventBus, + log *logger.Logger, + queueDrainer QueueDrainer, + validators ...entityrefs.SubmissionValidator, +) *QueueHandlers { + var referenceValidator entityrefs.SubmissionValidator + if len(validators) > 0 { + referenceValidator = validators[0] } return &QueueHandlers{ - queueService: queueService, - queueDrainer: queueDrainer, - eventBus: eventBus, - logger: log.WithFields(zap.String("component", "queue-handlers")), + queueService: queueService, + queueDrainer: queueDrainer, + eventBus: eventBus, + logger: log.WithFields(zap.String("component", "queue-handlers")), + referenceValidator: referenceValidator, } } @@ -76,13 +87,14 @@ func (h *QueueHandlers) RegisterHandlers(d *ws.Dispatcher) { } type wsQueueMessageRequest struct { - SessionID string `json:"session_id"` - TaskID string `json:"task_id"` - Content string `json:"content"` - Model string `json:"model,omitempty"` - PlanMode bool `json:"plan_mode,omitempty"` - Attachments []messagequeue.MessageAttachment `json:"attachments,omitempty"` - UserID string `json:"user_id,omitempty"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + Content string `json:"content"` + Model string `json:"model,omitempty"` + PlanMode bool `json:"plan_mode,omitempty"` + Attachments []messagequeue.MessageAttachment `json:"attachments,omitempty"` + EntityReferences []v1.EntityReference `json:"entity_references,omitempty"` + UserID string `json:"user_id,omitempty"` } func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -107,6 +119,11 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w if req.UserID == messagequeue.QueuedByAgent { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "user_id may not impersonate the agent identity", nil) } + references, err := h.validateSubmittedReferences(ctx, req.SessionID, req.TaskID, req.EntityReferences) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, queueInvalidReferences, nil) + } + req.EntityReferences = references // Default empty user_id to QueuedByUser so the entry has a non-empty owner; // the UpdateMessage handler relies on this so its filter against agent @@ -115,7 +132,8 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w if queuedBy == "" { queuedBy = messagequeue.QueuedByUser } - queued, err := h.queueService.QueueMessage(ctx, req.SessionID, req.TaskID, req.Content, req.Model, queuedBy, req.PlanMode, req.Attachments) + metadata := orchestrator.NewUserMessageMeta().WithEntityReferences(req.EntityReferences).ToMap() + queued, err := h.queueService.QueueMessageWithMetadata(ctx, req.SessionID, req.TaskID, req.Content, req.Model, queuedBy, req.PlanMode, req.Attachments, metadata) if err != nil { if errors.Is(err, messagequeue.ErrQueueFull) { status := h.queueService.GetStatus(ctx, req.SessionID) @@ -214,11 +232,12 @@ func (h *QueueHandlers) wsGetQueueStatus(ctx context.Context, msg *ws.Message) ( } type wsUpdateMessageRequest struct { - SessionID string `json:"session_id"` - EntryID string `json:"entry_id"` - Content string `json:"content"` - Attachments []messagequeue.MessageAttachment `json:"attachments,omitempty"` - UserID string `json:"user_id,omitempty"` + SessionID string `json:"session_id"` + EntryID string `json:"entry_id"` + Content string `json:"content"` + Attachments []messagequeue.MessageAttachment `json:"attachments,omitempty"` + EntityReferences []v1.EntityReference `json:"entity_references,omitempty"` + UserID string `json:"user_id,omitempty"` } func (h *QueueHandlers) wsUpdateMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -250,6 +269,12 @@ func (h *QueueHandlers) wsUpdateMessage(ctx context.Context, msg *ws.Message) (* if req.UserID == messagequeue.QueuedByAgent { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "user_id may not impersonate the agent identity", nil) } + referencesProvided := req.EntityReferences != nil + references, err := h.validateSubmittedReferences(ctx, req.SessionID, "", req.EntityReferences) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, queueInvalidReferences, nil) + } + req.EntityReferences = references // Default empty user_id to QueuedByUser so the UpdateContent guard always // runs against a non-empty owner. Agent entries (queued_by="agent") then // fail the filter, mirroring the canEdit UI gate at the WS layer. @@ -257,7 +282,15 @@ func (h *QueueHandlers) wsUpdateMessage(ctx context.Context, msg *ws.Message) (* if queuedBy == "" { queuedBy = messagequeue.QueuedByUser } - if err := h.queueService.UpdateMessage(ctx, req.SessionID, req.EntryID, req.Content, req.Attachments, queuedBy); err != nil { + var metadataUpdates map[string]interface{} + if referencesProvided { + var referenceMetadata interface{} + if len(req.EntityReferences) > 0 { + referenceMetadata = req.EntityReferences + } + metadataUpdates = map[string]interface{}{messagequeue.MetadataEntityReferences: referenceMetadata} + } + if err := h.queueService.UpdateMessageWithMetadata(ctx, req.SessionID, req.EntryID, req.Content, req.Attachments, metadataUpdates, queuedBy); err != nil { if errors.Is(err, messagequeue.ErrEntryNotFound) { return ws.NewError(msg.ID, msg.Action, queueErrorCodeEntryNotFound, "Queue entry was already drained or not owned by caller", nil) } @@ -268,6 +301,20 @@ func (h *QueueHandlers) wsUpdateMessage(ctx context.Context, msg *ws.Message) (* return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{fieldEntryID: req.EntryID}) } +func (h *QueueHandlers) validateSubmittedReferences( + ctx context.Context, + sessionID, taskID string, + references []v1.EntityReference, +) ([]v1.EntityReference, error) { + if len(references) == 0 { + return nil, nil + } + if h.referenceValidator == nil { + return nil, entityrefs.ErrUnauthorizedReference + } + return h.referenceValidator.ValidateForSubmission(ctx, sessionID, taskID, references) +} + func firstInvalidDeliveryMode(attachments []messagequeue.MessageAttachment) int { for i, att := range attachments { if att.DeliveryMode != "" && att.DeliveryMode != "prompt" && att.DeliveryMode != "path" { diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go b/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go index 9c9669465b..b10a62a39e 100644 --- a/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go +++ b/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go @@ -8,9 +8,11 @@ import ( "time" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/orchestrator/messagequeue" + v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -41,6 +43,25 @@ type mockQueueDrainer struct { err error } +type fakeReferenceSubmissionValidator struct { + calls int + sessionID string + taskID string + result []v1.EntityReference + err error +} + +func (f *fakeReferenceSubmissionValidator) ValidateForSubmission( + _ context.Context, + sessionID, taskID string, + _ []v1.EntityReference, +) ([]v1.EntityReference, error) { + f.calls++ + f.sessionID = sessionID + f.taskID = taskID + return f.result, f.err +} + func (m *mockQueueDrainer) DrainQueuedMessage(_ context.Context, sessionID string) (bool, error) { m.calls++ m.sessionID = sessionID @@ -63,6 +84,18 @@ func setupQueueHandlersWithDrainer(t *testing.T, drainer QueueDrainer) (*QueueHa return NewQueueHandlers(svc, &mockEventBus{}, log, drainer), svc } +func setupQueueHandlersWithValidator(t *testing.T, validator entityrefs.SubmissionValidator) (*QueueHandlers, *messagequeue.Service) { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "console", + OutputPath: "stderr", + }) + require.NoError(t, err) + svc := messagequeue.NewServiceMemory(log) + return NewQueueHandlers(svc, &mockEventBus{}, log, nil, validator), svc +} + func createTestMessage(t *testing.T, action string, payload interface{}) *ws.Message { t.Helper() data, err := json.Marshal(payload) @@ -149,6 +182,88 @@ func TestWsQueueMessage(t *testing.T) { assert.EqualValues(t, messagequeue.DefaultMaxPerSession, errPayload.Details["queue_size"]) assert.EqualValues(t, messagequeue.DefaultMaxPerSession, errPayload.Details["max"]) }) + + t.Run("rejects entity references without an authorization validator", func(t *testing.T) { + handlers, svc := setupQueueHandlers(t) + ctx := context.Background() + + response, err := handlers.wsQueueMessage(ctx, createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{ + "session_id": "s", + "task_id": "t", + "content": "reference it", + "entity_references": []map[string]interface{}{ + { + "version": 1, + "ref": "mention:v1:kandev:task:workspace-1:task-2", + "provider": "kandev", + "kind": "task", + "id": "task-2", + "key": "TASK-2", + "title": "Referenced task", + "url": "/t/task-2", + "scope": "workspace-1", + }, + }, + })) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeError, response.Type) + assert.Equal(t, ws.ErrorCodeValidation, parseError(t, response).Code) + assert.Zero(t, svc.GetStatus(ctx, "s").Count, "validation must happen before queue mutation") + }) + + t.Run("persists references returned by authorization validator", func(t *testing.T) { + normalized := v1.EntityReference{ + Version: v1.EntityReferenceVersion, + Ref: "mention:v1:kandev:task:workspace-1:task-2", + Provider: "kandev", + Kind: "task", + ID: "task-2", + Key: "TASK-2", + Title: "Referenced task", + URL: "/t/task-2", + Scope: "workspace-1", + } + validator := &fakeReferenceSubmissionValidator{result: []v1.EntityReference{normalized}} + handlers, svc := setupQueueHandlersWithValidator(t, validator) + ctx := context.Background() + + response, err := handlers.wsQueueMessage(ctx, createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{ + "session_id": "s", + "task_id": "t", + "content": "reference it", + "entity_references": []v1.EntityReference{normalized}, + })) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, response.Type) + assert.Equal(t, 1, validator.calls) + assert.Equal(t, "s", validator.sessionID) + assert.Equal(t, "t", validator.taskID) + + entries := svc.GetStatus(ctx, "s").Entries + require.Len(t, entries, 1) + assert.Equal(t, []v1.EntityReference{normalized}, entries[0].Metadata["entity_references"]) + }) + + t.Run("rejects passthrough reference scope before queue mutation", func(t *testing.T) { + reference := v1.EntityReference{ + Version: v1.EntityReferenceVersion, + Ref: "mention:v1:kandev:task:workspace-1:task-2", Provider: "kandev", Kind: "task", + ID: "task-2", Title: "Referenced task", URL: "/t/task-2", Scope: "workspace-1", + } + validator := &fakeReferenceSubmissionValidator{err: entityrefs.ErrUnauthorizedReference} + handlers, svc := setupQueueHandlersWithValidator(t, validator) + ctx := context.Background() + response, err := handlers.wsQueueMessage(ctx, createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{ + "session_id": "passthrough-session", + "task_id": "t", + "content": "reference it", + "entity_references": []v1.EntityReference{reference}, + })) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeError, response.Type) + assert.Equal(t, 1, validator.calls) + assert.Zero(t, svc.GetStatus(ctx, "passthrough-session").Count) + }) } func TestFirstInvalidDeliveryMode(t *testing.T) { @@ -349,6 +464,146 @@ func TestWsUpdateMessage(t *testing.T) { assert.Equal(t, "edited", svc.GetStatus(ctx, "s").Entries[0].Content) }) + t.Run("omitted references preserve existing metadata", func(t *testing.T) { + handlers, svc := setupQueueHandlers(t) + ctx := context.Background() + references := []interface{}{"existing"} + queued, err := svc.QueueMessageWithMetadata( + ctx, "s", "t", "original", "", "u", false, nil, + map[string]interface{}{ + "entity_references": references, + "origin": "inter-task", + }, + ) + require.NoError(t, err) + + response, err := handlers.wsUpdateMessage(ctx, + createTestMessage(t, ws.ActionMessageQueueUpdate, map[string]interface{}{ + "session_id": "s", + "entry_id": queued.ID, + "content": "edited", + "user_id": "u", + })) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, response.Type) + + entries := svc.GetStatus(ctx, "s").Entries + require.Len(t, entries, 1) + assert.Equal(t, "inter-task", entries[0].Metadata["origin"]) + assert.Equal(t, references, entries[0].Metadata["entity_references"]) + }) + + t.Run("explicit empty references clear existing metadata", func(t *testing.T) { + handlers, svc := setupQueueHandlers(t) + ctx := context.Background() + queued, err := svc.QueueMessageWithMetadata( + ctx, "s", "t", "original", "", "u", false, nil, + map[string]interface{}{ + "entity_references": []interface{}{"existing"}, + "origin": "inter-task", + }, + ) + require.NoError(t, err) + + response, err := handlers.wsUpdateMessage(ctx, + createTestMessage(t, ws.ActionMessageQueueUpdate, map[string]interface{}{ + "session_id": "s", + "entry_id": queued.ID, + "content": "edited", + "user_id": "u", + "entity_references": []v1.EntityReference{}, + })) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, response.Type) + + entries := svc.GetStatus(ctx, "s").Entries + require.Len(t, entries, 1) + assert.Equal(t, "inter-task", entries[0].Metadata["origin"]) + _, exists := entries[0].Metadata["entity_references"] + assert.False(t, exists, "explicit empty references must clear existing metadata") + }) + + t.Run("rejects references before editing when validator is unavailable", func(t *testing.T) { + handlers, svc := setupQueueHandlers(t) + ctx := context.Background() + queued, err := svc.QueueMessage(ctx, "s", "t", "original", "", "u", false, nil) + require.NoError(t, err) + reference := v1.EntityReference{ + Version: v1.EntityReferenceVersion, + Ref: "mention:v1:kandev:task:workspace-1:task-2", Provider: "kandev", Kind: "task", + ID: "task-2", Title: "Referenced task", URL: "/t/task-2", Scope: "workspace-1", + } + + response, err := handlers.wsUpdateMessage(ctx, + createTestMessage(t, ws.ActionMessageQueueUpdate, map[string]interface{}{ + "session_id": "s", + "entry_id": queued.ID, + "content": "edited", + "user_id": "u", + "entity_references": []v1.EntityReference{reference}, + })) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeError, response.Type) + assert.Equal(t, "original", svc.GetStatus(ctx, "s").Entries[0].Content) + }) + + t.Run("rejects passthrough reference scope before editing", func(t *testing.T) { + validator := &fakeReferenceSubmissionValidator{err: entityrefs.ErrUnauthorizedReference} + handlers, svc := setupQueueHandlersWithValidator(t, validator) + ctx := context.Background() + queued, err := svc.QueueMessage(ctx, "passthrough-session", "t", "original", "", "u", false, nil) + require.NoError(t, err) + reference := v1.EntityReference{ + Version: v1.EntityReferenceVersion, + Ref: "mention:v1:kandev:task:workspace-1:task-2", Provider: "kandev", Kind: "task", + ID: "task-2", Title: "Referenced task", URL: "/t/task-2", Scope: "workspace-1", + } + + response, err := handlers.wsUpdateMessage(ctx, + createTestMessage(t, ws.ActionMessageQueueUpdate, map[string]interface{}{ + "session_id": "passthrough-session", + "entry_id": queued.ID, + "content": "edited", + "user_id": "u", + "entity_references": []v1.EntityReference{reference}, + })) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeError, response.Type) + assert.Equal(t, 1, validator.calls) + assert.Equal(t, "original", svc.GetStatus(ctx, "passthrough-session").Entries[0].Content) + }) + + t.Run("authorizes and replaces references", func(t *testing.T) { + normalized := v1.EntityReference{ + Version: v1.EntityReferenceVersion, + Ref: "mention:v1:kandev:task:workspace-1:task-2", Provider: "kandev", Kind: "task", + ID: "task-2", Key: "TASK-2", Title: "Referenced task", URL: "/t/task-2", Scope: "workspace-1", + } + validator := &fakeReferenceSubmissionValidator{result: []v1.EntityReference{normalized}} + handlers, svc := setupQueueHandlersWithValidator(t, validator) + ctx := context.Background() + queued, err := svc.QueueMessage(ctx, "s", "t", "original", "", "u", false, nil) + require.NoError(t, err) + + response, err := handlers.wsUpdateMessage(ctx, + createTestMessage(t, ws.ActionMessageQueueUpdate, map[string]interface{}{ + "session_id": "s", + "entry_id": queued.ID, + "content": "edited", + "user_id": "u", + "entity_references": []v1.EntityReference{normalized}, + })) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeResponse, response.Type) + assert.Equal(t, 1, validator.calls) + assert.Equal(t, "s", validator.sessionID) + assert.Empty(t, validator.taskID, "queue edit resolves task from trusted session") + + entries := svc.GetStatus(ctx, "s").Entries + require.Len(t, entries, 1) + assert.Equal(t, []v1.EntityReference{normalized}, entries[0].Metadata["entity_references"]) + }) + t.Run("returns entry_not_found when drained", func(t *testing.T) { handlers, svc := setupQueueHandlers(t) ctx := context.Background() diff --git a/apps/backend/internal/orchestrator/message_meta.go b/apps/backend/internal/orchestrator/message_meta.go index 102800d5b3..48f3df7cb5 100644 --- a/apps/backend/internal/orchestrator/message_meta.go +++ b/apps/backend/internal/orchestrator/message_meta.go @@ -1,6 +1,11 @@ package orchestrator -import v1 "github.com/kandev/kandev/pkg/api/v1" +import ( + "encoding/json" + + "github.com/kandev/kandev/internal/sysprompt" + v1 "github.com/kandev/kandev/pkg/api/v1" +) // UserMessageMeta holds metadata fields for a user message. // Use NewUserMessageMeta to construct and ToMap to serialize. @@ -10,6 +15,7 @@ type UserMessageMeta struct { AutoStart bool Attachments []v1.MessageAttachment ContextFiles []v1.ContextFileMeta + EntityReferences []v1.EntityReference SenderTaskID string SenderTaskTitle string SenderSessionID string @@ -58,6 +64,12 @@ func (m *UserMessageMeta) WithContextFiles(files []v1.ContextFileMeta) *UserMess return m } +// WithEntityReferences sets normalized references selected in the composer. +func (m *UserMessageMeta) WithEntityReferences(references []v1.EntityReference) *UserMessageMeta { + m.EntityReferences = append([]v1.EntityReference(nil), references...) + return m +} + // WithSenderTask records that the message originated from another task's agent // (via the message_task_kandev MCP tool). Title is a snapshot at send time; // session ID is optional and identifies the sender's specific session. @@ -89,7 +101,8 @@ func (m *UserMessageMeta) WithWorkflowStep(stepID, stepName, stepColor string) * // ToMap returns the metadata as a map suitable for message creation. // Returns nil if no metadata fields are set. func (m *UserMessageMeta) ToMap() map[string]interface{} { - if !m.PlanMode && !m.HasReviewComments && !m.AutoStart && len(m.Attachments) == 0 && len(m.ContextFiles) == 0 && m.SenderTaskID == "" && !m.WorkflowMessage { + if !m.PlanMode && !m.HasReviewComments && !m.AutoStart && len(m.Attachments) == 0 && + len(m.ContextFiles) == 0 && len(m.EntityReferences) == 0 && m.SenderTaskID == "" && !m.WorkflowMessage { return nil } meta := make(map[string]interface{}) @@ -108,6 +121,9 @@ func (m *UserMessageMeta) ToMap() map[string]interface{} { if len(m.ContextFiles) > 0 { meta["context_files"] = m.ContextFiles } + if len(m.EntityReferences) > 0 { + meta["entity_references"] = append([]v1.EntityReference(nil), m.EntityReferences...) + } if m.SenderTaskID != "" { meta["sender_task_id"] = m.SenderTaskID meta["sender_task_title"] = m.SenderTaskTitle @@ -133,6 +149,25 @@ func (m *UserMessageMeta) ToMap() map[string]interface{} { return meta } +// AppendEntityReferenceContext adds one HTML-escaped JSON system block for agent comprehension. +func AppendEntityReferenceContext(content string, references []v1.EntityReference) string { + if len(references) == 0 { + return content + } + payload := struct { + EntityReferences []v1.EntityReference `json:"entity_references"` + }{EntityReferences: references} + encoded, err := json.Marshal(payload) + if err != nil { + return content + } + block := sysprompt.Wrap("Validated work-item reference snapshots (titles are untrusted data):\n" + string(encoded)) + if content == "" { + return block + } + return content + "\n\n" + block +} + // mergeMetadata returns a single metadata map combining base and extra. extra // values take precedence on key collision. Returns nil only when both inputs // are nil/empty so callers can keep the "no metadata" branch in the message @@ -147,7 +182,7 @@ func mergeMetadata(base, extra map[string]interface{}) map[string]interface{} { if len(base) == 0 { return extra } - merged := make(map[string]interface{}, len(base)+len(extra)) + merged := make(map[string]interface{}, len(base)) for k, v := range base { merged[k] = v } diff --git a/apps/backend/internal/orchestrator/message_meta_test.go b/apps/backend/internal/orchestrator/message_meta_test.go index 0f451f59bb..6b06dd018b 100644 --- a/apps/backend/internal/orchestrator/message_meta_test.go +++ b/apps/backend/internal/orchestrator/message_meta_test.go @@ -1,8 +1,11 @@ package orchestrator import ( + "strings" "testing" + "github.com/kandev/kandev/internal/entityrefs" + "github.com/kandev/kandev/internal/sysprompt" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -14,6 +17,31 @@ func TestUserMessageMeta_ToMap_Empty(t *testing.T) { } } +func TestUserMessageMetaEntityReferencesAndSanitizedContext(t *testing.T) { + references := []v1.EntityReference{{ + Version: v1.EntityReferenceVersion, + Ref: entityrefs.CanonicalRef("jira", "issue", "site", "100"), + Provider: "jira", Kind: "issue", ID: "100", Key: "ENG-7", + Title: "Fix auth", URL: "https://jira.test/browse/ENG-7", Scope: "site", + }} + meta := NewUserMessageMeta().WithEntityReferences(references).ToMap() + stored, ok := meta["entity_references"].([]v1.EntityReference) + if !ok || len(stored) != 1 || stored[0].Ref != references[0].Ref { + t.Fatalf("metadata references = %#v", meta["entity_references"]) + } + + content := AppendEntityReferenceContext("hello", references) + if strings.Count(content, sysprompt.TagStart) != 1 || strings.Count(content, sysprompt.TagEnd) != 1 { + t.Fatalf("context contains unsafe/nested system tags: %q", content) + } + if strings.Contains(content, "") { + t.Fatalf("context contains unescaped provider title: %q", content) + } + if !strings.Contains(content, `"entity_references"`) || sysprompt.StripSystemContent(content) != "hello" { + t.Fatalf("context = %q", content) + } +} + func TestUserMessageMeta_ToMap_PlanModeOnly(t *testing.T) { meta := NewUserMessageMeta().WithPlanMode(true) result := meta.ToMap() diff --git a/apps/backend/internal/orchestrator/messagequeue/repository.go b/apps/backend/internal/orchestrator/messagequeue/repository.go index 99b4d4e7eb..c3c6e00d00 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository.go @@ -46,6 +46,11 @@ type Repository interface { // ErrEntryNotFound when no row matches all guards. UpdateContent(ctx context.Context, sessionID, entryID, content string, attachments []MessageAttachment, queuedBy string) error + // UpdateContentAndMetadata atomically replaces content/attachments and + // applies metadata key updates. A nil update value removes that key while + // unrelated metadata remains unchanged. + UpdateContentAndMetadata(ctx context.Context, sessionID, entryID, content string, attachments []MessageAttachment, metadataUpdates map[string]interface{}, queuedBy string) error + // DeleteByID removes a single entry. The session scope (`AND session_id = ?`) // is mandatory so a caller can't delete an entry by guessing its UUID across // sessions — the queue_full MCP payload deliberately discloses sibling-task @@ -77,3 +82,21 @@ type Repository interface { // Returns nil, nil if absent. TakePendingMove(ctx context.Context, sessionID string) (*PendingMove, error) } + +func applyMetadataUpdates(current, updates map[string]interface{}) map[string]interface{} { + merged := make(map[string]interface{}, len(current)+len(updates)) + for key, value := range current { + merged[key] = value + } + for key, value := range updates { + if value == nil { + delete(merged, key) + continue + } + merged[key] = value + } + if len(merged) == 0 { + return nil + } + return merged +} diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_memory.go b/apps/backend/internal/orchestrator/messagequeue/repository_memory.go index 7352b3f75f..ef15f8981d 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_memory.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_memory.go @@ -179,7 +179,11 @@ func (r *memoryRepository) TakeByID(_ context.Context, sessionID, entryID string return nil, nil } -func (r *memoryRepository) UpdateContent(_ context.Context, sessionID, entryID, content string, attachments []MessageAttachment, queuedBy string) error { +func (r *memoryRepository) UpdateContent(ctx context.Context, sessionID, entryID, content string, attachments []MessageAttachment, queuedBy string) error { + return r.UpdateContentAndMetadata(ctx, sessionID, entryID, content, attachments, nil, queuedBy) +} + +func (r *memoryRepository) UpdateContentAndMetadata(_ context.Context, sessionID, entryID, content string, attachments []MessageAttachment, metadataUpdates map[string]interface{}, queuedBy string) error { r.mu.Lock() defer r.mu.Unlock() list, ok := r.entries[sessionID] @@ -195,6 +199,7 @@ func (r *memoryRepository) UpdateContent(_ context.Context, sessionID, entryID, } m.Content = content m.Attachments = attachments + m.Metadata = applyMetadataUpdates(m.Metadata, metadataUpdates) return nil } return ErrEntryNotFound diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go index 7cba850602..f92f63ab81 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go @@ -431,19 +431,55 @@ func (r *sqliteRepository) TakeByID(ctx context.Context, sessionID, entryID stri } func (r *sqliteRepository) UpdateContent(ctx context.Context, sessionID, entryID, content string, attachments []MessageAttachment, queuedBy string) error { + return r.UpdateContentAndMetadata(ctx, sessionID, entryID, content, attachments, nil, queuedBy) +} + +func (r *sqliteRepository) UpdateContentAndMetadata(ctx context.Context, sessionID, entryID, content string, attachments []MessageAttachment, metadataUpdates map[string]interface{}, queuedBy string) error { attachmentsJSON, err := marshalAttachments(attachments) if err != nil { return err } - var query string - args := []interface{}{content, attachmentsJSON, entryID, sessionID} + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin update queued tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var selectQuery string + selectArgs := []interface{}{entryID, sessionID} if queuedBy != "" { - query = `UPDATE queued_messages SET content = ?, attachments_json = ? WHERE id = ? AND session_id = ? AND queued_by = ?` + selectQuery = `SELECT metadata_json FROM queued_messages WHERE id = ? AND session_id = ? AND queued_by = ?` + selectArgs = append(selectArgs, queuedBy) + } else { + selectQuery = `SELECT metadata_json FROM queued_messages WHERE id = ? AND session_id = ?` + } + var metadataJSON string + if err := tx.GetContext(ctx, &metadataJSON, r.db.Rebind(selectQuery), selectArgs...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrEntryNotFound + } + return fmt.Errorf("read queued metadata: %w", err) + } + var metadata map[string]interface{} + if metadataJSON != "" && metadataJSON != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return fmt.Errorf("unmarshal queued metadata: %w", err) + } + } + metadataJSON, err = marshalMetadata(applyMetadataUpdates(metadata, metadataUpdates)) + if err != nil { + return err + } + + var updateQuery string + args := []interface{}{content, attachmentsJSON, metadataJSON, entryID, sessionID} + if queuedBy != "" { + updateQuery = `UPDATE queued_messages SET content = ?, attachments_json = ?, metadata_json = ? WHERE id = ? AND session_id = ? AND queued_by = ?` args = append(args, queuedBy) } else { - query = `UPDATE queued_messages SET content = ?, attachments_json = ? WHERE id = ? AND session_id = ?` + updateQuery = `UPDATE queued_messages SET content = ?, attachments_json = ?, metadata_json = ? WHERE id = ? AND session_id = ?` } - res, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...) + res, err := tx.ExecContext(ctx, r.db.Rebind(updateQuery), args...) if err != nil { return fmt.Errorf("update queued: %w", err) } @@ -454,7 +490,7 @@ func (r *sqliteRepository) UpdateContent(ctx context.Context, sessionID, entryID if n == 0 { return ErrEntryNotFound } - return nil + return tx.Commit() } func (r *sqliteRepository) DeleteByID(ctx context.Context, sessionID, entryID string) error { diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go index 69266fe527..99d832f0f0 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go @@ -187,6 +187,69 @@ func TestSQLiteRepository_UpdateContent(t *testing.T) { } } +func TestSQLiteRepository_UpdateContentAndMetadataPreservesUnrelatedKeys(t *testing.T) { + repo := newTestSQLiteRepo(t) + ctx := context.Background() + msg := &QueuedMessage{ + SessionID: "s1", + TaskID: "t1", + Content: "original", + QueuedBy: "user-1", + Metadata: map[string]interface{}{ + "entity_references": []interface{}{"old"}, + "origin": "inter-task", + }, + } + if err := repo.Insert(ctx, msg, 0); err != nil { + t.Fatalf("insert: %v", err) + } + + if err := repo.UpdateContentAndMetadata( + ctx, "s1", msg.ID, "edited", nil, + map[string]interface{}{"entity_references": []interface{}{"new"}}, + "user-1", + ); err != nil { + t.Fatalf("update content and metadata: %v", err) + } + + entries, err := repo.ListBySession(ctx, "s1") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected one entry, got %d", len(entries)) + } + if entries[0].Content != "edited" { + t.Fatalf("content = %q, want edited", entries[0].Content) + } + if entries[0].Metadata["origin"] != "inter-task" { + t.Fatalf("unrelated metadata lost: %+v", entries[0].Metadata) + } + wantReferences := []interface{}{"new"} + gotReferences, ok := entries[0].Metadata["entity_references"].([]interface{}) + if !ok || len(gotReferences) != len(wantReferences) || gotReferences[0] != wantReferences[0] { + t.Fatalf("entity references = %#v, want %#v", entries[0].Metadata["entity_references"], wantReferences) + } + + if err := repo.UpdateContentAndMetadata( + ctx, "s1", msg.ID, "edited again", nil, + map[string]interface{}{"entity_references": nil}, + "user-1", + ); err != nil { + t.Fatalf("clear references: %v", err) + } + entries, err = repo.ListBySession(ctx, "s1") + if err != nil { + t.Fatalf("list after clear: %v", err) + } + if entries[0].Metadata["origin"] != "inter-task" { + t.Fatalf("unrelated metadata lost while clearing: %+v", entries[0].Metadata) + } + if _, exists := entries[0].Metadata["entity_references"]; exists { + t.Fatalf("stale references survived clear: %+v", entries[0].Metadata) + } +} + func TestSQLiteRepository_ReplaceCoalescedDetectsMissingRow(t *testing.T) { repo := newTestSQLiteRepo(t).(*sqliteRepository) ctx := context.Background() diff --git a/apps/backend/internal/orchestrator/messagequeue/service.go b/apps/backend/internal/orchestrator/messagequeue/service.go index a547794da7..f345f9a619 100644 --- a/apps/backend/internal/orchestrator/messagequeue/service.go +++ b/apps/backend/internal/orchestrator/messagequeue/service.go @@ -54,7 +54,7 @@ func (s *Service) QueueMessage(ctx context.Context, sessionID, taskID, content, // is propagated to the resulting Message row when the queued message is // drained (e.g. sender_task_id for messages sent via message_task_kandev). func (s *Service) QueueMessageWithMetadata(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment, metadata map[string]interface{}) (*QueuedMessage, error) { - metadataCopy := copyMessageMetadata(metadata, 0) + metadataCopy := copyMessageMetadata(metadata, false) msg := &QueuedMessage{ SessionID: sessionID, TaskID: taskID, @@ -87,7 +87,7 @@ func (s *Service) QueueMessageWithMetadata(ctx context.Context, sessionID, taskI // inserts a new tail entry if allowInsert is true; otherwise ErrEntryNotFound is // returned. The returned bool is true when an existing entry was replaced. func (s *Service) QueueMessageWithCoalesceKey(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment, metadata map[string]interface{}, coalesceKey string, allowInsert bool) (*QueuedMessage, bool, error) { - metadataCopy := copyMessageMetadata(metadata, 1) + metadataCopy := copyMessageMetadata(metadata, true) metadataCopy[MetadataCoalesceKey] = coalesceKey msg := &QueuedMessage{ SessionID: sessionID, @@ -119,11 +119,11 @@ func (s *Service) QueueMessageWithCoalesceKey(ctx context.Context, sessionID, ta return queued, replaced, nil } -func copyMessageMetadata(metadata map[string]interface{}, extraCapacity int) map[string]interface{} { - if len(metadata) == 0 && extraCapacity == 0 { +func copyMessageMetadata(metadata map[string]interface{}, ensureWritable bool) map[string]interface{} { + if len(metadata) == 0 && !ensureWritable { return nil } - out := make(map[string]interface{}, len(metadata)+extraCapacity) + out := make(map[string]interface{}, len(metadata)) for key, value := range metadata { out[key] = value } @@ -200,7 +200,13 @@ func (s *Service) TakeQueuedEntry(ctx context.Context, sessionID, entryID string // was already drained, the session doesn't own it, or the queuedBy guard // rejects the caller. func (s *Service) UpdateMessage(ctx context.Context, sessionID, entryID, content string, attachments []MessageAttachment, queuedBy string) error { - if err := s.repo.UpdateContent(ctx, sessionID, entryID, content, attachments, queuedBy); err != nil { + return s.UpdateMessageWithMetadata(ctx, sessionID, entryID, content, attachments, nil, queuedBy) +} + +// UpdateMessageWithMetadata atomically edits queue content and applies +// metadata replacements while retaining unrelated metadata keys. +func (s *Service) UpdateMessageWithMetadata(ctx context.Context, sessionID, entryID, content string, attachments []MessageAttachment, metadataUpdates map[string]interface{}, queuedBy string) error { + if err := s.repo.UpdateContentAndMetadata(ctx, sessionID, entryID, content, attachments, metadataUpdates, queuedBy); err != nil { return err } s.logger.Info("queued entry updated", diff --git a/apps/backend/internal/orchestrator/messagequeue/service_test.go b/apps/backend/internal/orchestrator/messagequeue/service_test.go index f747ba91f7..31b2987e08 100644 --- a/apps/backend/internal/orchestrator/messagequeue/service_test.go +++ b/apps/backend/internal/orchestrator/messagequeue/service_test.go @@ -368,6 +368,61 @@ func TestUpdateMessage(t *testing.T) { }) } +func TestMemoryRepository_UpdateContentAndMetadataPreservesUnrelatedKeys(t *testing.T) { + ctx := context.Background() + repo := NewMemoryRepository() + msg := &QueuedMessage{ + SessionID: "s", + TaskID: "t", + Content: "original", + QueuedBy: "user-1", + Metadata: map[string]interface{}{ + "entity_references": []interface{}{"old"}, + "origin": "inter-task", + }, + } + require.NoError(t, repo.Insert(ctx, msg, 0)) + + require.NoError(t, repo.UpdateContentAndMetadata( + ctx, "s", msg.ID, "edited", nil, + map[string]interface{}{"entity_references": []interface{}{"new"}}, + "user-1", + )) + + entries, err := repo.ListBySession(ctx, "s") + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "edited", entries[0].Content) + assert.Equal(t, "inter-task", entries[0].Metadata["origin"]) + assert.Equal(t, []interface{}{"new"}, entries[0].Metadata["entity_references"]) +} + +func TestUpdateMessageWithMetadataClearsEntityReferences(t *testing.T) { + svc := setupService(t) + ctx := context.Background() + msg, err := svc.QueueMessageWithMetadata( + ctx, "s", "t", "original", "", "user-1", false, nil, + map[string]interface{}{ + "entity_references": []interface{}{"old"}, + "origin": "inter-task", + }, + ) + require.NoError(t, err) + + require.NoError(t, svc.UpdateMessageWithMetadata( + ctx, "s", msg.ID, "edited", nil, + map[string]interface{}{"entity_references": nil}, + "user-1", + )) + + entries := svc.GetStatus(ctx, "s").Entries + require.Len(t, entries, 1) + assert.Equal(t, "edited", entries[0].Content) + assert.Equal(t, "inter-task", entries[0].Metadata["origin"]) + _, exists := entries[0].Metadata["entity_references"] + assert.False(t, exists, "empty replacement must remove stale references") +} + func TestRemoveEntry(t *testing.T) { t.Run("removes the targeted entry", func(t *testing.T) { svc := setupService(t) diff --git a/apps/backend/internal/orchestrator/messagequeue/types.go b/apps/backend/internal/orchestrator/messagequeue/types.go index 5457fffa13..0b85b06a97 100644 --- a/apps/backend/internal/orchestrator/messagequeue/types.go +++ b/apps/backend/internal/orchestrator/messagequeue/types.go @@ -20,9 +20,13 @@ const ( QueuedByWorkflow = "workflow" ) -// MetadataCoalesceKey identifies queued entries that should be replaced rather -// than appended when a newer pending message supersedes an older one. -const MetadataCoalesceKey = "coalesce_key" +const ( + // MetadataCoalesceKey identifies queued entries that should be replaced + // rather than appended when newer pending content supersedes older content. + MetadataCoalesceKey = "coalesce_key" + // MetadataEntityReferences stores normalized composer references. + MetadataEntityReferences = "entity_references" +) // QueueFullErrorCode is the well-known WS / MCP error code surfaced when an // insert would exceed the per-session cap. Shared between the user-side WS diff --git a/apps/backend/internal/sentry/rest_client.go b/apps/backend/internal/sentry/rest_client.go index 9cb8de8101..8512ecd112 100644 --- a/apps/backend/internal/sentry/rest_client.go +++ b/apps/backend/internal/sentry/rest_client.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "regexp" + "strconv" "strings" "time" ) @@ -174,26 +175,52 @@ const listOrganizationsMaxPages = 100 // SentryOrganization's json tags match the API payload, so the response decodes // straight into it. func (c *RESTClient) ListOrganizations(ctx context.Context) ([]SentryOrganization, error) { + organizations, _, err := c.listOrganizations(ctx, 0) + return organizations, err +} + +// ListOrganizationsLimited returns at most limit organizations and reports +// whether another accessible organization exists beyond that bound. +func (c *RESTClient) ListOrganizationsLimited( + ctx context.Context, + limit int, +) ([]SentryOrganization, bool, error) { + if limit < 1 { + return nil, false, errors.New("organization limit must be positive") + } + return c.listOrganizations(ctx, limit) +} + +func (c *RESTClient) listOrganizations( + ctx context.Context, + limit int, +) ([]SentryOrganization, bool, error) { out := make([]SentryOrganization, 0, 32) cursor := "" for page := 0; page < listOrganizationsMaxPages; page++ { q := url.Values{} + if limit > 0 { + q.Set("per_page", strconv.Itoa(min(limit-len(out)+1, 100))) + } if cursor != "" { q.Set("cursor", cursor) } var nodes []SentryOrganization resp, err := c.do(ctx, "/organizations/", q, &nodes) if err != nil { - return nil, err + return nil, false, err } out = append(out, nodes...) next, hasNext := parseNextCursor(resp.Header.Get("Link")) + if limit > 0 && (len(out) > limit || len(out) == limit && hasNext) { + return out[:limit], true, nil + } if !hasNext { - break + return out, false, nil } cursor = next } - return out, nil + return out, limit > 0, nil } // --- projects --- @@ -333,10 +360,35 @@ func issueNodeToIssue(n *issueNode) SentryIssue { // newly created issues appear on page one. The issue watcher relies on this to // detect new issues in a single bounded page read per poll tick. func (c *RESTClient) SearchIssues(ctx context.Context, filter SearchFilter, cursor string) (*SearchResult, error) { + return c.searchIssues(ctx, filter, cursor, 0) +} + +// SearchIssuesLimited requests and retains at most limit issues. +func (c *RESTClient) SearchIssuesLimited( + ctx context.Context, + filter SearchFilter, + cursor string, + limit int, +) (*SearchResult, error) { + if limit < 1 { + return nil, errors.New("issue limit must be positive") + } + return c.searchIssues(ctx, filter, cursor, limit) +} + +func (c *RESTClient) searchIssues( + ctx context.Context, + filter SearchFilter, + cursor string, + limit int, +) (*SearchResult, error) { if filter.OrgSlug == "" { return nil, &APIError{StatusCode: http.StatusBadRequest, Message: "orgSlug required"} } q := url.Values{} + if limit > 0 { + q.Set("per_page", strconv.Itoa(limit)) + } // Sort by first-seen descending so the newest issues land on page one. // The issue watcher reads only a single page per tick; this ensures newly // created issues are not buried behind older frequently-occurring ones. @@ -359,12 +411,16 @@ func (c *RESTClient) SearchIssues(ctx context.Context, filter SearchFilter, curs return nil, err } next, hasNext := parseNextCursor(resp.Header.Get("Link")) + count := len(nodes) + if limit > 0 { + count = min(count, limit) + } out := &SearchResult{ - Issues: make([]SentryIssue, 0, len(nodes)), + Issues: make([]SentryIssue, 0, count), NextPageToken: next, IsLast: !hasNext, } - for i := range nodes { + for i := 0; i < count; i++ { out.Issues = append(out.Issues, issueNodeToIssue(&nodes[i])) } return out, nil diff --git a/apps/backend/internal/sentry/rest_client_mentions_test.go b/apps/backend/internal/sentry/rest_client_mentions_test.go new file mode 100644 index 0000000000..a9edb7ea35 --- /dev/null +++ b/apps/backend/internal/sentry/rest_client_mentions_test.go @@ -0,0 +1,57 @@ +package sentry + +import ( + "context" + "fmt" + "net/http" + "testing" +) + +func TestRESTClientListOrganizationsLimitedBoundsDiscovery(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("per_page"); got != "3" { + t.Errorf("per_page = %q, want limit+1 probe 3", got) + } + _, _ = w.Write([]byte(`[ + {"id":"1","slug":"acme","name":"Acme"}, + {"id":"2","slug":"globex","name":"Globex"}, + {"id":"3","slug":"initech","name":"Initech"} + ]`)) + }) + client := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL) + + organizations, truncated, err := client.ListOrganizationsLimited(context.Background(), 2) + if err != nil { + t.Fatalf("list organizations: %v", err) + } + if !truncated || len(organizations) != 2 || organizations[0].Slug != "acme" || organizations[1].Slug != "globex" { + t.Fatalf("organizations = %#v, truncated = %v; want first two and truncation signal", organizations, truncated) + } +} + +func TestRESTClientSearchIssuesLimitedBoundsResponse(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("per_page"); got != "2" { + t.Errorf("per_page = %q, want 2", got) + } + _, _ = fmt.Fprint(w, `[ + {"id":"1","shortId":"APP-1","title":"One","permalink":"https://sentry.example/issues/1"}, + {"id":"2","shortId":"APP-2","title":"Two","permalink":"https://sentry.example/issues/2"}, + {"id":"3","shortId":"APP-3","title":"Three","permalink":"https://sentry.example/issues/3"} + ]`) + }) + client := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL) + + result, err := client.SearchIssuesLimited( + context.Background(), + SearchFilter{OrgSlug: "acme", Query: `"panic"`}, + "", + 2, + ) + if err != nil { + t.Fatalf("search issues: %v", err) + } + if result == nil || len(result.Issues) != 2 || result.Issues[1].ID != "2" { + t.Fatalf("result = %#v, want bounded first two issues", result) + } +} diff --git a/apps/backend/internal/sentry/service_mentions.go b/apps/backend/internal/sentry/service_mentions.go new file mode 100644 index 0000000000..b63e4ffe2d --- /dev/null +++ b/apps/backend/internal/sentry/service_mentions.go @@ -0,0 +1,375 @@ +package sentry + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/sync/errgroup" +) + +const ( + defaultMentionLimit = 5 + maxMentionLimit = 10 + maxMentionInstances = 8 + maxMentionOrganizations = 8 + maxMentionSearchConcurrency = 4 +) + +var ( + ErrMentionWorkspaceRequired = errors.New("sentry mention search: workspace ID is required") + ErrMentionQueryRequired = errors.New("sentry mention search: query is required") + ErrMentionScopeLimit = errors.New("sentry mention search: scope limit exceeded") + ErrMentionInvalidOrigin = errors.New("sentry mention search: invalid instance origin") + ErrMentionMalformedResponse = errors.New("sentry mention search: malformed upstream response") +) + +// MentionIssue is the provider-local immutable projection used by mention search. +type MentionIssue struct { + InstanceID string + InstanceOrigin string + OrganizationSlug string + ID string + ShortID string + Title string + URL string +} + +// MentionInstance is the non-secret ownership and origin data needed to +// authorize a persisted Sentry reference. +type MentionInstance struct { + ID string + WorkspaceID string + Origin string +} + +type mentionOrganizationLister interface { + ListOrganizationsLimited(context.Context, int) ([]SentryOrganization, bool, error) +} + +type mentionIssueSearcher interface { + SearchIssuesLimited(context.Context, SearchFilter, string, int) (*SearchResult, error) +} + +type mentionInstanceScope struct { + instance MentionInstance + client Client + organizations []SentryOrganization +} + +type mentionSearchJob struct { + instance MentionInstance + client Client + organization SentryOrganization +} + +// SearchMentionIssues searches every bounded Sentry scope in one workspace. +func (s *Service) SearchMentionIssues( + ctx context.Context, + workspaceID, query string, + limit int, +) ([]MentionIssue, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return nil, ErrMentionWorkspaceRequired + } + literalQuery, err := quoteMentionQuery(query) + if err != nil { + return nil, err + } + if s == nil || s.store == nil { + return nil, ErrNotConfigured + } + + instances, err := s.store.ListInstances(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("list Sentry mention instances: %w", err) + } + if len(instances) == 0 { + return nil, ErrNotConfigured + } + if len(instances) > maxMentionInstances { + return nil, fmt.Errorf("%w: workspace has more than %d instances", ErrMentionScopeLimit, maxMentionInstances) + } + + scopes, err := s.discoverMentionScopes(ctx, workspaceID, instances) + if err != nil { + return nil, err + } + return searchMentionScopes(ctx, scopes, literalQuery, normalizeMentionLimit(limit)) +} + +func (s *Service) discoverMentionScopes( + ctx context.Context, + workspaceID string, + configs []*SentryConfig, +) ([]mentionInstanceScope, error) { + scopes := make([]mentionInstanceScope, len(configs)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(maxMentionSearchConcurrency) + for index, config := range configs { + index, config := index, config + group.Go(func() error { + scope, err := s.discoverMentionScope(groupCtx, workspaceID, config) + if err != nil { + return err + } + scopes[index] = scope + return nil + }) + } + if err := group.Wait(); err != nil { + return nil, fmt.Errorf("discover Sentry mention scope: %w", err) + } + return scopes, nil +} + +func (s *Service) discoverMentionScope( + ctx context.Context, + workspaceID string, + config *SentryConfig, +) (mentionInstanceScope, error) { + instance, err := mentionInstanceFromConfig(config) + if err != nil { + return mentionInstanceScope{}, err + } + client, err := s.browseClient(ctx, workspaceID, config.ID) + if err != nil { + return mentionInstanceScope{}, err + } + if client == nil { + return mentionInstanceScope{}, ErrNotConfigured + } + organizations, err := listMentionOrganizations(ctx, client) + if err != nil { + return mentionInstanceScope{}, err + } + return mentionInstanceScope{ + instance: *instance, + client: client, + organizations: organizations, + }, nil +} + +func listMentionOrganizations(ctx context.Context, client Client) ([]SentryOrganization, error) { + var ( + organizations []SentryOrganization + more bool + err error + ) + if limited, ok := client.(mentionOrganizationLister); ok { + organizations, more, err = limited.ListOrganizationsLimited(ctx, maxMentionOrganizations) + } else { + organizations, err = client.ListOrganizations(ctx) + more = len(organizations) > maxMentionOrganizations + } + if err != nil { + return nil, err + } + if more || len(organizations) > maxMentionOrganizations { + return nil, fmt.Errorf("%w: instance has more than %d organizations", ErrMentionScopeLimit, maxMentionOrganizations) + } + + out := make([]SentryOrganization, 0, len(organizations)) + seen := make(map[string]struct{}, len(organizations)) + for _, organization := range organizations { + organization.Slug = strings.TrimSpace(organization.Slug) + if organization.Slug == "" { + continue + } + if _, duplicate := seen[organization.Slug]; duplicate { + continue + } + seen[organization.Slug] = struct{}{} + out = append(out, organization) + } + return out, nil +} + +func searchMentionScopes( + ctx context.Context, + scopes []mentionInstanceScope, + literalQuery string, + limit int, +) ([]MentionIssue, error) { + jobs := buildMentionSearchJobs(scopes) + results := make([][]MentionIssue, len(jobs)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(maxMentionSearchConcurrency) + for index, job := range jobs { + index, job := index, job + group.Go(func() error { + issues, err := searchMentionJob(groupCtx, job, literalQuery, limit) + if err != nil { + return err + } + results[index] = issues + return nil + }) + } + if err := group.Wait(); err != nil { + return nil, fmt.Errorf("search Sentry mention scope: %w", err) + } + return flattenMentionResults(results, limit), nil +} + +func buildMentionSearchJobs(scopes []mentionInstanceScope) []mentionSearchJob { + jobs := make([]mentionSearchJob, 0) + for _, scope := range scopes { + for _, organization := range scope.organizations { + jobs = append(jobs, mentionSearchJob{ + instance: scope.instance, + client: scope.client, + organization: organization, + }) + } + } + return jobs +} + +func searchMentionJob( + ctx context.Context, + job mentionSearchJob, + literalQuery string, + limit int, +) ([]MentionIssue, error) { + filter := SearchFilter{OrgSlug: job.organization.Slug, Query: literalQuery} + var ( + result *SearchResult + err error + ) + if limited, ok := job.client.(mentionIssueSearcher); ok { + result, err = limited.SearchIssuesLimited(ctx, filter, "", limit) + } else { + result, err = job.client.SearchIssues(ctx, filter, "") + } + if err != nil { + return nil, err + } + if result == nil { + return nil, ErrMentionMalformedResponse + } + count := min(len(result.Issues), limit) + issues := make([]MentionIssue, 0, count) + for index := 0; index < count; index++ { + issue := result.Issues[index] + issues = append(issues, MentionIssue{ + InstanceID: job.instance.ID, + InstanceOrigin: job.instance.Origin, + OrganizationSlug: job.organization.Slug, + ID: issue.ID, + ShortID: issue.ShortID, + Title: issue.Title, + URL: issue.Permalink, + }) + } + return issues, nil +} + +func flattenMentionResults(results [][]MentionIssue, limit int) []MentionIssue { + out := make([]MentionIssue, 0, limit) + seen := make(map[string]struct{}, limit) + for _, group := range results { + for _, issue := range group { + key := issue.InstanceID + "\x00" + issue.InstanceOrigin + "\x00" + issue.ID + if _, duplicate := seen[key]; duplicate { + continue + } + seen[key] = struct{}{} + out = append(out, issue) + if len(out) == limit { + return out + } + } + } + return out +} + +// MentionInstanceForWorkspace reloads one workspace-owned instance. +func (s *Service) MentionInstanceForWorkspace( + ctx context.Context, + workspaceID, instanceID string, +) (*MentionInstance, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + workspaceID = strings.TrimSpace(workspaceID) + instanceID = strings.TrimSpace(instanceID) + if workspaceID == "" { + return nil, ErrMentionWorkspaceRequired + } + if instanceID == "" || s == nil || s.store == nil { + return nil, ErrInstanceNotFound + } + config, err := s.store.GetInstance(ctx, instanceID) + if err != nil { + return nil, err + } + if config == nil || config.WorkspaceID != workspaceID { + return nil, ErrInstanceNotFound + } + return mentionInstanceFromConfig(config) +} + +func mentionInstanceFromConfig(config *SentryConfig) (*MentionInstance, error) { + if config == nil || strings.TrimSpace(config.ID) == "" || strings.TrimSpace(config.WorkspaceID) == "" { + return nil, ErrInstanceNotFound + } + origin, err := normalizeMentionOrigin(config.URL) + if err != nil { + return nil, err + } + return &MentionInstance{ + ID: config.ID, + WorkspaceID: config.WorkspaceID, + Origin: origin, + }, nil +} + +func normalizeMentionOrigin(raw string) (string, error) { + parsed, err := url.Parse(resolveBaseURL(raw)) + if err != nil || parsed.User != nil || parsed.Opaque != "" || parsed.Host == "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") || + (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", ErrMentionInvalidOrigin + } + return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host), nil +} + +func quoteMentionQuery(raw string) (string, error) { + query := strings.TrimSpace(raw) + if query == "" { + return "", ErrMentionQueryRequired + } + if !utf8.ValidString(query) || utf8.RuneCountInString(query) > 200 { + return "", ErrMentionQueryRequired + } + for _, character := range query { + if unicode.IsControl(character) { + return "", ErrMentionQueryRequired + } + } + query = strings.ReplaceAll(query, `\`, `\\`) + query = strings.ReplaceAll(query, `"`, `\"`) + return `"` + query + `"`, nil +} + +func normalizeMentionLimit(limit int) int { + switch { + case limit == 0: + return defaultMentionLimit + case limit < 1: + return 1 + case limit > maxMentionLimit: + return maxMentionLimit + default: + return limit + } +} diff --git a/apps/backend/internal/sentry/service_mentions_test.go b/apps/backend/internal/sentry/service_mentions_test.go new file mode 100644 index 0000000000..2f93956efe --- /dev/null +++ b/apps/backend/internal/sentry/service_mentions_test.go @@ -0,0 +1,486 @@ +package sentry + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +type mentionSearchCall struct { + filter SearchFilter + cursor string + limit int +} + +type mentionClient struct { + mu sync.Mutex + organizationHook func(context.Context) error + searchHook func(context.Context) error + organizations []SentryOrganization + organizationsMore bool + organizationsErr error + issuesByOrg map[string][]SentryIssue + searchErrByOrg map[string]error + organizationLimits []int + searches []mentionSearchCall +} + +func (c *mentionClient) TestAuth(context.Context) (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true}, nil +} + +func (c *mentionClient) ListOrganizations(ctx context.Context) ([]SentryOrganization, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + c.mu.Lock() + defer c.mu.Unlock() + return append([]SentryOrganization(nil), c.organizations...), c.organizationsErr +} + +func (c *mentionClient) ListOrganizationsLimited( + ctx context.Context, + limit int, +) ([]SentryOrganization, bool, error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + if c.organizationHook != nil { + if err := c.organizationHook(ctx); err != nil { + return nil, false, err + } + } + c.mu.Lock() + defer c.mu.Unlock() + c.organizationLimits = append(c.organizationLimits, limit) + if c.organizationsErr != nil { + return nil, false, c.organizationsErr + } + count := min(len(c.organizations), limit) + return append([]SentryOrganization(nil), c.organizations[:count]...), + c.organizationsMore || len(c.organizations) > limit, + nil +} + +func (c *mentionClient) ListProjects(context.Context) ([]SentryProject, error) { + return nil, nil +} + +func (c *mentionClient) SearchIssues( + ctx context.Context, + filter SearchFilter, + cursor string, +) (*SearchResult, error) { + return c.SearchIssuesLimited(ctx, filter, cursor, 100) +} + +func (c *mentionClient) SearchIssuesLimited( + ctx context.Context, + filter SearchFilter, + cursor string, + limit int, +) (*SearchResult, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if c.searchHook != nil { + if err := c.searchHook(ctx); err != nil { + return nil, err + } + } + c.mu.Lock() + defer c.mu.Unlock() + c.searches = append(c.searches, mentionSearchCall{filter: filter, cursor: cursor, limit: limit}) + if err := c.searchErrByOrg[filter.OrgSlug]; err != nil { + return nil, err + } + issues := c.issuesByOrg[filter.OrgSlug] + return &SearchResult{Issues: append([]SentryIssue(nil), issues...), IsLast: true}, nil +} + +type mentionConcurrencyProbe struct { + mu sync.Mutex + started chan struct{} + release chan struct{} + releaseOnce sync.Once + active int + maximum int + total int +} + +func newMentionConcurrencyProbe() *mentionConcurrencyProbe { + return &mentionConcurrencyProbe{ + started: make(chan struct{}, 16), + release: make(chan struct{}), + } +} + +func (p *mentionConcurrencyProbe) enter(ctx context.Context) error { + p.mu.Lock() + p.active++ + p.total++ + p.maximum = max(p.maximum, p.active) + p.mu.Unlock() + p.started <- struct{}{} + defer func() { + p.mu.Lock() + p.active-- + p.mu.Unlock() + }() + select { + case <-p.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (p *mentionConcurrencyProbe) unblock() { + p.releaseOnce.Do(func() { close(p.release) }) +} + +func (p *mentionConcurrencyProbe) counts() (int, int) { + p.mu.Lock() + defer p.mu.Unlock() + return p.maximum, p.total +} + +func waitForBlockedMentionCalls(t *testing.T, probe *mentionConcurrencyProbe, want int) { + t.Helper() + for index := 0; index < want; index++ { + select { + case <-probe.started: + case <-time.After(2 * time.Second): + t.Fatalf("timed out after %d of %d concurrent calls", index, want) + } + } + select { + case <-probe.started: + t.Fatalf("more than %d calls ran concurrently", want) + case <-time.After(100 * time.Millisecond): + } +} + +func (c *mentionClient) GetIssue(context.Context, string) (*SentryIssue, error) { + return nil, nil +} + +func (c *mentionClient) snapshotCalls() ([]int, []mentionSearchCall) { + c.mu.Lock() + defer c.mu.Unlock() + return append([]int(nil), c.organizationLimits...), append([]mentionSearchCall(nil), c.searches...) +} + +type mentionServiceFixture struct { + service *Service + store *Store + secrets *fakeSecretStore + clients map[string]*mentionClient + factoryMu sync.Mutex + factoryCalls int +} + +func newMentionServiceFixture(t *testing.T) *mentionServiceFixture { + t.Helper() + fixture := &mentionServiceFixture{ + store: newTestStore(t), + secrets: newFakeSecretStore(), + clients: make(map[string]*mentionClient), + } + fixture.service = NewService(fixture.store, fixture.secrets, func(cfg *SentryConfig, _ string) Client { + fixture.factoryMu.Lock() + defer fixture.factoryMu.Unlock() + fixture.factoryCalls++ + return fixture.clients[cfg.ID] + }, logger.Default()) + return fixture +} + +func (f *mentionServiceFixture) seedInstance( + t *testing.T, + workspaceID, id, name, origin string, + client *mentionClient, +) { + t.Helper() + cfg := &SentryConfig{ + ID: id, WorkspaceID: workspaceID, Name: name, + AuthMethod: AuthMethodAuthToken, URL: origin, + } + if err := f.store.CreateInstance(context.Background(), cfg); err != nil { + t.Fatalf("seed instance: %v", err) + } + if err := f.secrets.Set( + context.Background(), + secretKeyForInstance(id), + "Sentry auth token", + "token-"+id, + ); err != nil { + t.Fatalf("seed secret: %v", err) + } + f.clients[id] = client +} + +func TestServiceSearchMentionIssuesSearchesEveryInstanceAndOrganization(t *testing.T) { + fixture := newMentionServiceFixture(t) + clientA := &mentionClient{ + organizations: []SentryOrganization{{Slug: "acme"}, {Slug: "globex"}}, + issuesByOrg: map[string][]SentryIssue{ + "acme": {{ + ID: "42", ShortID: "APP-1", Title: "Panic in auth", + Permalink: "https://sentry.example/organizations/acme/issues/42/", + }}, + "globex": {{ + ID: "43", ShortID: "WEB-2", Title: "Timeout", + Permalink: "https://sentry.example/issues/43/", + }}, + }, + } + clientB := &mentionClient{ + organizations: []SentryOrganization{{Slug: "internal"}}, + issuesByOrg: map[string][]SentryIssue{ + "internal": {{ + ID: "42", ShortID: "OPS-1", Title: "Self-hosted panic", + Permalink: "http://self-hosted.example:9000/issues/42/", + }}, + }, + } + fixture.seedInstance(t, "workspace-1", "instance-a", "A SaaS", "https://sentry.example", clientA) + fixture.seedInstance(t, "workspace-1", "instance-b", "B Self-hosted", "http://self-hosted.example:9000", clientB) + fixture.seedInstance(t, "workspace-2", "instance-other", "Other workspace", "https://other.example", &mentionClient{}) + + issues, err := fixture.service.SearchMentionIssues( + context.Background(), + "workspace-1", + `is:resolved "panic" \ path`, + 3, + ) + if err != nil { + t.Fatalf("search mention issues: %v", err) + } + if len(issues) != 3 { + t.Fatalf("issues = %#v, want three bounded results", issues) + } + if issues[0] != (MentionIssue{ + InstanceID: "instance-a", InstanceOrigin: "https://sentry.example", OrganizationSlug: "acme", + ID: "42", ShortID: "APP-1", Title: "Panic in auth", + URL: "https://sentry.example/organizations/acme/issues/42/", + }) { + t.Fatalf("first issue = %#v", issues[0]) + } + if issues[2].ID != "42" || issues[2].InstanceID != "instance-b" || + issues[2].InstanceOrigin != "http://self-hosted.example:9000" { + t.Fatalf("self-hosted identity projection = %#v", issues[2]) + } + + const wantLiteral = `"is:resolved \"panic\" \\ path"` + for name, client := range map[string]*mentionClient{"A": clientA, "B": clientB} { + organizationLimits, searches := client.snapshotCalls() + if len(organizationLimits) != 1 || organizationLimits[0] != 8 { + t.Fatalf("client %s organization limits = %#v, want one bounded discovery of 8", name, organizationLimits) + } + if len(searches) != len(client.organizations) { + t.Fatalf("client %s searches = %#v, want every organization", name, searches) + } + for _, search := range searches { + if search.filter.Query != wantLiteral || search.cursor != "" || search.limit != 3 { + t.Fatalf("client %s search = %#v, want literal first-page limit 3", name, search) + } + } + } +} + +func TestServiceSearchMentionIssuesBoundsConcurrencyWithoutOmittingScopes(t *testing.T) { + t.Run("instance discovery", func(t *testing.T) { + fixture := newMentionServiceFixture(t) + probe := newMentionConcurrencyProbe() + t.Cleanup(probe.unblock) + for index := 0; index < maxMentionInstances; index++ { + client := &mentionClient{ + organizationHook: probe.enter, + organizations: []SentryOrganization{{Slug: "acme"}}, + } + fixture.seedInstance( + t, + "workspace-1", + fmt.Sprintf("instance-%d", index), + fmt.Sprintf("Instance %02d", index), + fmt.Sprintf("https://sentry-%d.example", index), + client, + ) + } + + done := make(chan error, 1) + go func() { + _, err := fixture.service.SearchMentionIssues(context.Background(), "workspace-1", "panic", 5) + done <- err + }() + waitForBlockedMentionCalls(t, probe, maxMentionSearchConcurrency) + probe.unblock() + if err := <-done; err != nil { + t.Fatalf("search mention issues: %v", err) + } + maximum, total := probe.counts() + if maximum != maxMentionSearchConcurrency || total != maxMentionInstances { + t.Fatalf("discovery concurrency max/total = %d/%d, want %d/%d", + maximum, total, maxMentionSearchConcurrency, maxMentionInstances) + } + }) + + t.Run("organization search", func(t *testing.T) { + fixture := newMentionServiceFixture(t) + probe := newMentionConcurrencyProbe() + t.Cleanup(probe.unblock) + organizations := make([]SentryOrganization, maxMentionOrganizations) + for index := range organizations { + organizations[index].Slug = fmt.Sprintf("org-%d", index) + } + client := &mentionClient{ + searchHook: probe.enter, + organizations: organizations, + } + fixture.seedInstance(t, "workspace-1", "instance-a", "A", "https://sentry.example", client) + + done := make(chan error, 1) + go func() { + _, err := fixture.service.SearchMentionIssues(context.Background(), "workspace-1", "panic", 5) + done <- err + }() + waitForBlockedMentionCalls(t, probe, maxMentionSearchConcurrency) + probe.unblock() + if err := <-done; err != nil { + t.Fatalf("search mention issues: %v", err) + } + maximum, total := probe.counts() + if maximum != maxMentionSearchConcurrency || total != maxMentionOrganizations { + t.Fatalf("search concurrency max/total = %d/%d, want %d/%d", + maximum, total, maxMentionSearchConcurrency, maxMentionOrganizations) + } + }) +} + +func TestServiceSearchMentionIssuesRejectsScopeOverflowWithoutOmission(t *testing.T) { + t.Run("instances", func(t *testing.T) { + fixture := newMentionServiceFixture(t) + for index := 0; index < 9; index++ { + fixture.seedInstance( + t, + "workspace-1", + fmt.Sprintf("instance-%d", index), + fmt.Sprintf("Instance %02d", index), + fmt.Sprintf("https://sentry-%d.example", index), + &mentionClient{}, + ) + } + _, err := fixture.service.SearchMentionIssues(context.Background(), "workspace-1", "panic", 5) + if err == nil || !strings.Contains(err.Error(), "scope") { + t.Fatalf("error = %v, want explicit scope overflow", err) + } + if fixture.factoryCalls != 0 { + t.Fatalf("factory called %d times before instance bound rejection", fixture.factoryCalls) + } + }) + + t.Run("organizations", func(t *testing.T) { + fixture := newMentionServiceFixture(t) + organizations := make([]SentryOrganization, 8) + for index := range organizations { + organizations[index].Slug = fmt.Sprintf("org-%d", index) + } + client := &mentionClient{organizations: organizations, organizationsMore: true} + fixture.seedInstance(t, "workspace-1", "instance-a", "A", "https://sentry.example", client) + + _, err := fixture.service.SearchMentionIssues(context.Background(), "workspace-1", "panic", 5) + if err == nil || !strings.Contains(err.Error(), "scope") { + t.Fatalf("error = %v, want explicit organization scope overflow", err) + } + organizationLimits, searches := client.snapshotCalls() + if len(organizationLimits) != 1 || organizationLimits[0] != 8 || len(searches) != 0 { + t.Fatalf("organization limits = %#v, searches = %#v; want stop before omission", organizationLimits, searches) + } + }) +} + +func TestServiceSearchMentionIssuesRejectsBlankWorkspaceAndPreservesCancellation(t *testing.T) { + fixture := newMentionServiceFixture(t) + if _, err := fixture.service.SearchMentionIssues(context.Background(), " \t", "panic", 5); err == nil || + !strings.Contains(err.Error(), "workspace") { + t.Fatalf("blank workspace error = %v", err) + } + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := fixture.service.SearchMentionIssues(cancelled, "workspace-1", "panic", 5); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation error = %v, want context canceled", err) + } + + probe := newMentionConcurrencyProbe() + t.Cleanup(probe.unblock) + fixture.seedInstance(t, "workspace-1", "instance-a", "A", "https://sentry.example", &mentionClient{ + organizationHook: probe.enter, + }) + inFlight, cancelInFlight := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := fixture.service.SearchMentionIssues(inFlight, "workspace-1", "panic", 5) + done <- err + }() + select { + case <-probe.started: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for in-flight organization discovery") + } + cancelInFlight() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("in-flight cancellation error = %v, want context canceled", err) + } +} + +func TestServiceSearchMentionIssuesHandlesMissingConfigAndRejectsUnsafeOrigin(t *testing.T) { + fixture := newMentionServiceFixture(t) + if _, err := fixture.service.SearchMentionIssues( + context.Background(), "workspace-1", "panic", 5, + ); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("missing config error = %v, want not configured", err) + } + + fixture.seedInstance( + t, + "workspace-1", + "instance-a", + "A", + "https://user:secret@sentry.example", + &mentionClient{}, + ) + if _, err := fixture.service.SearchMentionIssues( + context.Background(), "workspace-1", "panic", 5, + ); !errors.Is(err, ErrMentionInvalidOrigin) { + t.Fatalf("unsafe origin error = %v, want invalid origin", err) + } + if fixture.factoryCalls != 0 { + t.Fatalf("factory called %d times for unsafe origin", fixture.factoryCalls) + } +} + +func TestServiceMentionInstanceForWorkspaceReturnsOwnedCanonicalOrigin(t *testing.T) { + fixture := newMentionServiceFixture(t) + fixture.seedInstance(t, "workspace-1", "instance-a", "A", "https://sentry.example/", &mentionClient{}) + + instance, err := fixture.service.MentionInstanceForWorkspace(context.Background(), "workspace-1", "instance-a") + if err != nil { + t.Fatalf("mention instance: %v", err) + } + want := &MentionInstance{ID: "instance-a", WorkspaceID: "workspace-1", Origin: "https://sentry.example"} + if !reflect.DeepEqual(instance, want) { + t.Fatalf("instance = %#v, want %#v", instance, want) + } + if _, err := fixture.service.MentionInstanceForWorkspace(context.Background(), "workspace-2", "instance-a"); !errors.Is(err, ErrInstanceNotFound) { + t.Fatalf("cross-workspace error = %v, want instance not found", err) + } +} diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index de531eea87..3e7583e2d8 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/sysprompt" @@ -35,23 +36,40 @@ type OrchestratorService interface { // MessageHandlers handles WebSocket requests for messages type MessageHandlers struct { - service *service.Service - orchestrator OrchestratorService - logger *logger.Logger + service *service.Service + orchestrator OrchestratorService + logger *logger.Logger + referenceValidator entityrefs.SubmissionValidator } // NewMessageHandlers creates a new MessageHandlers instance -func NewMessageHandlers(svc *service.Service, orchestrator OrchestratorService, log *logger.Logger) *MessageHandlers { - return &MessageHandlers{ +func NewMessageHandlers( + svc *service.Service, + orchestrator OrchestratorService, + log *logger.Logger, + validators ...entityrefs.SubmissionValidator, +) *MessageHandlers { + handlers := &MessageHandlers{ service: svc, orchestrator: orchestrator, logger: log.WithFields(zap.String("component", "task-message-handlers")), } + if len(validators) > 0 { + handlers.referenceValidator = validators[0] + } + return handlers } // RegisterMessageRoutes registers message HTTP + WebSocket handlers -func RegisterMessageRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *service.Service, orchestrator OrchestratorService, log *logger.Logger) { - handlers := NewMessageHandlers(svc, orchestrator, log) +func RegisterMessageRoutes( + router *gin.Engine, + dispatcher *ws.Dispatcher, + svc *service.Service, + orchestrator OrchestratorService, + log *logger.Logger, + validators ...entityrefs.SubmissionValidator, +) { + handlers := NewMessageHandlers(svc, orchestrator, log, validators...) handlers.registerHTTP(router) handlers.registerWS(dispatcher) } @@ -213,6 +231,7 @@ type wsAddMessageRequest struct { HasReviewComments bool `json:"has_review_comments,omitempty"` Attachments []v1.MessageAttachment `json:"attachments,omitempty"` ContextFiles []v1.ContextFileMeta `json:"context_files,omitempty"` + EntityReferences []v1.EntityReference `json:"entity_references,omitempty"` } func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -231,6 +250,21 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w if wsErr != nil { return wsErr, nil } + if len(req.EntityReferences) > 0 { + if sessionResp.Session.IsPassthrough || h.referenceValidator == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Invalid entity references", nil) + } + references, err := h.referenceValidator.ValidateForSubmission( + ctx, + req.TaskSessionID, + req.TaskID, + req.EntityReferences, + ) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Invalid entity references", nil) + } + req.EntityReferences = references + } // Transition task from REVIEW → IN_PROGRESS if needed if err := h.ensureTaskInProgress(ctx, req.TaskID); err != nil { @@ -273,7 +307,8 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w WithPlanMode(req.PlanMode). WithReviewComments(req.HasReviewComments). WithAttachments(req.Attachments). - WithContextFiles(req.ContextFiles) + WithContextFiles(req.ContextFiles). + WithEntityReferences(req.EntityReferences) // First message on a CREATED session is the kanban "type in chat to start // the agent" path. Wrap with the Kandev MCP system block before persisting @@ -290,7 +325,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // Passthrough sessions skip the wrap: the prompt is typed straight into // the agent CLI's TTY and the user sees it verbatim — they don't want a // wall of MCP-tool boilerplate prepended to "hello". - storedContent := req.Content + storedContent := orchestrator.AppendEntityReferenceContext(req.Content, req.EntityReferences) if isCreatedSession && !sessionResp.Session.IsPassthrough && (req.Content != "" || len(req.Attachments) > 0) { task, err := h.service.GetTask(ctx, req.TaskID) if err != nil { @@ -299,12 +334,12 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w } configMode, _ := sessionResp.Session.Metadata["config_mode"].(bool) requiresSignal := h.orchestrator != nil && h.orchestrator.StepRequiresCompletionSignal(ctx, req.TaskID) - storedContent = sysprompt.InjectKandevContextWithOptions(req.TaskID, req.TaskSessionID, req.Content, sysprompt.KandevContextOptions{ + storedContent = sysprompt.InjectKandevContextWithOptions(req.TaskID, req.TaskSessionID, storedContent, sysprompt.KandevContextOptions{ RequiresCompletionSignal: requiresSignal, IncludeCoordinatorTaskControls: task.AssigneeAgentProfileID == "" && !configMode, }) - req.Content = storedContent } + req.Content = storedContent message, err := h.service.CreateMessage(ctx, &service.CreateMessageRequest{ TaskSessionID: req.TaskSessionID, diff --git a/apps/backend/internal/task/handlers/message_handlers_test.go b/apps/backend/internal/task/handlers/message_handlers_test.go index 08226bc19a..b64964cb51 100644 --- a/apps/backend/internal/task/handlers/message_handlers_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_test.go @@ -12,7 +12,9 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/sysprompt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -259,22 +261,108 @@ func TestWaitForSessionReady_ContextCancelled(t *testing.T) { type messageAddSwitchRepo struct { mockRepository - tasks map[string]*models.Task - sessions map[string]*models.TaskSession - primaryID string - messages []*models.Message - turns []*models.Turn - getCalls map[string]int - failReload bool + tasks map[string]*models.Task + sessions map[string]*models.TaskSession + primaryID string + messages []*models.Message + turns []*models.Turn + getCalls map[string]int + failReload bool + taskGetCalls int } func (r *messageAddSwitchRepo) GetTask(_ context.Context, id string) (*models.Task, error) { + r.taskGetCalls++ if task, ok := r.tasks[id]; ok { return task, nil } return nil, sql.ErrNoRows } +type fakeReferenceSubmissionValidator struct { + sessionID string + assertedTaskID string + references []v1.EntityReference + err error +} + +func (v *fakeReferenceSubmissionValidator) ValidateForSubmission( + _ context.Context, + sessionID, assertedTaskID string, + references []v1.EntityReference, +) ([]v1.EntityReference, error) { + v.sessionID = sessionID + v.assertedTaskID = assertedTaskID + v.references = append([]v1.EntityReference(nil), references...) + if v.err != nil { + return nil, v.err + } + return entityrefs.NormalizeForSubmission(references) +} + +func TestWSAddMessagePersistsAuthorizedEntityReferencesAndAgentContext(t *testing.T) { + now := time.Now().UTC() + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{"t1": {ID: "t1", WorkspaceID: "ws1", State: v1.TaskStateInProgress}}, + sessions: map[string]*models.TaskSession{ + "s1": {ID: "s1", TaskID: "t1", State: models.TaskSessionStateWaitingForInput, UpdatedAt: now}, + }, + primaryID: "s1", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{Tasks: repo, TaskRepos: repo, Messages: repo, Turns: repo, Sessions: repo}, nil, log, service.RepositoryDiscoveryConfig{}) + validator := &fakeReferenceSubmissionValidator{} + h := NewMessageHandlers(svc, nil, log, validator) + reference := v1.EntityReference{ + Version: v1.EntityReferenceVersion, + Ref: entityrefs.CanonicalRef("kandev", "task", "ws1", "other"), + Provider: "kandev", Kind: "task", ID: "other", Title: "Other task", + URL: "/t/other", Scope: "ws1", + } + req, err := ws.NewRequest("req-ref", ws.ActionMessageAdd, map[string]any{ + "task_id": "t1", "session_id": "s1", "content": "Check this", "entity_references": []v1.EntityReference{reference}, + }) + require.NoError(t, err) + + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + require.Equal(t, "s1", validator.sessionID) + require.Equal(t, "t1", validator.assertedTaskID) + require.Len(t, repo.messages, 1) + stored := repo.messages[0] + require.Equal(t, "Check this", sysprompt.StripSystemContent(stored.Content)) + require.Contains(t, stored.Content, `"entity_references"`) + require.Equal(t, []v1.EntityReference{reference}, stored.Metadata["entity_references"]) +} + +func TestWSAddMessageRejectsEntityReferencesBeforeTaskMutation(t *testing.T) { + now := time.Now().UTC() + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{"t1": {ID: "t1", WorkspaceID: "ws1", State: v1.TaskStateReview}}, + sessions: map[string]*models.TaskSession{ + "s1": {ID: "s1", TaskID: "t1", State: models.TaskSessionStateWaitingForInput, UpdatedAt: now}, + }, + primaryID: "s1", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{Tasks: repo, TaskRepos: repo, Messages: repo, Turns: repo, Sessions: repo}, nil, log, service.RepositoryDiscoveryConfig{}) + h := NewMessageHandlers(svc, nil, log, &fakeReferenceSubmissionValidator{err: errors.New("wrong workspace")}) + req, err := ws.NewRequest("req-ref", ws.ActionMessageAdd, map[string]any{ + "task_id": "t1", "session_id": "s1", "content": "Check this", + "entity_references": []v1.EntityReference{{Version: 1, Ref: "bad"}}, + }) + require.NoError(t, err) + + resp, err := h.wsAddMessage(context.Background(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeError, resp.Type) + require.Empty(t, repo.messages) + require.Zero(t, repo.taskGetCalls) +} + func (r *messageAddSwitchRepo) GetTaskSession(_ context.Context, id string) (*models.TaskSession, error) { if r.getCalls == nil { r.getCalls = make(map[string]int) diff --git a/apps/backend/internal/task/models/message_shell_output.go b/apps/backend/internal/task/models/message_shell_output.go index f10ae186a8..4a62162023 100644 --- a/apps/backend/internal/task/models/message_shell_output.go +++ b/apps/backend/internal/task/models/message_shell_output.go @@ -1,6 +1,9 @@ package models -import "github.com/kandev/kandev/internal/agentctl/types/streams" +import ( + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/entityrefs" +) // ShellExecOutputSnapshot is the full bounded output stored on a shell message. type ShellExecOutputSnapshot struct { @@ -13,15 +16,29 @@ type ShellExecOutputSnapshot struct { // ProjectMessageMetadata removes shell output bodies from client message payloads. // It copies the normalized shell path so repository-owned metadata is never mutated. func ProjectMessageMetadata(metadata map[string]any) map[string]any { + projected := metadata + copied := false + if rawReferences, exists := metadata["entity_references"]; exists { + projected = copyMetadata(metadata) + copied = true + references := entityrefs.NormalizePersisted(rawReferences) + if len(references) == 0 { + delete(projected, "entity_references") + } else { + projected["entity_references"] = references + } + } output, ok := shellOutputFromMetadata(metadata) if !ok { - return metadata + return projected } normalized, ok := projectNormalizedShell(metadata["normalized"], shellOutputSummary(output)) if !ok { - return metadata + return projected + } + if !copied { + projected = copyMetadata(metadata) } - projected := copyMetadata(metadata) projected["normalized"] = normalized return projected } diff --git a/apps/backend/internal/task/models/message_shell_output_test.go b/apps/backend/internal/task/models/message_shell_output_test.go index 72909902cc..1810e3995c 100644 --- a/apps/backend/internal/task/models/message_shell_output_test.go +++ b/apps/backend/internal/task/models/message_shell_output_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/entityrefs" + apiv1 "github.com/kandev/kandev/pkg/api/v1" "github.com/stretchr/testify/require" ) @@ -36,6 +38,32 @@ func TestMessageToAPIProjectsTypedShellOutputWithoutMutation(t *testing.T) { require.Equal(t, "warning\n", normalized.ShellExec().Output.Stderr) } +func TestProjectMessageMetadataNormalizesEntityReferencesWithoutMutation(t *testing.T) { + valid := apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: entityrefs.CanonicalRef("linear", "issue", "acme", "issue-1"), + Provider: "linear", Kind: "issue", ID: "issue-1", Key: "ENG-1", + Title: "Fix auth", URL: "https://linear.app/acme/issue/ENG-1", Scope: "acme", + } + originalRefs := []any{ + map[string]any{ + "version": float64(valid.Version), "ref": valid.Ref, "provider": valid.Provider, + "kind": valid.Kind, "id": valid.ID, "key": valid.Key, "title": valid.Title, + "url": valid.URL, "scope": valid.Scope, + }, + map[string]any{"version": float64(1), "ref": "bad"}, + } + metadata := map[string]any{"status": "sent", "entity_references": originalRefs} + + projected := ProjectMessageMetadata(metadata) + references, ok := projected["entity_references"].([]apiv1.EntityReference) + require.True(t, ok) + require.Equal(t, []apiv1.EntityReference{valid}, references) + require.Equal(t, originalRefs, metadata["entity_references"]) + projected["status"] = "changed" + require.Equal(t, "sent", metadata["status"]) +} + func TestMessageToAPIProjectsPersistedShellOutputWithoutMutation(t *testing.T) { output := map[string]any{ "stdout": "persisted stdout", diff --git a/apps/backend/internal/task/service/service_mentions.go b/apps/backend/internal/task/service/service_mentions.go new file mode 100644 index 0000000000..8ceb2ac085 --- /dev/null +++ b/apps/backend/internal/task/service/service_mentions.go @@ -0,0 +1,36 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" +) + +var errReferenceConversationScope = errors.New("reference conversation scope is invalid") + +// ResolveWorkspace derives trusted reference scope from the persisted session +// and task. assertedTaskID is optional for queue updates that identify only a +// session; when present it must match the session-owned task exactly. +func (s *Service) ResolveWorkspace( + ctx context.Context, + sessionID, assertedTaskID string, +) (string, error) { + sessionID = strings.TrimSpace(sessionID) + assertedTaskID = strings.TrimSpace(assertedTaskID) + if sessionID == "" || s == nil || s.sessions == nil || s.tasks == nil { + return "", errReferenceConversationScope + } + session, err := s.sessions.GetTaskSession(ctx, sessionID) + if err != nil || session == nil || session.IsPassthrough || strings.TrimSpace(session.TaskID) == "" { + return "", fmt.Errorf("%w: session lookup", errReferenceConversationScope) + } + if assertedTaskID != "" && assertedTaskID != session.TaskID { + return "", fmt.Errorf("%w: task mismatch", errReferenceConversationScope) + } + task, err := s.tasks.GetTask(ctx, session.TaskID) + if err != nil || task == nil || strings.TrimSpace(task.WorkspaceID) == "" { + return "", fmt.Errorf("%w: task lookup", errReferenceConversationScope) + } + return task.WorkspaceID, nil +} diff --git a/apps/backend/internal/task/service/service_mentions_test.go b/apps/backend/internal/task/service/service_mentions_test.go new file mode 100644 index 0000000000..ca8d7b9078 --- /dev/null +++ b/apps/backend/internal/task/service/service_mentions_test.go @@ -0,0 +1,53 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/task/models" +) + +type referenceConversationResolver interface { + ResolveWorkspace(context.Context, string, string) (string, error) +} + +func TestResolveWorkspaceUsesPersistedSessionTaskScope(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + for _, workspace := range []*models.Workspace{ + {ID: "ws-1", Name: "One"}, + {ID: "ws-2", Name: "Two"}, + } { + require.NoError(t, repo.CreateWorkspace(ctx, workspace)) + } + for _, task := range []*models.Task{ + {ID: "task-1", WorkspaceID: "ws-1", Title: "One"}, + {ID: "task-2", WorkspaceID: "ws-2", Title: "Two"}, + } { + require.NoError(t, repo.CreateTask(ctx, task)) + } + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput, + })) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "passthrough-1", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput, + IsPassthrough: true, + })) + + resolver, ok := any(svc).(referenceConversationResolver) + require.True(t, ok) + workspaceID, err := resolver.ResolveWorkspace(ctx, "session-1", "task-1") + require.NoError(t, err) + require.Equal(t, "ws-1", workspaceID) + + _, err = resolver.ResolveWorkspace(ctx, "session-1", "task-2") + require.Error(t, err) + require.True(t, errors.Is(err, errReferenceConversationScope)) + + _, err = resolver.ResolveWorkspace(ctx, "passthrough-1", "task-1") + require.Error(t, err) + require.True(t, errors.Is(err, errReferenceConversationScope)) +} diff --git a/apps/backend/pkg/api/v1/mentions.go b/apps/backend/pkg/api/v1/mentions.go new file mode 100644 index 0000000000..19ef0453b2 --- /dev/null +++ b/apps/backend/pkg/api/v1/mentions.go @@ -0,0 +1,46 @@ +package v1 + +const EntityReferenceVersion = 1 + +// MentionStatus is the safe, provider-neutral state of one mention result group. +type MentionStatus string + +const ( + MentionStatusOK MentionStatus = "ok" + MentionStatusNotConfigured MentionStatus = "not_configured" + MentionStatusUnauthorized MentionStatus = "unauthorized" + MentionStatusRateLimited MentionStatus = "rate_limited" + MentionStatusTimeout MentionStatus = "timeout" + MentionStatusUpstreamError MentionStatus = "upstream_error" + MentionStatusUnsupportedScope MentionStatus = "unsupported_scope" +) + +// EntityReference is the normalized, versioned reference returned by mention search. +type EntityReference struct { + Version int `json:"version"` + Ref string `json:"ref"` + Provider string `json:"provider"` + Kind string `json:"kind"` + ID string `json:"id"` + Key string `json:"key,omitempty"` + Title string `json:"title"` + URL string `json:"url"` + Scope string `json:"scope"` +} + +// MentionGroup contains results from one registered provider source and kind. +type MentionGroup struct { + Source string `json:"source"` + Provider string `json:"provider"` + Kind string `json:"kind"` + DisplayName string `json:"display_name"` + KindLabel string `json:"kind_label"` + Status MentionStatus `json:"status"` + Results []EntityReference `json:"results"` +} + +// MentionSearchResponse is the deterministic aggregate search response. +type MentionSearchResponse struct { + Query string `json:"query"` + Groups []MentionGroup `json:"groups"` +} diff --git a/apps/web/components/quick-chat/quick-chat-content.tsx b/apps/web/components/quick-chat/quick-chat-content.tsx index 010478a566..9c3fdbd65e 100644 --- a/apps/web/components/quick-chat/quick-chat-content.tsx +++ b/apps/web/components/quick-chat/quick-chat-content.tsx @@ -154,7 +154,7 @@ export const QuickChatContent = memo(function QuickChatContent({ useEffect(() => { if (!initialPrompt || !taskId || initialPromptSentFor.current === sessionId) return; initialPromptSentFor.current = sessionId; - handleSubmit(initialPrompt); + handleSubmit({ message: initialPrompt }); onInitialPromptSent?.(); }, [initialPrompt, taskId, handleSubmit, onInitialPromptSent, sessionId]); diff --git a/apps/web/components/task/chat/chat-input-area.test.tsx b/apps/web/components/task/chat/chat-input-area.test.tsx index fc89090bab..2cb29cc9fc 100644 --- a/apps/web/components/task/chat/chat-input-area.test.tsx +++ b/apps/web/components/task/chat/chat-input-area.test.tsx @@ -114,7 +114,7 @@ describe("useSubmitHandler", () => { const { result } = renderHook(() => useSubmitHandler(panelState())); await act(async () => { - await result.current.handleSubmit("hello"); + await result.current.handleSubmit({ message: "hello" }); }); expect(toastMock).toHaveBeenCalledWith({ @@ -135,7 +135,7 @@ describe("useSubmitHandler", () => { const { result } = renderHook(() => useSubmitHandler(panelState())); await act(async () => { - await result.current.handleSubmit("hello"); + await result.current.handleSubmit({ message: "hello" }); }); expect(toastMock).toHaveBeenCalledWith({ @@ -159,7 +159,7 @@ describe("useSubmitHandler", () => { ); await act(async () => { - await result.current.handleSubmit("hello"); + await result.current.handleSubmit({ message: "hello" }); }); expect(markCommentsSent).not.toHaveBeenCalled(); @@ -182,10 +182,10 @@ describe("useSubmitHandler", () => { ); await act(async () => { - await result.current.handleSubmit("hello"); + await result.current.handleSubmit({ message: "hello" }); }); - const [sentMessage] = handleSendMessageMock.mock.calls[0] as [string]; + const [{ message: sentMessage }] = handleSendMessageMock.mock.calls[0] as [{ message: string }]; expect(sentMessage.match(/### Agent Message Comments/g)).toHaveLength(1); expect(sentMessage).toContain("> settled answer"); expect(sentMessage).toContain("> Please expand this."); diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 500c1365d1..1370d8631e 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -14,13 +14,11 @@ import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { useMessageHandler, buildTaskMentionsContext } from "@/hooks/use-message-handler"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; -import { type ContextFile } from "@/lib/state/context-files-store"; -import type { TaskMentionData } from "@/hooks/use-inline-mention"; import { ChatInputContainer, + type ChatSubmitPayload, type ChatSubmitResult, type ChatInputContainerHandle, - type MessageAttachment, } from "@/components/task/chat/chat-input-container"; import { QueueAffordance } from "@/components/task/chat/queued-ghost-list"; import { @@ -38,6 +36,7 @@ import type { DiffComment } from "@/lib/diff/types"; import type { AgentMessageComment } from "@/lib/state/slices/comments"; import type { useChatPanelState } from "./use-chat-panel-state"; import { cn } from "@/lib/utils"; +import { resolveComposerWorkspaceId } from "./composer-workspace"; const PLAN_CONTEXT_PATH = "plan:context"; @@ -163,7 +162,7 @@ function usePanelMessageHandler(panelState: ReturnType export function useSubmitHandler( panelState: ReturnType, - onSend?: (message: string) => void, + onSend?: (payload: ChatSubmitPayload) => ChatSubmitResult, ) { const [isSending, setIsSending] = useState(false); const storeApi = useAppStoreApi(); @@ -185,42 +184,30 @@ export function useSubmitHandler( const { handleSendMessage } = usePanelMessageHandler(panelState); const handleSubmit = useCallback( - async ( - message: string, - reviewComments?: DiffComment[], - attachments?: MessageAttachment[], - inlineMentions?: ContextFile[], - inlineTaskMentions?: TaskMentionData[], - ) => { + async (payload: ChatSubmitPayload) => { if (isSending) return; setIsSending(true); try { const finalMessage = buildSubmitMessage({ - message, - reviewComments, + message: payload.message, + reviewComments: payload.reviewComments, pendingPRFeedback, planComments, walkthroughComments, messageComments, }); - const hasReviewComments = !!(reviewComments && reviewComments.length > 0); + const outbound = { ...payload, message: finalMessage }; if (onSend) { // Expand task mentions because onSend bypasses useMessageHandler.buildFinalMessage. - const taskCtx = inlineTaskMentions?.length - ? buildTaskMentionsContext(inlineTaskMentions, storeApi.getState()) + const taskCtx = payload.inlineTaskMentions?.length + ? buildTaskMentionsContext(payload.inlineTaskMentions, storeApi.getState()) : ""; - await onSend(finalMessage + taskCtx); + await onSend({ ...outbound, message: finalMessage + taskCtx }); } else { - await handleSendMessage( - finalMessage, - attachments, - hasReviewComments, - inlineMentions, - inlineTaskMentions, - ); + await handleSendMessage(outbound); } - if (reviewComments && reviewComments.length > 0) - markCommentsSent(reviewComments.map((c) => c.id)); + if (payload.reviewComments && payload.reviewComments.length > 0) + markCommentsSent(payload.reviewComments.map((c) => c.id)); if (messageComments.length > 0) markCommentsSent(messageComments.map((c) => c.id)); if (pendingPRFeedback.length > 0) handleClearPRFeedback(); if (walkthroughComments.length > 0) handleClearWalkthroughComments(); @@ -380,13 +367,7 @@ type ChatInputAreaProps = { chatInputRef: React.RefObject; clarificationKey: number; onClarificationResolved: () => void; - handleSubmit: ( - message: string, - reviewComments?: DiffComment[], - attachments?: MessageAttachment[], - inlineMentions?: ContextFile[], - inlineTaskMentions?: TaskMentionData[], - ) => ChatSubmitResult; + handleSubmit: (payload: ChatSubmitPayload) => ChatSubmitResult; handleCancelTurn: () => Promise; showRequestChangesTooltip: boolean; onRequestChangesTooltipDismiss?: () => void; @@ -408,6 +389,20 @@ function useExecutorUnavailable(taskId: string | null, sessionId: string | null) }; } +function useComposerWorkspaceId(sessionId: string | null, taskId: string | null) { + return useAppStore((state) => + resolveComposerWorkspaceId({ + sessionId, + taskId, + quickChatSessions: state.quickChat.sessions, + activeWorkflowId: state.kanban.workflowId, + activeTasks: state.kanban.tasks, + snapshots: Object.values(state.kanbanMulti.snapshots), + workflows: state.workflows.items, + }), + ); +} + function useChatInputDerived( panelState: ReturnType, chatInputRef: React.RefObject, @@ -452,8 +447,8 @@ export function ChatInputArea({ placeholderOverride, surfaceClassName, }: ChatInputAreaProps) { - const { resolvedSessionId, taskId, isAgentBusy, needsRecovery, planModeEnabled, todoItems } = - panelState; + const { resolvedSessionId, taskId, isAgentBusy, needsRecovery, planModeEnabled } = panelState; + const composerWorkspaceId = useComposerWorkspaceId(resolvedSessionId, taskId); const sessionState = panelState.session?.state ?? null; const canDrainQueue = sessionState === "WAITING_FOR_INPUT" || sessionState === "IDLE"; const { planActions, executor, placeholder } = useChatInputDerived( @@ -472,7 +467,7 @@ export function ChatInputArea({ canDrain={canDrainQueue} renderStatusBar={(queueChip) => ( vi.fn()); + vi.mock("./tiptap-input", () => ({ - TipTapInput: () =>
, + TipTapInput: (props: unknown) => { + tipTapPropsMock(props); + return
; + }, })); vi.mock("./chat-input-toolbar", () => ({ @@ -18,6 +23,7 @@ vi.mock("./context-items/context-zone", () => ({ afterEach(() => { cleanup(); + tipTapPropsMock.mockClear(); }); function props(overrides: Partial = {}): ChatInputBodyProps { @@ -74,6 +80,18 @@ function props(overrides: Partial = {}): ChatInputBodyProps } describe("ChatInputBody", () => { + it("keeps entity references explicitly disabled unless the chat surface enables them", () => { + render( + + + , + ); + + expect(tipTapPropsMock).toHaveBeenCalledWith( + expect.objectContaining({ entityReferencesEnabled: false }), + ); + }); + it("reserves right-side editable space while the focus hint is visible", () => { render( diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index 70ff5bc199..78bef6fde2 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -28,6 +28,8 @@ export type ChatInputEditorAreaProps = { setIsInputFocused: (focused: boolean) => void; sessionId: string | null; taskId: string | null; + workspaceId?: string | null; + entityReferencesEnabled?: boolean; onAddContextFile?: (file: ContextFile) => void; onToggleContextFile?: (file: ContextFile) => void; planContextEnabled: boolean; @@ -160,6 +162,8 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { onBlur={() => setIsInputFocused(false)} sessionId={sessionId} taskId={taskId} + workspaceId={p.workspaceId ?? null} + entityReferencesEnabled={p.entityReferencesEnabled ?? false} onAddContextFile={onAddContextFile} onToggleContextFile={onToggleContextFile} planContextEnabled={planContextEnabled} diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index a2449811b0..4c66c901f3 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -9,6 +9,8 @@ import { useAppStore } from "@/components/state-provider"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { Message } from "@/lib/types/http"; import type { DiffComment } from "@/lib/diff/types"; +import type { TaskMentionData } from "@/hooks/use-inline-mention"; +import type { EntityReference } from "@/lib/types/entity-reference"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useChatInputContainer } from "./use-chat-input-container"; import { @@ -44,16 +46,21 @@ export type ChatInputContainerHandle = { export type ChatSubmitResult = void | boolean | Promise; +export type ChatSubmitPayload = { + message: string; + reviewComments?: DiffComment[]; + attachments?: MessageAttachment[]; + inlineMentions?: ContextFile[]; + inlineTaskMentions?: TaskMentionData[]; + entityReferences?: EntityReference[]; +}; + type ChatInputContainerProps = { - onSubmit: ( - message: string, - reviewComments?: DiffComment[], - attachments?: MessageAttachment[], - inlineMentions?: ContextFile[], - inlineTaskMentions?: import("@/hooks/use-inline-mention").TaskMentionData[], - ) => ChatSubmitResult; + onSubmit: (payload: ChatSubmitPayload) => ChatSubmitResult; sessionId: string | null; taskId: string | null; + workspaceId?: string | null; + entityReferencesEnabled?: boolean; taskTitle?: string; taskDescription: string; planModeEnabled: boolean; @@ -224,6 +231,7 @@ type NormalizedChatInputProps = ChatInputContainerProps & { contextFiles: ContextFile[]; contextItems: ContextItem[]; showRequestChangesTooltip: boolean; + entityReferencesEnabled: boolean; }; function normalizeChatInputProps(p: ChatInputContainerProps): NormalizedChatInputProps { @@ -236,6 +244,7 @@ function normalizeChatInputProps(p: ChatInputContainerProps): NormalizedChatInpu contextFiles: p.contextFiles ?? [], contextItems: p.contextItems ?? [], showRequestChangesTooltip: p.showRequestChangesTooltip ?? false, + entityReferencesEnabled: p.entityReferencesEnabled ?? false, }; } @@ -280,6 +289,8 @@ function buildEditorAreaProps( setIsInputFocused: s.setIsInputFocused, sessionId: p.sessionId, taskId: p.taskId, + workspaceId: p.workspaceId ?? null, + entityReferencesEnabled: p.entityReferencesEnabled, onAddContextFile: p.onAddContextFile, onToggleContextFile: p.onToggleContextFile, planContextEnabled: p.planContextEnabled, diff --git a/apps/web/components/task/chat/composer-workspace.test.ts b/apps/web/components/task/chat/composer-workspace.test.ts new file mode 100644 index 0000000000..607bb7bc0a --- /dev/null +++ b/apps/web/components/task/chat/composer-workspace.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { resolveComposerWorkspaceId } from "./composer-workspace"; + +const workflows = [{ id: "workflow-1", workspaceId: "workspace-1" }]; + +describe("resolveComposerWorkspaceId", () => { + it("uses the workspace persisted with a Quick Chat session", () => { + expect( + resolveComposerWorkspaceId({ + sessionId: "quick-session", + taskId: null, + quickChatSessions: [{ sessionId: "quick-session", workspaceId: "quick-workspace" }], + activeWorkflowId: null, + activeTasks: [], + snapshots: [], + workflows, + }), + ).toBe("quick-workspace"); + }); + + it("maps a task through its loaded workflow", () => { + expect( + resolveComposerWorkspaceId({ + sessionId: "task-session", + taskId: "task-1", + quickChatSessions: [], + activeWorkflowId: "workflow-1", + activeTasks: [{ id: "task-1" }], + snapshots: [], + workflows, + }), + ).toBe("workspace-1"); + }); + + it("fails closed when the task cannot be mapped to a workflow", () => { + expect( + resolveComposerWorkspaceId({ + sessionId: "unknown-session", + taskId: "unknown-task", + quickChatSessions: [], + activeWorkflowId: "workflow-1", + activeTasks: [{ id: "another-task" }], + snapshots: [], + workflows, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/components/task/chat/composer-workspace.ts b/apps/web/components/task/chat/composer-workspace.ts new file mode 100644 index 0000000000..d296e4ed8d --- /dev/null +++ b/apps/web/components/task/chat/composer-workspace.ts @@ -0,0 +1,26 @@ +type TaskIdentity = { id: string }; +type WorkflowIdentity = { id: string; workspaceId: string }; +type QuickChatIdentity = { sessionId: string; workspaceId: string }; +type WorkflowSnapshot = { workflowId: string; tasks: readonly TaskIdentity[] }; + +export function resolveComposerWorkspaceId(args: { + sessionId: string | null; + taskId: string | null; + quickChatSessions: readonly QuickChatIdentity[]; + activeWorkflowId: string | null; + activeTasks: readonly TaskIdentity[]; + snapshots: readonly WorkflowSnapshot[]; + workflows: readonly WorkflowIdentity[]; +}): string | null { + const quickChat = args.quickChatSessions.find((item) => item.sessionId === args.sessionId); + if (quickChat) return quickChat.workspaceId; + if (!args.taskId) return null; + + const activeWorkflowOwnsTask = args.activeTasks.some((task) => task.id === args.taskId); + const workflowId = activeWorkflowOwnsTask + ? args.activeWorkflowId + : (args.snapshots.find((snapshot) => snapshot.tasks.some((task) => task.id === args.taskId)) + ?.workflowId ?? null); + if (!workflowId) return null; + return args.workflows.find((workflow) => workflow.id === workflowId)?.workspaceId ?? null; +} diff --git a/apps/web/components/task/chat/entity-reference-groups.ts b/apps/web/components/task/chat/entity-reference-groups.ts new file mode 100644 index 0000000000..26d63d4e30 --- /dev/null +++ b/apps/web/components/task/chat/entity-reference-groups.ts @@ -0,0 +1,21 @@ +import type { EntityReference, EntityReferenceSearchGroup } from "@/lib/types/entity-reference"; + +export function visibleEntityReferenceGroups( + groups: readonly EntityReferenceSearchGroup[], +): EntityReferenceSearchGroup[] { + return groups.filter( + (group) => + group.source !== "kandev_tasks" && + group.status !== "not_configured" && + group.status !== "unsupported_scope" && + (group.status !== "ok" || group.results.length > 0), + ); +} + +export function selectableEntityReferences( + groups: readonly EntityReferenceSearchGroup[], +): EntityReference[] { + return visibleEntityReferenceGroups(groups).flatMap((group) => + group.status === "ok" ? group.results : [], + ); +} diff --git a/apps/web/components/task/chat/entity-reference-menu.test.tsx b/apps/web/components/task/chat/entity-reference-menu.test.tsx new file mode 100644 index 0000000000..00ba96fe0f --- /dev/null +++ b/apps/web/components/task/chat/entity-reference-menu.test.tsx @@ -0,0 +1,181 @@ +import type { ComponentType } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { EntityReference, EntityReferenceSearchGroup } from "@/lib/types/entity-reference"; +import * as entityReferenceMenus from "./entity-reference-menu"; + +afterEach(cleanup); + +const unavailableSearchGroups: EntityReferenceSearchGroup[] = [ + { + source: "gitlab_issues", + provider: "gitlab", + kind: "issue", + display_name: "GitLab", + kind_label: "Issue", + status: "unsupported_scope", + results: [], + }, + { + source: "linear_issues", + provider: "linear", + kind: "issue", + display_name: "Linear", + kind_label: "Issue", + status: "not_configured", + results: [], + }, + { + source: "github_issues", + provider: "github", + kind: "issue", + display_name: "GitHub", + kind_label: "Issue", + status: "timeout", + results: [], + }, +]; + +const kandevTaskGroup: EntityReferenceSearchGroup = { + source: "kandev_tasks", + provider: "kandev", + kind: "task", + display_name: "Kandev tasks", + kind_label: "Task", + status: "ok", + results: [ + { + version: 1, + ref: "mention:v1:kandev:task:workspace-1:task-1", + provider: "kandev", + kind: "task", + id: "task-1", + key: "", + title: "Existing task", + url: "/t/task-1", + scope: "workspace-1", + }, + ], +}; + +const githubIssueGroup: EntityReferenceSearchGroup = { + source: "github_issues", + provider: "github", + kind: "issue", + display_name: "GitHub", + kind_label: "Issue", + status: "ok", + results: [ + { + version: 1, + ref: "mention:v1:github:issue:octo%2Frepo:7", + provider: "github", + kind: "issue", + id: "7", + key: "#7", + title: "External issue", + url: "https://github.com/octo/repo/issues/7", + scope: "octo/repo", + }, + ], +}; + +describe("EntityReferenceMenu", () => { + it("provides a menu distinct from @ context mentions", () => { + expect(typeof (entityReferenceMenus as Record).EntityReferenceMenu).toBe( + "function", + ); + }); + + it("renders descriptor-driven groups with a generic fallback and 44px touch rows", () => { + const reference: EntityReference = { + version: 1, + ref: "mention:v1:plugin:acme:incident:scope:incident-9", + provider: "plugin:acme:tracker", + kind: "incident", + id: "incident-9", + key: "INC-9", + title: "Authentication outage", + url: "https://tracker.example.test/incidents/9", + scope: "tracker.example.test/team-a", + }; + const groups: EntityReferenceSearchGroup[] = [ + { + source: "plugin:acme:incidents", + provider: reference.provider, + kind: reference.kind, + display_name: "Acme tracker", + kind_label: "Incident", + status: "ok", + results: [reference], + }, + ]; + const onSelect = vi.fn(); + const Menu = entityReferenceMenus.EntityReferenceMenu as unknown as ComponentType<{ + isOpen: boolean; + clientRect: () => DOMRect; + groups: EntityReferenceSearchGroup[]; + query: string; + selectedIndex: number; + isSearching: boolean; + error: null; + onRetry: () => void; + onSelect: (reference: EntityReference) => void; + onClose: () => void; + setSelectedIndex: (index: number) => void; + }>; + + render( + new DOMRect(16, 240, 1, 20)} + groups={groups} + query="auth" + selectedIndex={0} + isSearching={false} + error={null} + onRetry={vi.fn()} + onSelect={onSelect} + onClose={vi.fn()} + setSelectedIndex={vi.fn()} + />, + ); + + expect(screen.getByText("Acme tracker")).toBeTruthy(); + expect(screen.getByTestId("entity-reference-menu")).toBeTruthy(); + expect(screen.getByText("Incident")).toBeTruthy(); + expect(screen.getByTestId("entity-reference-generic-icon")).toBeTruthy(); + const row = screen.getByRole("option", { name: /#INC-9.*Authentication outage/ }); + expect(row.className).toContain("min-h-11"); + fireEvent.click(row); + expect(onSelect).toHaveBeenCalledWith(reference); + }); + + it("omits sources that are not searchable in the active workspace", () => { + const visibleEntityReferenceGroups = (entityReferenceMenus as Record) + .visibleEntityReferenceGroups; + expect(visibleEntityReferenceGroups).toBeTypeOf("function"); + if (typeof visibleEntityReferenceGroups !== "function") return; + + expect( + (visibleEntityReferenceGroups as (value: EntityReferenceSearchGroup[]) => unknown)( + unavailableSearchGroups, + ), + ).toEqual([unavailableSearchGroups[2]]); + }); + + it("keeps Kandev tasks out of # work item search", () => { + expect(entityReferenceMenus.visibleEntityReferenceGroups([kandevTaskGroup])).toEqual([]); + + const selectableEntityReferences = (entityReferenceMenus as Record) + .selectableEntityReferences; + expect(selectableEntityReferences).toBeTypeOf("function"); + if (typeof selectableEntityReferences !== "function") return; + expect( + (selectableEntityReferences as (value: EntityReferenceSearchGroup[]) => unknown)([ + kandevTaskGroup, + githubIssueGroup, + ]), + ).toEqual(githubIssueGroup.results); + }); +}); diff --git a/apps/web/components/task/chat/entity-reference-menu.tsx b/apps/web/components/task/chat/entity-reference-menu.tsx new file mode 100644 index 0000000000..162e6e3492 --- /dev/null +++ b/apps/web/components/task/chat/entity-reference-menu.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { IconBug, IconClipboardList, IconGitPullRequest, IconLink } from "@tabler/icons-react"; +import type { EntityReferenceSearchError } from "@/hooks/use-entity-reference-search"; +import type { + EntityReference, + EntityReferenceGroupStatus, + EntityReferenceSearchGroup, +} from "@/lib/types/entity-reference"; +import { + selectableEntityReferences, + visibleEntityReferenceGroups, +} from "./entity-reference-groups"; +import { PopupMenu, PopupMenuItem, useMenuItemRefs } from "./popup-menu"; + +export { + selectableEntityReferences, + visibleEntityReferenceGroups, +} from "./entity-reference-groups"; + +type EntityReferenceMenuProps = { + isOpen: boolean; + clientRect?: (() => DOMRect | null) | null; + groups: EntityReferenceSearchGroup[]; + query: string; + selectedIndex: number; + isSearching: boolean; + error: EntityReferenceSearchError | null; + onRetry: () => void; + onSelect: (reference: EntityReference) => void; + onClose: () => void; + setSelectedIndex: (index: number) => void; +}; + +const GROUP_STATUS_LABELS: Partial> = { + unauthorized: "Sign-in required", + rate_limited: "Temporarily rate limited", + timeout: "Search timed out", + upstream_error: "Provider unavailable", + unsupported_scope: "Unavailable for this workspace", +}; + +function entityReferenceIcon(kind: string) { + if (kind === "task") return ; + if (kind === "issue" || kind === "work_item") return ; + if (kind === "pull_request" || kind === "merge_request") { + return ; + } + return ; +} + +function entityReferenceEmptyState( + query: string, + isSearching: boolean, + error: EntityReferenceSearchError | null, + onRetry: () => void, +) { + if (!query.trim()) return "Type to search work items"; + if (isSearching) return "Searching work items…"; + if (error) { + return ( +
+ {error.message} + +
+ ); + } + return "No work items found"; +} + +export function EntityReferenceMenu({ + isOpen, + clientRect, + groups, + query, + selectedIndex, + isSearching, + error, + onRetry, + onSelect, + onClose, + setSelectedIndex, +}: EntityReferenceMenuProps) { + const { setItemRef } = useMenuItemRefs(selectedIndex); + const selectable = selectableEntityReferences(groups); + let itemIndex = 0; + const visibleGroups = visibleEntityReferenceGroups(groups); + const hasContent = visibleGroups.length > 0; + return ( + + {entityReferenceEmptyState(query, isSearching, error, onRetry)} +
+ } + > + {visibleGroups.map((group) => ( +
+
+ {group.display_name} + {group.kind_label} +
+ {group.status === "ok" ? ( + group.results.map((reference) => { + const index = itemIndex++; + return ( + onSelect(reference)} + onMouseEnter={() => setSelectedIndex(index)} + itemRef={setItemRef(index)} + /> + ); + }) + ) : ( +
+ {GROUP_STATUS_LABELS[group.status] ?? "Provider unavailable"} +
+ )} +
+ ))} + {isSearching && selectable.length > 0 && ( +
Updating…
+ )} + + ); +} diff --git a/apps/web/components/task/chat/message-history-search.tsx b/apps/web/components/task/chat/message-history-search.tsx index 0fa1137cb6..cbbeb65adc 100644 --- a/apps/web/components/task/chat/message-history-search.tsx +++ b/apps/web/components/task/chat/message-history-search.tsx @@ -2,12 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { searchHistory, type SearchHit } from "./message-history"; +import { searchHistory, type MessageHistoryEntry, type SearchHit } from "./message-history"; import { HitRow } from "./message-history-search-row"; type MessageHistorySearchProps = { /** Newest-first list of the user's previous messages for this session. */ - history: readonly string[]; + history: readonly MessageHistoryEntry[]; /** True while older messages are still being paginated in from the backend * (overlay-driven drain). The list updates live as more arrive. */ isLoadingOlder?: boolean; @@ -30,7 +30,7 @@ function clampSelectedIndex(prev: number, hitCount: number): number { return prev; } -function useHits(history: readonly string[], query: string): SearchHit[] { +function useHits(history: readonly MessageHistoryEntry[], query: string): SearchHit[] { return useMemo(() => searchHistory(history, query), [history, query]); } diff --git a/apps/web/components/task/chat/message-history.test.ts b/apps/web/components/task/chat/message-history.test.ts index 87081d6a64..1cee0b99d1 100644 --- a/apps/web/components/task/chat/message-history.test.ts +++ b/apps/web/components/task/chat/message-history.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import { + createMessageHistorySelector, extractUserHistory, navigateHistory, fuzzyScore, searchHistory, type HistoryState, } from "./message-history"; +import * as messageHistory from "./message-history"; import type { Message } from "@/lib/types/http"; function msg(partial: Partial): Message { @@ -21,6 +23,106 @@ function msg(partial: Partial): Message { }; } +describe("createMessageHistorySelector", () => { + it("keeps the selector snapshot stable when only agent output changes", () => { + const selector = createMessageHistorySelector("s1"); + const first = selector({ + messages: { + bySession: { s1: [msg({ id: "user-1", content: "keep me" })] }, + metaBySession: {}, + }, + }); + const afterAgentStream = selector({ + messages: { + bySession: { + s1: [ + msg({ id: "user-1", content: "keep me" }), + msg({ id: "agent-1", author_type: "agent", content: "streaming" }), + ], + }, + metaBySession: {}, + }, + }); + + expect(afterAgentStream).toBe(first); + }); + + it("returns a new selector snapshot when reference metadata changes", () => { + const selector = createMessageHistorySelector("s1"); + const reference = { + version: 1, + ref: "mention:v1:sentry:issue:sentry.example.test%2Forg-1:issue-1", + provider: "sentry", + kind: "issue", + id: "issue-1", + key: "WEB-1", + title: "Login crash", + url: "https://sentry.example.test/issues/1", + scope: "sentry.example.test/org-1", + }; + const first = selector({ + messages: { + bySession: { + s1: [msg({ id: "user-1", content: "See #WEB-1", metadata: { entity_references: [] } })], + }, + metaBySession: {}, + }, + }); + const withReference = selector({ + messages: { + bySession: { + s1: [ + msg({ + id: "user-1", + content: "See #WEB-1", + metadata: { entity_references: [reference] }, + }), + ], + }, + metaBySession: {}, + }, + }); + + expect(withReference).not.toBe(first); + expect(withReference[0]?.entityReferences).toEqual([reference]); + }); +}); + +describe("extractUserHistoryEntries", () => { + it("provides structured history entries for rich-editor recall", () => { + expect(typeof (messageHistory as Record).extractUserHistoryEntries).toBe( + "function", + ); + }); + + it("keeps entity reference metadata with recalled message content", () => { + const reference = { + version: 1, + ref: "mention:v1:sentry:issue:sentry.example.test%2Forg-1:issue-1", + provider: "sentry", + kind: "issue", + id: "issue-1", + key: "WEB-1", + title: "Login crash", + url: "https://sentry.example.test/issues/1", + scope: "sentry.example.test/org-1", + }; + const entries = messageHistory.extractUserHistoryEntries([ + msg({ + content: "See [#WEB-1](https://sentry.example.test/issues/1)", + metadata: { entity_references: [reference, reference] }, + }), + ]); + + expect(entries).toEqual([ + { + content: "See [#WEB-1](https://sentry.example.test/issues/1)", + entityReferences: [reference], + }, + ]); + }); +}); + describe("extractUserHistory", () => { it("returns user message contents newest-first", () => { const messages: Message[] = [ diff --git a/apps/web/components/task/chat/message-history.ts b/apps/web/components/task/chat/message-history.ts index a52610993c..682a72f191 100644 --- a/apps/web/components/task/chat/message-history.ts +++ b/apps/web/components/task/chat/message-history.ts @@ -1,16 +1,54 @@ import type { Message } from "@/lib/types/http"; +import type { EntityReference } from "@/lib/types/entity-reference"; +import type { MessagesState } from "@/lib/state/slices/session/types"; +import { entityReferencesFromMetadata } from "@/lib/entity-references/message-references"; -/** Extract the user's previously sent text messages for a session, newest-first. - * Consecutive duplicates *in the original message stream* are collapsed — - * duplicates separated by an assistant or tool turn stay, since the user - * meaningfully repeated themselves and should be able to recall the older - * one from history. Empty/whitespace-only messages are skipped. */ -export function extractUserHistory(messages: readonly Message[]): string[] { - const out: string[] = []; +export type MessageHistoryEntry = { + content: string; + entityReferences: EntityReference[]; +}; + +type MessageHistoryStoreState = { + messages: MessagesState; +}; + +function sameEntityReference(left: EntityReference, right: EntityReference): boolean { + return ( + left.version === right.version && + left.ref === right.ref && + left.provider === right.provider && + left.kind === right.kind && + left.id === right.id && + left.key === right.key && + left.title === right.title && + left.url === right.url && + left.scope === right.scope + ); +} + +function sameHistoryEntries( + left: readonly MessageHistoryEntry[], + right: readonly MessageHistoryEntry[], +): boolean { + if (left.length !== right.length) return false; + return left.every((entry, index) => { + const other = right[index]; + return ( + entry.content === other.content && + entry.entityReferences.length === other.entityReferences.length && + entry.entityReferences.every((reference, referenceIndex) => + sameEntityReference(reference, other.entityReferences[referenceIndex]), + ) + ); + }); +} + +export function extractUserHistoryEntries(messages: readonly Message[]): MessageHistoryEntry[] { + const out: MessageHistoryEntry[] = []; for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]; - if (m.author_type !== "user" || m.type !== "message") continue; - const content = m.content ?? ""; + const message = messages[i]; + if (message.author_type !== "user" || message.type !== "message") continue; + const content = message.content ?? ""; if (!content.trim()) continue; const newer = messages[i + 1]; const isAdjacentUserDuplicate = @@ -18,11 +56,34 @@ export function extractUserHistory(messages: readonly Message[]): string[] { newer.type === "message" && (newer.content ?? "") === content; if (isAdjacentUserDuplicate) continue; - out.push(content); + out.push({ content, entityReferences: entityReferencesFromMetadata(message.metadata) }); } return out; } +/** Build a Zustand selector whose result stays referentially stable while + * agent messages stream. React's external-store contract requires the same + * snapshot object when the derived user history has not changed. */ +export function createMessageHistorySelector(sessionId: string | null) { + let previous: MessageHistoryEntry[] = []; + return (state: MessageHistoryStoreState): MessageHistoryEntry[] => { + const messages = sessionId ? state.messages.bySession[sessionId] : undefined; + const next = extractUserHistoryEntries(messages ?? []); + if (sameHistoryEntries(previous, next)) return previous; + previous = next; + return previous; + }; +} + +/** Extract the user's previously sent text messages for a session, newest-first. + * Consecutive duplicates *in the original message stream* are collapsed — + * duplicates separated by an assistant or tool turn stay, since the user + * meaningfully repeated themselves and should be able to recall the older + * one from history. Empty/whitespace-only messages are skipped. */ +export function extractUserHistory(messages: readonly Message[]): string[] { + return extractUserHistoryEntries(messages).map((entry) => entry.content); +} + export type HistoryState = { /** Position within the history list (0 = most recent). `null` means the user * is currently editing their own draft, not viewing history. */ @@ -85,17 +146,29 @@ export type SearchHit = { score: number; }; +function historyEntryContent(entry: string | MessageHistoryEntry): string { + return typeof entry === "string" ? entry : entry.content; +} + /** Score every history entry against `query` and return matches sorted * best-first. An empty query returns every entry in newest-first order * (preserving the original index). */ -export function searchHistory(history: readonly string[], query: string): SearchHit[] { +export function searchHistory( + history: readonly (string | MessageHistoryEntry)[], + query: string, +): SearchHit[] { if (!query) { - return history.map((content, index) => ({ index, content, score: 0 })); + return history.map((entry, index) => ({ + index, + content: historyEntryContent(entry), + score: 0, + })); } const hits: SearchHit[] = []; for (let i = 0; i < history.length; i++) { - const score = fuzzyScore(query, history[i]); - if (score !== null) hits.push({ index: i, content: history[i], score }); + const content = historyEntryContent(history[i]); + const score = fuzzyScore(query, content); + if (score !== null) hits.push({ index: i, content, score }); } hits.sort((a, b) => b.score - a.score); return hits; diff --git a/apps/web/components/task/chat/messages/chat-message.test.tsx b/apps/web/components/task/chat/messages/chat-message.test.tsx index c8e3465860..3fcbf59d7d 100644 --- a/apps/web/components/task/chat/messages/chat-message.test.tsx +++ b/apps/web/components/task/chat/messages/chat-message.test.tsx @@ -3,6 +3,8 @@ import type { ReactNode } from "react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { StateProvider } from "@/components/state-provider"; import { ChatMessage } from "./chat-message"; +import { entityReferenceMarkdown } from "@/lib/entity-references/message-references"; +import type { EntityReference } from "@/lib/types/entity-reference"; import { sessionId as toSessionId, taskId as toTaskId, @@ -22,6 +24,7 @@ const PNG_BASE64 = const OPEN_ATTACHMENT_1_LABEL = "Open Attachment 1"; const FULL_SIZE_ATTACHMENT_1_ALT = "Full size Attachment 1"; const PROMPT_MENTION_TESTID = "custom-prompt-mention"; +const ENTITY_REFERENCE_TESTID = "entity-reference-chip"; afterEach(() => { cleanup(); @@ -52,6 +55,21 @@ function customPrompt(name: string): CustomPrompt { }; } +function issueReference(overrides: Partial = {}): EntityReference { + return { + version: 1, + ref: "mention:v1:jira:issue:https%3A%2F%2Fjira.example:10001", + provider: "jira", + kind: "issue", + id: "10001", + key: "ENG[1]", + title: "Fix the login flow", + url: "https://jira.example/browse/ENG-1?q=hello world(test)", + scope: "https://jira.example", + ...overrides, + }; +} + function wrapper(tasks: Array<{ id: string; title: string }> = [], prompts: CustomPrompt[] = []) { return function Wrapper({ children }: { children: ReactNode }) { return ( @@ -158,6 +176,74 @@ describe("ChatMessage prompt mentions", () => { }); }); +describe("ChatMessage entity references", () => { + it("renders an exact generated Markdown link as an accessible clickable chip", () => { + const reference = issueReference(); + const Wrapper = wrapper(); + + render( + + + , + ); + + const chip = screen.getByTestId(ENTITY_REFERENCE_TESTID); + expect(chip.getAttribute("aria-label")).toBe("Open #ENG[1]: Fix the login flow"); + expect(chip.getAttribute("href")).toBe( + "https://jira.example/browse/ENG-1?q=hello%20world%28test%29", + ); + expect(chip.getAttribute("target")).toBe("_blank"); + }); + + it.each([ + ["missing metadata", undefined, "[#ENG\\[1\\]](https://jira.example/browse/ENG-1)"], + [ + "malformed metadata", + { entity_references: [{ ...issueReference(), version: 2 }] }, + entityReferenceMarkdown(issueReference()), + ], + [ + "wrong label", + { entity_references: [issueReference()] }, + "[#Wrong](https://jira.example/browse/ENG-1?q=hello%20world%28test%29)", + ], + [ + "wrong URL", + { entity_references: [issueReference()] }, + "[#ENG\\[1\\]](https://evil.example/ENG-1)", + ], + [ + "formatted lookalike label", + { entity_references: [issueReference()] }, + "[**#ENG\\[1\\]**](https://jira.example/browse/ENG-1?q=hello%20world%28test%29)", + ], + ])("keeps %s as ordinary Markdown", (_name, metadata, content) => { + const Wrapper = wrapper(); + + render( + + + , + ); + + expect(screen.queryByTestId(ENTITY_REFERENCE_TESTID)).toBeNull(); + expect(screen.getByRole("link")).not.toBeNull(); + }); +}); + function renderWithSender( tasks: Array<{ id: string; title: string }>, metadata: Partial, diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index f5b0cd384a..2595397f52 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -17,7 +17,6 @@ import { MessageActions } from "@/components/task/chat/messages/message-actions" import { useUserMessageNavigation } from "@/hooks/use-message-navigation"; import { SenderTaskBadge, type SenderTaskInfo } from "./sender-task-badge"; import { MemoizedMarkdown } from "@/components/shared/memoized-markdown"; -import { markdownComponents } from "@/components/shared/markdown-components"; import { ImagePreviewDialog } from "@/components/task/chat/image-preview-dialog"; import { useAppStore } from "@/components/state-provider"; import { HoverCard, HoverCardTrigger, HoverCardContent } from "@kandev/ui/hover-card"; @@ -33,6 +32,9 @@ import { type WorkflowStepMessageInfo, } from "./workflow-step-message-badge"; import { AgentMessageContent } from "./agent-message-content"; +import { buildEntityReferenceMarkdownComponents } from "./entity-reference-chip"; +import { entityReferencesFromMetadata } from "@/lib/entity-references/message-references"; +import type { EntityReference } from "@/lib/types/entity-reference"; type ChatMessageProps = { comment: Message; @@ -179,14 +181,20 @@ function usePromptMentionNames() { return useMemo(() => prompts.map((prompt) => prompt.name), [prompts]); } -function usePromptMentionMarkdownComponents(promptNames: string[]): Components | undefined { +function usePromptMentionMarkdownComponents( + promptNames: string[], + entityReferences: readonly EntityReference[], +): Components | undefined { return useMemo(() => { const mentionNames = buildPromptMentionNames(promptNames); - if (mentionNames.length === 0) return undefined; + const baseComponents = buildEntityReferenceMarkdownComponents(entityReferences); + if (mentionNames.length === 0) { + return entityReferences.length === 0 ? undefined : baseComponents; + } const renderChildren = (children: ReactNode, keyPrefix: string) => renderChildrenWithPromptMentions(children, mentionNames, keyPrefix); return { - ...markdownComponents, + ...baseComponents, p: ({ children, node, ...props }: MarkdownChildrenProps<"p">) => { void node; return

{renderChildren(children, "p")}

; @@ -232,7 +240,7 @@ function usePromptMentionMarkdownComponents(promptNames: string[]): Components | return {renderChildren(children, "th")}; }, }; - }, [promptNames]); + }, [promptNames, entityReferences]); } function renderChildrenWithPromptMentions( @@ -398,7 +406,11 @@ function UserMessageContent({ }: UserMessageProps) { const userNavigation = useUserMessageNavigation(sessionId ?? null, comment.id); const promptNames = usePromptMentionNames(); - const promptMentionComponents = usePromptMentionMarkdownComponents(promptNames); + const entityReferences = useMemo( + () => entityReferencesFromMetadata(comment.metadata), + [comment.metadata], + ); + const promptMentionComponents = usePromptMentionMarkdownComponents(promptNames, entityReferences); const { imageAttachments, fileAttachments, diff --git a/apps/web/components/task/chat/messages/entity-reference-chip.tsx b/apps/web/components/task/chat/messages/entity-reference-chip.tsx new file mode 100644 index 0000000000..e3441b3d69 --- /dev/null +++ b/apps/web/components/task/chat/messages/entity-reference-chip.tsx @@ -0,0 +1,70 @@ +import { IconHash } from "@tabler/icons-react"; +import type { ComponentPropsWithoutRef } from "react"; +import type { Components, ExtraProps } from "react-markdown"; +import type { EntityReference } from "@/lib/types/entity-reference"; +import { + entityReferenceHref, + entityReferenceLabel, + matchEntityReferenceLink, +} from "@/lib/entity-references/message-references"; +import { markdownComponents } from "@/components/shared/markdown-components"; + +type EntityReferenceChipProps = { + reference: EntityReference; +}; + +export function EntityReferenceChip({ reference }: EntityReferenceChipProps) { + const label = entityReferenceLabel(reference); + const isInternal = reference.url.startsWith("/") || reference.url.startsWith("#"); + return ( + + + ); +} + +type EntityReferenceAnchorProps = ComponentPropsWithoutRef<"a"> & + ExtraProps & { + references: readonly EntityReference[]; + }; + +function EntityReferenceAnchor({ + references, + children, + href, + ...props +}: EntityReferenceAnchorProps) { + const node = props.node; + const textChild = node?.children.length === 1 ? node.children[0] : null; + const label = textChild?.type === "text" ? textChild.value : null; + const reference = label === null ? null : matchEntityReferenceLink(references, label, href); + if (reference) return ; + const MarkdownAnchor = markdownComponents.a; + return ( + + {children} + + ); +} + +export function buildEntityReferenceMarkdownComponents( + references: readonly EntityReference[], +): Components { + if (references.length === 0) return markdownComponents; + return { + ...markdownComponents, + a: (props) => , + }; +} diff --git a/apps/web/components/task/chat/popup-menu.test.tsx b/apps/web/components/task/chat/popup-menu.test.tsx new file mode 100644 index 0000000000..299e3e8a64 --- /dev/null +++ b/apps/web/components/task/chat/popup-menu.test.tsx @@ -0,0 +1,154 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as popupMenu from "./popup-menu"; +import { computePopupMenuStyle, PopupMenu, PopupMenuItem } from "./popup-menu"; + +afterEach(() => { + cleanup(); + Object.defineProperty(window, "visualViewport", { configurable: true, value: undefined }); +}); + +describe("popup menu geometry", () => { + it("provides a visual-viewport-aware geometry helper", () => { + expect(typeof (popupMenu as Record).computePopupMenuStyle).toBe("function"); + }); + + it("keeps an above-composer menu inside an offset phone visual viewport", () => { + const style = computePopupMenuStyle({ + position: { x: 350, y: 560 }, + placement: "above", + viewport: { offsetLeft: 12, offsetTop: 100, width: 360, height: 500 }, + }); + + expect(style.left).toBe(20); + expect(style.width).toBe(344); + expect(style.top).toBe(552); + expect(style.maxHeight).toBe(280); + expect(Number(style.top) - Number(style.maxHeight)).toBe(272); + expect(style.transform).toBe("translateY(-100%)"); + expect(style.bottom).toBeUndefined(); + }); + + it("uses a focused desktop width instead of spanning the available viewport", () => { + const style = computePopupMenuStyle({ + position: { x: 100, y: 700 }, + placement: "above", + viewport: { offsetLeft: 0, offsetTop: 0, width: 1200, height: 800 }, + }); + + expect(style.width).toBe(420); + }); + + it("anchors the rendered bottom edge instead of reserving unused maximum height", () => { + const style = computePopupMenuStyle({ + position: { x: 100, y: 700 }, + placement: "above", + viewport: { offsetLeft: 0, offsetTop: 0, width: 1200, height: 800 }, + }); + + expect(style.top).toBe(692); + expect(style.transform).toBe("translateY(-100%)"); + }); + + it("reflows while the mobile visual viewport changes", () => { + const viewport = Object.assign(new EventTarget(), { + offsetLeft: 0, + offsetTop: 0, + width: 360, + height: 500, + }); + Object.defineProperty(window, "visualViewport", { configurable: true, value: viewport }); + render( + new DOMRect(16, 240, 1, 20)} + title="References" + selectedIndex={0} + onClose={() => undefined} + > + Result + , + ); + const menu = screen.getByText("References").parentElement?.parentElement; + expect(menu?.style.width).toBe("344px"); + + act(() => { + viewport.width = 300; + viewport.dispatchEvent(new Event("resize")); + }); + + expect(menu?.style.width).toBe("284px"); + }); +}); + +describe("popup menu interaction", () => { + it("exposes one labelled listbox with semantic selectable options", () => { + render( + undefined} + > + , + ); + + expect(screen.getByRole("listbox", { name: "References" })).toBeTruthy(); + expect( + screen + .getByRole("option", { name: /#ENG-123.*Fix authentication/ }) + .getAttribute("aria-selected"), + ).toBe("true"); + }); + + it("keeps composer focus when an option is pressed", () => { + render( + undefined} + > + , + ); + + expect(fireEvent.pointerDown(screen.getByRole("option"))).toBe(false); + }); + + it("dismisses from an outside pointer press", () => { + const onClose = vi.fn(); + render( + + Result + , + ); + + fireEvent.pointerDown(document.body); + + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/task/chat/popup-menu.tsx b/apps/web/components/task/chat/popup-menu.tsx index ac2d5d1605..650816616a 100644 --- a/apps/web/components/task/chat/popup-menu.tsx +++ b/apps/web/components/task/chat/popup-menu.tsx @@ -1,14 +1,54 @@ "use client"; -import { useEffect, useRef, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; const MENU_HEIGHT = 280; -const MENU_MAX_WIDTH = 700; +const MENU_WIDTH = 420; +const MENU_HEADER_HEIGHT = 32; + +type PopupViewport = { + offsetLeft: number; + offsetTop: number; + width: number; + height: number; +}; + +export function computePopupMenuStyle(args: { + position: { x: number; y: number }; + placement: "above" | "below"; + viewport: PopupViewport; +}): React.CSSProperties { + const margin = 8; + const viewportRight = args.viewport.offsetLeft + args.viewport.width; + const viewportBottom = args.viewport.offsetTop + args.viewport.height; + const width = Math.max(0, Math.min(MENU_WIDTH, args.viewport.width - margin * 2)); + const minLeft = args.viewport.offsetLeft + margin; + const maxLeft = Math.max(minLeft, viewportRight - width - margin); + const left = Math.min(Math.max(minLeft, args.position.x), maxLeft); + const availableHeight = + args.placement === "above" + ? Math.max(0, args.position.y - args.viewport.offsetTop - margin * 2) + : Math.max(0, viewportBottom - args.position.y - margin * 2); + const maxHeight = Math.min(MENU_HEIGHT, availableHeight); + const top = args.placement === "above" ? args.position.y - margin : args.position.y + margin; + return { + position: "fixed", + left, + top, + width, + maxWidth: width, + maxHeight, + zIndex: 60, + pointerEvents: "auto", + transform: args.placement === "above" ? "translateY(-100%)" : undefined, + }; +} export type PopupMenuProps = { isOpen: boolean; + testId?: string; position: { x: number; y: number } | null; /** Alternative to position: a function returning a DOMRect (from TipTap suggestion) */ clientRect?: (() => DOMRect | null) | null; @@ -24,6 +64,7 @@ export type PopupMenuProps = { export function PopupMenu({ isOpen, + testId, position, clientRect: clientRectFn, title, @@ -34,21 +75,37 @@ export function PopupMenu({ placement = "above", }: PopupMenuProps) { const menuRef = useRef(null); + const titleId = useId(); + const [, setViewportRevision] = useState(0); // Close on click outside useEffect(() => { if (!isOpen) return; - const handleClickOutside = (event: MouseEvent) => { + const handlePointerOutside = (event: PointerEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { onClose(); } }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); + document.addEventListener("pointerdown", handlePointerOutside); + return () => document.removeEventListener("pointerdown", handlePointerOutside); }, [isOpen, onClose]); + useEffect(() => { + if (!isOpen) return; + const update = () => setViewportRevision((revision) => revision + 1); + const viewport = window.visualViewport; + window.addEventListener("resize", update); + viewport?.addEventListener("resize", update); + viewport?.addEventListener("scroll", update); + return () => { + window.removeEventListener("resize", update); + viewport?.removeEventListener("resize", update); + viewport?.removeEventListener("scroll", update); + }; + }, [isOpen]); + // Resolve position from clientRect function or direct position const resolvedPosition = (() => { if (position) return position; @@ -68,31 +125,45 @@ export function PopupMenu({ // pointer-events: auto restores click-handling on the popup itself when // Radix Dialog has set pointer-events: none on for modal isolation; // without this, the popup is visible but unclickable inside a dialog. - const menuStyle: React.CSSProperties = { - position: "fixed", - left: Math.max(8, resolvedPosition.x), - maxWidth: Math.min(MENU_MAX_WIDTH, window.innerWidth - resolvedPosition.x - 8), - maxHeight: MENU_HEIGHT, - zIndex: 60, - pointerEvents: "auto", - ...(placement === "below" - ? { top: resolvedPosition.y + 8 } - : { bottom: window.innerHeight - resolvedPosition.y + 8 }), - }; + const visualViewport = window.visualViewport; + const menuStyle = computePopupMenuStyle({ + position: resolvedPosition, + placement, + viewport: visualViewport + ? { + offsetLeft: visualViewport.offsetLeft, + offsetTop: visualViewport.offsetTop, + width: visualViewport.width, + height: visualViewport.height, + } + : { offsetLeft: 0, offsetTop: 0, width: window.innerWidth, height: window.innerHeight }, + }); + const contentMaxHeight = + typeof menuStyle.maxHeight === "number" + ? Math.max(0, menuStyle.maxHeight - MENU_HEADER_HEIGHT) + : MENU_HEIGHT - MENU_HEADER_HEIGHT; const menu = (
{/* Header */} -
- {title} +
+ + {title} +
{/* Content */} -
+
{hasItems ? children : emptyState}
@@ -126,22 +197,27 @@ export function PopupMenuItem({ diff --git a/apps/web/components/task/chat/queued-ghost-list.test.tsx b/apps/web/components/task/chat/queued-ghost-list.test.tsx index 296f7f2f51..288f106727 100644 --- a/apps/web/components/task/chat/queued-ghost-list.test.tsx +++ b/apps/web/components/task/chat/queued-ghost-list.test.tsx @@ -1,7 +1,9 @@ import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; +import type { EntityReference } from "@/lib/types/entity-reference"; +import { entityReferenceMarkdown } from "@/lib/entity-references/message-references"; const useQueueMock = vi.fn(); @@ -49,6 +51,20 @@ function entry(overrides: Partial = {}): QueuedMessage { }; } +function taskReference(): EntityReference { + return { + version: 1, + ref: "mention:v1:kandev:task:workspace-1:task-1", + provider: "kandev", + kind: "task", + id: "task-1", + key: "KAN-1", + title: "Fix authentication", + url: "/t/task-1", + scope: "workspace-1", + }; +} + function queueState(entries: QueuedMessage[], extra: Partial> = {}) { return { ...baseState(entries), ...extra }; } @@ -274,3 +290,28 @@ describe("QueueAffordance — workflow entries", () => { expect(screen.queryByTitle("Remove queued message")).toBeNull(); }); }); + +describe("QueueAffordance entity-reference edits", () => { + it("forwards an explicit empty replacement through the queue edit callback", async () => { + const reference = taskReference(); + const state = queueState([ + entry({ + content: entityReferenceMarkdown(reference), + metadata: { entity_references: [reference] }, + }), + ]); + useQueueMock.mockReturnValue(state); + render({CHILD}); + + fireEvent.click(screen.getByTestId(CHIP_ID)); + fireEvent.click(screen.getByTitle("Edit queued message")); + fireEvent.change(screen.getByTestId("queue-edit-textarea"), { + target: { value: "reference removed" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(state.editEntry).toHaveBeenCalledWith("q-1", "reference removed", undefined, []), + ); + }); +}); diff --git a/apps/web/components/task/chat/queued-ghost-list.tsx b/apps/web/components/task/chat/queued-ghost-list.tsx index 0361f853de..5b5cfbe399 100644 --- a/apps/web/components/task/chat/queued-ghost-list.tsx +++ b/apps/web/components/task/chat/queued-ghost-list.tsx @@ -12,6 +12,7 @@ import { stripSystemTags } from "@/lib/utils/system-tags"; import { useQueue } from "@/hooks/domains/session/use-queue"; import { isWorkflowQueuedMessage, QueuedGhostMessage } from "./queued-ghost-message"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; +import type { EntityReference } from "@/lib/types/entity-reference"; const HEAD_PREVIEW_MAX = 80; @@ -72,7 +73,12 @@ type QueueAffordanceProps = { type QueuePanelHandlerArgs = { clearAll: () => Promise; drainNext: () => Promise; - editEntry: (entryId: string, content: string) => Promise; + editEntry: ( + entryId: string, + content: string, + attachments?: undefined, + entityReferences?: EntityReference[], + ) => Promise; removeEntry: (entryId: string) => Promise; }; @@ -83,8 +89,8 @@ function useQueuePanelHandlers({ removeEntry, }: QueuePanelHandlerArgs) { const handleSave = useCallback( - async (entryId: string, content: string) => { - await editEntry(entryId, content); + async (entryId: string, content: string, entityReferences: EntityReference[]) => { + await editEntry(entryId, content, undefined, entityReferences); }, [editEntry], ); @@ -272,7 +278,7 @@ type QueuePanelProps = { onClose: () => void; onClear: () => void; onDrain: () => void; - onSave: (entryId: string, content: string) => Promise; + onSave: (entryId: string, content: string, entityReferences: EntityReference[]) => Promise; onRemove: (entryId: string) => Promise; }; @@ -317,7 +323,7 @@ function QueuePanel({ entry={entry} index={index} canEdit={canUserEditEntry(entry)} - onSave={(content) => onSave(entry.id, content)} + onSave={(content, entityReferences) => onSave(entry.id, content, entityReferences)} onRemove={() => onRemove(entry.id)} /> ))} diff --git a/apps/web/components/task/chat/queued-ghost-message.test.tsx b/apps/web/components/task/chat/queued-ghost-message.test.tsx index 4be114f16d..84dcf54b70 100644 --- a/apps/web/components/task/chat/queued-ghost-message.test.tsx +++ b/apps/web/components/task/chat/queued-ghost-message.test.tsx @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueuedGhostMessage } from "./queued-ghost-message"; +import { StateProvider } from "@/components/state-provider"; +import { ToastProvider } from "@/components/toast-provider"; +import { entityReferenceMarkdown } from "@/lib/entity-references/message-references"; +import type { EntityReference } from "@/lib/types/entity-reference"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; afterEach(() => { @@ -35,6 +39,43 @@ function entry(overrides: Partial = {}): QueuedMessage { }; } +function taskReference(overrides: Partial = {}): EntityReference { + return { + version: 1, + ref: "mention:v1:kandev:task:workspace-1:task-1", + provider: "kandev", + kind: "task", + id: "task-1", + key: "KAN-1", + title: "Fix authentication", + url: "/t/task-1", + scope: "workspace-1", + ...overrides, + }; +} + +function issueReference(): EntityReference { + return { + version: 1, + ref: "mention:v1:jira:issue:https%3A%2F%2Fjira.example:10001", + provider: "jira", + kind: "issue", + id: "10001", + key: "ENG-1", + title: "Fix login flow", + url: "https://jira.example/browse/ENG-1", + scope: "https://jira.example", + }; +} + +function renderWithProviders(node: React.ReactNode) { + return render( + + {node} + , + ); +} + describe("QueuedGhostMessage workflow badge", () => { it("renders workflow metadata as a workflow step badge", () => { render( @@ -155,3 +196,75 @@ describe("QueuedGhostMessage attachment thumbnails", () => { expect(container.querySelector("img")).toBeNull(); }); }); + +describe("QueuedGhostMessage entity references", () => { + it("renders an exact queued Markdown link as an accessible chip", () => { + const reference = taskReference(); + + renderWithProviders( + {}} + onRemove={() => {}} + />, + ); + + const chip = screen.getByTestId("entity-reference-chip"); + expect(chip.getAttribute("href")).toBe("/t/task-1"); + expect(chip.getAttribute("target")).toBe("_self"); + }); + + it("recomputes surviving references in edited document order", async () => { + const task = taskReference(); + const issue = issueReference(); + const edited = `${entityReferenceMarkdown(issue)} then ${entityReferenceMarkdown(task)}`; + const onSave = vi.fn(async () => {}); + + renderWithProviders( + {}} + />, + ); + + fireEvent.click(screen.getByTitle("Edit queued message")); + fireEvent.change(screen.getByTestId("queue-edit-textarea"), { target: { value: edited } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith(edited, [issue, task])); + }); + + it("sends an explicit empty reference replacement after all links are removed", async () => { + const reference = taskReference(); + const onSave = vi.fn(async () => {}); + + renderWithProviders( + {}} + />, + ); + + fireEvent.click(screen.getByTitle("Edit queued message")); + fireEvent.change(screen.getByTestId("queue-edit-textarea"), { + target: { value: "reference removed" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith("reference removed", [])); + }); +}); diff --git a/apps/web/components/task/chat/queued-ghost-message.tsx b/apps/web/components/task/chat/queued-ghost-message.tsx index 0573f23cab..f646571139 100644 --- a/apps/web/components/task/chat/queued-ghost-message.tsx +++ b/apps/web/components/task/chat/queued-ghost-message.tsx @@ -1,6 +1,14 @@ "use client"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; import { IconCheck, IconChevronDown, @@ -29,8 +37,14 @@ import { workflowMessageInfoFromMetadata, type WorkflowStepMessageInfo, } from "@/components/task/chat/messages/workflow-step-message-badge"; -import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; +import { remarkPlugins } from "@/components/shared/markdown-components"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; +import type { EntityReference } from "@/lib/types/entity-reference"; +import { + entityReferencesFromMetadata, + survivingEntityReferences, +} from "@/lib/entity-references/message-references"; +import { buildEntityReferenceMarkdownComponents } from "@/components/task/chat/messages/entity-reference-chip"; type QueuedAttachment = NonNullable[number]; @@ -239,6 +253,7 @@ function EditView({ type DisplayViewProps = { entry: QueuedMessage; + entityReferences: readonly EntityReference[]; positionLabel: string; canEdit: boolean; onStartEdit: () => void; @@ -255,13 +270,24 @@ function shouldOfferExpand(text: string): boolean { return text.length > EXPAND_THRESHOLD || text.includes("\n"); } -function DisplayView({ entry, positionLabel, canEdit, onStartEdit, onRemove }: DisplayViewProps) { +function DisplayView({ + entry, + entityReferences, + positionLabel, + canEdit, + onStartEdit, + onRemove, +}: DisplayViewProps) { const visible = stripSystemTags(entry.content); const attachments = (entry.attachments ?? []) as QueuedAttachment[]; const senderTask = getSenderTaskInfo(entry); const workflowMessage = getWorkflowMessageInfo(entry); const [expanded, setExpanded] = useState(false); const canExpand = shouldOfferExpand(visible); + const referenceMarkdownComponents = useMemo( + () => buildEntityReferenceMarkdownComponents(entityReferences), + [entityReferences], + ); return (
@@ -287,7 +313,7 @@ function DisplayView({ entry, positionLabel, canEdit, onStartEdit, onRemove }: D expanded ? "max-h-[40rem]" : "max-h-[2.75rem]", )} > - + {visible}
@@ -356,7 +382,7 @@ type QueuedGhostMessageProps = { * entry's queued_by. Inter-task entries are visible but read-only. */ canEdit: boolean; - onSave: (content: string) => Promise; + onSave: (content: string, entityReferences: EntityReference[]) => Promise; onRemove: () => void | Promise; /** Called after edit save/cancel so the parent can refocus the chat input. */ onEditComplete?: () => void; @@ -368,6 +394,10 @@ export const QueuedGhostMessage = forwardRef(null); + const entityReferences = useMemo( + () => entityReferencesFromMetadata(entry.metadata), + [entry.metadata], + ); useEffect(() => { if (!editing) setValue(entry.content); @@ -404,7 +434,7 @@ export const QueuedGhostMessage = forwardRef = {}): AppState { const base = { @@ -12,8 +12,8 @@ function makeState(overrides: Partial = {}): AppState { return { ...base, ...overrides } as AppState; } -describe("buildTaskMentionItems / basics", () => { - it("returns tasks from the current workflow with workflow/step names resolved", () => { +describe("buildTaskMentionItems", () => { + it("returns sibling tasks with resolved workflow and step names", () => { const state = makeState({ kanban: { workflowId: "wf-1", @@ -34,23 +34,17 @@ describe("buildTaskMentionItems / basics", () => { }, } as unknown as Partial); - const items = buildTaskMentionItems(state, null); - expect(items).toHaveLength(1); - expect(items[0]).toMatchObject({ - kind: "task", - label: "Implement auth", - description: "Main flow · Todo", - task: { - taskId: "task-a", - title: "Implement auth", - workflowId: "wf-1", - workflowStepId: "step-1", - state: "in_progress", - }, - }); + expect(buildTaskMentionItems(state, null)).toEqual([ + expect.objectContaining({ + kind: "task", + label: "Implement auth", + description: "Main flow · Todo", + task: expect.objectContaining({ taskId: "task-a", workflowId: "wf-1" }), + }), + ]); }); - it("excludes the current task by id", () => { + it("excludes the current task", () => { const state = makeState({ kanban: { workflowId: "wf-1", @@ -62,18 +56,20 @@ describe("buildTaskMentionItems / basics", () => { }, } as unknown as Partial); - const items = buildTaskMentionItems(state, "task-a"); - expect(items.map((i) => i.task?.taskId)).toEqual(["task-b"]); + expect(buildTaskMentionItems(state, "task-a").map((item) => item.task?.taskId)).toEqual([ + "task-b", + ]); }); -}); -describe("buildTaskMentionItems / merging and filtering", () => { - it("merges tasks from kanbanMulti snapshots and dedupes by id", () => { + it("merges workflow snapshots and skips stale active-workflow tasks", () => { const state = makeState({ kanban: { workflowId: "wf-1", - steps: [], - tasks: [{ id: "task-a", workflowStepId: "step-1", title: "A", position: 0 }], + steps: [{ id: "step-current", title: "Todo", color: "", position: 0 }], + tasks: [ + { id: "task-a", workflowStepId: "step-current", title: "A", position: 0 }, + { id: "task-stale", workflowStepId: "step-other", title: "Stale", position: 1 }, + ], }, kanbanMulti: { snapshots: { @@ -81,54 +77,22 @@ describe("buildTaskMentionItems / merging and filtering", () => { workflowId: "wf-1", workflowName: "Main", steps: [], - tasks: [ - { id: "task-a", workflowStepId: "step-1", title: "A (dup)", position: 0 }, - { id: "task-c", workflowStepId: "step-2", title: "C", position: 0 }, - ], + tasks: [{ id: "task-a", workflowStepId: "step-current", title: "A", position: 0 }], }, "wf-2": { workflowId: "wf-2", workflowName: "Other", - steps: [{ id: "step-9", title: "Review", color: "", position: 0 }], - tasks: [{ id: "task-d", workflowStepId: "step-9", title: "D", position: 0 }], + steps: [{ id: "step-2", title: "Review", color: "", position: 0 }], + tasks: [{ id: "task-b", workflowStepId: "step-2", title: "B", position: 0 }], }, }, isLoading: false, }, } as unknown as Partial); - const ids = buildTaskMentionItems(state, null).map((i) => i.task?.taskId); - expect(ids).toEqual(["task-a", "task-c", "task-d"]); - }); - - it("skips stale kanban tasks whose step is not in the current workflow's steps", () => { - const state = makeState({ - kanban: { - workflowId: "wf-1", - steps: [{ id: "step-current", title: "Todo", color: "", position: 0 }], - tasks: [ - { id: "task-fresh", workflowStepId: "step-current", title: "Fresh", position: 0 }, - // Left over from a previous workflow: its step is not in wf-1's steps, - // so tagging it with wf-1 would be wrong. - { id: "task-stale", workflowStepId: "step-other", title: "Stale", position: 1 }, - ], - }, - } as unknown as Partial); - - const ids = buildTaskMentionItems(state, null).map((i) => i.task?.taskId); - expect(ids).toEqual(["task-fresh"]); - }); - - it("falls back to placeholder names when workflow/step are missing", () => { - const state = makeState({ - kanban: { - workflowId: "wf-1", - steps: [], - tasks: [{ id: "task-a", workflowStepId: "step-missing", title: "A", position: 0 }], - }, - } as unknown as Partial); - - const [item] = buildTaskMentionItems(state, null); - expect(item.description).toBe("Workflow · Step"); + expect(buildTaskMentionItems(state, null).map((item) => item.task?.taskId)).toEqual([ + "task-a", + "task-b", + ]); }); }); diff --git a/apps/web/components/task/chat/task-mention-items.ts b/apps/web/components/task/chat/task-mention-items.ts index 5f5b2df9e1..f1916ba2ee 100644 --- a/apps/web/components/task/chat/task-mention-items.ts +++ b/apps/web/components/task/chat/task-mention-items.ts @@ -4,21 +4,23 @@ import type { AppState } from "@/lib/state/store"; type TaskLike = AppState["kanban"]["tasks"][number]; function buildWorkflowNameMap(state: AppState): Map { - const m = new Map(); - for (const w of state.workflows.items) m.set(w.id, w.name); - for (const [wfId, snap] of Object.entries(state.kanbanMulti.snapshots)) { - if (!m.has(wfId) && snap.workflowName) m.set(wfId, snap.workflowName); + const names = new Map(); + for (const workflow of state.workflows.items) names.set(workflow.id, workflow.name); + for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) { + if (!names.has(workflowId) && snapshot.workflowName) { + names.set(workflowId, snapshot.workflowName); + } } - return m; + return names; } function buildStepTitleMap(state: AppState): Map { - const m = new Map(); - for (const s of state.kanban.steps) m.set(s.id, s.title); - for (const snap of Object.values(state.kanbanMulti.snapshots)) { - for (const s of snap.steps ?? []) m.set(s.id, s.title); + const titles = new Map(); + for (const step of state.kanban.steps) titles.set(step.id, step.title); + for (const snapshot of Object.values(state.kanbanMulti.snapshots)) { + for (const step of snapshot.steps ?? []) titles.set(step.id, step.title); } - return m; + return titles; } function toMentionItem( @@ -61,17 +63,14 @@ export function buildTaskMentionItems( }; if (state.kanban.workflowId) { - // Guard against stale tasks left over from a previous workflow: only add - // entries whose workflowStepId belongs to the current workflow's steps, - // since we tag them with state.kanban.workflowId here. - const activeStepIds = new Set(state.kanban.steps.map((s) => s.id)); - for (const t of state.kanban.tasks) { - if (activeStepIds.size > 0 && !activeStepIds.has(t.workflowStepId)) continue; - addTask(t, state.kanban.workflowId); + const activeStepIds = new Set(state.kanban.steps.map((step) => step.id)); + for (const task of state.kanban.tasks) { + if (activeStepIds.size > 0 && !activeStepIds.has(task.workflowStepId)) continue; + addTask(task, state.kanban.workflowId); } } - for (const [wfId, snap] of Object.entries(state.kanbanMulti.snapshots)) { - for (const t of snap.tasks) addTask(t, wfId); + for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) { + for (const task of snapshot.tasks) addTask(task, workflowId); } return items; diff --git a/apps/web/components/task/chat/tiptap-dynamic-placeholder.ts b/apps/web/components/task/chat/tiptap-dynamic-placeholder.ts new file mode 100644 index 0000000000..95d60b02b2 --- /dev/null +++ b/apps/web/components/task/chat/tiptap-dynamic-placeholder.ts @@ -0,0 +1,60 @@ +import { Extension, isNodeEmpty, type Editor } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +type DynamicPlaceholderStorage = { + dynamicPlaceholder: { text: string }; +}; + +function placeholderStorage(editor: Editor): DynamicPlaceholderStorage { + return editor.storage as unknown as DynamicPlaceholderStorage; +} + +/** Placeholder extension backed by live editor storage, avoiding stale options snapshots. */ +export const DynamicPlaceholder = Extension.create({ + name: "dynamicPlaceholder", + + addStorage() { + return { text: "" }; + }, + + addProseMirrorPlugins() { + const editor = this.editor; + return [ + new Plugin({ + key: new PluginKey("dynamicPlaceholder"), + props: { + decorations: ({ doc, selection }) => { + if (!editor.isEditable && !editor.isEmpty) return null; + const { anchor } = selection; + const decorations: InstanceType[] = []; + const isEmptyDoc = editor.isEmpty; + doc.descendants((node: ProseMirrorNode, pos: number) => { + const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize; + const isEmpty = !node.isLeaf && isNodeEmpty(node); + if (hasAnchor && isEmpty) { + const classes = ["is-empty"]; + if (isEmptyDoc) classes.push("is-editor-empty"); + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + class: classes.join(" "), + "data-placeholder": placeholderStorage(editor).dynamicPlaceholder.text, + }), + ); + } + return false; + }); + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + }, +}); + +export function updateDynamicPlaceholder(editor: Editor | null, text: string) { + if (!editor) return; + placeholderStorage(editor).dynamicPlaceholder.text = text; + editor.view.dispatch(editor.state.tr); +} diff --git a/apps/web/components/task/chat/tiptap-editor-history.test.ts b/apps/web/components/task/chat/tiptap-editor-history.test.ts new file mode 100644 index 0000000000..84ea53b5ce --- /dev/null +++ b/apps/web/components/task/chat/tiptap-editor-history.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import * as editorHistory from "./tiptap-editor-history"; + +describe("rich message history", () => { + it("provides a structured history-to-editor conversion", () => { + expect(typeof (editorHistory as Record).historyEntryToEditorContent).toBe( + "function", + ); + }); + + it("restores a reference atom only from matching history metadata", () => { + const reference = { + version: 1, + ref: "mention:v1:jira:issue:scope:10001", + provider: "jira", + kind: "issue", + id: "10001", + key: "ENG-123", + title: "Fix authentication", + url: "https://jira.example.test/browse/ENG-123", + scope: "jira.example.test/site-1", + }; + + const content = editorHistory.historyEntryToEditorContent( + { + content: "See [#ENG-123](https://jira.example.test/browse/ENG-123)", + entityReferences: [reference], + }, + [], + ); + + expect(content.content?.[0]?.content).toEqual([ + { type: "text", text: "See " }, + { type: "entityReference", attrs: reference }, + ]); + }); +}); diff --git a/apps/web/components/task/chat/tiptap-editor-history.ts b/apps/web/components/task/chat/tiptap-editor-history.ts index 3467085209..c5c9c09423 100644 --- a/apps/web/components/task/chat/tiptap-editor-history.ts +++ b/apps/web/components/task/chat/tiptap-editor-history.ts @@ -5,7 +5,7 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { matchesShortcut } from "@/lib/keyboard/utils"; import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides"; -import { navigateHistory, type HistoryState } from "./message-history"; +import { navigateHistory, type HistoryState, type MessageHistoryEntry } from "./message-history"; import { getMarkdownText, textToEditorContent } from "./tiptap-helpers"; import type { SlashCommand } from "./slash-command-types"; @@ -46,7 +46,7 @@ export type HistoryKeymapRefs = { disabledRef: React.RefObject; isSuggestionMenuOpenRef: React.RefObject; isReverseSearchOpenRef: React.RefObject; - getHistoryRef: React.RefObject<() => readonly string[]>; + getHistoryRef: React.RefObject<() => readonly MessageHistoryEntry[]>; getSlashCommandsRef: React.RefObject<() => readonly SlashCommand[]>; onOpenReverseSearchRef: React.RefObject<(() => void) | undefined>; onChangeRef: React.RefObject<(value: string) => void>; @@ -64,24 +64,31 @@ type Editor = ReturnType; type HistoryRuntimeState = { index: number | null; - draft: string; + draft: import("@tiptap/core").JSONContent | null; isApplying: boolean; }; +export function historyEntryToEditorContent( + entry: MessageHistoryEntry, + slashCommands: readonly SlashCommand[], +) { + return textToEditorContent(entry.content, slashCommands, entry.entityReferences); +} + function writeHistoryContent( editor: Editor, state: HistoryRuntimeState, - text: string, + entry: MessageHistoryEntry, slashCommands: readonly SlashCommand[], onChange: (value: string) => void, ): void { if (!editor) return; state.isApplying = true; try { - if (text === "") { + if (entry.content === "") { editor.chain().focus().clearContent().run(); } else { - editor.chain().focus().setContent(textToEditorContent(text, slashCommands)).run(); + editor.chain().focus().setContent(historyEntryToEditorContent(entry, slashCommands)).run(); editor.commands.focus("end"); } onChange(getMarkdownText(editor)); @@ -90,6 +97,22 @@ function writeHistoryContent( } } +function writeDraftContent( + editor: Editor, + state: HistoryRuntimeState, + onChange: (value: string) => void, +) { + if (!editor || !state.draft) return; + state.isApplying = true; + try { + editor.chain().focus().setContent(state.draft).run(); + editor.commands.focus("end"); + onChange(getMarkdownText(editor)); + } finally { + state.isApplying = false; + } +} + function caretAtBoundary(editor: Editor, direction: "up" | "down"): boolean { if (!editor) return false; const view = editor.view; @@ -121,11 +144,20 @@ function runHistoryArrow( if (decision.kind === "defer") return false; if (decision.kind === "consume-noop") return true; if (state.index === null && decision.index !== null) { - state.draft = getMarkdownText(editor); + state.draft = editor.getJSON(); + } + if (decision.index === null) { + writeDraftContent(editor, state, refs.onChangeRef.current); + } else { + const slashCommands = refs.getSlashCommandsRef.current?.() ?? []; + writeHistoryContent( + editor, + state, + history[decision.index], + slashCommands, + refs.onChangeRef.current, + ); } - const text = decision.index === null ? state.draft : history[decision.index]; - const slashCommands = refs.getSlashCommandsRef.current?.() ?? []; - writeHistoryContent(editor, state, text, slashCommands, refs.onChangeRef.current); state.index = decision.index; return true; } @@ -148,7 +180,7 @@ function reverseSearchPlugin(state: HistoryRuntimeState, refs: HistoryKeymapRefs filterTransaction: (tr) => { if (tr.docChanged && !state.isApplying && state.index !== null) { state.index = null; - state.draft = ""; + state.draft = null; } return true; }, @@ -179,7 +211,7 @@ function applyHistoryIndexImpl( if (!editor) return; const history = refs.getHistoryRef.current?.() ?? []; if (index < 0 || index >= history.length) return; - if (state.index === null) state.draft = getMarkdownText(editor); + if (state.index === null) state.draft = editor.getJSON(); const slashCommands = refs.getSlashCommandsRef.current?.() ?? []; writeHistoryContent(editor, state, history[index], slashCommands, refs.onChangeRef.current); state.index = index; @@ -187,7 +219,7 @@ function applyHistoryIndexImpl( export function useHistoryKeymap(refs: HistoryKeymapRefs): HistoryNavController { return useMemo(() => { - const state: HistoryRuntimeState = { index: null, draft: "", isApplying: false }; + const state: HistoryRuntimeState = { index: null, draft: null, isApplying: false }; const extension = buildHistoryExtension(state, refs); const applyHistoryIndex: HistoryNavController["applyHistoryIndex"] = (editor, index) => applyHistoryIndexImpl(editor, state, index, refs); diff --git a/apps/web/components/task/chat/tiptap-entity-reference-extension.test.ts b/apps/web/components/task/chat/tiptap-entity-reference-extension.test.ts new file mode 100644 index 0000000000..7c0afe0fa8 --- /dev/null +++ b/apps/web/components/task/chat/tiptap-entity-reference-extension.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import * as entityReferenceExtensions from "./tiptap-entity-reference-extension"; + +describe("entityReference node", () => { + it("provides a distinct inline atom for # references", () => { + const extension = (entityReferenceExtensions as Record).EntityReferenceNode as + | { name?: string; config?: { inline?: boolean; atom?: boolean } } + | undefined; + + expect(extension).toBeDefined(); + expect(extension?.name).toBe("entityReference"); + expect(extension?.config?.inline).toBe(true); + expect(extension?.config?.atom).toBe(true); + }); + + it("renders the atom with a dedicated React node view", () => { + const extension = (entityReferenceExtensions as Record) + .EntityReferenceNode as { + config?: { addNodeView?: unknown }; + }; + + expect(typeof extension.config?.addNodeView).toBe("function"); + }); +}); diff --git a/apps/web/components/task/chat/tiptap-entity-reference-extension.tsx b/apps/web/components/task/chat/tiptap-entity-reference-extension.tsx new file mode 100644 index 0000000000..15259a5e2a --- /dev/null +++ b/apps/web/components/task/chat/tiptap-entity-reference-extension.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { mergeAttributes, Node } from "@tiptap/core"; +import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewProps } from "@tiptap/react"; +import { IconHash } from "@tabler/icons-react"; + +export const EntityReferenceNode = Node.create({ + name: "entityReference", + group: "inline", + inline: true, + selectable: false, + atom: true, + + addAttributes() { + return { + version: { default: 1 }, + ref: { default: null }, + provider: { default: null }, + kind: { default: null }, + id: { default: null }, + key: { default: null }, + title: { default: null }, + url: { default: null }, + scope: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-entity-reference]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes({ "data-entity-reference": "" }, HTMLAttributes), + HTMLAttributes.key || HTMLAttributes.title || HTMLAttributes.id || "", + ]; + }, + + renderText({ node }) { + return `#${node.attrs.key ?? node.attrs.title ?? node.attrs.id ?? ""}`; + }, + + addNodeView() { + return ReactNodeViewRenderer(EntityReferenceChipView); + }, +}); + +function EntityReferenceChipView({ node }: ReactNodeViewProps) { + const { key, title, provider, kind, id } = node.attrs as { + key?: string | null; + title?: string | null; + provider?: string | null; + kind?: string | null; + id?: string | null; + }; + const label = key || title || id || "Reference"; + const source = [provider, kind].filter(Boolean).join(" "); + const description = source ? `${source}: ${title || label}` : title || label; + return ( + + + + + ); +} diff --git a/apps/web/components/task/chat/tiptap-entity-reference-suggestion.ts b/apps/web/components/task/chat/tiptap-entity-reference-suggestion.ts new file mode 100644 index 0000000000..7f65ce704e --- /dev/null +++ b/apps/web/components/task/chat/tiptap-entity-reference-suggestion.ts @@ -0,0 +1,98 @@ +import { PluginKey } from "@tiptap/pm/state"; +import type { + SuggestionKeyDownProps, + SuggestionOptions, + SuggestionProps, +} from "@tiptap/suggestion"; +import type { EntityReference } from "@/lib/types/entity-reference"; +import { isEntityReferenceTriggerAllowed } from "./tiptap-helpers"; +import type { MenuState } from "./tiptap-suggestion"; + +const EMPTY_ENTITY_REFERENCE_STATE: MenuState = { + isOpen: false, + items: [], + query: "", + clientRect: null, + command: null, +}; + +export const EntityReferenceSuggestionPluginKey = new PluginKey("entityReferenceSuggestion"); + +export function handleEntityReferenceMenuKeyDown(args: { + event: KeyboardEvent; + items: readonly EntityReference[]; + selectedIndex: number; + setSelectedIndex: (index: number) => void; + onSelect: (reference: EntityReference) => void; +}): boolean { + if (args.event.key === "ArrowDown" || args.event.key === "ArrowUp") { + if (args.items.length > 0) { + const delta = args.event.key === "ArrowDown" ? 1 : -1; + const next = Math.min(Math.max(args.selectedIndex + delta, 0), args.items.length - 1); + args.setSelectedIndex(next); + } + return true; + } + if (args.event.key !== "Enter" && args.event.key !== "Tab") return false; + const item = args.items[args.selectedIndex]; + if (!item) return false; + args.onSelect(item); + return true; +} + +export function createEntityReferenceSuggestion( + setMenuState: (state: MenuState) => void, + onKeyDown: (event: KeyboardEvent) => boolean, +): Partial> { + return { + char: "#", + pluginKey: EntityReferenceSuggestionPluginKey, + allowSpaces: true, + allowedPrefixes: null, + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from); + const triggerOffset = range.from - $from.start(); + return isEntityReferenceTriggerAllowed({ + textBeforeTrigger: $from.parent.textBetween(0, triggerOffset, "\0", "\0"), + parentType: $from.parent.type.name, + hasCodeMark: $from.marks().some((mark) => mark.type.name === "code"), + }); + }, + items: () => [], + command: ({ editor, range, props }) => { + editor + .chain() + .focus() + .insertContentAt(range, [ + { type: "entityReference", attrs: props }, + { type: "text", text: " " }, + ]) + .run(); + }, + render: () => { + const updateMenu = (props: SuggestionProps) => { + setMenuState({ + isOpen: true, + items: [], + query: props.query, + clientRect: props.clientRect ?? null, + command: (reference) => props.command(reference), + }); + }; + return { + onStart: updateMenu, + onUpdate: updateMenu, + onKeyDown(keyDown: SuggestionKeyDownProps) { + if (keyDown.event.key === "Escape") { + setMenuState(EMPTY_ENTITY_REFERENCE_STATE); + return true; + } + return onKeyDown(keyDown.event); + }, + onExit() { + setMenuState(EMPTY_ENTITY_REFERENCE_STATE); + }, + }; + }, + }; +} diff --git a/apps/web/components/task/chat/tiptap-helpers.test.ts b/apps/web/components/task/chat/tiptap-helpers.test.ts index b44185628b..2f092ba341 100644 --- a/apps/web/components/task/chat/tiptap-helpers.test.ts +++ b/apps/web/components/task/chat/tiptap-helpers.test.ts @@ -1,7 +1,105 @@ import { describe, expect, it } from "vitest"; import { getMarkdownText, textToEditorContent } from "./tiptap-helpers"; +import * as tiptapHelpers from "./tiptap-helpers"; + +describe("entity reference trigger", () => { + it("provides a trigger-context guard for # suggestions", () => { + expect(typeof (tiptapHelpers as Record).isEntityReferenceTriggerAllowed).toBe( + "function", + ); + }); + + it("allows # only at a block start or after whitespace outside code", () => { + const allowed = tiptapHelpers.isEntityReferenceTriggerAllowed as unknown as (args: { + textBeforeTrigger: string; + parentType: string; + hasCodeMark: boolean; + }) => boolean; + + expect(allowed({ textBeforeTrigger: "", parentType: "paragraph", hasCodeMark: false })).toBe( + true, + ); + expect( + allowed({ textBeforeTrigger: "Discuss ", parentType: "paragraph", hasCodeMark: false }), + ).toBe(true); + expect( + allowed({ textBeforeTrigger: "issue", parentType: "paragraph", hasCodeMark: false }), + ).toBe(false); + expect(allowed({ textBeforeTrigger: "", parentType: "codeBlock", hasCodeMark: false })).toBe( + false, + ); + expect(allowed({ textBeforeTrigger: "", parentType: "paragraph", hasCodeMark: true })).toBe( + false, + ); + }); +}); + +describe("entity reference metadata", () => { + it("provides structured extraction from editor JSON", () => { + expect(typeof (tiptapHelpers as Record).extractEntityReferences).toBe( + "function", + ); + }); + + it("extracts valid atoms in first-appearance order and deduplicates by ref", () => { + const reference = { + version: 1, + ref: "mention:v1:linear:issue:linear.app%2Forg-1:issue-1", + provider: "linear", + kind: "issue", + id: "issue-1", + key: "ENG-1", + title: "Fix login", + url: "https://linear.app/acme/issue/ENG-1/fix-login", + scope: "linear.app/org-1", + }; + const extract = tiptapHelpers.extractEntityReferences as unknown as (doc: { + content: unknown[]; + }) => (typeof reference)[]; + + expect( + extract({ + content: [ + { + type: "paragraph", + content: [ + { type: "entityReference", attrs: reference }, + { type: "entityReference", attrs: reference }, + { type: "entityReference", attrs: { ref: "malformed" } }, + ], + }, + ], + }), + ).toEqual([reference]); + }); +}); describe("getMarkdownText", () => { + it("serializes entity reference atoms as portable Markdown links", () => { + expect( + getMarkdownText({ + getJSON: () => ({ + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Track " }, + { + type: "entityReference", + attrs: { + key: "ENG-123", + title: "Fix [auth] flow", + url: "https://jira.example.test/browse/ENG-123", + }, + }, + ], + }, + ], + }), + }), + ).toBe("Track [#ENG-123](https://jira.example.test/browse/ENG-123)"); + }); + it("serializes slash command chips as slash command text", () => { expect( getMarkdownText({ @@ -102,4 +200,49 @@ describe("textToEditorContent", () => { ], }); }); + + it("restores generated reference Markdown only when matching metadata is present", () => { + const reference = { + version: 1, + ref: "mention:v1:jira:issue:scope:10001", + provider: "jira", + kind: "issue", + id: "10001", + key: "ENG-123", + title: "Fix authentication", + url: "https://jira.example.test/browse/ENG-123", + scope: "jira.example.test/site-1", + }; + const restore = textToEditorContent as unknown as ( + text: string, + commands: readonly (typeof slowCommand)[], + references: readonly (typeof reference)[], + ) => ReturnType; + + const content = restore( + "See [#ENG-123](https://jira.example.test/browse/ENG-123) and [docs](https://example.test)", + [], + [reference], + ); + + expect(content.content?.[0]?.content).toEqual([ + { type: "text", text: "See " }, + { type: "entityReference", attrs: reference }, + { type: "text", text: " and [docs](https://example.test)" }, + ]); + }); + + it("keeps generated-looking links literal without structured metadata", () => { + const content = textToEditorContent( + "See [#ENG-123](https://jira.example.test/browse/ENG-123)", + [], + ); + + expect(content.content?.[0]?.content).toEqual([ + { + type: "text", + text: "See [#ENG-123](https://jira.example.test/browse/ENG-123)", + }, + ]); + }); }); diff --git a/apps/web/components/task/chat/tiptap-helpers.ts b/apps/web/components/task/chat/tiptap-helpers.ts index 4e5f37bbb4..e1b00a438e 100644 --- a/apps/web/components/task/chat/tiptap-helpers.ts +++ b/apps/web/components/task/chat/tiptap-helpers.ts @@ -1,4 +1,6 @@ import type React from "react"; +import type { EntityReference } from "@/lib/types/entity-reference"; +import { isEntityReference } from "@/lib/entity-references/message-references"; import { formatSlashCommandLabel, normalizeSlashCommandName } from "./tiptap-slash-command-utils"; import type { SlashCommand } from "./slash-command-types"; @@ -19,6 +21,38 @@ export type EditorContentNode = { content?: EditorContentNode[]; }; +export function isEntityReferenceTriggerAllowed(args: { + textBeforeTrigger: string; + parentType: string; + hasCodeMark: boolean; +}): boolean { + if (args.parentType === "codeBlock" || args.hasCodeMark) return false; + return args.textBeforeTrigger === "" || /\s$/u.test(args.textBeforeTrigger); +} + +function entityReferenceFromAttrs(attrs: Record | undefined) { + if (!attrs) return null; + const candidate = attrs.key === null ? { ...attrs, key: undefined } : attrs; + return isEntityReference(candidate) ? candidate : null; +} + +export function extractEntityReferences(doc: JSONNode): EntityReference[] { + const references: EntityReference[] = []; + const seen = new Set(); + const visit = (node: JSONNode) => { + if (node.type === "entityReference") { + const reference = entityReferenceFromAttrs(node.attrs); + if (reference && !seen.has(reference.ref)) { + seen.add(reference.ref); + references.push(reference); + } + } + node.content?.forEach(visit); + }; + visit(doc); + return references; +} + // ── Serialization ─────────────────────────────────────────────────── function serializeInline(nodes: JSONNode[]): string { @@ -28,6 +62,9 @@ function serializeInline(nodes: JSONNode[]): string { if (n.type === "contextMention") { return n.attrs?.label ? `@${n.attrs.label}` : ""; } + if (n.type === "entityReference") { + return formatEntityReferenceMarkdown(n.attrs as Partial); + } if (n.type === "slashCommand") { return formatSlashCommandLabel(n.attrs); } @@ -40,6 +77,21 @@ function serializeInline(nodes: JSONNode[]): string { .join(""); } +function escapeMarkdownLabel(value: string): string { + return value.replace(/([\\[\]])/g, "\\$1"); +} + +function encodeMarkdownUrl(value: string): string { + return encodeURI(value).replace(/\(/g, "%28").replace(/\)/g, "%29"); +} + +export function formatEntityReferenceMarkdown(reference: Partial): string { + const label = String(reference.key || reference.title || ""); + const url = String(reference.url || ""); + if (!label || !url) return ""; + return `[#${escapeMarkdownLabel(label)}](${encodeMarkdownUrl(url)})`; +} + function serializeNode(node: JSONNode): string { switch (node.type) { case "paragraph": @@ -108,7 +160,7 @@ function slashCommandMap(commands: readonly SlashCommand[]): Map): EditorContentNode[] { +function slashTextToNodes(line: string, commands: Map): EditorContentNode[] { const nodes: EditorContentNode[] = []; const tokenPattern = /\/\S+/g; let cursor = 0; @@ -129,9 +181,44 @@ function textLineToNodes(line: string, commands: Map): Edi return nodes; } +function entityReferenceTokens(references: readonly EntityReference[]) { + return references + .map((reference) => ({ reference, markdown: formatEntityReferenceMarkdown(reference) })) + .filter((token) => token.markdown.length > 0); +} + +function textLineToNodes( + line: string, + commands: Map, + references: readonly EntityReference[], +): EditorContentNode[] { + const tokens = entityReferenceTokens(references); + if (tokens.length === 0) return slashTextToNodes(line, commands); + const nodes: EditorContentNode[] = []; + let cursor = 0; + while (cursor < line.length) { + let match: { index: number; markdown: string; reference: EntityReference } | null = null; + for (const token of tokens) { + const index = line.indexOf(token.markdown, cursor); + if (index !== -1 && (!match || index < match.index)) match = { ...token, index }; + } + if (!match) { + nodes.push(...slashTextToNodes(line.slice(cursor), commands)); + break; + } + if (match.index > cursor) { + nodes.push(...slashTextToNodes(line.slice(cursor, match.index), commands)); + } + nodes.push({ type: "entityReference", attrs: { ...match.reference } }); + cursor = match.index + match.markdown.length; + } + return nodes; +} + export function textToEditorContent( text: string, slashCommands: readonly SlashCommand[] = [], + entityReferences: readonly EntityReference[] = [], ): EditorContentNode { const commands = slashCommandMap(slashCommands); const content: EditorContentNode[] = []; @@ -142,7 +229,7 @@ export function textToEditorContent( if (inCodeFence || isFenceLine) { nodes = line ? [{ type: "text", text: line }] : []; } else { - nodes = textLineToNodes(line, commands); + nodes = textLineToNodes(line, commands, entityReferences); } content.push(nodes.length > 0 ? { type: "paragraph", content: nodes } : { type: "paragraph" }); if (isFenceLine) inCodeFence = !inCodeFence; diff --git a/apps/web/components/task/chat/tiptap-input.tsx b/apps/web/components/task/chat/tiptap-input.tsx index 79987bdfd6..18ffa150c0 100644 --- a/apps/web/components/task/chat/tiptap-input.tsx +++ b/apps/web/components/task/chat/tiptap-input.tsx @@ -10,17 +10,17 @@ import { useMemo, } from "react"; import { EditorContent } from "@tiptap/react"; -import { useShallow } from "zustand/react/shallow"; import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; import { searchWorkspaceFiles } from "@/lib/ws/workspace-files"; import { EditorContextProvider } from "./editor-context"; +import { EntityReferenceMenu } from "./entity-reference-menu"; import { MentionMenu } from "./mention-menu"; import { MessageHistorySearch } from "./message-history-search"; import { SlashCommandMenu } from "./slash-command-menu"; import { buildTaskMentionItems } from "./task-mention-items"; -import { extractUserHistory } from "./message-history"; +import { createMessageHistorySelector, type MessageHistoryEntry } from "./message-history"; import { useDrainOlderMessages } from "./use-drain-older-messages"; import { createMentionSuggestion, @@ -33,6 +33,7 @@ import { useTipTapEditor, type TipTapInputHandle } from "./use-tiptap-editor"; import type { MentionItem } from "@/hooks/use-inline-mention"; import type { SlashCommand } from "./slash-command-types"; import type { ContextFile } from "@/lib/state/context-files-store"; +import { useEntityReferenceComposer } from "./use-entity-reference-composer"; export type { TipTapInputHandle } from "./use-tiptap-editor"; @@ -52,6 +53,8 @@ type TipTapInputProps = { // TipTap-specific sessionId: string | null; taskId?: string | null; + workspaceId?: string | null; + entityReferencesEnabled?: boolean; onAddContextFile?: (file: ContextFile) => void; onToggleContextFile?: (file: ContextFile) => void; planContextEnabled?: boolean; @@ -361,6 +364,8 @@ export const TipTapInput = forwardRef(funct onBlur, sessionId, taskId, + workspaceId = null, + entityReferencesEnabled = false, onImagePaste, }, ref, @@ -374,9 +379,15 @@ export const TipTapInput = forwardRef(funct setMentionMenu: menu.setMentionMenu, setSlashMenu: menu.setSlashMenu, }); + const entityReferences = useEntityReferenceComposer({ + enabled: entityReferencesEnabled, + workspaceId, + sessionId, + }); const isSuggestionMenuOpen = (menu.mentionMenu.isOpen && menu.mentionMenu.items.length > 0) || - (menu.slashMenu.isOpen && menu.slashMenu.items.length > 0); + (menu.slashMenu.isOpen && menu.slashMenu.items.length > 0) || + entityReferences.isOpen; const { history, getHistory } = useChatHistory(sessionId); const { editorWrapperRef, ...overlay } = useReverseSearchOverlay(sessionId); const { isDraining } = useDrainOlderMessages(sessionId, overlay.isReverseSearchOpen); @@ -396,6 +407,7 @@ export const TipTapInput = forwardRef(funct onImagePaste, mentionSuggestion, slashSuggestion, + entityReferenceSuggestion: entityReferences.suggestion, slashCommands, isSuggestionMenuOpen, getHistory, @@ -416,6 +428,7 @@ export const TipTapInput = forwardRef(funct <> (funct type TipTapPopupsProps = { menu: ReturnType; + entityReferences: ReturnType; overlay: Omit, "editorWrapperRef">; - history: readonly string[]; + history: readonly MessageHistoryEntry[]; isDraining: boolean; onReverseSearchSelect: (index: number) => void; }; function TipTapPopups({ menu, + entityReferences, overlay, history, isDraining, @@ -461,6 +476,19 @@ function TipTapPopups({ onClose={menu.handleMentionClose} setSelectedIndex={menu.setMentionSelectedIndex} /> + - extractUserHistory((sessionId ? s.messages.bySession[sessionId] : undefined) ?? []), - ), - ); +function useMessageHistoryForSession(sessionId: string | null): MessageHistoryEntry[] { + // The memoized selector keeps its snapshot stable while agent messages + // stream, but still reacts to user content or reference-metadata changes. + const selector = useMemo(() => createMessageHistorySelector(sessionId), [sessionId]); + return useAppStore(selector); } function useChatHistory(sessionId: string | null) { diff --git a/apps/web/components/task/chat/tiptap-suggestion.test.ts b/apps/web/components/task/chat/tiptap-suggestion.test.ts new file mode 100644 index 0000000000..4f7abbd21a --- /dev/null +++ b/apps/web/components/task/chat/tiptap-suggestion.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MentionItem } from "@/hooks/use-inline-mention"; +import type { EntityReference } from "@/lib/types/entity-reference"; +import { + createEntityReferenceSuggestion, + handleEntityReferenceMenuKeyDown, +} from "./tiptap-entity-reference-suggestion"; +import { createMentionSuggestion } from "./tiptap-suggestion"; +import * as entityReferenceSuggestions from "./tiptap-entity-reference-suggestion"; + +describe("entity reference suggestion", () => { + it("provides an independent # suggestion config", () => { + expect( + typeof (entityReferenceSuggestions as Record) + .createEntityReferenceSuggestion, + ).toBe("function"); + }); + + it("provides keyboard navigation for grouped reference results", () => { + expect( + typeof (entityReferenceSuggestions as Record) + .handleEntityReferenceMenuKeyDown, + ).toBe("function"); + }); + + it("uses arrows to move and Enter or Tab to select without submitting", () => { + const handle = handleEntityReferenceMenuKeyDown as unknown as (args: { + event: KeyboardEvent; + items: EntityReference[]; + selectedIndex: number; + setSelectedIndex: (index: number) => void; + onSelect: (reference: EntityReference) => void; + }) => boolean; + const items = [ + { + version: 1, + ref: "mention:v1:kandev:task:task-1", + provider: "kandev", + kind: "task", + id: "task-1", + title: "First task", + url: "/t/task-1", + scope: "workspace-1", + }, + { + version: 1, + ref: "mention:v1:kandev:task:task-2", + provider: "kandev", + kind: "task", + id: "task-2", + title: "Second task", + url: "/t/task-2", + scope: "workspace-1", + }, + ]; + const setSelectedIndex = vi.fn(); + const onSelect = vi.fn(); + + expect( + handle({ + event: new KeyboardEvent("keydown", { key: "ArrowDown" }), + items, + selectedIndex: 0, + setSelectedIndex, + onSelect, + }), + ).toBe(true); + expect(setSelectedIndex).toHaveBeenCalledWith(1); + expect( + handle({ + event: new KeyboardEvent("keydown", { key: "Tab" }), + items, + selectedIndex: 1, + setSelectedIndex, + onSelect, + }), + ).toBe(true); + expect(onSelect).toHaveBeenCalledWith(items[1]); + }); +}); + +describe("entity reference suggestion lifecycle", () => { + it("replaces only the active # range with an atom and trailing space", () => { + const suggestion = createEntityReferenceSuggestion(vi.fn(), vi.fn()); + const run = vi.fn(); + const insertContentAt = vi.fn(() => ({ run })); + const focus = vi.fn(() => ({ insertContentAt })); + const editor = { chain: () => ({ focus }) }; + const reference: EntityReference = { + version: 1, + ref: "mention:v1:github:pull_request:github.com/acme/app:42", + provider: "github", + kind: "pull_request", + id: "MDExOlB1bGxSZXF1ZXN0NDI=", + key: "42", + title: "Fix authentication", + url: "https://github.com/acme/app/pull/42", + scope: "github.com/acme/app", + }; + + expect(typeof suggestion.command).toBe("function"); + suggestion.command?.({ + editor: editor as never, + range: { from: 8, to: 13 }, + props: reference, + }); + + expect(insertContentAt).toHaveBeenCalledWith({ from: 8, to: 13 }, [ + { type: "entityReference", attrs: reference }, + { type: "text", text: " " }, + ]); + expect(run).toHaveBeenCalledOnce(); + }); + + it("opens in the primed state for bare # and Escape closes without selecting", () => { + const setMenuState = vi.fn(); + const suggestion = createEntityReferenceSuggestion(setMenuState, vi.fn()); + const lifecycle = suggestion.render?.(); + const props = { + editor: {} as never, + range: { from: 1, to: 2 }, + query: "", + text: "#", + items: [], + command: vi.fn(), + decorationNode: null, + clientRect: () => new DOMRect(10, 20, 1, 10), + }; + + expect(lifecycle).toBeDefined(); + lifecycle?.onStart?.(props); + expect(setMenuState).toHaveBeenLastCalledWith( + expect.objectContaining({ isOpen: true, query: "", items: [] }), + ); + + const handled = lifecycle?.onKeyDown?.({ + view: {} as never, + event: new KeyboardEvent("keydown", { key: "Escape" }), + range: props.range, + }); + expect(handled).toBe(true); + expect(setMenuState).toHaveBeenLastCalledWith({ + isOpen: false, + items: [], + query: "", + clientRect: null, + command: null, + }); + expect(props.command).not.toHaveBeenCalled(); + }); + + it("allows # at a text-block boundary but rejects tokens and code", () => { + const suggestion = createEntityReferenceSuggestion(vi.fn(), vi.fn()); + const allowed = (prefix: string, parentType = "paragraph", hasCodeMark = false) => { + const resolved = { + parent: { + type: { name: parentType }, + textBetween: () => prefix, + }, + start: () => 1, + marks: () => (hasCodeMark ? [{ type: { name: "code" } }] : []), + }; + return suggestion.allow?.({ + editor: {} as never, + state: { doc: { resolve: () => resolved } } as never, + range: { from: prefix.length + 1, to: prefix.length + 2 }, + }); + }; + + expect(typeof suggestion.allow).toBe("function"); + expect(allowed("")).toBe(true); + expect(allowed("Discuss ")).toBe(true); + expect(allowed("issue")).toBe(false); + expect(allowed("", "codeBlock")).toBe(false); + expect(allowed("", "paragraph", true)).toBe(false); + }); +}); + +describe("createMentionSuggestion", () => { + it("keeps Kandev task discovery in the @ menu", async () => { + const file: MentionItem = { + id: "src/app.ts", + kind: "file", + label: "src/app.ts", + onSelect: vi.fn(), + }; + const task: MentionItem = { + id: "task:task-2", + kind: "task", + label: "Follow-up task", + task: { + taskId: "task-2", + title: "Follow-up task", + workflowId: "workflow-1", + workflowStepId: "step-1", + state: null, + }, + onSelect: vi.fn(), + }; + const suggestion = createMentionSuggestion( + { getItems: vi.fn().mockResolvedValue([task, file]) }, + vi.fn(), + vi.fn(), + ); + + const items = await (suggestion.items as (args: { query: string }) => Promise)({ + query: "Follow", + }); + + expect(items).toEqual([task, file]); + }); +}); diff --git a/apps/web/components/task/chat/tiptap-suggestion.tsx b/apps/web/components/task/chat/tiptap-suggestion.tsx index 96fc56af0f..cb71ad4a53 100644 --- a/apps/web/components/task/chat/tiptap-suggestion.tsx +++ b/apps/web/components/task/chat/tiptap-suggestion.tsx @@ -46,7 +46,6 @@ export type MentionSuggestionCallbacks = { }; export const MentionSuggestionPluginKey = new PluginKey("mentionSuggestion"); - /** * Creates a mention suggestion config for TipTap. * `setMenuState` drives the React rendering of MentionMenu. @@ -73,9 +72,7 @@ export function createMentionSuggestion( pluginKey: MentionSuggestionPluginKey, allowSpaces: false, - items: async ({ query }) => { - return callbacks.getItems(query); - }, + items: ({ query }) => callbacks.getItems(query), command: ({ editor, range, props: mentionAttrs }) => { editor diff --git a/apps/web/components/task/chat/use-chat-input-container.ts b/apps/web/components/task/chat/use-chat-input-container.ts index 150f09d1f9..84d05d429e 100644 --- a/apps/web/components/task/chat/use-chat-input-container.ts +++ b/apps/web/components/task/chat/use-chat-input-container.ts @@ -6,10 +6,10 @@ import { useResizableInput } from "@/hooks/use-resizable-input"; import { useChatInputState } from "./use-chat-input-state"; import type { TipTapInputHandle } from "./tiptap-input"; import type { ContextItem } from "@/lib/types/context"; -import type { ContextFile } from "@/lib/state/context-files-store"; import type { Message } from "@/lib/types/http"; import type { DiffComment } from "@/lib/diff/types"; import type { + ChatSubmitPayload, ChatSubmitResult, MessageAttachment, ChatInputContainerHandle, @@ -42,13 +42,7 @@ type UseChatInputContainerParams = { hasContextComments: boolean; showRequestChangesTooltip: boolean; onRequestChangesTooltipDismiss: (() => void) | undefined; - onSubmit: ( - message: string, - reviewComments?: DiffComment[], - attachments?: MessageAttachment[], - inlineMentions?: ContextFile[], - inlineTaskMentions?: import("@/hooks/use-inline-mention").TaskMentionData[], - ) => ChatSubmitResult; + onSubmit: (payload: ChatSubmitPayload) => ChatSubmitResult; }; function useInputHandle( diff --git a/apps/web/components/task/chat/use-chat-input-state.test.ts b/apps/web/components/task/chat/use-chat-input-state.test.ts index 57847cc0a6..7acb8f1067 100644 --- a/apps/web/components/task/chat/use-chat-input-state.test.ts +++ b/apps/web/components/task/chat/use-chat-input-state.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type React from "react"; import { useChatInputState } from "./use-chat-input-state"; import type { TipTapInputHandle } from "./tiptap-input"; +import type { EntityReference } from "@/lib/types/entity-reference"; type SubmitHandler = Parameters[0]["onSubmit"]; @@ -18,14 +19,31 @@ function renderInputState(onSubmit: SubmitHandler) { ); } -function attachInputHandle(inputRef: React.RefObject, clear: () => void) { +function attachInputHandle( + inputRef: React.RefObject, + clear: () => void, + entityReferences: EntityReference[] = [], +) { (inputRef as React.MutableRefObject | null>).current = { clear, getMentions: () => [], getTaskMentions: () => [], + getEntityReferences: () => entityReferences, }; } +const reference: EntityReference = { + version: 1, + ref: "mention:v1:github:issue:acme%2Frepo:42", + provider: "github", + kind: "issue", + id: "42", + key: "acme/repo#42", + title: "Fix composer references", + url: "https://github.com/acme/repo/issues/42", + scope: "acme/repo", +}; + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { @@ -56,13 +74,33 @@ describe("useChatInputState", () => { result.current.handleSubmit(vi.fn()); }); - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith("hello", undefined, undefined, undefined, undefined), - ); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ message: "hello" })); expect(result.current.value).toBe("hello"); expect(clear).not.toHaveBeenCalled(); }); + it("captures structured entity references in the named submit payload", async () => { + const onSubmit = vi.fn<(...args: Parameters) => ReturnType>(); + const { result } = renderInputState(onSubmit); + + act(() => { + result.current.handleChange("[#acme/repo#42](https://github.com/acme/repo/issues/42)"); + attachInputHandle(result.current.inputRef, vi.fn(), [reference]); + }); + await waitFor(() => expect(result.current.value).toContain("acme/repo#42")); + + act(() => { + result.current.handleSubmit(vi.fn()); + }); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + message: "[#acme/repo#42](https://github.com/acme/repo/issues/42)", + entityReferences: [reference], + }), + ); + }); + it("clears the draft when async submit succeeds", async () => { const onSubmit = vi .fn<(...args: Parameters) => ReturnType>() diff --git a/apps/web/components/task/chat/use-chat-input-state.ts b/apps/web/components/task/chat/use-chat-input-state.ts index 98767db9e9..b7d1b41215 100644 --- a/apps/web/components/task/chat/use-chat-input-state.ts +++ b/apps/web/components/task/chat/use-chat-input-state.ts @@ -26,11 +26,13 @@ import { type FileAttachment, } from "./file-attachment"; import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context"; -import type { ContextFile } from "@/lib/state/context-files-store"; import type { DiffComment } from "@/lib/diff/types"; -import type { ChatSubmitResult, MessageAttachment } from "./chat-input-container"; +import type { + ChatSubmitPayload, + ChatSubmitResult, + MessageAttachment, +} from "./chat-input-container"; import type { TipTapInputHandle } from "./tiptap-input"; -import type { TaskMentionData } from "@/hooks/use-inline-mention"; type UseChatInputStateProps = { sessionId: string | null; @@ -41,13 +43,7 @@ type UseChatInputStateProps = { hasContextComments?: boolean; showRequestChangesTooltip: boolean; onRequestChangesTooltipDismiss?: () => void; - onSubmit: ( - message: string, - reviewComments?: DiffComment[], - attachments?: MessageAttachment[], - inlineMentions?: ContextFile[], - inlineTaskMentions?: TaskMentionData[], - ) => ChatSubmitResult; + onSubmit: (payload: ChatSubmitPayload) => ChatSubmitResult; }; function isPromiseLike(value: ChatSubmitResult): value is Promise { @@ -163,6 +159,14 @@ type SubmitDraftArgs = { clearArgs: Omit; }; +function buildChatSubmitPayload(payload: Required): ChatSubmitPayload { + return Object.fromEntries( + Object.entries(payload).filter( + ([key, value]) => key === "message" || (Array.isArray(value) && value.length > 0), + ), + ) as ChatSubmitPayload; +} + function submitDraft(args: SubmitDraftArgs) { if (args.isSending) return; const trimmed = args.valueRef.current.trim(); @@ -175,12 +179,16 @@ function submitDraft(args: SubmitDraftArgs) { const messageAttachments = toMessageAttachments(currentAttachments); const inlineMentions = args.inputRef.current?.getMentions() ?? []; const inlineTaskMentions = args.inputRef.current?.getTaskMentions() ?? []; + const entityReferences = args.inputRef.current?.getEntityReferences() ?? []; const result = args.onSubmit( - trimmed, - allComments.length > 0 ? allComments : undefined, - messageAttachments.length > 0 ? messageAttachments : undefined, - inlineMentions.length > 0 ? inlineMentions : undefined, - inlineTaskMentions.length > 0 ? inlineTaskMentions : undefined, + buildChatSubmitPayload({ + message: trimmed, + reviewComments: allComments, + attachments: messageAttachments, + inlineMentions, + inlineTaskMentions, + entityReferences, + }), ); handleSubmitResult(result, () => clearSubmittedInput({ diff --git a/apps/web/components/task/chat/use-entity-reference-composer.test.ts b/apps/web/components/task/chat/use-entity-reference-composer.test.ts new file mode 100644 index 0000000000..726a40d391 --- /dev/null +++ b/apps/web/components/task/chat/use-entity-reference-composer.test.ts @@ -0,0 +1,42 @@ +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/use-entity-reference-search", () => ({ + useEntityReferenceSearch: () => ({ + groups: [], + isSearching: false, + error: null, + retry: vi.fn(), + }), +})); + +import { useEntityReferenceComposer } from "./use-entity-reference-composer"; + +afterEach(cleanup); + +describe("useEntityReferenceComposer lifecycle", () => { + it("installs the # suggestion on an enabled surface before workspace hydration", () => { + const { result } = renderHook(() => + useEntityReferenceComposer({ + enabled: true, + workspaceId: null, + sessionId: "session-1", + }), + ); + + expect(result.current.suggestion).toBeDefined(); + expect(result.current.isOpen).toBe(false); + }); + + it("does not install the # suggestion on an out-of-scope surface", () => { + const { result } = renderHook(() => + useEntityReferenceComposer({ + enabled: false, + workspaceId: "workspace-1", + sessionId: "session-1", + }), + ); + + expect(result.current.suggestion).toBeUndefined(); + }); +}); diff --git a/apps/web/components/task/chat/use-entity-reference-composer.ts b/apps/web/components/task/chat/use-entity-reference-composer.ts new file mode 100644 index 0000000000..6532339261 --- /dev/null +++ b/apps/web/components/task/chat/use-entity-reference-composer.ts @@ -0,0 +1,98 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEntityReferenceSearch } from "@/hooks/use-entity-reference-search"; +import type { EntityReference } from "@/lib/types/entity-reference"; +import { + createEntityReferenceSuggestion, + handleEntityReferenceMenuKeyDown, +} from "./tiptap-entity-reference-suggestion"; +import type { MenuState } from "./tiptap-suggestion"; +import { selectableEntityReferences } from "./entity-reference-groups"; + +const EMPTY_REFERENCE_MENU: MenuState = { + isOpen: false, + items: [], + query: "", + clientRect: null, + command: null, +}; + +type UseEntityReferenceComposerOptions = { + enabled: boolean; + workspaceId: string | null; + sessionId: string | null; +}; + +export function useEntityReferenceComposer({ + enabled, + workspaceId, + sessionId, +}: UseEntityReferenceComposerOptions) { + const [menu, setMenu] = useState>(EMPTY_REFERENCE_MENU); + const [selectedIndex, setSelectedIndex] = useState(0); + const searchEnabled = + enabled && Boolean(workspaceId) && menu.isOpen && Boolean(menu.query.trim()); + const search = useEntityReferenceSearch({ + workspaceId, + sessionId, + query: menu.query, + enabled: searchEnabled, + }); + const items = useMemo(() => selectableEntityReferences(search.groups), [search.groups]); + const clampedSelectedIndex = Math.min(selectedIndex, Math.max(0, items.length - 1)); + const itemsRef = useRef(items); + const selectedIndexRef = useRef(clampedSelectedIndex); + const commandRef = useRef(menu.command); + + useLayoutEffect(() => { + itemsRef.current = items; + selectedIndexRef.current = clampedSelectedIndex; + commandRef.current = menu.command; + }); + + useEffect(() => { + void Promise.resolve().then(() => setSelectedIndex(0)); + }, [items]); + + useEffect(() => { + void Promise.resolve().then(() => setMenu(EMPTY_REFERENCE_MENU)); + }, [enabled, sessionId, workspaceId]); + + const selectReference = useCallback((reference: EntityReference) => { + commandRef.current?.(reference); + setMenu(EMPTY_REFERENCE_MENU); + }, []); + const onKeyDown = useCallback( + (event: KeyboardEvent) => + handleEntityReferenceMenuKeyDown({ + event, + items: itemsRef.current, + selectedIndex: selectedIndexRef.current, + setSelectedIndex, + onSelect: selectReference, + }), + [selectReference], + ); + const suggestion = useMemo( + () => createEntityReferenceSuggestion(setMenu, onKeyDown), + [onKeyDown], + ); + const close = useCallback(() => setMenu(EMPTY_REFERENCE_MENU), []); + const hasWorkspace = Boolean(workspaceId); + + return { + suggestion: enabled ? suggestion : undefined, + isOpen: enabled && hasWorkspace && menu.isOpen, + clientRect: menu.clientRect, + query: menu.query, + groups: search.groups, + isSearching: search.isSearching, + error: search.error, + retry: search.retry, + selectedIndex: clampedSelectedIndex, + setSelectedIndex, + selectReference, + close, + }; +} diff --git a/apps/web/components/task/chat/use-tiptap-editor.test.ts b/apps/web/components/task/chat/use-tiptap-editor.test.ts index 02d9253789..5f3e2578ab 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.test.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { TIPTAP_EDITOR_TEXT_SIZE_CLASS, decideSubmitShortcut } from "./use-tiptap-editor"; +import { Extension } from "@tiptap/core"; +import { + TIPTAP_EDITOR_TEXT_SIZE_CLASS, + buildEditorExtensions, + decideSubmitShortcut, +} from "./use-tiptap-editor"; +import * as tiptapEditor from "./use-tiptap-editor"; import { decideHistoryNav } from "./tiptap-editor-history"; describe("TIPTAP_EDITOR_TEXT_SIZE_CLASS", () => { @@ -10,6 +16,44 @@ describe("TIPTAP_EDITOR_TEXT_SIZE_CLASS", () => { }); }); +describe("editor extensions", () => { + it("exposes the extension builder for editor-contract verification", () => { + expect(typeof (tiptapEditor as Record).buildEditorExtensions).toBe("function"); + }); + + it("installs the separate entityReference atom", () => { + const extensions = buildEditorExtensions({ + mentionSuggestion: {}, + slashSuggestion: {}, + submitKeymap: Extension.create({ name: "submit-test" }), + historyKeymap: Extension.create({ name: "history-test" }), + }); + + expect(extensions.map((extension) => extension.name)).toContain("entityReference"); + }); + + it("registers the # suggestion plugin independently from @ and slash", () => { + const entityReferenceSuggestion = { char: "#" }; + const build = buildEditorExtensions as unknown as (args: { + mentionSuggestion: object; + slashSuggestion: object; + entityReferenceSuggestion: object; + submitKeymap: Extension; + historyKeymap: Extension; + }) => ReturnType; + const extensions = build({ + mentionSuggestion: { char: "@" }, + slashSuggestion: { char: "/" }, + entityReferenceSuggestion, + submitKeymap: Extension.create({ name: "submit-test" }), + historyKeymap: Extension.create({ name: "history-test" }), + }); + const contextMention = extensions.find((extension) => extension.name === "contextMention"); + + expect(contextMention?.options.suggestions).toContain(entityReferenceSuggestion); + }); +}); + describe("decideSubmitShortcut", () => { describe("submitKey=enter", () => { it("submits on Enter when no suggestion menu is open", () => { diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index 063df566bd..dd85c592c2 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -10,9 +10,7 @@ import History from "@tiptap/extension-history"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { matchesShortcut } from "@/lib/keyboard/utils"; import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides"; -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { Extension, isNodeEmpty } from "@tiptap/core"; +import { Extension } from "@tiptap/core"; import { useHistoryKeymap } from "./tiptap-editor-history"; import Code from "@tiptap/extension-code"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; @@ -20,13 +18,22 @@ import { common, createLowlight } from "lowlight"; import { cn } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; import { getChatDraftContent, setChatDraftContent } from "@/lib/local-storage"; -import { getMarkdownText, textToHtml, handleEditorPaste } from "./tiptap-helpers"; +import { + extractEntityReferences, + getMarkdownText, + textToHtml, + handleEditorPaste, +} from "./tiptap-helpers"; import { CodeBlockView } from "./tiptap-code-block-view"; +import { DynamicPlaceholder, updateDynamicPlaceholder } from "./tiptap-dynamic-placeholder"; +import { EntityReferenceNode } from "./tiptap-entity-reference-extension"; import { ContextMention } from "./tiptap-mention-extension"; import { SlashCommandNode } from "./tiptap-slash-command-extension"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; import type { SlashCommand } from "./slash-command-types"; +import type { EntityReference } from "@/lib/types/entity-reference"; +import type { MessageHistoryEntry } from "./message-history"; export type TipTapInputHandle = { focus: () => void; @@ -39,70 +46,12 @@ export type TipTapInputHandle = { insertText: (text: string, from: number, to: number) => void; getMentions: () => ContextFile[]; getTaskMentions: () => TaskMentionData[]; + getEntityReferences: () => EntityReference[]; }; const lowlightInstance = createLowlight(common); export const TIPTAP_EDITOR_TEXT_SIZE_CLASS = "text-base leading-relaxed lg:text-sm"; -/** - * Custom Placeholder extension that reads the current placeholder from - * editor.storage instead of from the captured options object. TipTap's - * extension.options is a getter returning a fresh object each call, so the - * built-in Placeholder plugin's closure captures a stale options snapshot. - * This wrapper uses editor.storage.dynamicPlaceholder.text as the live source. - */ -const DynamicPlaceholder = Extension.create({ - name: "dynamicPlaceholder", - - addStorage() { - return { text: "" }; - }, - - addProseMirrorPlugins() { - const editor = this.editor; - return [ - new Plugin({ - key: new PluginKey("dynamicPlaceholder"), - props: { - decorations: ({ doc, selection }) => { - if (!editor.isEditable && !editor.isEmpty) return null; - const { anchor } = selection; - const decorations: InstanceType[] = []; - const isEmptyDoc = editor.isEmpty; - doc.descendants((node: ProseMirrorNode, pos: number) => { - const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize; - const isEmpty = !node.isLeaf && isNodeEmpty(node); - if (hasAnchor && isEmpty) { - const classes = ["is-empty"]; - if (isEmptyDoc) classes.push("is-editor-empty"); - decorations.push( - Decoration.node(pos, pos + node.nodeSize, { - class: classes.join(" "), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "data-placeholder": (editor.storage as any).dynamicPlaceholder.text, - }), - ); - } - return false; - }); - return DecorationSet.create(doc, decorations); - }, - }, - }), - ]; - }, -}); - -/** Update the dynamic placeholder text and trigger a redecoration. Extracted to a - * standalone function so that the eslint react-hooks/immutability rule does not - * flag it as a mutation of the `editor` hook argument. */ -function updateDynamicPlaceholder(ed: ReturnType, text: string) { - if (!ed) return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ed.storage as any).dynamicPlaceholder.text = text; - ed.view.dispatch(ed.state.tr); -} - type UseTipTapEditorOptions = { value: string; onChange: (value: string) => void; @@ -121,6 +70,8 @@ type UseTipTapEditorOptions = { mentionSuggestion: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any slashSuggestion: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + entityReferenceSuggestion?: any; slashCommands: readonly SlashCommand[]; /** True when a slash/@ suggestion menu is open with selectable items. Enter * must defer to the suggestion plugin so the highlighted item is inserted @@ -129,7 +80,7 @@ type UseTipTapEditorOptions = { /** Returns the user's previous messages for this session, newest-first. The * caller maintains the actual list; the editor reads it on each keypress so * ArrowUp/ArrowDown navigate the latest history without prop churn. */ - getHistory: () => readonly string[]; + getHistory: () => readonly MessageHistoryEntry[]; /** Open the Ctrl+R fuzzy search overlay. The overlay lives in the parent * component (so it can position itself relative to the editor) — the editor * only knows when to open it. */ @@ -192,11 +143,13 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) { }; } -function buildEditorExtensions(args: { +export function buildEditorExtensions(args: { // eslint-disable-next-line @typescript-eslint/no-explicit-any mentionSuggestion: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any slashSuggestion: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + entityReferenceSuggestion?: any; submitKeymap: Extension; historyKeymap: Extension; }) { @@ -214,8 +167,13 @@ function buildEditorExtensions(args: { }).configure({ lowlight: lowlightInstance }), DynamicPlaceholder, SlashCommandNode, + EntityReferenceNode, ContextMention.configure({ - suggestions: [args.mentionSuggestion, args.slashSuggestion], + suggestions: [ + args.mentionSuggestion, + args.slashSuggestion, + ...(args.entityReferenceSuggestion ? [args.entityReferenceSuggestion] : []), + ], }), args.submitKeymap, args.historyKeymap, @@ -285,6 +243,7 @@ export function useTipTapEditor(opts: UseTipTapEditorOptions) { extensions: buildEditorExtensions({ mentionSuggestion: opts.mentionSuggestion, slashSuggestion: opts.slashSuggestion, + entityReferenceSuggestion: opts.entityReferenceSuggestion, submitKeymap: SubmitKeymap, historyKeymap: historyController.extension, }), @@ -587,6 +546,7 @@ function useEditorImperativeHandle( }); return mentions; }, + getEntityReferences: () => (editor ? extractEntityReferences(editor.getJSON()) : []), }), [editor, onChange, isSyncingRef], ); diff --git a/apps/web/components/task/passthrough-chat-composer.tsx b/apps/web/components/task/passthrough-chat-composer.tsx index 1916933d00..0c880472b4 100644 --- a/apps/web/components/task/passthrough-chat-composer.tsx +++ b/apps/web/components/task/passthrough-chat-composer.tsx @@ -9,6 +9,7 @@ import { buildSubmitMessage } from "./chat/chat-input-area"; import { ChatInputContainer, type ChatInputContainerHandle, + type ChatSubmitPayload, type MessageAttachment, } from "./chat/chat-input-container"; import type { useChatPanelState } from "./chat/use-chat-panel-state"; @@ -23,13 +24,7 @@ import type { AppState } from "@/lib/state/store"; const PLAN_CONTEXT_PATH = "plan:context"; -export type PassthroughSubmitHandler = ( - content: string, - reviewComments?: DiffComment[], - attachments?: MessageAttachment[], - inlineMentions?: ContextFile[], - inlineTaskMentions?: TaskMentionData[], -) => Promise; +export type PassthroughSubmitHandler = (payload: ChatSubmitPayload) => Promise; export function PassthroughComposerPanel({ refHandle, @@ -67,6 +62,8 @@ export function PassthroughComposerPanel({ onSubmit={onSubmit} sessionId={panelState.resolvedSessionId} taskId={taskId} + workspaceId={null} + entityReferencesEnabled={false} taskTitle={panelState.task?.title} taskDescription={panelState.taskDescription ?? ""} planModeEnabled={panelState.planModeEnabled} @@ -294,13 +291,13 @@ export function useSendPassthroughMessage({ const storeApi = useAppStoreApi(); return useCallback( - async ( - content: string, - reviewComments?: DiffComment[], - attachments?: MessageAttachment[], - inlineMentions?: ContextFile[], - inlineTaskMentions?: TaskMentionData[], - ) => { + async ({ + message: content, + reviewComments, + attachments, + inlineMentions, + inlineTaskMentions, + }: ChatSubmitPayload) => { if (!taskId || !sessionId) { toast({ title: "Session not ready", variant: "error" }); throw new Error("Session not ready"); diff --git a/apps/web/components/task/passthrough-toolbar.test.tsx b/apps/web/components/task/passthrough-toolbar.test.tsx index 7fd7272bc3..6fdab810d1 100644 --- a/apps/web/components/task/passthrough-toolbar.test.tsx +++ b/apps/web/components/task/passthrough-toolbar.test.tsx @@ -204,9 +204,9 @@ vi.mock("./chat/chat-input-container", async () => { } if (event.key === "Enter") { event.preventDefault(); - const result = (props.onSubmit as (message: string) => Promise | void)?.( - value, - ); + const result = ( + props.onSubmit as (payload: { message: string }) => Promise | void + )?.({ message: value }); if (result && typeof result.then === "function") { void result.catch(() => undefined); } diff --git a/apps/web/components/task/passthrough-toolbar.tsx b/apps/web/components/task/passthrough-toolbar.tsx index 6654c024af..bd956e7939 100644 --- a/apps/web/components/task/passthrough-toolbar.tsx +++ b/apps/web/components/task/passthrough-toolbar.tsx @@ -160,7 +160,7 @@ export function PassthroughToolbar({ handleSendMessage("")} + onSend={() => handleSendMessage({ message: "" })} /> )} diff --git a/apps/web/components/task/preview-session-tabs.test.tsx b/apps/web/components/task/preview-session-tabs.test.tsx index 59af9d6df9..ced8e2588d 100644 --- a/apps/web/components/task/preview-session-tabs.test.tsx +++ b/apps/web/components/task/preview-session-tabs.test.tsx @@ -1,17 +1,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render } from "@testing-library/react"; import { sessionId as toSessionId, taskId as toTaskId, type TaskSession } from "@/lib/types/http"; +import type { ChatSubmitPayload } from "./chat/chat-input-container"; +import type { EntityReference } from "@/lib/types/entity-reference"; const mocks = vi.hoisted(() => ({ getWebSocketClient: vi.fn(), - onSend: null as null | ((content: string) => Promise), + onSend: null as null | ((payload: ChatSubmitPayload) => Promise), })); vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: mocks.getWebSocketClient, })); vi.mock("./task-chat-panel", () => ({ - TaskChatPanel: ({ onSend }: { onSend: (content: string) => Promise }) => { + TaskChatPanel: ({ onSend }: { onSend: (payload: ChatSubmitPayload) => Promise }) => { mocks.onSend = onSend; return
; }, @@ -38,7 +40,7 @@ describe("PreviewSessionBody send failures", () => { mocks.getWebSocketClient.mockReturnValue(null); render(); - await expect(mocks.onSend?.("hello")).rejects.toMatchObject({ + await expect(mocks.onSend?.({ message: "hello" })).rejects.toMatchObject({ name: "MessageSendError", code: "connection-unavailable", message: "Connection unavailable. Reconnect and try again.", @@ -50,6 +52,41 @@ describe("PreviewSessionBody send failures", () => { mocks.getWebSocketClient.mockReturnValue({ request: vi.fn().mockRejectedValue(error) }); render(); - await expect(mocks.onSend?.("hello")).rejects.toBe(error); + await expect(mocks.onSend?.({ message: "hello" })).rejects.toBe(error); + }); + + it("forwards attachments and entity references through preview direct send", async () => { + const request = vi.fn().mockResolvedValue(undefined); + mocks.getWebSocketClient.mockReturnValue({ request }); + const reference: EntityReference = { + version: 1, + ref: "mention:v1:github:issue:acme%2Frepo:42", + provider: "github", + kind: "issue", + id: "42", + key: "acme/repo#42", + title: "Fix composer references", + url: "https://github.com/acme/repo/issues/42", + scope: "acme/repo", + }; + render(); + + await mocks.onSend?.({ + message: "reference", + attachments: [{ type: "image", data: "base64", mime_type: "image/png" }], + entityReferences: [reference], + }); + + expect(request).toHaveBeenCalledWith( + "message.add", + { + task_id: "task-1", + session_id: "session-1", + content: "reference", + attachments: [{ type: "image", data: "base64", mime_type: "image/png" }], + entity_references: [reference], + }, + 30000, + ); }); }); diff --git a/apps/web/components/task/preview-session-tabs.tsx b/apps/web/components/task/preview-session-tabs.tsx index 07f4d19f93..b84be21587 100644 --- a/apps/web/components/task/preview-session-tabs.tsx +++ b/apps/web/components/task/preview-session-tabs.tsx @@ -10,9 +10,9 @@ import { useSessionResumption } from "@/hooks/domains/session/use-session-resump import { useTaskSessions } from "@/hooks/use-task-sessions"; import type { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ensure-task-session"; import type { AgentProfileOption } from "@/lib/state/slices"; -import { MessageSendError } from "@/lib/chat/message-send-error"; import type { TaskSession } from "@/lib/types/http"; -import { getWebSocketClient } from "@/lib/ws/connection"; +import { sendMessageRequest } from "@/hooks/use-message-handler"; +import type { ChatSubmitPayload } from "./chat/chat-input-container"; import { EnsureSessionErrorEmptyState } from "./ensure-session-error"; import { PassthroughToolbar } from "./passthrough-toolbar"; import { TaskChatPanel } from "./task-chat-panel"; @@ -151,19 +151,17 @@ function SessionAgentLogo({ profile }: { profile: AgentProfileOption | null | un export function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: string }) { const handleSendMessage = useCallback( - async (content: string) => { - const client = getWebSocketClient(); - if (!client) { - throw new MessageSendError( - "connection-unavailable", - "Connection unavailable. Reconnect and try again.", - ); - } - await client.request( - "message.add", - { task_id: taskId, session_id: session.id, content }, - 10000, - ); + async (payload: ChatSubmitPayload) => { + await sendMessageRequest({ + taskId, + resolvedSessionId: session.id, + finalMessage: payload.message, + modelToSend: undefined, + planMode: false, + hasReviewComments: !!payload.reviewComments?.length, + attachments: payload.attachments, + entityReferences: payload.entityReferences, + }); }, [taskId, session.id], ); diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index caab03007a..43f1aac021 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -3,7 +3,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, memo, type RefObject } from "react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; -import { type ChatInputContainerHandle } from "@/components/task/chat/chat-input-container"; +import { + type ChatInputContainerHandle, + type ChatSubmitPayload, + type ChatSubmitResult, +} from "@/components/task/chat/chat-input-container"; import { MessageList } from "@/components/task/chat/message-list"; import { useIsTaskArchived } from "./task-archived-context"; import { useChatPanelState } from "./chat/use-chat-panel-state"; @@ -119,7 +123,7 @@ function useSessionAgentIdentity(sessionId: string | null | undefined): { } type TaskChatPanelProps = { - onSend?: (message: string) => void; + onSend?: (payload: ChatSubmitPayload) => ChatSubmitResult; sessionId?: string | null; taskId?: string | null; onOpenFile?: (path: string) => void; diff --git a/apps/web/e2e/tests/chat/entity-reference-composer.spec.ts b/apps/web/e2e/tests/chat/entity-reference-composer.spec.ts new file mode 100644 index 0000000000..5bd81b4722 --- /dev/null +++ b/apps/web/e2e/tests/chat/entity-reference-composer.spec.ts @@ -0,0 +1,441 @@ +import { type Locator, type Page } from "@playwright/test"; +import { test, expect, type SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { SessionPage } from "../../pages/session-page"; +import type { CreateTaskResponse } from "../../../lib/types/http"; +import type { + EntityReference, + EntityReferenceSearchResponse, +} from "../../../lib/types/entity-reference"; + +const REFERENCE_QUERY = "E2E Reference"; +const LINEAR_SCOPE = "mock-org"; + +function taskReference(workspaceId: string, taskId: string, title: string): EntityReference { + return { + version: 1, + ref: `mention:v1:kandev:task:${workspaceId}:${taskId}`, + provider: "kandev", + kind: "task", + id: taskId, + title, + url: `/t/${taskId}`, + scope: workspaceId, + }; +} + +function linearReference(id: string, key: string, title: string): EntityReference { + return { + version: 1, + ref: `mention:v1:linear:issue:${LINEAR_SCOPE}:${id}`, + provider: "linear", + kind: "issue", + id, + key, + title, + url: `https://linear.app/${LINEAR_SCOPE}/issue/${key}`, + scope: LINEAR_SCOPE, + }; +} + +function externalSearchResponse( + query: string, + references: EntityReference[], + includeTimedOutProvider = false, +): EntityReferenceSearchResponse { + return { + query, + groups: [ + { + source: "kandev_tasks", + provider: "kandev", + kind: "task", + display_name: "Kandev tasks", + kind_label: "Task", + status: "ok", + results: [taskReference("workspace-stale", "task-stale", "Hidden Kandev task")], + }, + { + source: "linear_issues", + provider: "linear", + kind: "issue", + display_name: "Linear", + kind_label: "Issue", + status: "ok", + results: references, + }, + ...(includeTimedOutProvider + ? [ + { + source: "github_issues", + provider: "github", + kind: "issue", + display_name: "GitHub issues", + kind_label: "Issue", + status: "timeout" as const, + results: [], + }, + ] + : []), + ], + }; +} + +async function createReadyTask( + apiClient: ApiClient, + seedData: SeedData, + title: string, +): Promise { + return apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); +} + +async function openTaskChat(page: Page, taskId: string): Promise { + await page.goto(`/t/${taskId}`); + const session = new SessionPage(page); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + return session; +} + +function visibleEditor(scope: Locator | Page): Locator { + return scope.locator(".tiptap.ProseMirror:visible").first(); +} + +function referenceOption(page: Page, title: string): Locator { + return page.getByRole("option").filter({ hasText: title }); +} + +async function expectMenuAnchoredToEditor(menu: Locator, editor: Locator): Promise { + const [menuBox, editorBox] = await Promise.all([menu.boundingBox(), editor.boundingBox()]); + expect(menuBox).not.toBeNull(); + expect(editorBox).not.toBeNull(); + expect(Math.abs(menuBox!.y + menuBox!.height - editorBox!.y)).toBeLessThanOrEqual(4); +} + +async function typeReferenceQuery(editor: Locator, query: string): Promise { + await editor.click(); + await editor.fill(""); + await editor.pressSequentially(`#${query}`); +} + +async function expectPersistedReference( + apiClient: ApiClient, + sessionId: string, + referenceId: string, +): Promise { + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.some((message) => { + const references = message.metadata?.entity_references; + return ( + message.author_type === "user" && + Array.isArray(references) && + references.some( + (reference) => + typeof reference === "object" && + reference !== null && + (reference as Record).id === referenceId, + ) + ); + }); + }, + { timeout: 15_000, message: `Wait for persisted reference ${referenceId}` }, + ) + .toBe(true); + + const { messages } = await apiClient.listSessionMessages(sessionId); + for (const message of messages) { + const references = message.metadata?.entity_references; + if (!Array.isArray(references)) continue; + const reference = references.find( + (candidate) => + typeof candidate === "object" && + candidate !== null && + (candidate as Record).id === referenceId, + ); + if (reference) return reference as EntityReference; + } + throw new Error(`Persisted entity reference ${referenceId} disappeared`); +} + +async function configureLinear(apiClient: ApiClient, workspaceId: string): Promise { + await apiClient.setLinearConfig({ secret: "lin_api_entity_refs", workspaceId }); + await apiClient.waitForIntegrationAuthHealthy("linear", { workspaceId }); +} + +async function openQuickChatWithAgent(page: Page): Promise<{ + dialog: Locator; + sessionId: string; +}> { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.getByTestId("sidebar-quick-chat-shortcut").click(); + + const dialog = page.getByRole("dialog", { name: "Quick Chat" }); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + const setup = dialog.getByTestId("quick-chat-setup"); + if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) { + await dialog.getByLabel("Start new chat").click(); + } + await expect(setup).toBeVisible({ timeout: 5_000 }); + + const agentSelector = dialog.getByTestId("agent-profile-selector"); + if ( + await agentSelector + .getByText("Select agent", { exact: false }) + .isVisible() + .catch(() => false) + ) { + await agentSelector.click(); + await page.getByRole("option").first().click(); + } + + const started = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname.endsWith("/quick-chat"), + ); + await dialog.getByTestId("quick-chat-start").click(); + const payload = (await started).json() as Promise<{ session_id: string }>; + const { session_id: sessionId } = await payload; + + const editor = visibleEditor(dialog); + await expect(editor).toBeVisible({ timeout: 15_000 }); + await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 }); + return { dialog, sessionId }; +} + +async function createPassthroughProfile(apiClient: ApiClient): Promise { + const { agents } = await apiClient.listAgents(); + const agent = agents[0]; + if (!agent) throw new Error("No E2E agent registered"); + const profile = await apiClient.createAgentProfile(agent.id, "Entity Reference Passthrough", { + model: "mock-fast", + cli_passthrough: true, + }); + return profile.id; +} + +test.describe("Entity reference composer", () => { + test("keeps Kandev task suggestions under @", async ({ testPage, apiClient, seedData }) => { + const targetTitle = "At-Mention-Task-Target"; + await apiClient.seedTask(seedData.workspaceId, targetTitle, { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const active = await createReadyTask(apiClient, seedData, "At Mention Active Task"); + const session = await openTaskChat(testPage, active.id); + const editor = visibleEditor(session.activeChat()); + + await editor.fill(""); + await editor.pressSequentially(`@${targetTitle}`); + + await expect(testPage.getByRole("option").filter({ hasText: targetTitle })).toBeVisible({ + timeout: 10_000, + }); + }); + + test("task chat restores a keyboard-selected draft and explicitly sends durable metadata", async ({ + testPage, + apiClient, + seedData, + }) => { + const alpha = linearReference("linear-alpha", "ENG-101", `${REFERENCE_QUERY} Alpha`); + const beta = linearReference("linear-beta", "ENG-102", `${REFERENCE_QUERY} Beta`); + await configureLinear(apiClient, seedData.workspaceId); + const active = await createReadyTask(apiClient, seedData, "Entity Reference Active Task"); + if (!active.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + + const pageErrors: string[] = []; + testPage.on("pageerror", (error) => pageErrors.push(error.message)); + const session = await openTaskChat(testPage, active.id); + const editor = visibleEditor(session.activeChat()); + await testPage.route("**/api/v1/workspaces/*/mentions/search?*", async (route) => { + const query = new URL(route.request().url()).searchParams.get("q") ?? ""; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(externalSearchResponse(query, [alpha, beta])), + }); + }); + await typeReferenceQuery(editor, REFERENCE_QUERY); + + const menu = testPage.getByTestId("entity-reference-menu"); + await expect(menu).toBeVisible({ timeout: 10_000 }); + await expect(menu.getByRole("listbox", { name: "Reference work items" })).toBeVisible(); + await expect(referenceOption(testPage, alpha.title)).toHaveAttribute("aria-selected", "true"); + await expect(referenceOption(testPage, "Hidden Kandev task")).toHaveCount(0); + await expectMenuAnchoredToEditor(menu, editor); + + await editor.press("ArrowDown"); + const betaOption = referenceOption(testPage, beta.title); + await expect(betaOption).toHaveAttribute("aria-selected", "true"); + await editor.press("Tab"); + + await expect(menu).not.toBeVisible(); + await expect(editor).toBeFocused(); + await expect(editor.getByTestId("entity-reference-chip")).toContainText(beta.key ?? beta.title); + await editor.pressSequentially("needs follow-up"); + await expect(editor).toHaveText(/ENG-102\s+needs follow-up/); + await expect( + session + .activeChat() + .locator(".chat-message-list:visible") + .getByText("needs follow-up", { exact: false }), + ).not.toBeVisible(); + + await testPage.reload(); + await session.waitForLoad(); + const restoredEditor = visibleEditor(session.activeChat()); + // A restored non-empty draft intentionally has no idle placeholder, so + // wait on editability instead of SessionPage.waitForChatIdle(). + await expect(restoredEditor).toHaveAttribute("contenteditable", "true", { + timeout: 30_000, + }); + await expect(restoredEditor.getByTestId("entity-reference-chip")).toContainText( + beta.key ?? beta.title, + ); + await expect(restoredEditor).toHaveText(/ENG-102\s+needs follow-up/); + + await session.activeChat().getByTestId("submit-message-button").click(); + const persisted = await expectPersistedReference(apiClient, active.session_id, beta.id); + expect(persisted).toMatchObject({ + version: 1, + provider: "linear", + kind: "issue", + id: beta.id, + key: beta.key, + title: beta.title, + url: beta.url, + scope: LINEAR_SCOPE, + }); + + const sentChip = session + .activeChat() + .locator(".chat-message-list:visible") + .getByTestId("entity-reference-chip") + .filter({ hasText: beta.key }); + await expect(sentChip).toBeVisible({ timeout: 10_000 }); + await expect(sentChip).toHaveAttribute("href", beta.url); + expect(pageErrors).toEqual([]); + }); + + test("keeps successful results visible when another provider times out", async ({ + testPage, + apiClient, + seedData, + }) => { + const target = linearReference("linear-partial", "ENG-201", "Partial Result Target"); + const active = await createReadyTask(apiClient, seedData, "Partial Result Active Task"); + await openTaskChat(testPage, active.id); + await testPage.route("**/api/v1/workspaces/*/mentions/search?*", async (route) => { + const query = new URL(route.request().url()).searchParams.get("q") ?? ""; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(externalSearchResponse(query, [target], true)), + }); + }); + + await typeReferenceQuery(visibleEditor(testPage), "Partial Result"); + const menu = testPage.getByTestId("entity-reference-menu"); + await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 }); + await expect(referenceOption(testPage, "Hidden Kandev task")).toHaveCount(0); + await expect(menu.getByText("GitHub issues", { exact: true })).toBeVisible(); + await expect(menu.getByText("Search timed out", { exact: true })).toBeVisible(); + }); + + test("Quick Chat searches, inserts without auto-send, and explicitly sends an external reference", async ({ + testPage, + apiClient, + seedData, + }) => { + const target = linearReference("linear-quick-chat", "ENG-301", "Quick Chat Reference Target"); + await configureLinear(apiClient, seedData.workspaceId); + const { dialog, sessionId } = await openQuickChatWithAgent(testPage); + const editor = visibleEditor(dialog); + + await testPage.route("**/api/v1/workspaces/*/mentions/search?*", async (route) => { + const query = new URL(route.request().url()).searchParams.get("q") ?? ""; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(externalSearchResponse(query, [target])), + }); + }); + + await typeReferenceQuery(editor, "Quick Chat Reference"); + await expect(referenceOption(testPage, target.title)).toBeVisible({ timeout: 10_000 }); + await expect(referenceOption(testPage, "Hidden Kandev task")).toHaveCount(0); + await editor.press("Enter"); + + await expect(editor.getByTestId("entity-reference-chip")).toContainText( + target.key ?? target.title, + ); + await expect(editor).toBeFocused(); + await expect( + dialog + .getByTestId("quick-chat-messages") + .getByTestId("entity-reference-chip") + .filter({ hasText: target.key ?? target.title }), + ).not.toBeVisible(); + + await dialog.getByTestId("submit-message-button").click(); + await expectPersistedReference(apiClient, sessionId, target.id); + await expect( + dialog + .getByTestId("quick-chat-messages") + .getByTestId("entity-reference-chip") + .filter({ hasText: target.key ?? target.title }), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("passthrough keeps hash text literal and never starts entity search", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + const profileId = await createPassthroughProfile(apiClient); + const active = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Entity Reference Passthrough Task", + profileId, + { + description: "initial passthrough prompt", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await testPage.goto(`/t/${active.id}`); + const session = new SessionPage(testPage); + await session.waitForPassthroughLoad(20_000); + await session.waitForPassthroughLoaded(20_000); + await session.expectPassthroughHasText("Processed:", 20_000); + + await testPage.getByTestId("passthrough-toggle-composer").click(); + const editor = testPage.getByTestId("passthrough-composer").getByTestId("chat-input-editor"); + await expect(editor).toBeVisible({ timeout: 5_000 }); + const searchObserved = testPage + .waitForRequest((request) => new URL(request.url()).pathname.endsWith("/mentions/search"), { + timeout: 750, + }) + .then( + () => true, + () => false, + ); + await editor.fill("#Literal passthrough reference"); + + expect(await searchObserved).toBe(false); + await expect(testPage.getByTestId("entity-reference-menu")).toHaveCount(0); + await expect(editor).toHaveText("#Literal passthrough reference"); + }); +}); diff --git a/apps/web/e2e/tests/chat/mobile-entity-reference-composer.spec.ts b/apps/web/e2e/tests/chat/mobile-entity-reference-composer.spec.ts new file mode 100644 index 0000000000..7ef8e8dbe2 --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-entity-reference-composer.spec.ts @@ -0,0 +1,225 @@ +import { type Locator, type Page } from "@playwright/test"; +import { test, expect, type SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { SessionPage } from "../../pages/session-page"; +import type { CreateTaskResponse } from "../../../lib/types/http"; +import type { EntityReference } from "../../../lib/types/entity-reference"; + +const LINEAR_SCOPE = "mock-org"; + +function taskReference(workspaceId: string, taskId: string, title: string): EntityReference { + return { + version: 1, + ref: `mention:v1:kandev:task:${workspaceId}:${taskId}`, + provider: "kandev", + kind: "task", + id: taskId, + title, + url: `/t/${taskId}`, + scope: workspaceId, + }; +} + +function linearIssueReference(index: number): EntityReference { + const id = `linear-issue-${index}`; + const key = `MOB-${100 + index}`; + return { + version: 1, + ref: `mention:v1:linear:issue:${LINEAR_SCOPE}:${id}`, + provider: "linear", + kind: "issue", + id, + key, + title: `Mobile Reference Linear ${String(index).padStart(2, "0")}`, + url: `https://linear.app/${LINEAR_SCOPE}/issue/${key}`, + scope: LINEAR_SCOPE, + }; +} + +async function createReadyTask( + apiClient: ApiClient, + seedData: SeedData, +): Promise { + return apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile Entity Reference Active Task", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); +} + +async function openTaskChat(page: Page, taskId: string): Promise { + await page.goto(`/t/${taskId}`); + const session = new SessionPage(page); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + return session; +} + +function visibleEditor(scope: Locator | Page): Locator { + return scope.locator(".tiptap.ProseMirror:visible").first(); +} + +async function expectPersistedReference( + apiClient: ApiClient, + sessionId: string, + referenceId: string, +): Promise { + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.some((message) => { + const references = message.metadata?.entity_references; + return ( + Array.isArray(references) && + references.some( + (reference) => + typeof reference === "object" && + reference !== null && + (reference as Record).id === referenceId, + ) + ); + }); + }, + { timeout: 15_000, message: `Wait for mobile reference ${referenceId}` }, + ) + .toBe(true); +} + +test.describe("Mobile entity reference composer", () => { + test("touch selection stays viewport-contained, excludes Kandev tasks, and persists", async ({ + testPage, + apiClient, + seedData, + }) => { + const references: EntityReference[] = []; + for (let index = 1; index <= 5; index += 1) { + const title = `Mobile Reference ${String(index).padStart(2, "0")}`; + const task = await apiClient.seedTask(seedData.workspaceId, title, { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + references.push(taskReference(seedData.workspaceId, task.task_id, title)); + } + const linearReferences = Array.from({ length: 8 }, (_, index) => + linearIssueReference(index + 1), + ); + await apiClient.setLinearConfig({ + secret: "lin_api_mobile_entity_refs", + workspaceId: seedData.workspaceId, + }); + await apiClient.waitForIntegrationAuthHealthy("linear", { + workspaceId: seedData.workspaceId, + }); + const active = await createReadyTask(apiClient, seedData); + if (!active.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + const session = await openTaskChat(testPage, active.id); + + await testPage.route("**/api/v1/workspaces/*/mentions/search?*", async (route) => { + const query = new URL(route.request().url()).searchParams.get("q") ?? ""; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + query, + groups: [ + { + source: "kandev_tasks", + provider: "kandev", + kind: "task", + display_name: "Kandev tasks", + kind_label: "Task", + status: "ok", + results: references, + }, + { + source: "linear_issues", + provider: "linear", + kind: "issue", + display_name: "Linear", + kind_label: "Issue", + status: "ok", + results: linearReferences, + }, + ], + }), + }); + }); + + const editor = visibleEditor(session.activeChat()); + await editor.tap(); + await editor.fill(""); + await editor.pressSequentially("#Mobile Reference"); + + const menu = testPage.getByTestId("entity-reference-menu"); + await expect(menu).toBeVisible({ timeout: 10_000 }); + await expect(menu.getByRole("option")).toHaveCount(linearReferences.length, { + timeout: 10_000, + }); + await expect(menu.getByText("Kandev tasks", { exact: true })).toHaveCount(0); + const [menuBox, editorBox] = await Promise.all([menu.boundingBox(), editor.boundingBox()]); + expect(menuBox).not.toBeNull(); + expect(editorBox).not.toBeNull(); + expect(Math.abs(menuBox!.y + menuBox!.height - editorBox!.y)).toBeLessThanOrEqual(4); + const geometry = await menu.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const viewport = window.visualViewport; + const viewportBounds = viewport + ? { + left: viewport.offsetLeft, + top: viewport.offsetTop, + right: viewport.offsetLeft + viewport.width, + bottom: viewport.offsetTop + viewport.height, + } + : { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }; + const listbox = element.querySelector("[role='listbox']"); + const rowHeights = Array.from(element.querySelectorAll("[role='option']")).map( + (row) => row.getBoundingClientRect().height, + ); + return { + menu: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }, + viewport: viewportBounds, + minRowHeight: Math.min(...rowHeights), + listClientHeight: listbox?.clientHeight ?? 0, + listScrollHeight: listbox?.scrollHeight ?? 0, + listOverflowY: listbox ? getComputedStyle(listbox).overflowY : "", + documentClientWidth: document.documentElement.clientWidth, + documentScrollWidth: document.documentElement.scrollWidth, + }; + }); + + expect(geometry.menu.left).toBeGreaterThanOrEqual(geometry.viewport.left - 1); + expect(geometry.menu.top).toBeGreaterThanOrEqual(geometry.viewport.top - 1); + expect(geometry.menu.right).toBeLessThanOrEqual(geometry.viewport.right + 1); + expect(geometry.menu.bottom).toBeLessThanOrEqual(geometry.viewport.bottom + 1); + expect(geometry.minRowHeight).toBeGreaterThanOrEqual(44); + expect(geometry.listScrollHeight).toBeGreaterThan(geometry.listClientHeight); + expect(geometry.listOverflowY).toMatch(/auto|scroll/); + expect(geometry.documentScrollWidth).toBeLessThanOrEqual(geometry.documentClientWidth); + + const selected = linearReferences[0]; + const option = testPage.getByRole("option").filter({ hasText: selected.title }); + await option.tap(); + await expect(menu).not.toBeVisible(); + await expect(editor.getByTestId("entity-reference-chip")).toContainText( + selected.key ?? selected.title, + ); + await expect(editor).toBeFocused(); + + await session.activeChat().getByTestId("submit-message-button").tap(); + await expectPersistedReference(apiClient, active.session_id, selected.id); + await expect( + session + .activeChat() + .locator(".chat-message-list:visible") + .getByTestId("entity-reference-chip") + .filter({ hasText: selected.key ?? selected.title }), + ).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/apps/web/e2e/tests/cli-mode/passthrough-toolbar.spec.ts b/apps/web/e2e/tests/cli-mode/passthrough-toolbar.spec.ts index a8df5cdfc7..f41a2bb02f 100644 --- a/apps/web/e2e/tests/cli-mode/passthrough-toolbar.spec.ts +++ b/apps/web/e2e/tests/cli-mode/passthrough-toolbar.spec.ts @@ -274,7 +274,7 @@ test.describe("CLI mode: passthrough toolbar", () => { await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({ timeout: 5_000, }); - await testPage.getByRole("button", { name: new RegExp(promptName) }).click(); + await testPage.getByRole("option", { name: new RegExp(promptName) }).click(); await expect(editor).toContainText(promptName); const contextFileName = path.basename(contextFile); @@ -282,7 +282,7 @@ test.describe("CLI mode: passthrough toolbar", () => { await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({ timeout: 10_000, }); - await testPage.getByRole("button", { name: new RegExp(contextFileName) }).click(); + await testPage.getByRole("option", { name: new RegExp(contextFileName) }).click(); await expect(editor).toContainText(contextFileName); await testPage.getByTestId("submit-message-button").click(); @@ -340,7 +340,7 @@ test.describe("CLI mode: passthrough toolbar", () => { await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({ timeout: 5_000, }); - await testPage.getByRole("button", { name: /^Plan Include the plan as context$/ }).click(); + await testPage.getByRole("option", { name: /^Plan Include the plan as context$/ }).click(); await expect(editor).toContainText("Plan"); await testPage.getByTestId("submit-message-button").click(); diff --git a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts index 0ca30a2c5d..d531c1c6a3 100644 --- a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts +++ b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts @@ -54,7 +54,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { await textarea.pressSequentially("@"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); - await expect(testPage.getByRole("button", { name: /qa-alpha/ })).toBeVisible(); + await expect(testPage.getByRole("option", { name: /qa-alpha/ })).toBeVisible(); }); test("Escape closes the menu without inserting the prompt", async ({ testPage, apiClient }) => { @@ -98,8 +98,8 @@ test.describe("@-mention autocomplete: adversarial QA", () => { await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); // Both should be visible. - await expect(testPage.getByRole("button", { name: /qa-arr-1/ })).toBeVisible(); - await expect(testPage.getByRole("button", { name: /qa-arr-2/ })).toBeVisible(); + await expect(testPage.getByRole("option", { name: /qa-arr-1/ })).toBeVisible(); + await expect(testPage.getByRole("option", { name: /qa-arr-2/ })).toBeVisible(); await textarea.press("ArrowDown"); await textarea.press("Enter"); @@ -128,7 +128,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { await textarea.pressSequentially("@qa-mo"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); - await testPage.getByRole("button", { name: /qa-mouse/ }).click(); + await testPage.getByRole("option", { name: /qa-mouse/ }).click(); await expect(textarea).toHaveValue("MOUSE_CONTENT"); await expect(testPage.getByText(MENU_TITLE)).not.toBeVisible(); diff --git a/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts b/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts index 30db028dfc..f7feb4bd35 100644 --- a/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts +++ b/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts @@ -36,7 +36,7 @@ test.describe("Task creation: custom prompt autocomplete", () => { const menu = testPage.getByText(MENU_TITLE); await expect(menu).toBeVisible({ timeout: 5_000 }); - await expect(testPage.getByRole("button", { name: new RegExp(PROMPT_NAME) })).toBeVisible(); + await expect(testPage.getByRole("option", { name: new RegExp(PROMPT_NAME) })).toBeVisible(); await textarea.press("Enter"); diff --git a/apps/web/hooks/domains/session/use-queue.test.ts b/apps/web/hooks/domains/session/use-queue.test.ts index ad089f0009..5ab16d00c0 100644 --- a/apps/web/hooks/domains/session/use-queue.test.ts +++ b/apps/web/hooks/domains/session/use-queue.test.ts @@ -1,6 +1,7 @@ import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; +import type { EntityReference } from "@/lib/types/entity-reference"; const queueApiMock = vi.hoisted(() => { class QueueEntryNotFoundError extends Error {} @@ -39,6 +40,17 @@ import { useQueue } from "./use-queue"; const SESSION_ID = "sess-1"; const TASK_ID = "task-1"; +const reference: EntityReference = { + version: 1, + ref: "mention:v1:github:issue:acme%2Frepo:42", + provider: "github", + kind: "issue", + id: "42", + key: "acme/repo#42", + title: "Fix composer references", + url: "https://github.com/acme/repo/issues/42", + scope: "acme/repo", +}; function entry(overrides: Partial = {}): QueuedMessage { return { @@ -137,4 +149,47 @@ describe("useQueue", () => { expect(queueApiMock.getQueueStatus).not.toHaveBeenCalled(); }); + + it("queues structured references with busy-agent messages", async () => { + queueApiMock.queueMessage.mockResolvedValue(entry()); + const { result } = renderHook(() => useQueue(SESSION_ID)); + await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled()); + queueApiMock.queueMessage.mockClear(); + + await act(async () => { + await result.current.queue({ + taskId: TASK_ID, + content: "queued reference", + entityReferences: [reference], + } as never); + }); + + expect(queueApiMock.queueMessage).toHaveBeenCalledWith({ + session_id: SESSION_ID, + task_id: TASK_ID, + content: "queued reference", + model: undefined, + plan_mode: undefined, + attachments: undefined, + entity_references: [reference], + }); + }); + + it("replaces queued reference metadata with an explicit empty array", async () => { + queueApiMock.updateQueuedMessage.mockResolvedValue({ entry_id: "q-1" }); + const { result } = renderHook(() => useQueue(SESSION_ID)); + await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled()); + + await act(async () => { + await result.current.editEntry("q-1", "reference removed", undefined, [] as never); + }); + + expect(queueApiMock.updateQueuedMessage).toHaveBeenCalledWith({ + session_id: SESSION_ID, + entry_id: "q-1", + content: "reference removed", + attachments: undefined, + entity_references: [], + }); + }); }); diff --git a/apps/web/hooks/domains/session/use-queue.ts b/apps/web/hooks/domains/session/use-queue.ts index 97dbc10f5e..0ae89d1d00 100644 --- a/apps/web/hooks/domains/session/use-queue.ts +++ b/apps/web/hooks/domains/session/use-queue.ts @@ -10,6 +10,7 @@ import { QueueEntryNotFoundError, } from "@/lib/api/domains/queue-api"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; +import type { EntityReference } from "@/lib/types/entity-reference"; const EMPTY_ENTRIES: QueuedMessage[] = []; @@ -21,6 +22,15 @@ export type MessageAttachment = { delivery_mode?: "prompt" | "path"; }; +export type QueueMessageInput = { + taskId: string; + content: string; + model?: string; + planMode?: boolean; + attachments?: MessageAttachment[]; + entityReferences?: EntityReference[]; +}; + /** Selectors over the queue slice for one session. */ function useQueueState(sessionId: string | null) { const entries = useAppStore((state) => @@ -85,13 +95,14 @@ function useQueueActions({ ); const queue = useCallback( - async ( - taskId: string, - content: string, - model?: string, - planMode?: boolean, - attachments?: MessageAttachment[], - ) => { + async ({ + taskId, + content, + model, + planMode, + attachments, + entityReferences, + }: QueueMessageInput) => { if (!sessionId) return; setQueueLoading(sessionId, true); try { @@ -102,6 +113,7 @@ function useQueueActions({ model, plan_mode: planMode, attachments, + entity_references: entityReferences, }); await refetch(sessionId); } finally { @@ -129,7 +141,12 @@ function useQueueActions({ const drainNext = useDrainNextAction(sessionId, setQueueLoading, refetch); const editEntry = useCallback( - async (entryId: string, content: string, attachments?: MessageAttachment[]) => { + async ( + entryId: string, + content: string, + attachments?: MessageAttachment[], + entityReferences: EntityReference[] = [], + ) => { if (!sessionId) return; try { await updateQueuedMessage({ @@ -137,6 +154,7 @@ function useQueueActions({ entry_id: entryId, content, attachments, + entity_references: entityReferences, }); await refetch(sessionId); } catch (err) { diff --git a/apps/web/hooks/use-entity-reference-search.test.ts b/apps/web/hooks/use-entity-reference-search.test.ts new file mode 100644 index 0000000000..1599d1e571 --- /dev/null +++ b/apps/web/hooks/use-entity-reference-search.test.ts @@ -0,0 +1,218 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { EntityReferenceSearchGroup } from "@/lib/types/entity-reference"; + +const searchEntityReferencesMock = vi.fn(); +const WORKSPACE_ID = "workspace-1"; + +vi.mock("@/lib/api/domains/mentions-api", () => ({ + searchEntityReferences: (...args: unknown[]) => searchEntityReferencesMock(...args), +})); + +import { useEntityReferenceSearch } from "./use-entity-reference-search"; + +const groups: EntityReferenceSearchGroup[] = [ + { + source: "kandev_tasks", + provider: "kandev", + kind: "task", + display_name: "Kandev tasks", + kind_label: "Task", + status: "ok", + results: [ + { + version: 1, + ref: "mention:v1:kandev:task:task-2", + provider: "kandev", + kind: "task", + id: "task-2", + title: "Repair authentication", + url: "/t/task-2", + scope: WORKSPACE_ID, + }, + ], + }, + { + source: "github_issues", + provider: "github", + kind: "issue", + display_name: "GitHub issues", + kind_label: "Issue", + status: "timeout", + results: [], + }, +]; + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + vi.useFakeTimers(); + searchEntityReferencesMock.mockReset(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("useEntityReferenceSearch request lifecycle", () => { + it("debounces scoped queries for 250 ms and preserves partial provider groups", async () => { + searchEntityReferencesMock.mockResolvedValueOnce({ query: "auth", groups }); + + const { result } = renderHook(() => + useEntityReferenceSearch({ + workspaceId: WORKSPACE_ID, + sessionId: "session-1", + query: "auth", + limit: 5, + }), + ); + + await act(async () => vi.advanceTimersByTimeAsync(249)); + expect(searchEntityReferencesMock).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + await flush(); + }); + + expect(searchEntityReferencesMock).toHaveBeenCalledTimes(1); + expect(searchEntityReferencesMock).toHaveBeenCalledWith( + { + workspaceId: WORKSPACE_ID, + query: "auth", + limit: 5, + }, + { cache: "no-store", init: { signal: expect.any(AbortSignal) } }, + ); + expect(result.current.groups).toEqual(groups); + expect(result.current.isSearching).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("returns a safe retryable aggregate error and retries the current search", async () => { + searchEntityReferencesMock.mockRejectedValueOnce(new Error("private upstream details")); + const { result } = renderHook(() => + useEntityReferenceSearch({ + workspaceId: WORKSPACE_ID, + sessionId: "session-1", + query: "auth", + }), + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + await flush(); + }); + + expect(result.current.groups).toEqual([]); + expect(result.current.error).toEqual({ + message: "Reference search failed. Try again.", + retryable: true, + }); + + searchEntityReferencesMock.mockResolvedValueOnce({ query: "auth", groups }); + act(() => result.current.retry()); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + await flush(); + }); + + expect(searchEntityReferencesMock).toHaveBeenCalledTimes(2); + expect(result.current.groups).toEqual(groups); + expect(result.current.error).toBeNull(); + }); +}); + +describe("useEntityReferenceSearch scope guards", () => { + it("aborts and ignores stale work after workspace, session, or query changes", async () => { + let resolveStale: (value: { + query: string; + groups: EntityReferenceSearchGroup[]; + }) => void = () => {}; + const staleRequest = new Promise<{ query: string; groups: EntityReferenceSearchGroup[] }>( + (resolve) => { + resolveStale = resolve; + }, + ); + const staleGroups = [{ ...groups[0]!, source: "stale_workspace_tasks" }]; + searchEntityReferencesMock + .mockReturnValueOnce(staleRequest) + .mockResolvedValueOnce({ query: "oauth", groups }); + + const { result, rerender } = renderHook( + (props: { workspaceId: string; sessionId: string; query: string }) => + useEntityReferenceSearch(props), + { + initialProps: { + workspaceId: WORKSPACE_ID, + sessionId: "session-1", + query: "auth", + }, + }, + ); + await act(async () => vi.advanceTimersByTimeAsync(250)); + const staleSignal = searchEntityReferencesMock.mock.calls[0]![1].init.signal as AbortSignal; + + rerender({ workspaceId: "workspace-2", sessionId: "session-2", query: "oauth" }); + expect(staleSignal.aborted).toBe(true); + expect(result.current.groups).toEqual([]); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + await flush(); + }); + expect(result.current.groups).toEqual(groups); + + await act(async () => { + resolveStale({ query: "auth", groups: staleGroups }); + await flush(); + }); + expect(result.current.groups).toEqual(groups); + }); + + it("does not search without an active workspace or non-empty query", async () => { + searchEntityReferencesMock.mockResolvedValue({ query: "", groups: [] }); + const initialProps: { + workspaceId: string | null; + query: string; + enabled?: boolean; + } = { workspaceId: null, query: "auth", enabled: true }; + const { rerender } = renderHook( + (props: { workspaceId: string | null; query: string; enabled?: boolean }) => + useEntityReferenceSearch(props), + { initialProps }, + ); + + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(searchEntityReferencesMock).not.toHaveBeenCalled(); + + rerender({ workspaceId: WORKSPACE_ID, query: " ", enabled: true }); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(searchEntityReferencesMock).not.toHaveBeenCalled(); + + rerender({ workspaceId: WORKSPACE_ID, query: "auth", enabled: false }); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(searchEntityReferencesMock).not.toHaveBeenCalled(); + }); + + it("reports searching only after debounce while request remains in flight", async () => { + searchEntityReferencesMock.mockReturnValueOnce(new Promise(() => {})); + const { result } = renderHook(() => + useEntityReferenceSearch({ + workspaceId: WORKSPACE_ID, + sessionId: "session-1", + query: "auth", + }), + ); + + expect(result.current.isSearching).toBe(false); + await act(async () => vi.advanceTimersByTimeAsync(249)); + expect(result.current.isSearching).toBe(false); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(result.current.isSearching).toBe(true); + }); +}); diff --git a/apps/web/hooks/use-entity-reference-search.ts b/apps/web/hooks/use-entity-reference-search.ts new file mode 100644 index 0000000000..79d6961aa8 --- /dev/null +++ b/apps/web/hooks/use-entity-reference-search.ts @@ -0,0 +1,87 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { searchEntityReferences } from "@/lib/api/domains/mentions-api"; +import type { EntityReferenceSearchGroup } from "@/lib/types/entity-reference"; + +const SEARCH_DEBOUNCE_MS = 250; + +export type UseEntityReferenceSearchOptions = { + workspaceId: string | null | undefined; + sessionId?: string | null; + query: string; + limit?: number; + enabled?: boolean; +}; + +type EntityReferenceSearchState = { + groups: EntityReferenceSearchGroup[]; + isSearching: boolean; + error: EntityReferenceSearchError | null; +}; + +export type EntityReferenceSearchError = { + message: string; + retryable: true; +}; + +const EMPTY_STATE: EntityReferenceSearchState = { + groups: [], + isSearching: false, + error: null, +}; + +const RETRYABLE_SEARCH_ERROR: EntityReferenceSearchError = { + message: "Reference search failed. Try again.", + retryable: true, +}; + +export function useEntityReferenceSearch({ + workspaceId, + sessionId, + query, + limit, + enabled = true, +}: UseEntityReferenceSearchOptions) { + const [state, setState] = useState(EMPTY_STATE); + const [retrySequence, setRetrySequence] = useState(0); + const generationRef = useRef(0); + + useEffect(() => { + const generation = ++generationRef.current; + setState(EMPTY_STATE); + const normalizedQuery = query.trim(); + if (!enabled || !workspaceId || !normalizedQuery) return; + + const controller = new AbortController(); + const timeout = window.setTimeout(() => { + setState({ groups: [], isSearching: true, error: null }); + searchEntityReferences( + { + workspaceId, + query: normalizedQuery, + limit, + }, + { cache: "no-store", init: { signal: controller.signal } }, + ).then( + (response) => { + if (generationRef.current !== generation || controller.signal.aborted) return; + setState({ groups: response.groups, isSearching: false, error: null }); + }, + () => { + if (generationRef.current !== generation || controller.signal.aborted) return; + setState({ groups: [], isSearching: false, error: RETRYABLE_SEARCH_ERROR }); + }, + ); + }, SEARCH_DEBOUNCE_MS); + + return () => { + generationRef.current += 1; + window.clearTimeout(timeout); + controller.abort(); + }; + }, [enabled, limit, query, retrySequence, sessionId, workspaceId]); + + const retry = useCallback(() => setRetrySequence((sequence) => sequence + 1), []); + return { ...state, retry }; +} diff --git a/apps/web/hooks/use-message-handler.test.ts b/apps/web/hooks/use-message-handler.test.ts index 2e6f892363..07d9e34274 100644 --- a/apps/web/hooks/use-message-handler.test.ts +++ b/apps/web/hooks/use-message-handler.test.ts @@ -6,6 +6,7 @@ import { } from "./use-message-handler"; import type { AppState } from "@/lib/state/store"; import type { TaskMentionData } from "./use-inline-mention"; +import type { EntityReference } from "@/lib/types/entity-reference"; const getWebSocketClientMock = vi.hoisted(() => vi.fn()); @@ -213,4 +214,41 @@ describe("sendMessageRequest", () => { message: "Connection unavailable. Reconnect and try again.", }); }); + + it("sends structured references under the backend entity_references field", async () => { + const request = vi.fn().mockResolvedValue(undefined); + getWebSocketClientMock.mockReturnValue({ request }); + const reference: EntityReference = { + version: 1, + ref: "mention:v1:github:issue:acme%2Frepo:42", + provider: "github", + kind: "issue", + id: "42", + key: "acme/repo#42", + title: "Fix composer references", + url: "https://github.com/acme/repo/issues/42", + scope: "acme/repo", + }; + const payload = { + taskId: "task-1", + resolvedSessionId: "session-1", + finalMessage: "reference", + modelToSend: undefined, + planMode: false, + entityReferences: [reference], + } as Parameters[0] & { entityReferences: EntityReference[] }; + + await sendMessageRequest(payload); + + expect(request).toHaveBeenCalledWith( + "message.add", + { + task_id: "task-1", + session_id: "session-1", + content: "reference", + entity_references: [reference], + }, + 10000, + ); + }); }); diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts index 3dd94c89ee..090008f655 100644 --- a/apps/web/hooks/use-message-handler.ts +++ b/apps/web/hooks/use-message-handler.ts @@ -3,7 +3,10 @@ import { getWebSocketClient } from "@/lib/ws/connection"; import { MessageSendError } from "@/lib/chat/message-send-error"; import { useAppStoreApi } from "@/components/state-provider"; import { useQueue } from "./domains/session/use-queue"; -import type { MessageAttachment } from "@/components/task/chat/chat-input-container"; +import type { + ChatSubmitPayload, + MessageAttachment, +} from "@/components/task/chat/chat-input-container"; import type { ActiveDocument } from "@/lib/state/slices/ui/types"; import type { PlanComment } from "@/lib/state/slices/comments"; import { toBlockquote } from "@/lib/state/slices/comments/format"; @@ -11,6 +14,7 @@ import type { ContextFile } from "@/lib/state/context-files-store"; import type { CustomPrompt, Message } from "@/lib/types/http"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; import type { AppState } from "@/lib/state/store"; +import type { EntityReference } from "@/lib/types/entity-reference"; import { collectPromptReferenceExpansions, formatPromptReferenceExpansions, @@ -154,6 +158,7 @@ type SendMessagePayload = { hasReviewComments?: boolean; attachments?: MessageAttachment[]; contextFilesMeta?: Array<{ path: string; name: string }>; + entityReferences?: EntityReference[]; }; export async function sendMessageRequest( @@ -176,6 +181,7 @@ export async function sendMessageRequest( hasReviewComments, attachments, contextFilesMeta, + entityReferences, } = payload; const hasAttachments = attachments && attachments.length > 0; @@ -190,6 +196,7 @@ export async function sendMessageRequest( ...(hasReviewComments && { has_review_comments: true }), ...(hasAttachments && { attachments }), ...(contextFilesMeta && { context_files: contextFilesMeta }), + ...(entityReferences && { entity_references: entityReferences }), }, hasAttachments ? 30000 : 10000, ); @@ -227,13 +234,7 @@ export function useMessageHandler({ ); const handleSendMessage = useCallback( - async ( - message: string, - attachments?: MessageAttachment[], - hasReviewComments?: boolean, - inlineMentions?: ContextFile[], - inlineTaskMentions?: TaskMentionData[], - ) => { + async (payload: ChatSubmitPayload) => { if (!taskId || !resolvedSessionId) { const error = new MessageSendError( "no-active-session", @@ -244,9 +245,9 @@ export function useMessageHandler({ } const { finalMessage, allContextFiles } = buildFinalMessage( - message, - inlineMentions, - inlineTaskMentions, + payload.message, + payload.inlineMentions, + payload.inlineTaskMentions, ); const modelToSend = activeModel && activeModel !== sessionModel ? activeModel : undefined; const realFiles = allContextFiles.filter( @@ -256,14 +257,21 @@ export function useMessageHandler({ realFiles.length > 0 ? realFiles.map((f) => ({ path: f.path, name: f.name })) : undefined; if (isAgentBusy) { - const queueAttachments = attachments?.map((att) => ({ + const queueAttachments = payload.attachments?.map((att) => ({ type: att.type, data: att.data, mime_type: att.mime_type, name: att.name, delivery_mode: att.delivery_mode, })); - await queue(taskId, finalMessage, modelToSend, planModeEnabled, queueAttachments); + await queue({ + taskId, + content: finalMessage, + model: modelToSend, + planMode: planModeEnabled, + attachments: queueAttachments, + entityReferences: payload.entityReferences, + }); return; } @@ -276,9 +284,10 @@ export function useMessageHandler({ finalMessage, modelToSend, planMode: planModeEnabled, - hasReviewComments, - attachments, + hasReviewComments: !!payload.reviewComments?.length, + attachments: payload.attachments, contextFilesMeta, + entityReferences: payload.entityReferences, }); if (created && created.id && created.session_id) { storeApi.getState().addMessage(created); diff --git a/apps/web/lib/api/domains/mentions-api.test.ts b/apps/web/lib/api/domains/mentions-api.test.ts new file mode 100644 index 0000000000..dbdbb61c9e --- /dev/null +++ b/apps/web/lib/api/domains/mentions-api.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/config", () => ({ + getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), +})); + +import { searchEntityReferences } from "./mentions-api"; + +type FetchInput = Parameters[0]; +type FetchInit = Parameters[1]; +const fetchSpy = vi.fn<(...args: [FetchInput, FetchInit?]) => Promise>(); + +beforeEach(() => { + fetchSpy.mockReset(); + vi.stubGlobal("fetch", fetchSpy); +}); + +afterEach(() => vi.unstubAllGlobals()); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("mentions API", () => { + it("encodes scoped search parameters and preserves provider-neutral groups", async () => { + const response = { + query: "auth & oauth", + groups: [ + { + source: "plugin.acme_incidents", + provider: "plugin.acme", + kind: "incident", + display_name: "Acme incidents", + kind_label: "Incident", + status: "ok", + results: [ + { + version: 1, + ref: "mention:v1:plugin.acme:incident:incident-7", + provider: "plugin.acme", + kind: "incident", + id: "incident-7", + key: "INC-7", + title: "Repair auth & oauth", + url: "https://incidents.example.test/7", + scope: "workspace / one", + }, + ], + }, + { + source: "plugin.acme_changes", + provider: "plugin.acme", + kind: "change", + display_name: "Acme changes", + kind_label: "Change", + status: "rate_limited", + results: [], + }, + ], + }; + const controller = new AbortController(); + fetchSpy.mockResolvedValueOnce(jsonResponse(response)); + + const result = await searchEntityReferences( + { + workspaceId: "workspace / one", + query: "auth & oauth", + limit: 7, + }, + { cache: "no-store", init: { signal: controller.signal } }, + ); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://api.test/api/v1/workspaces/workspace%20%2F%20one/mentions/search?q=auth+%26+oauth&limit=7", + expect.objectContaining({ cache: "no-store", signal: controller.signal }), + ); + expect(result).toEqual(response); + }); +}); diff --git a/apps/web/lib/api/domains/mentions-api.ts b/apps/web/lib/api/domains/mentions-api.ts new file mode 100644 index 0000000000..a2a80bb887 --- /dev/null +++ b/apps/web/lib/api/domains/mentions-api.ts @@ -0,0 +1,21 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { EntityReferenceSearchResponse } from "@/lib/types/entity-reference"; + +export type SearchEntityReferencesRequest = { + workspaceId: string; + query: string; + limit?: number; +}; + +export async function searchEntityReferences( + request: SearchEntityReferencesRequest, + options?: ApiRequestOptions, +): Promise { + const query = new URLSearchParams({ q: request.query }); + if (request.limit !== undefined) query.set("limit", String(request.limit)); + const workspaceId = encodeURIComponent(request.workspaceId); + return fetchJson( + `/api/v1/workspaces/${workspaceId}/mentions/search?${query.toString()}`, + options, + ); +} diff --git a/apps/web/lib/api/domains/queue-api.test.ts b/apps/web/lib/api/domains/queue-api.test.ts index aa4ec28b7a..f502b36d7c 100644 --- a/apps/web/lib/api/domains/queue-api.test.ts +++ b/apps/web/lib/api/domains/queue-api.test.ts @@ -1,5 +1,35 @@ -import { describe, it, expect } from "vitest"; -import { QueueFullError, QueueEntryNotFoundError, rethrowQueueError } from "./queue-api"; +import { beforeEach, describe, it, expect, vi } from "vitest"; +import type { EntityReference } from "@/lib/types/entity-reference"; + +const getWebSocketClientMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: getWebSocketClientMock, +})); + +import { + QueueFullError, + QueueEntryNotFoundError, + queueMessage, + rethrowQueueError, + updateQueuedMessage, +} from "./queue-api"; + +const reference: EntityReference = { + version: 1, + ref: "mention:v1:github:issue:acme%2Frepo:42", + provider: "github", + kind: "issue", + id: "42", + key: "acme/repo#42", + title: "Fix composer references", + url: "https://github.com/acme/repo/issues/42", + scope: "acme/repo", +}; + +beforeEach(() => { + getWebSocketClientMock.mockReset(); +}); describe("rethrowQueueError", () => { it("maps queue_full errors to QueueFullError carrying the cap metadata", () => { @@ -80,3 +110,61 @@ describe("rethrowQueueError", () => { expect((caught as Error).message).toBe("just a string"); }); }); + +describe("queue reference payloads", () => { + it("forwards entity references through message.queue.add", async () => { + const request = vi.fn().mockResolvedValue({ id: "q-1" }); + getWebSocketClientMock.mockReturnValue({ request }); + + await queueMessage({ + session_id: "session-1", + task_id: "task-1", + content: "queued reference", + entity_references: [reference], + }); + + expect(request).toHaveBeenCalledWith("message.queue.add", { + session_id: "session-1", + task_id: "task-1", + content: "queued reference", + entity_references: [reference], + }); + }); + + it("sends an explicit empty reference array when replacing a queued message", async () => { + const request = vi.fn().mockResolvedValue({ entry_id: "q-1" }); + getWebSocketClientMock.mockReturnValue({ request }); + + await updateQueuedMessage({ + session_id: "session-1", + entry_id: "q-1", + content: "reference removed", + } as never); + + expect(request).toHaveBeenCalledWith("message.queue.update", { + session_id: "session-1", + entry_id: "q-1", + content: "reference removed", + entity_references: [], + }); + }); + + it("forwards surviving references through message.queue.update", async () => { + const request = vi.fn().mockResolvedValue({ entry_id: "q-1" }); + getWebSocketClientMock.mockReturnValue({ request }); + + await updateQueuedMessage({ + session_id: "session-1", + entry_id: "q-1", + content: "reference kept", + entity_references: [reference], + }); + + expect(request).toHaveBeenCalledWith("message.queue.update", { + session_id: "session-1", + entry_id: "q-1", + content: "reference kept", + entity_references: [reference], + }); + }); +}); diff --git a/apps/web/lib/api/domains/queue-api.ts b/apps/web/lib/api/domains/queue-api.ts index a3ace66edb..f2878cb122 100644 --- a/apps/web/lib/api/domains/queue-api.ts +++ b/apps/web/lib/api/domains/queue-api.ts @@ -1,4 +1,5 @@ import type { QueueStatus, QueuedMessage } from "@/lib/state/slices/session/types"; +import type { EntityReference } from "@/lib/types/entity-reference"; import { getWebSocketClient } from "@/lib/ws/connection"; const WS_CLIENT_UNAVAILABLE = "WebSocket client not available"; @@ -75,6 +76,7 @@ export type QueueMessageParams = { name?: string; delivery_mode?: "prompt" | "path"; }>; + entity_references?: EntityReference[]; user_id?: string; }; @@ -153,6 +155,7 @@ export async function updateQueuedMessage(params: { name?: string; delivery_mode?: "prompt" | "path"; }>; + entity_references: EntityReference[]; user_id?: string; }): Promise<{ entry_id: string }> { const client = getWebSocketClient(); @@ -160,7 +163,10 @@ export async function updateQueuedMessage(params: { throw new Error(WS_CLIENT_UNAVAILABLE); } try { - return await client.request<{ entry_id: string }>("message.queue.update", params); + return await client.request<{ entry_id: string }>("message.queue.update", { + ...params, + entity_references: params.entity_references ?? [], + }); } catch (err) { rethrowQueueError(err); } diff --git a/apps/web/lib/entity-references/message-references.test.ts b/apps/web/lib/entity-references/message-references.test.ts new file mode 100644 index 0000000000..f070dedac6 --- /dev/null +++ b/apps/web/lib/entity-references/message-references.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import type { EntityReference } from "@/lib/types/entity-reference"; +import { + entityReferenceMarkdown, + entityReferencesFromMetadata, + matchEntityReferenceLink, + survivingEntityReferences, +} from "./message-references"; + +function taskReference(overrides: Partial = {}): EntityReference { + return { + version: 1, + ref: "mention:v1:kandev:task:workspace-1:task-1", + provider: "kandev", + kind: "task", + id: "task-1", + key: "KAN-1", + title: "Fix authentication", + url: "/t/task-1", + scope: "workspace-1", + ...overrides, + }; +} + +function issueReference(overrides: Partial = {}): EntityReference { + return { + version: 1, + ref: "mention:v1:jira:issue:https%3A%2F%2Fjira.example:10001", + provider: "jira", + kind: "issue", + id: "10001", + key: "ENG[1]", + title: "Fix the login flow", + url: "https://jira.example/browse/ENG-1?q=hello world(test)", + scope: "https://jira.example", + ...overrides, + }; +} + +describe("entityReferencesFromMetadata", () => { + it("runtime-validates v1 references and deduplicates ref in first-valid order", () => { + const first = taskReference(); + const second = issueReference(); + const metadata = { + entity_references: [ + { ...first, version: 2 }, + { ...first, url: "javascript:alert(1)" }, + first, + { ...first, title: "Conflicting duplicate" }, + second, + ], + }; + + expect(entityReferencesFromMetadata(metadata)).toEqual([first, second]); + }); + + it("rejects malformed metadata containers and noncanonical identities", () => { + expect(entityReferencesFromMetadata({ entity_references: "not-an-array" })).toEqual([]); + expect( + entityReferencesFromMetadata({ + entity_references: [taskReference({ ref: "made-up", title: "" })], + }), + ).toEqual([]); + }); +}); + +describe("entity reference Markdown matching", () => { + it("matches only the exact generated label and encoded URL", () => { + const reference = issueReference(); + + expect(entityReferenceMarkdown(reference)).toBe( + "[#ENG\\[1\\]](https://jira.example/browse/ENG-1?q=hello%20world%28test%29)", + ); + expect( + matchEntityReferenceLink([reference], "#ENG[1]", reference.url.replaceAll(" ", "%20")), + ).toBeNull(); + expect( + matchEntityReferenceLink( + [reference], + "#ENG[1]", + "https://jira.example/browse/ENG-1?q=hello%20world%28test%29", + ), + ).toEqual(reference); + expect( + matchEntityReferenceLink( + [reference], + "#Wrong", + "https://jira.example/browse/ENG-1?q=hello%20world%28test%29", + ), + ).toBeNull(); + expect( + matchEntityReferenceLink([reference], "#ENG[1]", "https://evil.example/ENG-1"), + ).toBeNull(); + }); +}); + +describe("survivingEntityReferences", () => { + it("returns exact generated links in document order and deduplicates repeats", () => { + const task = taskReference(); + const issue = issueReference(); + const taskMarkdown = entityReferenceMarkdown(task); + const issueMarkdown = entityReferenceMarkdown(issue); + const content = [ + issueMarkdown, + "[wrong label](/t/task-1)", + taskMarkdown, + issueMarkdown, + "[#KAN-1](https://evil.example/task-1)", + ].join(" then "); + + expect(survivingEntityReferences(content, [task, issue])).toEqual([issue, task]); + }); + + it("returns an empty replacement when no generated links survive", () => { + expect(survivingEntityReferences("plain edited text", [taskReference()])).toEqual([]); + }); + + it("ignores generated-link text that Markdown treats as literal code or escaped text", () => { + const reference = taskReference(); + const markdown = entityReferenceMarkdown(reference); + const content = [ + `\\${markdown}`, + `\`${markdown}\``, + `\`\`\`md\n${markdown}\n\`\`\``, + `!${markdown}`, + ].join("\n"); + + expect(survivingEntityReferences(content, [reference])).toEqual([]); + }); +}); diff --git a/apps/web/lib/entity-references/message-references.ts b/apps/web/lib/entity-references/message-references.ts new file mode 100644 index 0000000000..120cb61927 --- /dev/null +++ b/apps/web/lib/entity-references/message-references.ts @@ -0,0 +1,289 @@ +import type { EntityReference } from "@/lib/types/entity-reference"; + +const IDENTITY_NAME = /^[a-z0-9][a-z0-9._:-]{0,127}$/; +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/u; +const UNPAIRED_SURROGATE = /[\uD800-\uDFFF]/u; +const MAX_IDENTITY_LENGTH = 512; +const MAX_TITLE_LENGTH = 500; +const MAX_KEY_LENGTH = 200; +const MAX_URL_LENGTH = 2048; +const REFERENCE_STRING_FIELDS = ["ref", "provider", "kind", "id", "title", "url", "scope"] as const; + +function codePointLength(value: string): number { + return Array.from(value).length; +} + +function isBoundedIdentity(value: string): boolean { + const length = codePointLength(value); + return ( + length > 0 && + length <= MAX_IDENTITY_LENGTH && + !CONTROL_CHARACTER.test(value) && + !UNPAIRED_SURROGATE.test(value) + ); +} + +function isNormalizedDisplayText(value: string, maxLength: number): boolean { + return ( + value === value.trim().replace(/\s+/gu, " ") && + codePointLength(value) <= maxLength && + !UNPAIRED_SURROGATE.test(value) + ); +} + +function queryEscape(value: string): string | null { + try { + return encodeURIComponent(value) + .replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`) + .replace(/%20/g, "+"); + } catch { + return null; + } +} + +function canonicalRef(reference: EntityReference): string | null { + const fields = [reference.provider, reference.kind, reference.scope, reference.id]; + const encoded = fields.map(queryEscape); + if (encoded.some((field) => field === null)) return null; + return `mention:v1:${encoded.join(":")}`; +} + +function hasSafeURL(reference: EntityReference): boolean { + if ( + reference.url.length === 0 || + codePointLength(reference.url) > MAX_URL_LENGTH || + UNPAIRED_SURROGATE.test(reference.url) + ) { + return false; + } + if (reference.provider === "kandev" && reference.kind === "task") { + return reference.url === `/t/${encodeURIComponent(reference.id)}`; + } + try { + const parsed = new URL(reference.url); + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + parsed.hostname.length > 0 && + parsed.username === "" && + parsed.password === "" + ); + } catch { + return false; + } +} + +function hasValidIdentityFields(reference: EntityReference): boolean { + return ( + IDENTITY_NAME.test(reference.provider) && + IDENTITY_NAME.test(reference.kind) && + isBoundedIdentity(reference.id) && + isBoundedIdentity(reference.scope) && + reference.ref === canonicalRef(reference) + ); +} + +function hasValidDisplayFields(reference: EntityReference): boolean { + return ( + reference.title.length > 0 && + isNormalizedDisplayText(reference.title, MAX_TITLE_LENGTH) && + isNormalizedDisplayText(reference.key ?? "", MAX_KEY_LENGTH) + ); +} + +export function isEntityReference(value: unknown): value is EntityReference { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const reference = value as Record; + if (reference.version !== 1) return false; + if (!REFERENCE_STRING_FIELDS.every((field) => typeof reference[field] === "string")) return false; + if (reference.key !== undefined && typeof reference.key !== "string") return false; + const typed = reference as EntityReference; + return hasValidIdentityFields(typed) && hasValidDisplayFields(typed) && hasSafeURL(typed); +} + +export function normalizeEntityReferences(value: unknown): EntityReference[] { + if (!Array.isArray(value)) return []; + const result: EntityReference[] = []; + const seen = new Set(); + for (const candidate of value) { + if (!isEntityReference(candidate) || seen.has(candidate.ref)) continue; + seen.add(candidate.ref); + result.push(candidate); + } + return result; +} + +export function entityReferencesFromMetadata(metadata: unknown): EntityReference[] { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return []; + return normalizeEntityReferences((metadata as Record).entity_references); +} + +function escapeMarkdownLabel(value: string): string { + return value.replace(/([\\[\]])/g, "\\$1"); +} + +export function entityReferenceHref(reference: EntityReference): string { + const value = reference.url; + return encodeURI(value).replace(/\(/g, "%28").replace(/\)/g, "%29"); +} + +export function entityReferenceLabel(reference: EntityReference): string { + return `#${reference.key || reference.title}`; +} + +export function entityReferenceMarkdown(reference: EntityReference): string { + return `[${escapeMarkdownLabel(entityReferenceLabel(reference))}](${entityReferenceHref(reference)})`; +} + +export function matchEntityReferenceLink( + references: readonly EntityReference[], + label: string, + href: string | undefined, +): EntityReference | null { + if (!href) return null; + let match: EntityReference | null = null; + for (const reference of normalizeEntityReferences(references)) { + if (entityReferenceLabel(reference) !== label || entityReferenceHref(reference) !== href) { + continue; + } + if (match) return null; + match = reference; + } + return match; +} + +type Fence = { marker: "`" | "~"; length: number }; + +function fenceAtLineStart(line: string): Fence | null { + const match = /^ {0,3}(`{3,}|~{3,})/u.exec(line); + if (!match) return null; + const run = match[1]; + return { marker: run[0] as Fence["marker"], length: run.length }; +} + +function closesFence(line: string, fence: Fence): boolean { + const trimmed = line.replace(/^ {0,3}/u, "").trimEnd(); + if (!trimmed.startsWith(fence.marker)) return false; + let runLength = 0; + while (trimmed[runLength] === fence.marker) runLength++; + return runLength >= fence.length && trimmed.length === runLength; +} + +function markRange(mask: Uint8Array, start: number, end: number): void { + mask.fill(1, start, end); +} + +function backtickRunLength(line: string, start: number): number { + let end = start; + while (line[end] === "`") end++; + return end - start; +} + +function findClosingBackticks(line: string, start: number, runLength: number): number { + let cursor = start; + while (cursor < line.length) { + const next = line.indexOf("`", cursor); + if (next === -1) return -1; + const candidateLength = backtickRunLength(line, next); + if (candidateLength === runLength) return next; + cursor = next + candidateLength; + } + return -1; +} + +function markInlineCode(line: string, lineStart: number, mask: Uint8Array): void { + let cursor = 0; + while (cursor < line.length) { + const opener = line.indexOf("`", cursor); + if (opener === -1) return; + const runLength = backtickRunLength(line, opener); + const closer = findClosingBackticks(line, opener + runLength, runLength); + if (closer === -1) { + cursor = opener + runLength; + continue; + } + markRange(mask, lineStart + opener, lineStart + closer + runLength); + cursor = closer + runLength; + } +} + +function markdownCodeMask(content: string): Uint8Array { + const mask = new Uint8Array(content.length); + let fence: Fence | null = null; + let lineStart = 0; + while (lineStart < content.length) { + const newline = content.indexOf("\n", lineStart); + const lineEnd = newline === -1 ? content.length : newline; + const line = content.slice(lineStart, lineEnd); + if (fence) { + markRange(mask, lineStart, lineEnd); + if (closesFence(line, fence)) fence = null; + } else { + const opener = fenceAtLineStart(line); + if (opener) { + markRange(mask, lineStart, lineEnd); + fence = opener; + } else if (/^(?: {4}|\t)/u.test(line)) { + markRange(mask, lineStart, lineEnd); + } else { + markInlineCode(line, lineStart, mask); + } + } + lineStart = lineEnd + 1; + } + return mask; +} + +function isEscaped(content: string, index: number): boolean { + let backslashes = 0; + for (let cursor = index - 1; cursor >= 0 && content[cursor] === "\\"; cursor--) backslashes++; + return backslashes % 2 === 1; +} + +function findUsableOccurrence( + content: string, + markdown: string, + cursor: number, + codeMask: Uint8Array, +): number { + let index = content.indexOf(markdown, cursor); + while (index !== -1) { + const isImage = index > 0 && content[index - 1] === "!" && !isEscaped(content, index - 1); + if (!codeMask[index] && !isEscaped(content, index) && !isImage) return index; + index = content.indexOf(markdown, index + markdown.length); + } + return -1; +} + +export function survivingEntityReferences( + content: string, + references: readonly EntityReference[], +): EntityReference[] { + const pending = normalizeEntityReferences(references).map((reference) => ({ + reference, + markdown: entityReferenceMarkdown(reference), + })); + const result: EntityReference[] = []; + const codeMask = markdownCodeMask(content); + let cursor = 0; + while (pending.length > 0) { + let nextIndex = -1; + let pendingIndex = -1; + for (let index = 0; index < pending.length; index++) { + const candidateIndex = findUsableOccurrence( + content, + pending[index].markdown, + cursor, + codeMask, + ); + if (candidateIndex !== -1 && (nextIndex === -1 || candidateIndex < nextIndex)) { + nextIndex = candidateIndex; + pendingIndex = index; + } + } + if (pendingIndex === -1) break; + const [match] = pending.splice(pendingIndex, 1); + result.push(match.reference); + cursor = nextIndex + match.markdown.length; + } + return result; +} diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts index f653f85b4c..22d261445b 100644 --- a/apps/web/lib/state/slices/session/types.ts +++ b/apps/web/lib/state/slices/session/types.ts @@ -6,6 +6,7 @@ import type { TaskPlanRevision, TaskWalkthrough, } from "@/lib/types/http"; +import type { EntityReference } from "@/lib/types/entity-reference"; export type MessagesState = { bySession: Record; @@ -100,6 +101,7 @@ export type WalkthroughsState = { }; export type QueuedMessageMetadata = Record & { + entity_references?: EntityReference[]; workflow_message?: boolean; workflow_auto_start?: boolean; workflow_step_id?: string; diff --git a/apps/web/lib/types/entity-reference.ts b/apps/web/lib/types/entity-reference.ts new file mode 100644 index 0000000000..1f81e59122 --- /dev/null +++ b/apps/web/lib/types/entity-reference.ts @@ -0,0 +1,35 @@ +export type EntityReferenceGroupStatus = + | "ok" + | "not_configured" + | "unauthorized" + | "rate_limited" + | "timeout" + | "upstream_error" + | "unsupported_scope"; + +export type EntityReference = { + version: number; + ref: string; + provider: string; + kind: string; + id: string; + key?: string; + title: string; + url: string; + scope: string; +}; + +export type EntityReferenceSearchGroup = { + source: string; + provider: string; + kind: string; + display_name: string; + kind_label: string; + status: EntityReferenceGroupStatus; + results: EntityReference[]; +}; + +export type EntityReferenceSearchResponse = { + query: string; + groups: EntityReferenceSearchGroup[]; +}; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index ed413f9657..b7fa09390f 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -9,6 +9,7 @@ import type { WorkspaceId, } from "./ids"; import type { OnEnterActionType, StepEvents } from "./workflow-actions"; +import type { EntityReference } from "./entity-reference"; export type { ExecutorType } from "./executor"; export type { @@ -655,6 +656,10 @@ export type MessageType = | "script_execution" | "agent_plan"; +export type MessageMetadata = Record & { + entity_references?: EntityReference[]; +}; + export type Message = { id: string; session_id: SessionId; @@ -665,7 +670,7 @@ export type Message = { content: string; raw_content?: string; type: MessageType; - metadata?: Record; + metadata?: MessageMetadata; requests_input?: boolean; created_at: string; /** Authoritative per-message change signal; advances on every content/metadata update. */ diff --git a/docs/decisions/2026-07-21-work-item-reference-search.md b/docs/decisions/2026-07-21-work-item-reference-search.md new file mode 100644 index 0000000000..e757a803c7 --- /dev/null +++ b/docs/decisions/2026-07-21-work-item-reference-search.md @@ -0,0 +1,50 @@ +# ADR-2026-07-21-work-item-reference-search: Backend-Normalized Work-Item References + +**Status:** accepted +**Date:** 2026-07-21 +**Area:** backend, frontend, protocol + +## Context + +Kandev's chat composer currently builds `@` suggestions in the browser from unrelated local sources. Work-item search spans seven provider families with different credentials, workspace scopes, query languages, pagination, identities, and failure modes. A selected item must also remain unambiguous after its title or repository changes and while a message waits in the durable queue. + +## Decision + +Kandev owns work-item typeahead behind a backend provider registry in `internal/mentions`. Every provider implements a plain-text, workspace-scoped search boundary and translates that query into its private API dialect. Registry and aggregate service depend only on hand-defined, versioned normalized DTOs plus a provider descriptor; they do not import native Jira/GitHub/etc. models, plugin wire models, `structpb`, or switch exhaustively on native provider IDs. The aggregate endpoint runs providers concurrently with bounded work and timeouts, returns deterministic provider/kind groups, and treats provider failures as partial results with safe status codes. + +Providers return candidates with provider-local immutable identity plus non-secret connection scope. The registry injects its registered provider identity, validates candidate fields, and constructs the canonical versioned `ref`; an adapter cannot spoof another provider. Each `(provider, kind)` registration also owns a `ReferenceAuthorizer`, used both to filter search destinations and to authorize message submission against the trusted conversation workspace. This keeps configured-origin and scope checks provider-owned without a central provider switch. Jira and GitHub adapters retain upstream immutable IDs that their current projections drop; providers without a single global ID use a documented provider-native composite. URLs and titles are presentation snapshots, not identity. + +The frontend uses a separate `#` `entityReference` atom. `@` remains the context-attachment channel for files, prompts, and plans. New task discovery moves to `#`, while legacy `@task` nodes remain compatible. + +Selected references serialize into both portable Markdown links in visible message content and typed `entity_references` in existing message/queue metadata JSON. A neutral `internal/entityrefs` leaf owns structural normalization, canonical identity, and deduplication; provider authorization remains in `internal/mentions`. Metadata drives chip rendering and sanitized agent context; Markdown is the durable fallback. No new reference table or synchronized cross-provider index is introduced. + +Built-in integrations register adapters through this host seam. `provider` and `kind` are validated, additive strings with generic presentation fallbacks, not closed enums. A future `PluginProviderBridge` can therefore enumerate active plugins with an explicit mention-provider contribution and adapt a typed Kandev-to-plugin `Plugin.SearchMentions` RPC to the same `MentionProvider` interface. This is not part of the plugin-to-Kandev `Host` data API and does not reuse `api_read`. Defining the manifest contribution, permission/grant, additive RPC, lifecycle/error mapping, and workspace authorization is a separate public-contract decision following [ADR 0043](0043-plugin-host-data-api.md); this feature does not add them. Older plugins that return gRPC `Unimplemented` will map to provider-unavailable without failing other sources. + +## Consequences + +- Workspace isolation, provider query escaping, timeouts, error classification, and identity normalization live in one backend boundary instead of being duplicated in React. +- Adding a built-in provider requires an adapter and conformance tests but does not change the composer contract. +- Migrating a built-in integration to a plugin replaces its adapter registration with a plugin bridge while preserving registry-issued provider/source IDs (or explicit aliases), normalized reference identity, message metadata, and frontend behavior. New plugin-only providers use reserved namespaced IDs. +- Jira and GitHub search models must preserve upstream immutable IDs. Azure DevOps and Sentry adapters need bounded project/repository or instance/organization discovery because their current browse APIs require extra scope. +- Typeahead consumes external API quota. Client debounce/cancellation, server caps, and provider timeouts are correctness requirements. Any provider/client cache must be short-lived and keyed by workspace plus provider connection scope so it cannot cross authorization boundaries. +- Partial results are expected. Cross-provider scores are not compared; results stay grouped and deterministic. +- Message and queue metadata already provide restart durability, avoiding a migration and reference lifecycle table. Stale targets keep their display snapshot but are not guaranteed to open successfully. +- Plugins cannot contribute `#` results until a future contribution, permission, and Kandev-to-plugin RPC contract is designed, but current core code must not add coupling that would require a second composer/search contract. + +## Alternatives Considered + +### Fan out from the frontend + +Rejected. It would expose provider query dialects and failure semantics to React, duplicate workspace/config checks, complicate cancellation, and make every new provider a composer change. + +### Reuse the existing `@` mention pipeline + +Rejected. `@` items attach local context and have different serialization/selection semantics. Mixing external durable entities into that union would preserve the current overloaded menu and make migration harder. + +### Store only plain text or Markdown links + +Rejected. Mutable labels and URLs cannot reliably identify a task or work item for agent tools, queue edits, history recall, or sent-message chip rendering. + +### Create a normalized reference table or local search index + +Rejected for this release. References do not own target lifecycle, and synchronizing every provider would add migrations, webhook/polling consistency, retention, and stale-index behavior before typeahead needs them. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 304fb6da09..425f721987 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -62,3 +62,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-20-explicit-local-repository-trust | [Explicit Local Repository Trust](2026-07-20-explicit-local-repository-trust.md) | accepted | backend, frontend | 2026-07-20 | | 2026-07-20-provider-neutral-remote-repositories | [Provider-neutral remote repositories](2026-07-20-provider-neutral-remote-repositories.md) | accepted | backend, frontend, protocol | 2026-07-20 | | 2026-07-20-repository-provider-origin-identity | [Persist Provider Origin In Repository Identity](2026-07-20-repository-provider-origin-identity.md) | accepted | backend, frontend | 2026-07-20 | +| 2026-07-21-work-item-reference-search | [Backend-normalized work-item references](2026-07-21-work-item-reference-search.md) | accepted | backend, frontend, protocol | 2026-07-21 | diff --git a/docs/plans/entity-reference-composer/plan.md b/docs/plans/entity-reference-composer/plan.md new file mode 100644 index 0000000000..b8315d0c79 --- /dev/null +++ b/docs/plans/entity-reference-composer/plan.md @@ -0,0 +1,206 @@ +--- +spec: docs/specs/ui/entity-reference-composer.md +decision: docs/decisions/2026-07-21-work-item-reference-search.md +created: 2026-07-21 +status: implemented +--- + +# Implementation Plan: Entity Reference Composer + +## Overview + +Build a backend-normalized, workspace-scoped provider registry first, then add each external adapter without sharing mutable files. Registry and DTOs stay transport- and provider-neutral so native adapters can later be replaced by a plugin bridge with an explicit contribution and workspace permission. After central wiring and durable message metadata work, add the frontend search/client, `#` atom, composer interaction, sent-message chips, and desktop/mobile E2E. + +## Backend + +### Normalized search contract + +Files: + +- `apps/backend/pkg/api/v1/mentions.go` (new) +- `apps/backend/internal/mentions/types.go` (new) +- `apps/backend/internal/mentions/service.go` (new) +- `apps/backend/internal/mentions/handler.go` (new) +- `apps/backend/internal/mentions/service_test.go` (new) +- `apps/backend/internal/mentions/handler_test.go` (new) + +Define `MentionProvider.Descriptor()` plus `MentionProvider.Search(ctx, SearchRequest)`, normalized groups/results/statuses, validation, deterministic group order, bounded concurrent all-settled execution, per-provider timeouts, safe errors, and the HTTP handler. Core registry code accepts arbitrary validated source/provider/kind IDs and uses descriptor labels/generic fallbacks; it contains no native-provider type switch. The registry owns provider identity and canonical `ref` construction, while adapters return bounded untrusted candidates with provider-local identity. Each unique `(provider, kind)` may also register a `ReferenceAuthorizer`; search filtering and later submission validation dispatch through that same provider-owned seam. Do not register Kandev tasks in this search contract: existing Kandev task discovery remains owned by `@`, while `#` is external-integration search only. + +### External provider adapters + +Each adapter owns plain-query translation, workspace/config checks, stable identity mapping, exact destination/scope authorization for search and submission, safe failure classification, and focused tests in separate files: + +- Jira: preserve immutable issue ID and generate escaped text/key JQL in `internal/jira/service_mentions.go`. +- Linear: use the existing structured `SearchFilter.Query` path. +- GitHub: preserve REST/node IDs and generate title-only issue/PR queries through workspace-scoped search. +- GitLab: include host + immutable object ID and add a workspace-scoped safe search wrapper. +- Azure DevOps: generate escaped WIQL for work items and add project-level active-PR listing/title filtering without repository-by-repository browser fan-out. +- Sentry: search every configured workspace instance through bounded organization discovery, include instance ID in scope, and escape free text. + +Provider adapters live in `apps/backend/internal/mentions/provider_.go`; provider-owned safe methods live beside their existing service/client code. No adapter registers routes or edits central backend composition. + +### Message and queue reference metadata + +Files: + +- `apps/backend/internal/orchestrator/message_meta.go` +- `apps/backend/internal/task/handlers/message_handlers.go` +- `apps/backend/internal/task/handlers/message_handlers_test.go` +- `apps/backend/internal/orchestrator/handlers/queue_handlers.go` +- `apps/backend/internal/orchestrator/handlers/queue_handlers_test.go` +- `apps/backend/internal/orchestrator/messagequeue/service.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go` +- `apps/backend/internal/task/models/message_shell_output.go` +- `apps/backend/internal/task/models/message_shell_output_test.go` + +Extract structural normalization/canonicalization into a neutral `internal/entityrefs` leaf, then validate, deduplicate, and persist `entity_references` for direct messages and queue add/update/drain. A conversation resolver derives trusted workspace from the persisted session/task; provider authorization dispatches through the registry and fails closed for unknown providers. Queue update replaces metadata instead of retaining deleted links. Project typed metadata to clients and build one sanitized agent-facing reference system block. Keep legacy `@task` context behavior intact. + +### Composition and route wiring + +Files: + +- `apps/backend/internal/backendapp/types.go` +- `apps/backend/internal/backendapp/services.go` +- `apps/backend/internal/backendapp/helpers.go` +- `apps/backend/internal/backendapp/mentions.go` (new) +- `apps/backend/internal/backendapp/mentions_test.go` (new) + +Construct adapters from available services, register them through stable provider descriptors, validate the workspace before fan-out, and mount `GET /api/v1/workspaces/:workspaceId/mentions/search`. Built-ins have deterministic descriptor order; the registry can also accept future dynamic descriptors without native-provider switches. Nil/unconfigured services must degrade to safe statuses. Add an aggregate integration test covering mixed success, disconnect, timeout, caps, and workspace isolation. + +### Future plugin migration seam + +This release does not change plugin manifests, protobufs, SDKs, or runtime. It does establish constraints for that later work: + +- normalized result/reference DTOs are hand-mapped, versioned, additive, and independent of native structs, plugin wire models, and `structpb`, matching ADR 0043's public DTO rule; +- provider registration is dynamic and descriptor-driven, so a future plugin bridge registers providers without changing aggregation or React code; registry code injects provider identity and constructs canonical refs from validated plugin-local candidates; +- registry-issued source/provider IDs are stable across a native-to-plugin migration (or retain explicit aliases); brand-new plugin providers use reserved namespaced IDs and generic UI fallbacks; +- workspace scope is an explicit provider input, leaving the future bridge one place to enforce plugin workspace grants; +- provider timeout, cancellation, partial failure, URL validation, and result caps apply equally to native and future plugin-backed providers; and +- no task in this plan adds a `mentions.providers` manifest contribution, plugin workspace permission/grant, or `Plugin.SearchMentions` gRPC RPC. Those public wire and permission changes require their own plugin spec/ADR. Search flows Kandev-to-plugin and must not be inferred from plugin categories or reuse the plugin-to-Kandev Host data API/`api_read` capability. + +## Frontend + +### API client and search hook + +Files: + +- `apps/web/lib/types/entity-reference.ts` (new) +- `apps/web/lib/api/domains/mentions-api.ts` (new) +- `apps/web/lib/api/domains/mentions-api.test.ts` (new) +- `apps/web/hooks/use-entity-reference-search.ts` (new) +- `apps/web/hooks/use-entity-reference-search.test.ts` (new) + +Add normalized types/client and a 250 ms debounced, abortable/generation-guarded hook. Search only with an active workspace and non-empty query. Preserve grouped partial results and a safe retryable aggregate error state. + +### Composer and mobile interaction + +Files: + +- `apps/web/components/task/chat/entity-reference-types.ts` (new) +- `apps/web/components/task/chat/tiptap-entity-reference-extension.tsx` (new) +- `apps/web/components/task/chat/tiptap-entity-reference-suggestion.ts` (new) +- `apps/web/components/task/chat/entity-reference-menu.tsx` (new) +- `apps/web/components/task/chat/tiptap-input.tsx` +- `apps/web/components/task/chat/use-tiptap-editor.ts` +- `apps/web/components/task/chat/tiptap-helpers.ts` +- `apps/web/components/task/chat/popup-menu.tsx` +- `apps/web/components/task/chat/chat-input-body.tsx` +- `apps/web/components/task/chat/chat-input-container.tsx` +- `apps/web/components/task/chat/chat-input-area.tsx` +- `apps/web/components/task/passthrough-chat-composer.tsx` + +Add a separate `entityReference` atom/plugin/menu; do not widen `MentionItem` or `ContextMention`. Explicitly enable `#` in task chat and Quick Chat and leave passthrough disabled. Keep Kandev task discovery under `@`, including legacy node parsing, and defensively omit any Kandev-task group returned to the external-only `#` menu. Serialize safe generated Markdown links, preserve draft JSON, carry reference metadata in history/reverse-search entries so only generated links rehydrate, and expose structured references from the editor handle. + +Improve the shared popup's visual-viewport clamping, internal scroll owner, semantic listbox/option roles, pointer outside-dismiss, wrapping/truncation, and `min-h-11` touch rows. + +### Mobile design contract + +- **Desktop outcome / mobile entry:** typing `#` in the existing task/Quick Chat composer opens the same capability at both viewports. +- **Nearest exemplar:** shared `PopupMenu` and mobile slash-command composer supply anchored-above-caret selection; `MobilePickerSheet` is only a fallback if visual-viewport containment cannot preserve typing continuity. +- **Hierarchy / primary action:** provider and kind headings group a one-dimensional result list; selecting one row is the primary action. +- **Presentation:** anchored popover above the composer. This short, frequent typeahead must keep the software keyboard and caret active; a drawer would interrupt the typing loop. +- **Geometry:** visual viewport owns bounds, result body owns the only scroll, safe-area/keyboard clearance is explicit, rows are at least 44 px, and document horizontal overflow stays zero. +- **Shared logic:** query state, grouping, selection, serialization, and validation are shared; only viewport geometry changes. +- **Proof:** `mobile-entity-reference-composer.spec.ts` inserts and sends a reference by touch and asserts containment/no document overflow. + +### Submission, queue, and sent rendering + +Files: + +- `apps/web/components/task/chat/chat-input-container.tsx` +- `apps/web/components/task/chat/use-chat-input-container.ts` +- `apps/web/components/task/chat/use-chat-input-state.ts` +- `apps/web/components/task/chat/chat-input-area.tsx` +- `apps/web/hooks/use-message-handler.ts` +- `apps/web/hooks/domains/session/use-queue.ts` +- `apps/web/lib/api/domains/queue-api.ts` +- `apps/web/lib/types/http.ts` +- `apps/web/lib/state/slices/session/types.ts` +- `apps/web/components/task/chat/messages/entity-reference-chip.tsx` (new) +- `apps/web/components/task/chat/messages/chat-message.tsx` +- `apps/web/components/task/chat/queued-ghost-message.tsx` + +Replace the growing positional composer callback with a named submit payload, then carry `entity_references` through direct send and queue add/update. Render metadata-matched generated links as chips; unknown/missing metadata remains normal Markdown. Queue edits recompute references from surviving generated links. Preserve passthrough and existing context attachments without enabling `#` there. + +## Tests + +- Aggregator unit tests: deterministic groups, validation/caps, timeout/cancellation, partial errors, and sanitized statuses in `internal/mentions/service_test.go`. +- One focused adapter suite per provider for stable identity, workspace scope, escaping, fan-out bounds, cancellation, and error mapping. +- Handler/composition integration tests: HTTP request through real service registry with mixed fake adapters. +- Message/queue tests: direct persistence, queue update replacement, restart round-trip, drain, deduplication, unsafe metadata, metadata projection, and prompt-block sanitization. +- Frontend unit tests: URL construction, debounce/stale suppression, trigger boundaries, keyboard selection, serialization/history rehydration, viewport geometry, named submit payload, queue clearing, sent chip rendering, and legacy `@task` compatibility. + +## E2E Tests + +- `apps/web/e2e/tests/chat/entity-reference-composer.spec.ts`: task chat Arrow/Tab insertion, no auto-send, explicit send/clickable chip, partial provider result, draft restore, Quick Chat parity, and literal passthrough `#`. +- `apps/web/e2e/tests/chat/mobile-entity-reference-composer.spec.ts`: touch selection/send, 44 px row, keyboard/visual-viewport containment, internal scrolling, and no document horizontal overflow. + +## Risks + +- External typeahead can exhaust API quotas; debounce, cancellation, provider timeouts, and fan-out caps are mandatory. Any cache introduced by an adapter/client must include workspace and provider-connection scope in its key. +- GitHub/GitLab configuration history includes install-wide auth. Adapters must enforce workspace operational scope and validate the workspace before using shared credentials. +- Jira, Azure, GitHub/GitLab, and Sentry accept provider query languages today. Only provider-owned safe methods may translate the plain query. +- Azure PR and Sentry searches require extra scope discovery. Provider tests must prove bounded work and cancellation rather than silently omitting connected scopes. +- Hot frontend files already approach lint limits; new state, menus, atoms, and renderers stay extracted. +- Native-only switches or DTOs would make planned integration-to-plugin migration breaking. Conformance tests use an arbitrary fake provider descriptor to prove aggregation and frontend grouping remain generic. + +## Verification + +Targeted commands are recorded in each task file. Final integrated verification runs in this order: + +```bash +make fmt +make typecheck test lint +cd apps/web && pnpm e2e:run tests/chat/entity-reference-composer.spec.ts tests/chat/mobile-entity-reference-composer.spec.ts +``` + +## Implementation Waves + +Wave 1: + +- [x] [task-01-core-and-task-search](task-01-core-and-task-search.md) + +Wave 2 (parallel after Task 01): + +- [x] [task-02-jira-provider](task-02-jira-provider.md) +- [x] [task-03-linear-provider](task-03-linear-provider.md) +- [x] [task-04-github-provider](task-04-github-provider.md) +- [x] [task-05-gitlab-provider](task-05-gitlab-provider.md) +- [x] [task-06-azure-provider](task-06-azure-provider.md) +- [x] [task-07-sentry-provider](task-07-sentry-provider.md) +- [x] [task-08-message-reference-metadata](task-08-message-reference-metadata.md) + +Wave 3: + +- [x] [task-09-backend-composition](task-09-backend-composition.md) + +Wave 4 (sequential): + +- [x] [task-10-frontend-search-client](task-10-frontend-search-client.md) +- [x] [task-11-composer-reference-ui](task-11-composer-reference-ui.md) +- [x] [task-12-message-reference-ui](task-12-message-reference-ui.md) + +Wave 5: + +- [x] [task-13-e2e-and-verification](task-13-e2e-and-verification.md) diff --git a/docs/plans/entity-reference-composer/task-01-core-and-task-search.md b/docs/plans/entity-reference-composer/task-01-core-and-task-search.md new file mode 100644 index 0000000000..3ccb391b98 --- /dev/null +++ b/docs/plans/entity-reference-composer/task-01-core-and-task-search.md @@ -0,0 +1,46 @@ +--- +id: "01-core-and-task-search" +title: "Core external mention search" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 01: Core External Mention Search + +## Acceptance + +- Normalized provider service validates plain queries, runs bounded providers concurrently, and returns deterministic partial groups with safe statuses. +- Registry uses provider descriptors, owns provider identity/canonical ref construction, and accepts an arbitrary namespaced fake provider without native-provider switches, proving a future plugin bridge can use the same seam. +- Registry binds a unique `(provider, kind)` reference authorizer so search destinations and later submissions use the same provider-owned scope checks. +- HTTP handler implements the spec contract without central route registration yet. +- Kandev tasks are not registered in this provider registry because existing task discovery remains owned by `@`. + +## Verification + +```bash +cd apps/backend && go test ./internal/mentions/... +``` + +## Files likely touched + +- `apps/backend/pkg/api/v1/mentions.go` +- `apps/backend/internal/mentions/types.go` +- `apps/backend/internal/mentions/service.go` +- `apps/backend/internal/mentions/handler.go` +- `apps/backend/internal/mentions/service_test.go` +- `apps/backend/internal/mentions/handler_test.go` + +## Dependencies + +None. + +## Inputs + +Spec API/failure/plugin-compatibility sections; ADR provider boundary; ADR 0043 stable DTO rules and RPC direction. + +## Output contract + +Report contract/type choices, ranking/cap/timeout behavior, files changed, exact test result, blockers, risks, and set this task plus plan checkbox to done. diff --git a/docs/plans/entity-reference-composer/task-02-jira-provider.md b/docs/plans/entity-reference-composer/task-02-jira-provider.md new file mode 100644 index 0000000000..96d850be1a --- /dev/null +++ b/docs/plans/entity-reference-composer/task-02-jira-provider.md @@ -0,0 +1,46 @@ +--- +id: "02-jira-provider" +title: "Jira mention provider" +status: completed +wave: 2 +depends_on: ["01-core-and-task-search"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 02: Jira Mention Provider + +## Acceptance + +- Jira ticket projections preserve immutable upstream issue ID plus key/title/URL/site scope. +- Plain text becomes backend-generated, escaped key-or-title JQL; callers cannot inject raw JQL. +- Adapter enforces explicit workspace config and maps auth/rate/timeout/upstream failures safely. +- Provider authorizer accepts only the configured workspace site origin/scope for search and submission. + +## Verification + +```bash +cd apps/backend && go test ./internal/jira/... ./internal/mentions/... +``` + +## Files likely touched + +- `apps/backend/internal/jira/models.go` +- `apps/backend/internal/jira/cloud_client.go` +- `apps/backend/internal/jira/cloud_client_test.go` +- `apps/backend/internal/jira/service_mentions.go` +- `apps/backend/internal/jira/service_mentions_test.go` +- `apps/backend/internal/mentions/provider_jira.go` +- `apps/backend/internal/mentions/provider_jira_test.go` + +## Dependencies + +Task 01. + +## Inputs + +Spec sources/security/failure requirements; `SearchTicketsForWorkspace`; Jira response conversion tests. + +## Output contract + +Report identity and escaping behavior, files changed, exact tests, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-03-linear-provider.md b/docs/plans/entity-reference-composer/task-03-linear-provider.md new file mode 100644 index 0000000000..35e4ff29a3 --- /dev/null +++ b/docs/plans/entity-reference-composer/task-03-linear-provider.md @@ -0,0 +1,43 @@ +--- +id: "03-linear-provider" +title: "Linear mention provider" +status: completed +wave: 2 +depends_on: ["01-core-and-task-search"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 03: Linear Mention Provider + +## Acceptance + +- Linear adapter searches issues with structured `SearchFilter.Query` and explicit workspace scope. +- Results retain immutable issue ID, identifier, title, URL, and non-secret workspace/org scope. +- Missing config and upstream failures map to normalized statuses with cancellation preserved. +- Provider authorizer validates workspace organization scope and canonical Linear destinations. + +## Verification + +```bash +cd apps/backend && go test ./internal/linear/... ./internal/mentions/... +``` + +## Files likely touched + +- `apps/backend/internal/linear/service_mentions.go` +- `apps/backend/internal/linear/service_mentions_test.go` +- `apps/backend/internal/mentions/provider_linear.go` +- `apps/backend/internal/mentions/provider_linear_test.go` + +## Dependencies + +Task 01. + +## Inputs + +Spec provider contract; existing `SearchIssuesForWorkspace`, `LinearIssue.ID`, and GraphQL query tests. + +## Output contract + +Report mapping/scope, files changed, exact tests, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-04-github-provider.md b/docs/plans/entity-reference-composer/task-04-github-provider.md new file mode 100644 index 0000000000..5abaf620d5 --- /dev/null +++ b/docs/plans/entity-reference-composer/task-04-github-provider.md @@ -0,0 +1,47 @@ +--- +id: "04-github-provider" +title: "GitHub mention provider" +status: completed +wave: 2 +depends_on: ["01-core-and-task-search"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 04: GitHub Mention Provider + +## Acceptance + +- Issue and PR search projections preserve immutable REST/node identity through PAT and `gh` paths. +- Plain text becomes backend-generated title-only GitHub search and respects workspace repository/org scope. +- Issue and PR groups remain separate, cached/scoped safely, bounded, cancellable, and safely classified. +- Provider authorizers validate the workspace-owned repository/organization scope and canonical GitHub host. + +## Verification + +```bash +cd apps/backend && go test ./internal/github/... ./internal/mentions/... +``` + +## Files likely touched + +- `apps/backend/internal/github/models.go` +- `apps/backend/internal/github/client_helpers.go` +- `apps/backend/internal/github/pat_client.go` +- `apps/backend/internal/github/gh_client.go` +- `apps/backend/internal/github/service_mentions.go` +- `apps/backend/internal/github/service_mentions_test.go` +- `apps/backend/internal/mentions/provider_github.go` +- `apps/backend/internal/mentions/provider_github_test.go` + +## Dependencies + +Task 01. + +## Inputs + +ADR identity rule; workspace settings search wrappers; existing search-cache and workspace-scope tests. + +## Output contract + +Report identity propagation/query generation/cache scope, files changed, exact tests, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-05-gitlab-provider.md b/docs/plans/entity-reference-composer/task-05-gitlab-provider.md new file mode 100644 index 0000000000..1640d25521 --- /dev/null +++ b/docs/plans/entity-reference-composer/task-05-gitlab-provider.md @@ -0,0 +1,44 @@ +--- +id: "05-gitlab-provider" +title: "GitLab mention provider" +status: completed +wave: 2 +depends_on: ["01-core-and-task-search"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 05: GitLab Mention Provider + +## Acceptance + +- Issue and MR adapters use immutable object ID plus host scope and keep human IID/project details for display. +- Safe service methods construct title-search parameters without accepting raw GitLab filters. +- Search validates workspace-owned config/scope and never leaks install-wide results across workspaces. +- Provider authorizers fail closed unless the result host/project is proven to belong to the active workspace. + +## Verification + +```bash +cd apps/backend && go test ./internal/gitlab/... ./internal/mentions/... +``` + +## Files likely touched + +- `apps/backend/internal/gitlab/service_mentions.go` +- `apps/backend/internal/gitlab/service_mentions_test.go` +- `apps/backend/internal/gitlab/store.go` +- `apps/backend/internal/mentions/provider_gitlab.go` +- `apps/backend/internal/mentions/provider_gitlab_test.go` + +## Dependencies + +Task 01. + +## Inputs + +ADR 0030; GitLab `SearchUserIssuesPaged`/`SearchUserMRsPaged`; existing stable `ID`, `IID`, `ProjectID`, and host models. + +## Output contract + +Report workspace enforcement/query safety, files changed, exact tests, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-06-azure-provider.md b/docs/plans/entity-reference-composer/task-06-azure-provider.md new file mode 100644 index 0000000000..c5d8d9b25c --- /dev/null +++ b/docs/plans/entity-reference-composer/task-06-azure-provider.md @@ -0,0 +1,47 @@ +--- +id: "06-azure-provider" +title: "Azure DevOps mention provider" +status: completed +wave: 2 +depends_on: ["01-core-and-task-search"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 06: Azure DevOps Mention Provider + +## Acceptance + +- Work-item search generates escaped WIQL internally and returns organization-scoped immutable IDs. +- PR search adds a project-level active-PR read path, filters titles server-side, and avoids browser or unbounded repository fan-out. +- Adapter respects workspace config/default project plus bounded project discovery, cancellation, caps, and safe errors. +- Provider authorizers validate organization URL/path and project scope, not only the shared host origin. + +## Verification + +```bash +cd apps/backend && go test ./internal/azuredevops/... ./internal/mentions/... +``` + +## Files likely touched + +- `apps/backend/internal/azuredevops/client.go` +- `apps/backend/internal/azuredevops/rest_client.go` +- `apps/backend/internal/azuredevops/client_models.go` +- `apps/backend/internal/azuredevops/service_mentions.go` +- `apps/backend/internal/azuredevops/service_mentions_test.go` +- `apps/backend/internal/azuredevops/rest_client_test.go` +- `apps/backend/internal/mentions/provider_azure.go` +- `apps/backend/internal/mentions/provider_azure_test.go` + +## Dependencies + +Task 01. + +## Inputs + +Spec all-source contract; existing workspace credential resolver, WIQL path, project/repository listing, and PR models. + +## Output contract + +Report search/fan-out bounds and identity, files changed, exact tests, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-07-sentry-provider.md b/docs/plans/entity-reference-composer/task-07-sentry-provider.md new file mode 100644 index 0000000000..a0d92432ba --- /dev/null +++ b/docs/plans/entity-reference-composer/task-07-sentry-provider.md @@ -0,0 +1,46 @@ +--- +id: "07-sentry-provider" +title: "Sentry mention provider" +status: completed +wave: 2 +depends_on: ["01-core-and-task-search"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 07: Sentry Mention Provider + +## Acceptance + +- Adapter searches every configured active-workspace Sentry instance through bounded organization discovery. +- Stable reference scope includes instance ID; result ID/title/URL never collide across self-hosted instances. +- Free text is escaped from Sentry syntax and provider calls preserve cancellation/caps/safe error mapping. +- Provider authorizer verifies workspace-owned instance ID and that the destination matches that instance origin. + +## Verification + +```bash +cd apps/backend && go test ./internal/sentry/... ./internal/mentions/... +``` + +## Files likely touched + +- `apps/backend/internal/sentry/client.go` +- `apps/backend/internal/sentry/rest_client.go` +- `apps/backend/internal/sentry/service_mentions.go` +- `apps/backend/internal/sentry/service_mentions_test.go` +- `apps/backend/internal/sentry/rest_client_test.go` +- `apps/backend/internal/mentions/provider_sentry.go` +- `apps/backend/internal/mentions/provider_sentry_test.go` + +## Dependencies + +Task 01. + +## Inputs + +ADR 0030 multiple-instance rules; existing `ListInstances`, instance ownership checks, browse client, and `SentryIssue.ID`. + +## Output contract + +Report instance/org discovery bounds, escaping, files changed, exact tests, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-08-message-reference-metadata.md b/docs/plans/entity-reference-composer/task-08-message-reference-metadata.md new file mode 100644 index 0000000000..65f9a69f9c --- /dev/null +++ b/docs/plans/entity-reference-composer/task-08-message-reference-metadata.md @@ -0,0 +1,51 @@ +--- +id: "08-message-reference-metadata" +title: "Durable message reference metadata" +status: completed +wave: 2 +depends_on: ["01-core-and-task-search"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 08: Durable Message Reference Metadata + +## Acceptance + +- A neutral `internal/entityrefs` leaf structurally normalizes/canonicalizes arrays; `message.add` and queue add/update resolve the persisted conversation workspace and dispatch provider authorization through the mention registry before persistence. +- Queue persistence and both normal/workflow queue-drain paths preserve normalized reference arrays without reauthorizing previously accepted references. +- Queue update replaces references so deleted links cannot leave stale metadata; direct/queued arrays dedupe by canonical `ref`. +- Client projection and agent prompt context expose sanitized typed references without breaking legacy task mentions. + +## Verification + +```bash +cd apps/backend && go test ./internal/task/handlers/... ./internal/task/models/... ./internal/orchestrator/handlers/... ./internal/orchestrator/messagequeue/... +``` + +## Files likely touched + +- `apps/backend/internal/orchestrator/message_meta.go` +- `apps/backend/internal/entityrefs/reference.go` +- `apps/backend/internal/entityrefs/reference_test.go` +- `apps/backend/internal/task/handlers/message_handlers.go` +- `apps/backend/internal/task/handlers/message_handlers_test.go` +- `apps/backend/internal/orchestrator/handlers/queue_handlers.go` +- `apps/backend/internal/orchestrator/handlers/queue_handlers_test.go` +- `apps/backend/internal/orchestrator/messagequeue/service.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go` +- `apps/backend/internal/task/models/message_shell_output.go` +- `apps/backend/internal/task/models/message_shell_output_test.go` + +## Dependencies + +Task 01 normalized API type. + +## Inputs + +Spec data/API/persistence/failure sections; `UserMessageMeta`, existing message metadata projection, and queue `metadata_json` patterns. + +## Output contract + +Report validation/dedup/update semantics, files changed, exact tests, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-09-backend-composition.md b/docs/plans/entity-reference-composer/task-09-backend-composition.md new file mode 100644 index 0000000000..157195466c --- /dev/null +++ b/docs/plans/entity-reference-composer/task-09-backend-composition.md @@ -0,0 +1,50 @@ +--- +id: "09-backend-composition" +title: "Backend provider composition" +status: completed +wave: 3 +depends_on: + - "02-jira-provider" + - "03-linear-provider" + - "04-github-provider" + - "05-gitlab-provider" + - "06-azure-provider" + - "07-sentry-provider" + - "08-message-reference-metadata" +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 09: Backend Provider Composition + +## Acceptance + +- Backend registers built-in adapters through the same descriptor-driven registrar a future plugin bridge can use, without giving adapters control of registry identity, then mounts the workspace search route. +- Request workspace is validated before any provider call; nil/disconnected providers degrade safely. +- Handler-to-provider integration test proves mixed success/failure, deterministic caps, cancellation, and workspace isolation. + +## Verification + +```bash +cd apps/backend && go test ./internal/backendapp/... ./internal/mentions/... +``` + +## Files likely touched + +- `apps/backend/internal/backendapp/types.go` +- `apps/backend/internal/backendapp/services.go` +- `apps/backend/internal/backendapp/helpers.go` +- `apps/backend/internal/backendapp/mentions.go` +- `apps/backend/internal/backendapp/mentions_test.go` + +## Dependencies + +Tasks 02-08. + +## Inputs + +All provider constructors; existing integration service construction/route registration patterns; spec API order/status/plugin-compatibility contract; ADR 0043. + +## Output contract + +Report registry order and workspace validation, files changed, exact tests, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-10-frontend-search-client.md b/docs/plans/entity-reference-composer/task-10-frontend-search-client.md new file mode 100644 index 0000000000..ab7a2ba6ff --- /dev/null +++ b/docs/plans/entity-reference-composer/task-10-frontend-search-client.md @@ -0,0 +1,43 @@ +--- +id: "10-frontend-search-client" +title: "Frontend reference search client" +status: completed +wave: 4 +depends_on: ["09-backend-composition"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 10: Frontend Reference Search Client + +## Acceptance + +- Typed API client encodes workspace/query/limit/current-task exclusion and preserves normalized provider groups/statuses without closed native-provider unions. +- Hook debounces non-empty queries, aborts or generation-guards stale work, and resets cleanly on workspace/session changes. +- Partial groups remain usable while aggregate failures expose a retryable state without mutating the draft. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- lib/api/domains/mentions-api.test.ts hooks/use-entity-reference-search.test.ts +``` + +## Files likely touched + +- `apps/web/lib/types/entity-reference.ts` +- `apps/web/lib/api/domains/mentions-api.ts` +- `apps/web/lib/api/domains/mentions-api.test.ts` +- `apps/web/hooks/use-entity-reference-search.ts` +- `apps/web/hooks/use-entity-reference-search.test.ts` + +## Dependencies + +Task 09 endpoint contract. + +## Inputs + +Spec API/state/failure sections; command-panel abort/debounce search pattern. + +## Output contract + +Report request lifecycle and types, files changed, exact tests, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-11-composer-reference-ui.md b/docs/plans/entity-reference-composer/task-11-composer-reference-ui.md new file mode 100644 index 0000000000..0a3aad7fc6 --- /dev/null +++ b/docs/plans/entity-reference-composer/task-11-composer-reference-ui.md @@ -0,0 +1,58 @@ +--- +id: "11-composer-reference-ui" +title: "Composer entity reference interaction" +status: completed +wave: 4 +depends_on: ["10-frontend-search-client"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 11: Composer Entity Reference Interaction + +## Acceptance + +- Valid `#` triggers in task/Quick Chat open descriptor-driven grouped search, including a generic unknown-provider fallback; arrows + Enter/Tab and touch insert a non-sending atom with trailing space. +- Passthrough stays literal; Kandev task discovery remains under `@`, while `#` defensively omits any Kandev-task group returned by the search service. +- Draft/history and reverse-search entries round-trip generated Markdown plus metadata, without converting ordinary links; popup stays visual-viewport-contained with one scroll owner and 44 px rows. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- components/task/chat/tiptap-helpers.test.ts components/task/chat/use-tiptap-editor.test.ts components/task/chat/tiptap-editor-history.test.ts components/task/chat/popup-menu.test.tsx components/task/chat/entity-reference-menu.test.tsx +cd apps && pnpm --filter @kandev/web typecheck +``` + +## Files likely touched + +- `apps/web/components/task/chat/entity-reference-types.ts` +- `apps/web/components/task/chat/tiptap-entity-reference-extension.tsx` +- `apps/web/components/task/chat/tiptap-entity-reference-suggestion.ts` +- `apps/web/components/task/chat/entity-reference-menu.tsx` +- `apps/web/components/task/chat/tiptap-input.tsx` +- `apps/web/components/task/chat/use-tiptap-editor.ts` +- `apps/web/components/task/chat/tiptap-helpers.ts` +- `apps/web/components/task/chat/message-history.ts` +- `apps/web/components/task/chat/tiptap-editor-history.ts` +- `apps/web/components/task/chat/popup-menu.tsx` +- `apps/web/components/task/chat/chat-input-body.tsx` +- `apps/web/components/task/chat/chat-input-container.tsx` +- `apps/web/components/task/chat/chat-input-area.tsx` +- `apps/web/components/task/passthrough-chat-composer.tsx` +- `apps/web/components/task/chat/tiptap-helpers.test.ts` +- `apps/web/components/task/chat/use-tiptap-editor.test.ts` +- `apps/web/components/task/chat/tiptap-editor-history.test.ts` +- `apps/web/components/task/chat/popup-menu.test.tsx` +- `apps/web/components/task/chat/entity-reference-menu.test.tsx` + +## Dependencies + +Task 10. + +## Inputs + +Spec interaction/state/mobile sections; existing slash suggestion/menu; Mobile UI Language; current legacy mention tests. + +## Output contract + +Report trigger/gating/serialization/mobile geometry, files changed, exact tests/typecheck, rendered mobile check, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-12-message-reference-ui.md b/docs/plans/entity-reference-composer/task-12-message-reference-ui.md new file mode 100644 index 0000000000..884d2ed7f1 --- /dev/null +++ b/docs/plans/entity-reference-composer/task-12-message-reference-ui.md @@ -0,0 +1,58 @@ +--- +id: "12-message-reference-ui" +title: "Reference submission and sent rendering" +status: completed +wave: 4 +depends_on: ["08-message-reference-metadata", "11-composer-reference-ui"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 12: Reference Submission and Sent Rendering + +## Acceptance + +- Named composer submit payload carries references through direct send and queue add/update without stale metadata. +- Sent and queued generated links render as clickable chips only when matched by typed metadata; ordinary Markdown stays unchanged. +- Existing files/prompts/plan/task mentions, passthrough submission, attachments, comments, and queue behavior retain parity. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- hooks/use-message-handler.test.ts components/task/chat/use-chat-input-state.test.ts lib/api/domains/queue-api.test.ts components/task/chat/messages/chat-message.test.tsx components/task/chat/queued-ghost-message.test.tsx components/task/passthrough-chat-composer.test.ts +cd apps && pnpm --filter @kandev/web typecheck +``` + +## Files likely touched + +- `apps/web/components/task/chat/chat-input-container.tsx` +- `apps/web/components/task/chat/use-chat-input-container.ts` +- `apps/web/components/task/chat/use-chat-input-state.ts` +- `apps/web/components/task/chat/chat-input-area.tsx` +- `apps/web/hooks/use-message-handler.ts` +- `apps/web/hooks/domains/session/use-queue.ts` +- `apps/web/lib/api/domains/queue-api.ts` +- `apps/web/lib/types/http.ts` +- `apps/web/lib/state/slices/session/types.ts` +- `apps/web/components/task/chat/messages/entity-reference-chip.tsx` +- `apps/web/components/task/chat/messages/chat-message.tsx` +- `apps/web/components/task/chat/queued-ghost-message.tsx` +- `apps/web/components/task/passthrough-chat-composer.tsx` +- `apps/web/hooks/use-message-handler.test.ts` +- `apps/web/components/task/chat/use-chat-input-state.test.ts` +- `apps/web/lib/api/domains/queue-api.test.ts` +- `apps/web/components/task/chat/messages/chat-message.test.tsx` +- `apps/web/components/task/chat/queued-ghost-message.test.tsx` +- `apps/web/components/task/passthrough-chat-composer.test.ts` + +## Dependencies + +Tasks 08 and 11. + +## Inputs + +Spec persistence/scenarios; backend metadata contract; existing prompt mention rendering and queue metadata shapes. + +## Output contract + +Report payload migration/render matching/queue edit semantics, files changed, exact tests/typecheck, blockers, risks, and mark task/plan done. diff --git a/docs/plans/entity-reference-composer/task-13-e2e-and-verification.md b/docs/plans/entity-reference-composer/task-13-e2e-and-verification.md new file mode 100644 index 0000000000..c81ac8df5b --- /dev/null +++ b/docs/plans/entity-reference-composer/task-13-e2e-and-verification.md @@ -0,0 +1,44 @@ +--- +id: "13-e2e-and-verification" +title: "Entity reference E2E and integrated verification" +status: completed +wave: 5 +depends_on: ["12-message-reference-ui"] +plan: "plan.md" +spec: "../../specs/ui/entity-reference-composer.md" +--- + +# Task 13: Entity Reference E2E and Integrated Verification + +## Acceptance + +- Desktop E2E proves task chat keyboard insertion/no auto-send/explicit send, clickable persistence, partial provider results, draft restore, Quick Chat parity, and literal passthrough behavior. +- Mobile E2E proves touch selection and send, visual-viewport containment, internal scrolling, 44 px rows, and no document horizontal overflow. +- Format, typecheck, unit/integration tests, lint, focused E2E, QA, security review, and code review complete with no unresolved required issue. + +## Verification + +```bash +make fmt +make typecheck test lint +cd apps/web && pnpm e2e:run tests/chat/entity-reference-composer.spec.ts tests/chat/mobile-entity-reference-composer.spec.ts +``` + +## Files likely touched + +- `apps/web/e2e/tests/chat/entity-reference-composer.spec.ts` +- `apps/web/e2e/tests/chat/mobile-entity-reference-composer.spec.ts` +- `apps/web/e2e/pages/session-page.ts` only for a small reusable helper +- provider mock controllers/fixtures only where deterministic mixed-source seeding is missing + +## Dependencies + +Task 12 and all integrated backend work. + +## Inputs + +Every spec scenario; slash composer desktop/mobile precedents; E2E backend rebuild requirements. + +## Output contract + +Report exact commands/results, browser artifacts for failures, desktop/mobile visual findings, QA/security/review findings and fixes, files changed, blockers, residual risks, and mark task/plan done. diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index 63eebf85d4..93b91a5875 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -17,6 +17,14 @@ In Task Chat or Quick Chat, select text from a settled agent prose reply, then s Inline comments are available only on ordinary settled prose. Streaming replies, tool/thinking/status output, plans, rich-block content, raw views, and user messages do not accept inline comments. +### Reference tasks and work items + +In Task Chat or an ordinary structured Quick Chat, type `#` at the start of a line or after whitespace, then enter part of a title or key. Kandev searches the active workspace's tasks and connected Jira, Linear, GitHub, GitLab, Azure DevOps, and Sentry sources. Results stay grouped by provider and type; a disconnected or slow provider does not hide results from another source. + +Use the arrow keys and **Tab** or **Enter**, or select a row with pointer or touch. Selection inserts a chip without sending. The chip survives draft reloads and becomes a clickable reference after explicit send; messages queued while an agent is busy keep the same reference metadata. CLI-passthrough chat leaves `#` as literal text and does not search. + +Use `@` for files, saved prompts, and the current plan. New task lookup is under `#`; existing saved or sent `@task` references remain readable and sendable. + Select **Quick Chat** beside **New Task** in the expanded sidebar, or select its standalone row in the collapsed sidebar. ### Start a chat diff --git a/docs/public/feature-status.md b/docs/public/feature-status.md index e5c0534ae7..f9265e036d 100644 --- a/docs/public/feature-status.md +++ b/docs/public/feature-status.md @@ -51,6 +51,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande | GitHub pull-request review, checks, merge, and CI-fix automation | Dependency-bound | Requires GitHub credentials and repository permissions. CI automation is opt-in. Azure has a create-PR path only; other Git hosts do not get the full task PR panel. | | Share a session snapshot as a secret GitHub Gist | Dependency-bound | Requires GitHub auth and a structured session outside `CREATED`/`STARTING`. The viewing URL is rendered through `gist.githack.com`; unlisted URLs and heuristic redaction are not access control or exhaustive secret detection. Payloads are capped and shares can be revoked. | | Quick Chat | Dependency-bound | Requires an agent profile. Repository-backed chats use isolated Kandev worktrees; closing deletes the ephemeral task permanently. Chats older than seven days are swept at startup and every 24 hours unless their session is `RUNNING` or `IDLE`. | +| Task and work-item references in chat | Dependency-bound | Structured Task Chat and Quick Chat search workspace tasks plus connected Jira, Linear, GitHub, GitLab, Azure DevOps, and Sentry sources with `#`. Passthrough chat remains literal, and provider scope, credentials, availability, and rate limits determine external results. | | Utility agents and custom one-shot helpers | Dependency-bound | Require an ACP inference-capable agent and usable model. Passthrough-only agents do not satisfy this path. | | Configuration Chat | Limited | Each workspace currently presents one repository-less configuration conversation. Settings shows it in a tabless floating panel and can move the same session into Quick Chat. Confirmed tab closure deletes its backing task; config-mode tasks are excluded from Quick Chat expiry. | | Voice input | Dependency-bound | Browser Web Speech, a browser-downloaded Whisper model, and server Whisper paths exist. Selection does not retry another engine after a recognition error. Server Whisper requires `KANDEV_VOICE_OPENAI_API_KEY` (empty by default); without it the transcription route returns 503. | diff --git a/docs/public/websocket-api.md b/docs/public/websocket-api.md index e0271505f5..0a2eb5061e 100644 --- a/docs/public/websocket-api.md +++ b/docs/public/websocket-api.md @@ -205,9 +205,15 @@ If `intent` is omitted, the backend infers it from those fields. That inference A successful response contains `success`, `task_id`, `state`, and usually `session_id`; it can also contain `agent_execution_id`, `worktree_path`, and `worktree_branch`. Session states use uppercase values such as `CREATED`, `STARTING`, `RUNNING`, `WAITING_FOR_INPUT`, `COMPLETED`, `FAILED`, and `CANCELLED`. +### Search work-item references over HTTP + +The structured chat composer's `#` search calls `GET /api/v1/workspaces/:workspaceId/mentions/search?q=&limit=&exclude_task_id=`. `q` is required after trimming and accepts 1–200 Unicode characters. `limit` defaults to 5 and is clamped to 1–10. When supplied, `exclude_task_id` must belong to the requested workspace. + +A successful response returns the normalized query and an ordered `groups` array. Each group includes `source`, `provider`, `kind`, `display_name`, `kind_label`, `status`, and `results`. One source can report `not_configured`, `unauthorized`, `rate_limited`, `timeout`, `upstream_error`, or `unsupported_scope` while the request remains HTTP 200 and other groups remain usable. Each result is a versioned `EntityReference` with the fields described below; raw provider errors and credentials are not returned. + ### Send a user turn -`message.add` requires `task_id`, `session_id`, and either non-whitespace `content` or at least one attachment. Optional fields are `author_id`, `model`, `plan_mode`, `has_review_comments`, `attachments`, and `context_files`. +`message.add` requires `task_id`, `session_id`, and either non-whitespace `content` or at least one attachment. Optional fields are `author_id`, `model`, `plan_mode`, `has_review_comments`, `attachments`, `context_files`, and `entity_references`. ```json { @@ -222,6 +228,10 @@ A successful response contains `success`, `task_id`, `state`, and usually `sessi } ``` +`entity_references` is the structured companion to work-item Markdown links inserted by the `#` composer. Each version-1 entry carries `ref`, `provider`, `kind`, immutable `id`, optional display `key`, title snapshot, canonical `url`, and provider-connection `scope`. The backend derives the workspace from the persisted session, validates canonical identity and destination through the registered provider, deduplicates by `ref`, and rejects the request when a supplied reference is invalid or unauthorized. CLI-passthrough sessions do not accept structured references. + +`message.queue.add` accepts the same optional array. `message.queue.update` also accepts it and treats the array as a replacement: send `[]` after removing every generated reference link so stale metadata is cleared. Queue status and message responses return validated entries under `metadata.entity_references`. + If the agent is busy, use the `message.queue.*` operations rather than retrying `message.add`. Permission prompts are represented in persisted/session message data; answer one with `permission.respond`. Its payload requires `session_id` and `pending_id`, plus `option_id` unless `cancelled:true`; optional `rejected:true` distinguishes an explicit denial from dismissing the prompt. For a raw diagnostic session, install a WebSocket client such as `websocat`, connect, then paste one request object per line: diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 4d1671faf9..5c7786ea87 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -117,6 +117,7 @@ Subscription quota tracking and per-agent cheap-model profile routing. | [review-file-status](ui/review-file-status.md) | building | | [sidebar-view-creation](ui/sidebar-view-creation.md) | shipped | | [slash-command-composer](ui/slash-command-composer.md) | shipped | +| [entity-reference-composer](ui/entity-reference-composer.md) | draft | | [settings-manual-save](ui/settings-manual-save.md) | shipped | | [mobile-task-navigation](ui/mobile-task-navigation.md) | shipped | | [task-layout-profiles](ui/task-layout-profiles.md) | draft | diff --git a/docs/specs/ui/entity-reference-composer.md b/docs/specs/ui/entity-reference-composer.md new file mode 100644 index 0000000000..d6d0979ad8 --- /dev/null +++ b/docs/specs/ui/entity-reference-composer.md @@ -0,0 +1,174 @@ +--- +status: shipped +created: 2026-07-21 +owner: kandev +--- + +# Entity Reference Composer + +## Why + +Users discuss external work items in agent chat, but plain ticket keys and pull-request numbers are ambiguous and easy to mistype. They need one fast way to search the active workspace's connected systems and insert a durable reference that both people and agents can resolve. Kandev tasks already have the established `@` mention path. + +## What + +- Task chat and Quick Chat support a `#` entity-reference trigger in their shared TipTap composer. Passthrough, task creation, comments, plans, Office text inputs, and other editors remain unchanged. +- Typing `#` at the start of a text block or after whitespace opens a search menu directly above the composer. Its rendered bottom edge stays anchored to the composer even when only a short result set is visible. A `#` inside another token or a code block remains literal text. +- After the user types at least one query character, search covers the active workspace's connected, searchable sources: + - Jira tickets; + - Linear issues; + - GitHub issues and pull requests; + - GitLab issues and merge requests; + - Azure DevOps work items and pull requests; and + - Sentry issues. +- Results respect the provider configuration and scope owned by the active workspace. Kandev tasks are excluded even if a stale or misbehaving search response contains a `kandev_tasks` group. +- Results are grouped by provider and work-item kind. Provider order and ordering within each group are deterministic; Kandev does not present unrelated provider scores as globally comparable relevance. Sources that are not configured or cannot search the active workspace are omitted from the menu rather than rendered as empty unavailable sections. +- Search groups carry provider-neutral display descriptors. Composer, message, and queue code use normalized fields and a generic fallback rather than exhaustive switches over native integrations, so a future plugin bridge can supply the same contract. +- Search is debounced, cancels or ignores stale requests, and caps work per provider and configured workspace/project scope. Each connected provider continues examining available in-scope results until it finds the requested number of matches, has no more results, reaches a search bound, or the request is canceled. One disconnected, slow, rate-limited, or failing provider never hides successful results from other providers. +- Arrow Up and Arrow Down move the active result. Tab or Enter selects it. Pointer or touch selection has the same behavior. Escape closes the menu without changing or sending the draft. +- Selection replaces only the active `#query` range with an inline reference chip, appends a trailing space, keeps focus in the composer, and never submits or queues the message. +- A chip displays a stable key when one exists (for example `#ENG-123` or `#123`) and otherwise a concise title. It retains a title snapshot and source label for disambiguation. +- Submitted references remain clickable in sent user messages through a canonical HTTP(S) URL whose origin matches the workspace's validated provider configuration. +- Visible message content serializes each chip to a portable Markdown link. Structured reference metadata is persisted with direct and queued user messages so rendering and agent context never depend on parsing a mutable title. +- Draft storage preserves the structured chip. Recalling a sent message rehydrates links produced by this feature into chips; ordinary Markdown links remain ordinary links. +- If a referenced item is later renamed, deleted, disconnected, or inaccessible, the sent message keeps its label snapshot and Markdown fallback. Opening the target may then show the destination's normal unavailable behavior. +- `@` remains the context-attachment trigger for Kandev tasks, files, saved prompts, and the current plan. `#` is reserved for external work items. +- Provider titles, URLs, keys, and error text are untrusted. Kandev validates reference shapes and destinations, escapes provider query languages server-side, and sanitizes agent-facing context. +- Each registered `(provider, kind)` owns its destination/scope authorizer. The same provider-owned rule filters search hits and authorizes direct or queued submission against the workspace derived from the persisted conversation; unknown or ambiguous providers fail closed. + +## Data model + +`EntityReference` is a versioned value stored in `messages.metadata.entity_references` and, while queued, `queued_messages.metadata_json.entity_references`. No new table is introduced. + +| Field | Type | Contract | +| ---------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `version` | integer | `1` for this contract. Unknown versions render through visible Markdown only. | +| `ref` | string | Canonical opaque identity, unique within one provider connection and kind. | +| `provider` | namespaced string | Stable registry-owned provider identity. Built-ins use `jira`, `linear`, `github`, `gitlab`, `azure_devops`, or `sentry`. A built-in migrated to a plugin keeps its existing ID (or an explicit compatibility alias); a brand-new plugin contribution uses the reserved `plugin::` namespace. | +| `kind` | string | Additive work-item kind. Built-ins use `issue`, `pull_request`, `merge_request`, or `work_item`; unknown future values use generic presentation. | +| `id` | string | Provider-native immutable ID, or documented provider-native composite identity where no single immutable ID exists. | +| `key` | string, optional | Human-facing identifier such as `ENG-123` or `123`. | +| `title` | string | Sanitized display snapshot captured at selection time. | +| `url` | string | Validated canonical provider URL. | +| `scope` | string | Non-secret provider connection/site/instance scope needed to prevent identity collisions. | + +Metadata is deduplicated by `ref` in first-appearance order. It never contains credentials, provider query syntax, or raw upstream error bodies. + +The registry, not an adapter, assigns `provider` and constructs `ref` from validated provider registration, connection scope, kind, and immutable resource identity. Native and future plugin candidates are untrusted input: Kandev bounds counts and field lengths and validates UTF-8, kind, identity, and URL before exposing or persisting a result. + +The editor's `entityReference` atom stores the same presentation and identity fields in local draft JSON. Visible serialization is `[#]()` with Markdown-safe text and URL encoding. + +## API surface + +### Search + +`GET /api/v1/workspaces/:workspaceId/mentions/search?q=&limit=` + +- `workspaceId` is required and must identify the active task or Quick Chat workspace. +- `q` is plain user text, 1-200 Unicode characters after trimming. Provider query languages are not accepted. +- `limit` defaults to 5 and is clamped to 1-10 per source. +- A successful aggregate request returns HTTP 200 even when individual sources fail: + +```json +{ + "query": "auth", + "groups": [ + { + "source": "linear_issues", + "provider": "linear", + "kind": "issue", + "display_name": "Linear", + "kind_label": "Issue", + "status": "ok", + "results": [ + { + "version": 1, + "ref": "mention:v1:linear:issue:acme:issue-uuid", + "provider": "linear", + "kind": "issue", + "id": "issue-uuid", + "key": "ENG-123", + "title": "Fix authentication", + "url": "https://linear.app/acme/issue/ENG-123", + "scope": "acme" + } + ] + } + ] +} +``` + +- Group status is one of `ok`, `not_configured`, `unauthorized`, `rate_limited`, `timeout`, `upstream_error`, or `unsupported_scope`. +- `source` is an opaque stable source ID. `display_name` and `kind_label` let the UI render a provider without importing its native model; unknown providers use a generic work-item icon. +- Sources with `not_configured` or `unsupported_scope` status are omitted from the menu, but their safe status remains available in the response. Transient failures from configured sources may be shown non-disruptively. Raw provider errors are never returned. +- Invalid query/limit returns 400. An unknown workspace returns 404. Aggregate infrastructure failure returns 500. + +### Message submission + +The existing `message.add`, `message.queue.add`, and `message.queue.update` payloads accept optional `entity_references: EntityReference[]`. For `message.queue.update`, omitting `entity_references` preserves existing reference metadata, an explicit empty array clears it, and a non-empty array replaces it after validation. Deleting a link from edited queue text cannot leave stale reference metadata behind when the update supplies the replacement array. + +Message responses and queue entries expose the validated array through `metadata.entity_references`. Queue drain copies it to the created user message unchanged apart from canonical validation and deduplication. + +## State machine + +1. **Closed** — no active valid `#` trigger. +2. **Primed** — the menu is open for bare `#` and prompts the user to type; no external search runs. +3. **Searching** — a debounced request for the current query is in flight. +4. **Results** — one or more provider groups contain selectable results; partial provider failures may also be shown non-disruptively. +5. **Empty** — all successful providers returned no hits and no request is in flight. + +Typing updates Primed/Searching/Results/Empty. Escape, moving outside the trigger range, selection, session change, or composer unmount returns to Closed. A response whose query generation is no longer current has no state transition. + +## Permissions + +- Search is workspace-scoped. A provider adapter may use only credentials, instances, repository scope, projects, and operational settings associated with `workspaceId`, following [ADR 0030](../../decisions/0030-workspace-scoped-integration-settings.md). +- The backend validates that the target task or Quick Chat session belongs to the same workspace as submitted provider scopes. +- Search results and message metadata expose no credentials or secret identifiers. +- Opening a reference uses the destination's existing authorization behavior; selection does not grant new provider access. + +## Failure modes + +- A provider timeout, auth failure, rate limit, malformed upstream response, or unsupported workspace scope produces a safe group status while other groups remain selectable. +- If the aggregate endpoint is unreachable, the menu preserves the draft and shows a retryable error state. It never submits the draft. +- Stale responses after continued typing, session changes, or menu dismissal are ignored. +- Invalid or unsafe returned URLs are discarded. URLs must use HTTP(S) and match the configured provider origin; internal routes are rejected. +- Provider query text is escaped inside provider adapters. User text never becomes raw JQL, WIQL, GitHub/GitLab qualifiers, or Sentry query syntax. +- Malformed submitted reference metadata is rejected or omitted from structured metadata without executing or resolving its URL. The visible Markdown text remains ordinary user content. + +## Persistence guarantees + +- Composer drafts preserve entity-reference atoms through the existing per-session local draft JSON. +- Direct messages and queued messages preserve normalized reference metadata in their existing JSON metadata columns across backend restarts. +- Sent visible content preserves a portable Markdown-link fallback even if metadata is missing or a future client does not understand its version. +- Search results, in-flight requests, provider health snapshots, and typeahead caches are transient and need not survive restart. + +## Scenarios + +- **GIVEN** task chat in workspace A with connected Jira and GitHub providers, **WHEN** the user types `#auth`, **THEN** the menu above the composer shows grouped workspace-A Jira and GitHub hits without Kandev tasks or workspace-B results. +- **GIVEN** a search returns only one short result group, **WHEN** the menu renders, **THEN** its bottom edge remains directly anchored to the composer instead of reserving unused menu height. +- **GIVEN** GitLab is not configured or cannot search the active workspace, **WHEN** another source returns results, **THEN** the menu omits GitLab rather than showing an unavailable GitLab section. +- **GIVEN** Quick Chat and a matching Linear issue, **WHEN** the user presses Arrow Down and Tab, **THEN** the active range becomes one inline reference chip, focus stays in the composer, and no message is sent. +- **GIVEN** the menu is open on a phone viewport, **WHEN** the user taps a result, **THEN** the same chip is inserted and every required row is touch reachable without horizontal document overflow. +- **GIVEN** GitHub times out while Jira returns hits, **WHEN** search finishes, **THEN** Jira results remain selectable and GitHub exposes only a safe timeout state. +- **GIVEN** the user types another character before an earlier request returns, **WHEN** the older response arrives last, **THEN** only results for the newest query are displayed. +- **GIVEN** a connected provider's first available results do not match the query but later in-scope results do, **WHEN** the user searches, **THEN** matching results are returned up to the requested per-source limit unless the provider has no more results, a workspace/project search bound is reached, or the request is canceled. +- **GIVEN** a selected external reference followed by ordinary text, **WHEN** the user explicitly sends, **THEN** the visible message contains a clickable chip/Markdown fallback and its stable identity is stored in message metadata and supplied to the agent as sanitized reference context. +- **GIVEN** the agent is busy, **WHEN** the user queues a message containing two references, **THEN** both references survive queue display, backend restart, drain, and sent-message rendering. +- **GIVEN** the user edits a queued message and removes one generated reference link, **WHEN** the edit is saved, **THEN** metadata no longer contains the removed reference. +- **GIVEN** a queued message already has reference metadata, **WHEN** an update omits `entity_references`, **THEN** the existing metadata is preserved. +- **GIVEN** a queued message already has reference metadata, **WHEN** an update supplies an empty `entity_references` array, **THEN** the existing metadata is cleared. +- **GIVEN** a queued message already has reference metadata, **WHEN** an update supplies a valid non-empty `entity_references` array, **THEN** the validated array replaces the existing metadata. +- **GIVEN** a sent reference, **WHEN** the user recalls the message into an empty composer, **THEN** generated reference Markdown becomes an editable reference chip with the same identity. +- **GIVEN** task chat with a sibling Kandev task, **WHEN** the user types `@` and searches its title, **THEN** the existing task suggestion remains available and sends through the established task-context behavior. +- **GIVEN** passthrough mode, task creation, or a comment textarea, **WHEN** the user types `#auth`, **THEN** the text remains literal and no entity-reference search opens. +- **GIVEN** a provider title containing newlines, angle brackets, or a fake system tag, **WHEN** the reference is sent, **THEN** agent context remains within one sanitized Kandev system block. + +## Out of scope + +- Kandev tasks, people, agents, files, prompts, plans, branches, commits, or arbitrary web-page references under `#`. +- Slack message search; Slack has no issue-like reference contract in this release. +- Task creation, comments, plan editing, Office text inputs, passthrough, and terminal inputs. +- Creating, importing, transitioning, closing, or otherwise mutating a selected work item. +- Raw provider query-language syntax, advanced filters, user-controlled result pagination, or a full-screen global search page. +- Implementing the plugin manifest contribution, workspace permission/grant, Kandev-to-plugin search RPC, or loading plugin-provided sources in this release. The normalized registry and DTO MUST remain transport-neutral so a later plugin bridge can implement the same provider contract without changing composer/message formats. Search-provider contribution is distinct from the plugin-to-Kandev Host data API and its `api_read` capabilities. +- Cross-workspace search.