Skip to content

feat(providers): recluster vendors by host:port when website_url empty#670

Merged
ding113 merged 2 commits intodevfrom
feat/provider-vendor-recluster-host-port
Jan 28, 2026
Merged

feat(providers): recluster vendors by host:port when website_url empty#670
ding113 merged 2 commits intodevfrom
feat/provider-vendor-recluster-host-port

Conversation

@ding113
Copy link
Owner

@ding113 ding113 commented Jan 28, 2026

Summary

Add computeVendorKey helper with host:port support for IP-based providers. When website_url is empty, use host:port as vendor key (different ports = different vendors).

Related:

Problem

When providers use IP addresses without a website_url, they were all grouped under the same vendor based on hostname alone. This caused issues when running multiple services on the same IP with different ports (e.g., 192.168.1.1:8080 and 192.168.1.1:9090 should be separate vendors).

Solution

  • Add computeVendorKey helper that uses host:port as vendor key when website_url is empty
  • Support IPv6 addresses with [ipv6]:port format
  • Use protocol default ports (http=80, https=443) when port not specified
  • Add reclusterProviderVendors action with preview/apply mode for migrating existing providers
  • Add ReclusterVendorsDialog UI component to settings and dashboard providers pages

Changes

Core Changes

  • src/repository/provider-endpoints.ts - Add computeVendorKey helper and normalizeHostWithPort function
  • src/actions/providers.ts - Add reclusterProviderVendors action with preview/apply modes

UI Changes

  • src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx - New dialog component
  • src/app/[locale]/settings/providers/page.tsx - Add recluster button
  • src/app/[locale]/dashboard/providers/page.tsx - Add recluster button

i18n (5 languages)

  • messages/*/settings/providers/recluster.json - New translation files
  • messages/*/settings/index.ts - Export new translations

Tests

  • tests/unit/repository/provider-endpoints-vendor-key.test.ts - 21 test cases for computeVendorKey
  • tests/unit/actions/providers-recluster.test.ts - 10 test cases for reclusterProviderVendors

Testing

Automated Tests

  • Unit tests for computeVendorKey (21 test cases)
  • Unit tests for reclusterProviderVendors action (10 test cases)
  • TypeScript type check passes
  • Biome lint check passes

Manual Testing

  1. Navigate to Settings > Providers
  2. Click "Recluster" button to open dialog
  3. Preview shows providers that will be moved and new vendor keys
  4. Apply changes to update vendor grouping

Checklist

  • Code follows project conventions
  • Self-review completed
  • Tests pass locally
  • i18n strings added for all 5 languages

Description enhanced by Claude AI

Greptile Overview

Greptile Summary

This PR enhances vendor clustering by using host:port as the vendor key when website_url is empty, solving the problem where multiple services on the same IP with different ports were incorrectly grouped together.

Key Changes:

  • Added computeVendorKey() helper function that uses host:port format when website_url is empty (supports IPv6 with bracket notation)
  • Implemented reclusterProviderVendors() server action with preview/apply modes for migrating existing providers
  • Created ReclusterVendorsDialog UI component with statistics and change preview table
  • Added comprehensive test coverage (31 total test cases across both test files)
  • Provided i18n translations for all 5 supported languages

Performance Improvements:
The implementation addresses previous review feedback by batch-loading all vendor data upfront using Promise.all() and a Map for O(1) lookups (lines 3623-3633 in providers.ts), avoiding N+1 query issues.

Code Quality:

  • Well-structured code with clear separation of concerns
  • Extensive test coverage validates edge cases (IPv6, default ports, URL normalization)
  • Proper error handling and transaction management
  • User-friendly preview mode prevents accidental data changes

Confidence Score: 5/5

  • This PR is safe to merge with high confidence - it's well-tested, addresses previous performance concerns, and includes proper safeguards.
  • Score reflects comprehensive test coverage (31 test cases), performance optimizations addressing previous feedback, safe preview/apply pattern preventing accidental changes, and clean implementation following project conventions.
  • No files require special attention - all changes are well-implemented and tested.

Important Files Changed

Filename Overview
src/repository/provider-endpoints.ts Added computeVendorKey and normalizeHostWithPort helpers for host:port-based vendor clustering when websiteUrl is empty. Well-tested with comprehensive unit tests.
src/actions/providers.ts Added reclusterProviderVendors action with preview/apply modes. Implements batch vendor loading optimization (addresses previous review comment).
src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx New dialog component for vendor reclustering UI. Includes preview mode, statistics display, and change table with proper error handling.
tests/unit/repository/provider-endpoints-vendor-key.test.ts Comprehensive test coverage for computeVendorKey with 21 test cases covering websiteUrl priority, host:port format, IPv6, and edge cases.
tests/unit/actions/providers-recluster.test.ts Unit tests for reclusterProviderVendors action with 10 test cases covering permissions, preview mode, apply mode, and error handling.

Sequence Diagram

sequenceDiagram
    participant User
    participant UI as ReclusterVendorsDialog
    participant Action as reclusterProviderVendors
    participant Repo as provider-endpoints
    participant DB as Database

    User->>UI: Click "Recluster" button
    UI->>Action: reclusterProviderVendors({confirm: false})
    Action->>Repo: findAllProvidersFresh()
    Repo->>DB: SELECT * FROM providers
    DB-->>Repo: All providers
    Repo-->>Action: providers[]
    
    Action->>Repo: findProviderVendorById(id) [batch]
    Repo->>DB: SELECT * FROM provider_vendors WHERE id IN (...)
    DB-->>Repo: Vendor records
    Repo-->>Action: vendorMap
    
    loop For each provider
        Action->>Repo: computeVendorKey({providerUrl, websiteUrl})
        Repo-->>Action: newVendorKey (host:port if websiteUrl empty)
        Action->>Action: Compare with current vendor domain
        alt Vendor key changed
            Action->>Action: Record change
        end
    end
    
    Action-->>UI: {preview: stats, changes: [], applied: false}
    UI->>User: Display preview table with changes
    
    User->>UI: Click "Apply Changes"
    UI->>Action: reclusterProviderVendors({confirm: true})
    Action->>Action: Recalculate changes (same as preview)
    
    alt Changes exist
        Action->>DB: BEGIN TRANSACTION
        loop For each change
            Action->>Repo: getOrCreateProviderVendorIdFromUrls()
            Repo->>DB: INSERT INTO provider_vendors (upsert)
            DB-->>Repo: newVendorId
            Action->>DB: UPDATE providers SET provider_vendor_id = newVendorId
        end
        Action->>DB: COMMIT TRANSACTION
        
        Action->>Repo: backfillProviderEndpointsFromProviders()
        Repo->>DB: INSERT INTO provider_endpoints (batch)
        
        loop For each old vendor
            Action->>Repo: tryDeleteProviderVendorIfEmpty(oldVendorId)
            Repo->>DB: DELETE FROM provider_vendors (if no active providers)
        end
        
        Action->>Action: publishProviderCacheInvalidation()
    end
    
    Action-->>UI: {applied: true, preview: stats}
    UI->>UI: Invalidate React Query cache
    UI->>User: Show success toast
Loading

- Add computeVendorKey helper with host:port support for IP-based providers
- When website_url is empty, use host:port as vendor key (different ports = different vendors)
- Support IPv6 addresses with [ipv6]:port format
- Use protocol default ports (http=80, https=443) when port not specified
- Add reclusterProviderVendors action with preview/apply mode
- Add ReclusterVendorsDialog UI component
- Add i18n support for 5 languages (zh-CN, zh-TW, en, ja, ru)
- Existing behavior unchanged when website_url is present (hostname only)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Jan 28, 2026

📝 Walkthrough

Walkthrough

新增供应商“recluster”功能:包含多语言文案、后端重新聚类操作、供应商键计算工具、前端对话框组件以及对应的单元测试,支持预览与应用两种模式并在应用时进行事务性数据库更新与回填/清理操作。

Changes

Cohort / File(s) 变更摘要
多语言本地化
messages/en/settings/index.ts, messages/en/settings/providers/recluster.json, messages/ja/settings/index.ts, messages/ja/settings/providers/recluster.json, messages/ru/settings/index.ts, messages/ru/settings/providers/recluster.json, messages/zh-CN/settings/index.ts, messages/zh-CN/settings/providers/recluster.json, messages/zh-TW/settings/index.ts, messages/zh-TW/settings/providers/recluster.json
新增 recluster 本地化 JSON 并在各语言的 index.ts 中导入并将 recluster 添加到导出的 providers 映射。均为静态 UI 文案。
后端动作
src/actions/providers.ts
新增管理员操作 reclusterProviderVendors(args: { confirm: boolean }),支持预览与应用;在应用模式内以事务更新 provider.vendorId、回填 endpoint、删除空供应商并尝试发布缓存失效。新增相关类型 ReclusterChange / ReclusterResult
供应商键与 URL 工具
src/repository/provider-endpoints.ts
新增并导出 computeVendorKey({ providerUrl, websiteUrl }) 与内部 normalizeHostWithPort,调整现有回填/创建流程使用该函数以支持 host:port、IPv6 与默认端口逻辑。
前端对话框组件
src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx
新增 ReclusterVendorsDialog 客户端组件:加载预览、展示统计卡与变更表、支持应用确认、处理加载/错误/成功通知并使相关查询失效。
UI 集成点
src/app/[locale]/dashboard/providers/page.tsx, src/app/[locale]/settings/providers/page.tsx
在仪表板与设置页面导入并渲染 ReclusterVendorsDialog
单元测试
tests/unit/actions/providers-recluster.test.ts, tests/unit/repository/provider-endpoints-vendor-key.test.ts
新增针对 reclusterProviderVendorscomputeVendorKey 的全面单元测试,覆盖权限、预览/应用、事务、IPv6/端口/无效 URL 等边界情况。

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 标题清晰准确地反映了此PR的核心变化:为空website_url的提供商添加按host:port进行供应商重新聚类的功能。
Description check ✅ Passed 描述详细完整,涵盖了问题、解决方案、所有主要变更、测试和手动验证步骤,与提交的代码变更高度相关。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/provider-vendor-recluster-host-port

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @ding113, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly refines how providers are grouped into vendors, especially for those lacking a direct website URL. By implementing a new clustering logic that leverages the host and port of a provider's URL, the system can now more accurately differentiate and group IP-based services. This enhancement is exposed through a new administrative action and a dedicated user interface, enabling administrators to preview and apply these re-clustering operations for improved organization and management of provider vendors.

Highlights

  • Enhanced Vendor Clustering Logic: Providers without a specified website_url will now be clustered into vendors using their host:port from the providerUrl. This allows for more granular and accurate grouping of IP-based providers, differentiating between services running on the same host but different ports.
  • New computeVendorKey Helper: A new utility function, computeVendorKey, has been introduced to standardize how vendor keys are generated. It intelligently handles various URL formats, including IPv6 addresses (e.g., [ipv6]:port) and automatically assigns default ports (80 for HTTP, 443 for HTTPS) when not explicitly provided in the URL.
  • Administrative Recluster Action and UI: A new reclusterProviderVendors action has been added, supporting both a preview mode to visualize changes and an apply mode to execute them. This action is integrated into the UI via a ReclusterVendorsDialog component, accessible from both settings and dashboard provider pages, providing a user-friendly way to manage vendor groupings.
  • Internationalization Support: The new recluster feature includes comprehensive internationalization (i18n) support, with translations provided for English, Japanese, Russian, Simplified Chinese, and Traditional Chinese, ensuring accessibility for a wider user base.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a valuable feature for reclustering provider vendors using host:port when a website_url is absent. The implementation is robust, featuring a new server action, a user-friendly dialog for previewing and applying changes, and thorough unit tests. The core logic in computeVendorKey is well-designed and extensively tested. I've identified a couple of opportunities to enhance performance in the reclusterProviderVendors action by optimizing database queries and data lookups within loops. Overall, this is a well-executed and significant improvement.

Comment on lines 3623 to 3654
for (const provider of allProviders) {
const newVendorKey = computeVendorKey({
providerUrl: provider.url,
websiteUrl: provider.websiteUrl,
});

if (!newVendorKey) {
skippedInvalidUrl++;
continue;
}

// Get current vendor domain
const currentVendor = provider.providerVendorId
? await findProviderVendorById(provider.providerVendorId)
: null;
const currentDomain = currentVendor?.websiteDomain ?? "";

// If key changed, record the change
if (currentDomain !== newVendorKey) {
newVendorKeys.add(newVendorKey);
if (provider.providerVendorId) {
oldVendorIds.add(provider.providerVendorId);
}
changes.push({
providerId: provider.id,
providerName: provider.name,
oldVendorId: provider.providerVendorId ?? 0,
oldVendorDomain: currentDomain,
newVendorDomain: newVendorKey,
});
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation uses await inside a for loop to fetch vendor information, which leads to sequential database calls. This can be inefficient if there are many providers. To improve performance, you can parallelize these database lookups using Promise.all before iterating through the providers.

    const vendorLookups = await Promise.all(
      allProviders.map((p) =>
        p.providerVendorId ? findProviderVendorById(p.providerVendorId) : null,
      ),
    );
    const providerToVendorMap = new Map(
      allProviders.map((p, i) => [p.id, vendorLookups[i]]),
    );

    // Calculate new vendor key for each provider
    for (const provider of allProviders) {
      const newVendorKey = computeVendorKey({
        providerUrl: provider.url,
        websiteUrl: provider.websiteUrl,
      });

      if (!newVendorKey) {
        skippedInvalidUrl++;
        continue;
      }

      // Get current vendor domain
      const currentVendor = providerToVendorMap.get(provider.id);
      const currentDomain = currentVendor?.websiteDomain ?? "";

      // If key changed, record the change
      if (currentDomain !== newVendorKey) {
        newVendorKeys.add(newVendorKey);
        if (provider.providerVendorId) {
          oldVendorIds.add(provider.providerVendorId);
        }
        changes.push({
          providerId: provider.id,
          providerName: provider.name,
          oldVendorId: provider.providerVendorId ?? 0,
          oldVendorDomain: currentDomain,
          newVendorDomain: newVendorKey,
        });
      }
    }

Comment on lines 3676 to 3691
if (changes.length > 0) {
await db.transaction(async (tx) => {
for (const change of changes) {
// Get or create new vendor
const newVendorId = await getOrCreateProviderVendorIdFromUrls({
providerUrl: allProviders.find((p) => p.id === change.providerId)?.url ?? "",
websiteUrl: allProviders.find((p) => p.id === change.providerId)?.websiteUrl ?? null,
});

// Update provider's vendorId
await tx
.update(providersTable)
.set({ providerVendorId: newVendorId, updatedAt: new Date() })
.where(eq(providersTable.id, change.providerId));
}
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Inside the transaction, allProviders.find() is called within a loop, which has a time complexity of O(N^2) in the worst case. This can be optimized to O(N) by creating a Map of providers for quick lookups before entering the transaction block.

Suggested change
if (changes.length > 0) {
await db.transaction(async (tx) => {
for (const change of changes) {
// Get or create new vendor
const newVendorId = await getOrCreateProviderVendorIdFromUrls({
providerUrl: allProviders.find((p) => p.id === change.providerId)?.url ?? "",
websiteUrl: allProviders.find((p) => p.id === change.providerId)?.websiteUrl ?? null,
});
// Update provider's vendorId
await tx
.update(providersTable)
.set({ providerVendorId: newVendorId, updatedAt: new Date() })
.where(eq(providersTable.id, change.providerId));
}
});
if (changes.length > 0) {
const providerMap = new Map(allProviders.map((p) => [p.id, p]));
await db.transaction(async (tx) => {
for (const change of changes) {
const provider = providerMap.get(change.providerId);
// Get or create new vendor
const newVendorId = await getOrCreateProviderVendorIdFromUrls({
providerUrl: provider?.url ?? "",
websiteUrl: provider?.websiteUrl ?? null,
});
// Update provider's vendorId
await tx
.update(providersTable)
.set({ providerVendorId: newVendorId, updatedAt: new Date() })
.where(eq(providersTable.id, change.providerId));
}
});

@github-actions github-actions bot added the size/L Large PR (< 1000 lines) label Jan 28, 2026
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +3589 to +3596
*/
export async function reclusterProviderVendors(args: {
confirm: boolean;
}): Promise<ActionResult<ReclusterResult>> {
try {
const session = await getSession();
if (!session || session.user.role !== "admin") {
return { ok: false, error: "NO_PERMISSION" };
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potential performance issue: this loops through all providers and makes a DB call (findProviderVendorById) for each one

consider batch-loading all vendor data upfront:

Suggested change
*/
export async function reclusterProviderVendors(args: {
confirm: boolean;
}): Promise<ActionResult<ReclusterResult>> {
try {
const session = await getSession();
if (!session || session.user.role !== "admin") {
return { ok: false, error: "NO_PERMISSION" };
// Get current vendor domain
const allVendorIds = allProviders
.map(p => p.providerVendorId)
.filter((id): id is number => id !== null && id !== undefined);
const vendors = await Promise.all(
[...new Set(allVendorIds)].map(id => findProviderVendorById(id))
);
const vendorMap = new Map(vendors.filter((v): v is NonNullable<typeof v> => v !== null).map(v => [v.id, v]));

then in the loop:

const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actions/providers.ts
Line: 3589:3596

Comment:
potential performance issue: this loops through all providers and makes a DB call (`findProviderVendorById`) for each one

consider batch-loading all vendor data upfront:

```suggestion
    // Get current vendor domain
    const allVendorIds = allProviders
      .map(p => p.providerVendorId)
      .filter((id): id is number => id !== null && id !== undefined);
    const vendors = await Promise.all(
      [...new Set(allVendorIds)].map(id => findProviderVendorById(id))
    );
    const vendorMap = new Map(vendors.filter((v): v is NonNullable<typeof v> => v !== null).map(v => [v.id, v]));
```

then in the loop:
```
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 3679 to 3688
// Get or create new vendor
const newVendorId = await getOrCreateProviderVendorIdFromUrls({
providerUrl: allProviders.find((p) => p.id === change.providerId)?.url ?? "",
websiteUrl: allProviders.find((p) => p.id === change.providerId)?.websiteUrl ?? null,
});

// Update provider's vendorId
await tx
.update(providersTable)
.set({ providerVendorId: newVendorId, updatedAt: new Date() })
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potential N+1 query issue: loops through changes and makes DB calls for each provider's URL lookup

the allProviders.find() calls in the loop are unnecessary since you already have the provider data from earlier

Suggested change
// Get or create new vendor
const newVendorId = await getOrCreateProviderVendorIdFromUrls({
providerUrl: allProviders.find((p) => p.id === change.providerId)?.url ?? "",
websiteUrl: allProviders.find((p) => p.id === change.providerId)?.websiteUrl ?? null,
});
// Update provider's vendorId
await tx
.update(providersTable)
.set({ providerVendorId: newVendorId, updatedAt: new Date() })
// Build a map for quick provider lookup
const providerMap = new Map(allProviders.map(p => [p.id, p]));
for (const change of changes) {
const provider = providerMap.get(change.providerId);
if (!provider) continue;
// Get or create new vendor
const newVendorId = await getOrCreateProviderVendorIdFromUrls({
providerUrl: provider.url,
websiteUrl: provider.websiteUrl ?? null,
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actions/providers.ts
Line: 3679:3688

Comment:
potential N+1 query issue: loops through changes and makes DB calls for each provider's URL lookup

the `allProviders.find()` calls in the loop are unnecessary since you already have the provider data from earlier

```suggestion
        // Build a map for quick provider lookup
        const providerMap = new Map(allProviders.map(p => [p.id, p]));
        
        for (const change of changes) {
          const provider = providerMap.get(change.providerId);
          if (!provider) continue;

          // Get or create new vendor
          const newVendorId = await getOrCreateProviderVendorIdFromUrls({
            providerUrl: provider.url,
            websiteUrl: provider.websiteUrl ?? null,
          });
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR adds a new feature to recluster provider vendors by host:port when website_url is empty. The implementation is generally well-structured with good test coverage.

PR Size: L

  • Lines changed: 1124 (1117 additions, 7 deletions)
  • Files changed: 17

Note: This is a large PR. Consider splitting future similar changes into smaller PRs for easier review.

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 0 0 0
Security 0 0 0 0
Error Handling 0 0 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
Performance 0 1 0 0

High Priority Issues (Should Fix)

1. [PERFORMANCE-ISSUE] Inefficient O(n*m) lookup inside transaction loop (src/actions/providers.ts:3681-3682)

The allProviders.find() is called twice per change inside the loop. For a large number of providers and changes, this results in O(n*m) complexity.

Suggested fix: Build a Map for O(1) lookups before the loop:

const providerMap = new Map(allProviders.map((p) => [p.id, p]));

await db.transaction(async (tx) => {
  for (const change of changes) {
    const provider = providerMap.get(change.providerId);
    const newVendorId = await getOrCreateProviderVendorIdFromUrls({
      providerUrl: provider?.url ?? "",
      websiteUrl: provider?.websiteUrl ?? null,
    });
    // ...
  }
});

Review Coverage

  • Logic and correctness - Clean
  • Security (OWASP Top 10) - Clean
  • Error handling - Clean
  • Type safety - Clean
  • Documentation accuracy - Clean
  • Test coverage - Adequate (31 test cases)
  • Code clarity - Good

Automated review by Claude AI

… lookup

- Batch load all vendor data upfront with Promise.all to avoid N+1 queries
- Use Map for O(1) provider lookup instead of O(N) find() in transaction loop
- Addresses bugbot review comments from gemini-code-assist and greptile-apps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@ding113 ding113 merged commit 8160704 into dev Jan 28, 2026
2 of 7 checks passed
@github-project-automation github-project-automation bot moved this from Backlog to Done in Claude Code Hub Roadmap Jan 28, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/repository/provider-endpoints.ts`:
- Around line 63-92: normalizeHostWithPort currently returns host:port and for
raw IPv6 hostnames like "2001:db8::1" produces "2001:db8::1:443", which is
invalid; update normalizeHostWithPort to detect IPv6 hostnames (hostname
includes ':' and does not already start with '[') and wrap the hostname in
brackets before appending the port so the returned value follows the
"[ipv6]:port" form; use parsed.hostname/parsed.port/parsed.protocol and preserve
existing bracketed addresses (i.e., only add brackets when needed) so all return
values are normalizedHostnameWithPort in the expected format.
🧹 Nitpick comments (1)
tests/unit/repository/provider-endpoints-vendor-key.test.ts (1)

160-205: 建议:考虑增加 websiteUrl 无效格式的测试用例

当前边缘用例测试了 providerUrl 无效的情况,但未测试 websiteUrl 格式无效时的行为。建议补充此场景以确保完整覆盖。

💡 建议添加的测试用例
     test("returns null for truly invalid URL", () => {
       expect(
         computeVendorKey({
           providerUrl: "://invalid",
           websiteUrl: null,
         })
       ).toBeNull();
     });
+
+    test("falls back to providerUrl when websiteUrl is invalid", () => {
+      expect(
+        computeVendorKey({
+          providerUrl: "http://192.168.1.1:8080/v1/messages",
+          websiteUrl: "://invalid",
+        })
+      ).toBe("192.168.1.1:8080");
+    });
   });
 });

Comment on lines +63 to +92
function normalizeHostWithPort(rawUrl: string): string | null {
const trimmed = rawUrl.trim();
if (!trimmed) return null;

// Add https:// if no scheme present
let urlString = trimmed;
if (!/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) {
urlString = `https://${trimmed}`;
}

try {
const parsed = new URL(urlString);
const hostname = parsed.hostname?.toLowerCase();
if (!hostname) return null;

// Strip www. prefix
const normalizedHostname = hostname.startsWith("www.") ? hostname.slice(4) : hostname;

// Determine port
let port: string;
if (parsed.port) {
port = parsed.port;
} else {
// Use protocol default port
port = parsed.protocol === "http:" ? "80" : "443";
}

// IPv6 addresses already have brackets from URL parser (e.g., "[::1]")
// Just append the port directly
return `${normalizedHostname}:${port}`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

IPv6 主机未加方括号会生成错误的 vendor key
当前拼接会在 IPv6 情况下生成 2001:db8::1:443 这类非标准格式,与注释的 [ipv6]:port 规则不一致,可能导致聚类键冲突或显示异常。建议在包含冒号的主机名上补 []

建议修复
-    // IPv6 addresses already have brackets from URL parser (e.g., "[::1]")
-    // Just append the port directly
-    return `${normalizedHostname}:${port}`;
+    // Wrap IPv6 hosts in brackets to keep host:port unambiguous
+    const hostForKey = normalizedHostname.includes(":")
+      ? `[${normalizedHostname}]`
+      : normalizedHostname;
+    return `${hostForKey}:${port}`;
🤖 Prompt for AI Agents
In `@src/repository/provider-endpoints.ts` around lines 63 - 92,
normalizeHostWithPort currently returns host:port and for raw IPv6 hostnames
like "2001:db8::1" produces "2001:db8::1:443", which is invalid; update
normalizeHostWithPort to detect IPv6 hostnames (hostname includes ':' and does
not already start with '[') and wrap the hostname in brackets before appending
the port so the returned value follows the "[ipv6]:port" form; use
parsed.hostname/parsed.port/parsed.protocol and preserve existing bracketed
addresses (i.e., only add brackets when needed) so all return values are
normalizedHostnameWithPort in the expected format.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/actions/providers.ts`:
- Around line 3649-3654: The line declaring currentDomain violates Biome
line-wrapping rules; modify the const declaration for currentDomain (currently
using currentVendor?.websiteDomain ?? "") to span multiple lines so it wraps
properly — e.g., break after the optional chaining or the nullish coalescing
operator and indent the fallback string on the next line; adjust the const in
the same block where provider, vendorMap and currentVendor are used to preserve
semantics.
- Around line 3631-3675: The preview's vendorsCreated can be inflated because
newVendorKeys currently includes domains that already exist as
vendor.websiteDomain even if they have no providers; to fix, build a set of
existing vendor domains from vendors (e.g., existingVendorDomains) and when
iterating allProviders and computing newVendorKey (via computeVendorKey), only
add newVendorKey to newVendorKeys if newVendorKey is truthy and not present in
existingVendorDomains (and of course not already in newVendorKeys); reference
vendorMap, newVendorKeys, computeVendorKey, allProviders and ensure
preview.vendorsCreated uses the filtered newVendorKeys.size.
- Around line 3594-3597: Replace the hard-coded "NO_PERMISSION" error string
with the project-standard error code AUTH_ERRORS.PERMISSION_DENIED (imported
from src/lib/utils/error-messages.ts) so the returned ActionResult uses the
correct errorCode and supports translations; update the return in the
getSession() check around the session.user.role !== "admin" guard to return {
ok: false, error: AUTH_ERRORS.PERMISSION_DENIED } and add the necessary import
for AUTH_ERRORS at the top of the file, and then propagate the same replacement
for other permission checks in this file that currently return "NO_PERMISSION"
(the other similar checks noted in the review).
🧹 Nitpick comments (1)
src/actions/providers.ts (1)

3691-3707: 建议让 vendor 创建与 provider 更新同一事务

getOrCreateProviderVendorIdFromUrls 目前使用全局 db 实例创建 vendor,而 provider 更新在事务内执行。若事务回滚,vendor 会被遗留在数据库中(孤立 vendor)。建议将 tx 参数传入该函数,并在创建 vendor 时使用 tx 而非全局 db

此修改需要更新函数签名及其 5 个调用点(provider.ts 2 处、provider-endpoints.ts 1 处、actions/providers.ts 1 处及本处)。由于部分调用点不在事务context中,建议将 tx 参数设为可选,或采用函数重载处理。

Comment on lines +3594 to +3597
const session = await getSession();
if (!session || session.user.role !== "admin") {
return { ok: false, error: "NO_PERMISSION" };
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 查找 NO_PERMISSION 的前端映射或显示位置
rg -n -C3 "NO_PERMISSION" -g'*.{ts,tsx,js,jsx}'

Repository: ding113/claude-code-hub

Length of output: 492


🏁 Script executed:

#!/bin/bash
# 查找 i18n/翻译相关的文件
fd -e json -e ts -e tsx | grep -i -E '(i18n|messages|locale|translation|lang)' | head -20

Repository: ding113/claude-code-hub

Length of output: 1134


🏁 Script executed:

#!/bin/bash
# 查看 src/actions/providers.ts 中其他的 error 返回方式
rg -n 'return.*error' src/actions/providers.ts | head -20

Repository: ding113/claude-code-hub

Length of output: 1092


🏁 Script executed:

#!/bin/bash
# 查找i18n翻译文件的位置
fd -type f -E 'node_modules' -E 'dist' -E 'build' | grep -E '(messages|locale|lang)' | grep -v test | head -20

Repository: ding113/claude-code-hub

Length of output: 237


🏁 Script executed:

#!/bin/bash
# 查看 src/actions/providers.ts 中 NO_PERMISSION 行的完整上下文
sed -n '3590,3610p' src/actions/providers.ts

Repository: ding113/claude-code-hub

Length of output: 651


🏁 Script executed:

#!/bin/bash
# 查找翻译文件中是否有 NO_PERMISSION
rg "NO_PERMISSION" -E '.json$|.ts$' --type-list | grep -v node_modules | grep -v test

Repository: ding113/claude-code-hub

Length of output: 456


🏁 Script executed:

#!/bin/bash
# 查找i18n翻译文件的位置
find src -type f \( -name "*messages*" -o -name "*locale*" -o -name "*i18n*" \) 2>/dev/null | head -20

Repository: ding113/claude-code-hub

Length of output: 749


🏁 Script executed:

#!/bin/bash
# 搜索NO_PERMISSION在整个项目中的使用
rg "NO_PERMISSION" -n

Repository: ding113/claude-code-hub

Length of output: 148


🏁 Script executed:

#!/bin/bash
# 查看src目录的结构
ls -la src/ | head -30

Repository: ding113/claude-code-hub

Length of output: 834


🏁 Script executed:

#!/bin/bash
# 查看 src/i18n 目录的内容
ls -la src/i18n/

Repository: ding113/claude-code-hub

Length of output: 481


🏁 Script executed:

#!/bin/bash
# 查看 error-messages.ts 文件内容
cat src/lib/utils/error-messages.ts

Repository: ding113/claude-code-hub

Length of output: 8132


🏁 Script executed:

#!/bin/bash
# 查找翻译文件中是否有 permission 相关的内容
rg -i "permission" src/i18n/ -l

Repository: ding113/claude-code-hub

Length of output: 49


🏁 Script executed:

#!/bin/bash
# 查看 src/actions/providers.ts 中其他的错误返回是否都使用硬编码中文
grep -n 'error:' src/actions/providers.ts | grep -v ERROR_CODES | grep -v PERMISSION_DENIED | head -10

Repository: ding113/claude-code-hub

Length of output: 722


🏁 Script executed:

#!/bin/bash
# 检查项目中是否有其他地方使用了 NO_PERMISSION 这样的非标准错误代码
rg '"[A-Z_]+"\s*}' src/actions/ -A1 -B1 | grep error | head -20

Repository: ding113/claude-code-hub

Length of output: 222


改用项目标准错误代码 PERMISSION_DENIED

"NO_PERMISSION" 不符合项目的错误代码规范。应根据 src/lib/utils/error-messages.ts 的定义,改用 AUTH_ERRORS.PERMISSION_DENIED,以便:

  • 支持多语言翻译映射
  • 符合 ActionResulterrorCode 规范

其他权限检查(如 490、658 行)也存在同样的硬编码中文问题,建议同步修正。

🤖 Prompt for AI Agents
In `@src/actions/providers.ts` around lines 3594 - 3597, Replace the hard-coded
"NO_PERMISSION" error string with the project-standard error code
AUTH_ERRORS.PERMISSION_DENIED (imported from src/lib/utils/error-messages.ts) so
the returned ActionResult uses the correct errorCode and supports translations;
update the return in the getSession() check around the session.user.role !==
"admin" guard to return { ok: false, error: AUTH_ERRORS.PERMISSION_DENIED } and
add the necessary import for AUTH_ERRORS at the top of the file, and then
propagate the same replacement for other permission checks in this file that
currently return "NO_PERMISSION" (the other similar checks noted in the review).

Comment on lines +3631 to +3675
const vendorMap = new Map(
vendors.filter((v): v is NonNullable<typeof v> => v !== null).map((v) => [v.id, v])
);

// Build provider map for quick lookup in transaction
const providerMap = new Map(allProviders.map((p) => [p.id, p]));

// Calculate new vendor key for each provider
for (const provider of allProviders) {
const newVendorKey = computeVendorKey({
providerUrl: provider.url,
websiteUrl: provider.websiteUrl,
});

if (!newVendorKey) {
skippedInvalidUrl++;
continue;
}

// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
const currentDomain = currentVendor?.websiteDomain ?? "";

// If key changed, record the change
if (currentDomain !== newVendorKey) {
newVendorKeys.add(newVendorKey);
if (provider.providerVendorId) {
oldVendorIds.add(provider.providerVendorId);
}
changes.push({
providerId: provider.id,
providerName: provider.name,
oldVendorId: provider.providerVendorId ?? 0,
oldVendorDomain: currentDomain,
newVendorDomain: newVendorKey,
});
}
}

const preview = {
providersMoved: changes.length,
vendorsCreated: newVendorKeys.size,
vendorsToDelete: oldVendorIds.size,
skippedInvalidUrl,
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

vendorsCreated 预览可能被高估
新 key 可能已存在 vendor(当前无 provider 绑定),统计会偏大;建议扣除已存在域名。

修复示例
     const vendorMap = new Map(
       vendors.filter((v): v is NonNullable<typeof v> => v !== null).map((v) => [v.id, v])
     );
+    const existingVendorDomains = new Set(
+      Array.from(vendorMap.values()).map((vendor) => vendor.websiteDomain)
+    );
@@
     const preview = {
       providersMoved: changes.length,
-      vendorsCreated: newVendorKeys.size,
+      vendorsCreated: Array.from(newVendorKeys).filter(
+        (key) => !existingVendorDomains.has(key)
+      ).length,
       vendorsToDelete: oldVendorIds.size,
       skippedInvalidUrl,
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const vendorMap = new Map(
vendors.filter((v): v is NonNullable<typeof v> => v !== null).map((v) => [v.id, v])
);
// Build provider map for quick lookup in transaction
const providerMap = new Map(allProviders.map((p) => [p.id, p]));
// Calculate new vendor key for each provider
for (const provider of allProviders) {
const newVendorKey = computeVendorKey({
providerUrl: provider.url,
websiteUrl: provider.websiteUrl,
});
if (!newVendorKey) {
skippedInvalidUrl++;
continue;
}
// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
const currentDomain = currentVendor?.websiteDomain ?? "";
// If key changed, record the change
if (currentDomain !== newVendorKey) {
newVendorKeys.add(newVendorKey);
if (provider.providerVendorId) {
oldVendorIds.add(provider.providerVendorId);
}
changes.push({
providerId: provider.id,
providerName: provider.name,
oldVendorId: provider.providerVendorId ?? 0,
oldVendorDomain: currentDomain,
newVendorDomain: newVendorKey,
});
}
}
const preview = {
providersMoved: changes.length,
vendorsCreated: newVendorKeys.size,
vendorsToDelete: oldVendorIds.size,
skippedInvalidUrl,
};
const vendorMap = new Map(
vendors.filter((v): v is NonNullable<typeof v> => v !== null).map((v) => [v.id, v])
);
const existingVendorDomains = new Set(
Array.from(vendorMap.values()).map((vendor) => vendor.websiteDomain)
);
// Build provider map for quick lookup in transaction
const providerMap = new Map(allProviders.map((p) => [p.id, p]));
// Calculate new vendor key for each provider
for (const provider of allProviders) {
const newVendorKey = computeVendorKey({
providerUrl: provider.url,
websiteUrl: provider.websiteUrl,
});
if (!newVendorKey) {
skippedInvalidUrl++;
continue;
}
// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
const currentDomain = currentVendor?.websiteDomain ?? "";
// If key changed, record the change
if (currentDomain !== newVendorKey) {
newVendorKeys.add(newVendorKey);
if (provider.providerVendorId) {
oldVendorIds.add(provider.providerVendorId);
}
changes.push({
providerId: provider.id,
providerName: provider.name,
oldVendorId: provider.providerVendorId ?? 0,
oldVendorDomain: currentDomain,
newVendorDomain: newVendorKey,
});
}
}
const preview = {
providersMoved: changes.length,
vendorsCreated: Array.from(newVendorKeys).filter(
(key) => !existingVendorDomains.has(key)
).length,
vendorsToDelete: oldVendorIds.size,
skippedInvalidUrl,
};
🧰 Tools
🪛 GitHub Actions: 🧪 Test Suite

[error] 3649-3653: Formatter would have printed different content. Run the formatter to fix code style issues.

🪛 GitHub Actions: PR Build Check

[error] 3649-3654: Code formatter would reformat the following content. The format:check failed (exit code 1). Run 'bun run format' or 'bun run format:write' to fix formatting in this file.

🤖 Prompt for AI Agents
In `@src/actions/providers.ts` around lines 3631 - 3675, The preview's
vendorsCreated can be inflated because newVendorKeys currently includes domains
that already exist as vendor.websiteDomain even if they have no providers; to
fix, build a set of existing vendor domains from vendors (e.g.,
existingVendorDomains) and when iterating allProviders and computing
newVendorKey (via computeVendorKey), only add newVendorKey to newVendorKeys if
newVendorKey is truthy and not present in existingVendorDomains (and of course
not already in newVendorKeys); reference vendorMap, newVendorKeys,
computeVendorKey, allProviders and ensure preview.vendorsCreated uses the
filtered newVendorKeys.size.

Comment on lines +3649 to +3654

// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
const currentDomain = currentVendor?.websiteDomain ?? "";

// If key changed, record the change
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

该行触发格式化失败
CI 已提示此处需要按 Biome 规则换行。

修复示例
-      const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
+      const currentVendor = provider.providerVendorId
+        ? vendorMap.get(provider.providerVendorId)
+        : null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId ? vendorMap.get(provider.providerVendorId) : null;
const currentDomain = currentVendor?.websiteDomain ?? "";
// If key changed, record the change
// Get current vendor domain from pre-loaded map
const currentVendor = provider.providerVendorId
? vendorMap.get(provider.providerVendorId)
: null;
const currentDomain = currentVendor?.websiteDomain ?? "";
// If key changed, record the change
🧰 Tools
🪛 GitHub Actions: 🧪 Test Suite

[error] 3649-3653: Formatter would have printed different content. Run the formatter to fix code style issues.

🪛 GitHub Actions: PR Build Check

[error] 3649-3654: Code formatter would reformat the following content. The format:check failed (exit code 1). Run 'bun run format' or 'bun run format:write' to fix formatting in this file.

🤖 Prompt for AI Agents
In `@src/actions/providers.ts` around lines 3649 - 3654, The line declaring
currentDomain violates Biome line-wrapping rules; modify the const declaration
for currentDomain (currently using currentVendor?.websiteDomain ?? "") to span
multiple lines so it wraps properly — e.g., break after the optional chaining or
the nullish coalescing operator and indent the fallback string on the next line;
adjust the const in the same block where provider, vendorMap and currentVendor
are used to preserve semantics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:i18n area:provider area:UI enhancement New feature or request size/L Large PR (< 1000 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant