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)}

Reply via email to