This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
The following commit(s) were added to refs/heads/main by this push:
new c8a7711c2 fix(sonarqube): use filter parameter for project search
(#8849)
c8a7711c2 is described below
commit c8a7711c2ab97a4fe5de9a1ab84027bbe82e3847
Author: Joshua Smith <[email protected]>
AuthorDate: Sat Apr 25 07:00:25 2026 -0600
fix(sonarqube): use filter parameter for project search (#8849)
SonarQube's components/search_projects endpoint does not accept a bare
q parameter for text search; it is silently ignored. The correct
approach is the filter language: filter=query = "term". Sending q=term
caused DevLake to return the full unfiltered project list regardless of
the search input.
Also fix three related pagination bugs in the remote scope search UI:
- results were replaced instead of accumulated on page 2+
- getHasMore was inverted (tested loading state instead of hasMore)
- onScroll could trigger duplicate requests while one was in flight
- typing a new query did not reset page and hasMore state
Fixes #8805
Signed-off-by: Joshua Smith <[email protected]>
---
backend/plugins/sonarqube/api/blueprint_v200.go | 9 +++-
backend/plugins/sonarqube/api/remote_api.go | 21 ++++++--
backend/plugins/sonarqube/api/remote_api_test.go | 34 ++++++++++++
.../components/data-scope-remote/search-remote.tsx | 63 ++++++++++++++--------
4 files changed, 102 insertions(+), 25 deletions(-)
diff --git a/backend/plugins/sonarqube/api/blueprint_v200.go
b/backend/plugins/sonarqube/api/blueprint_v200.go
index 3bcf47785..ba3b5eedf 100644
--- a/backend/plugins/sonarqube/api/blueprint_v200.go
+++ b/backend/plugins/sonarqube/api/blueprint_v200.go
@@ -130,7 +130,9 @@ func GetApiProject(
Data []models.SonarqubeApiProject `json:"components"`
}
query := url.Values{}
- query.Set("q", projectKey)
+ query.Set("p", "1")
+ query.Set("ps", "100")
+ query.Set("filter", sonarqubeSearchProjectsQueryFilter(projectKey))
// Use components/search_projects for consistency and normal-token
(Browse) support.
res, err := apiClient.Get("components/search_projects", query, nil)
if err != nil {
@@ -143,6 +145,11 @@ func GetApiProject(
if err != nil {
return nil, err
}
+ for i := range resData.Data {
+ if resData.Data[i].ProjectKey == projectKey {
+ return &resData.Data[i], nil
+ }
+ }
if len(resData.Data) > 0 {
return &resData.Data[0], nil
}
diff --git a/backend/plugins/sonarqube/api/remote_api.go
b/backend/plugins/sonarqube/api/remote_api.go
index d782a02cf..02ca0f35f 100644
--- a/backend/plugins/sonarqube/api/remote_api.go
+++ b/backend/plugins/sonarqube/api/remote_api.go
@@ -20,6 +20,7 @@ package api
import (
"fmt"
"net/url"
+ "strings"
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/plugin"
@@ -33,6 +34,16 @@ type SonarqubeRemotePagination struct {
PageSize int `json:"ps"`
}
+// sonarqubeSearchProjectsQueryFilter builds the "filter" query value for
+// api/components/search_projects. SonarQube expects text search via the filter
+// language (e.g. filter=query = "term"), not a bare "q" parameter; see
+// SearchProjectsAction in SonarQube.
+func sonarqubeSearchProjectsQueryFilter(term string) string {
+ term = strings.TrimSpace(term)
+ term = strings.ReplaceAll(term, `"`, "")
+ return fmt.Sprintf(`query = "%s"`, term)
+}
+
func querySonarqubeProjects(
apiClient plugin.ApiClient,
keyword string,
@@ -49,11 +60,15 @@ func querySonarqubeProjects(
page.Page = 1
}
// Use components/search_projects so non-admin (Browse) tokens can list
projects.
- res, err := apiClient.Get("components/search_projects", url.Values{
+ q := url.Values{
"p": {fmt.Sprintf("%v", page.Page)},
"ps": {fmt.Sprintf("%v", page.PageSize)},
- "q": {keyword},
- }, nil)
+ }
+ keyword = strings.TrimSpace(keyword)
+ if keyword != "" {
+ q.Set("filter", sonarqubeSearchProjectsQueryFilter(keyword))
+ }
+ res, err := apiClient.Get("components/search_projects", q, nil)
if err != nil {
return
}
diff --git a/backend/plugins/sonarqube/api/remote_api_test.go
b/backend/plugins/sonarqube/api/remote_api_test.go
new file mode 100644
index 000000000..a3b290dbf
--- /dev/null
+++ b/backend/plugins/sonarqube/api/remote_api_test.go
@@ -0,0 +1,34 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import "testing"
+
+func TestSonarqubeSearchProjectsQueryFilter(t *testing.T) {
+ t.Parallel()
+ if g, w := sonarqubeSearchProjectsQueryFilter("iap"), `query = "iap"`;
g != w {
+ t.Fatalf("got %q want %q", g, w)
+ }
+ if g, w := sonarqubeSearchProjectsQueryFilter(" my term "), `query =
"my term"`; g != w {
+ t.Fatalf("got %q want %q", g, w)
+ }
+ // double quotes are stripped to avoid breaking the filter string
+ if g, w := sonarqubeSearchProjectsQueryFilter(`a"x`), `query = "ax"`; g
!= w {
+ t.Fatalf("got %q want %q", g, w)
+ }
+}
diff --git
a/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
b/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
index 82cbf3804..5f61d7410 100644
--- a/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
+++ b/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
@@ -54,6 +54,8 @@ export const SearchRemote = ({ mode, plugin, connectionId,
config, disabledScope
nextTokenMap: {},
});
+ const PAGE_SIZE = 50;
+
const [search, setSearch] = useState<{
loading: boolean;
items: McsItem<T.ResItem>[];
@@ -61,6 +63,7 @@ export const SearchRemote = ({ mode, plugin, connectionId,
config, disabledScope
query: string;
page: number;
total: number;
+ hasMore: boolean;
}>({
loading: true,
items: [],
@@ -68,6 +71,7 @@ export const SearchRemote = ({ mode, plugin, connectionId,
config, disabledScope
query: '',
page: 1,
total: 0,
+ hasMore: false,
});
const searchDebounce = useDebounce(search.query, { wait: 500 });
@@ -129,24 +133,35 @@ export const SearchRemote = ({ mode, plugin,
connectionId, config, disabledScope
const searchItems = async () => {
if (!searchDebounce) return;
- const res = await API.scope.searchRemote(plugin, connectionId, {
- search: searchDebounce,
- page: search.page,
- pageSize: 20,
- });
-
- const newItems = (res.children ?? []).map((it) => ({
- ...it,
- title: getPluginScopeName(plugin, it) || it.fullName || it.name,
- }));
-
- setSearch((s) => ({
- ...s,
- loading: false,
- items: [...allItems, ...newItems],
- currentItems: newItems,
- total: res.count,
- }));
+ try {
+ const res = await API.scope.searchRemote(plugin, connectionId, {
+ search: searchDebounce,
+ page: search.page,
+ pageSize: PAGE_SIZE,
+ });
+
+ const newItems = (res.children ?? []).map((it) => ({
+ ...it,
+ title: getPluginScopeName(plugin, it) || it.fullName || it.name,
+ }));
+
+ const total = res.count ?? 0;
+ // If the backend returns a real total, use it; otherwise fall back to
+ // the heuristic: a full page means there are likely more results.
+ const hasMore = total > 0 ? search.page * PAGE_SIZE < total :
newItems.length >= PAGE_SIZE;
+
+ setSearch((s) => ({
+ ...s,
+ loading: false,
+ items: [...allItems, ...newItems],
+ // Accumulate results across pages so previous pages remain visible
+ currentItems: s.page === 1 ? newItems : [...s.currentItems,
...newItems],
+ total,
+ hasMore,
+ }));
+ } catch {
+ setSearch((s) => ({ ...s, loading: false, hasMore: false }));
+ }
};
useEffect(() => {
@@ -178,7 +193,9 @@ export const SearchRemote = ({ mode, plugin, connectionId,
config, disabledScope
prefix={<SearchOutlined />}
placeholder={config.searchPlaceholder ?? 'Search'}
value={search.query}
- onChange={(e) => setSearch({ ...search, query: e.target.value,
loading: true, currentItems: [] })}
+ onChange={(e) =>
+ setSearch({ ...search, query: e.target.value, loading: true,
currentItems: [], page: 1, hasMore: false })
+ }
/>
{!searchDebounce ? (
<MillerColumnsSelect
@@ -210,8 +227,12 @@ export const SearchRemote = ({ mode, plugin, connectionId,
config, disabledScope
columnCount={1}
columnHeight={300}
getCanExpand={() => false}
- getHasMore={() => search.loading}
- onScroll={() => setSearch({ ...search, page: search.page + 1 })}
+ getHasMore={() => search.hasMore}
+ onScroll={() => {
+ if (!search.loading && search.hasMore) {
+ setSearch((s) => ({ ...s, loading: true, page: s.page + 1 }));
+ }
+ }}
renderLoading={() => <Loading size={20} style={{ padding: '4px
12px' }} />}
disabledIds={(disabledScope ?? []).map((it) => it.id)}
selectedIds={selectedScope.map((it) => it.id)}