refactor: use ungh for github api (#34108)

This commit is contained in:
Stephen Zhou
2026-03-26 14:37:17 +08:00
committed by GitHub
parent 554ba6b8f3
commit c32eebf57d
26 changed files with 233 additions and 425 deletions
@@ -1,11 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import nock from 'nock'
import * as React from 'react'
import GithubStar from '../index'
const GITHUB_HOST = 'https://api.github.com'
const GITHUB_PATH = '/repos/langgenius/dify'
const GITHUB_STAR_URL = 'https://ungh.cc/repos/langgenius/dify'
const renderWithQueryClient = () => {
const queryClient = new QueryClient({
@@ -18,40 +15,66 @@ const renderWithQueryClient = () => {
)
}
const mockGithubStar = (status: number, body: Record<string, unknown>, delayMs = 0) => {
return nock(GITHUB_HOST).get(GITHUB_PATH).delay(delayMs).reply(status, body)
const createJsonResponse = (body: Record<string, unknown>, status = 200) => {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
const createDeferred = <T,>() => {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('GithubStar', () => {
beforeEach(() => {
nock.cleanAll()
vi.restoreAllMocks()
vi.clearAllMocks()
})
// Shows fetched star count when request succeeds
// Covers the fetched star count shown after a successful request.
it('should render fetched star count', async () => {
mockGithubStar(200, { stargazers_count: 123456 })
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
createJsonResponse({ repo: { stars: 123456 } }),
)
renderWithQueryClient()
expect(await screen.findByText('123,456')).toBeInTheDocument()
expect(fetchSpy).toHaveBeenCalledWith(GITHUB_STAR_URL)
})
// Falls back to default star count when request fails
// Covers the fallback star count shown when the request fails.
it('should render default star count on error', async () => {
mockGithubStar(500, {})
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
createJsonResponse({}, 500),
)
renderWithQueryClient()
expect(await screen.findByText('110,918')).toBeInTheDocument()
})
// Renders loader while fetching data
// Covers the loading indicator while the fetch promise is still pending.
it('should show loader while fetching', async () => {
mockGithubStar(200, { stargazers_count: 222222 }, 50)
const deferred = createDeferred<Response>()
vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(deferred.promise)
const { container } = renderWithQueryClient()
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
await waitFor(() => expect(screen.getByText('222,222')).toBeInTheDocument())
deferred.resolve(createJsonResponse({ repo: { stars: 222222 } }))
await waitFor(() => {
expect(screen.getByText('222,222')).toBeInTheDocument()
})
})
})
+13 -11
View File
@@ -1,16 +1,19 @@
'use client'
import type { FC } from 'react'
import type { GithubRepo } from '@/models/common'
import { RiLoader2Line } from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import { IS_DEV } from '@/config'
const defaultData = {
stargazers_count: 110918,
type GithubStarResponse = {
repo: {
stars: number
}
}
const defaultData: GithubStarResponse = {
repo: { stars: 110918 },
}
const getStar = async () => {
const res = await fetch('https://api.github.com/repos/langgenius/dify')
const res = await fetch('https://ungh.cc/repos/langgenius/dify')
if (!res.ok)
throw new Error('Failed to fetch github star')
@@ -19,21 +22,20 @@ const getStar = async () => {
}
const GithubStar: FC<{ className: string }> = (props) => {
const { isFetching, isError, data } = useQuery<GithubRepo>({
const { isFetching, isError, data } = useQuery<GithubStarResponse>({
queryKey: ['github-star'],
queryFn: getStar,
enabled: !IS_DEV,
retry: false,
placeholderData: defaultData,
})
if (isFetching)
return <RiLoader2Line className="size-3 shrink-0 animate-spin text-text-tertiary" />
return <span className="i-ri-loader-2-line size-3 shrink-0 animate-spin text-text-tertiary" />
if (isError)
return <span {...props}>{defaultData.stargazers_count.toLocaleString()}</span>
return <span {...props}>{defaultData.repo.stars.toLocaleString()}</span>
return <span {...props}>{data?.stargazers_count.toLocaleString()}</span>
return <span {...props}>{data?.repo.stars.toLocaleString()}</span>
}
export default GithubStar
@@ -1,6 +1,5 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useGitHubReleases, useGitHubUpload } from '../hooks'
import { checkForUpdates, fetchReleases, handleUpload } from '../hooks'
const mockNotify = vi.fn()
vi.mock('@/app/components/base/ui/toast', () => ({
@@ -15,10 +14,6 @@ vi.mock('@/app/components/base/ui/toast', () => ({
}),
}))
vi.mock('@/config', () => ({
GITHUB_ACCESS_TOKEN: '',
}))
const mockUploadGitHub = vi.fn()
vi.mock('@/service/plugins', () => ({
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
@@ -37,17 +32,17 @@ describe('install-plugin/hooks', () => {
it('fetches releases from GitHub API and formats them', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve([
{
tag_name: 'v1.0.0',
assets: [{ browser_download_url: 'https://example.com/v1.zip', name: 'plugin.zip' }],
body: 'Release notes',
},
]),
json: () => Promise.resolve({
releases: [
{
tag: 'v1.0.0',
assets: [{ downloadUrl: 'https://example.com/plugin.zip' }],
},
],
}),
})
const { result } = renderHook(() => useGitHubReleases())
const releases = await result.current.fetchReleases('owner', 'repo')
const releases = await fetchReleases('owner', 'repo')
expect(releases).toHaveLength(1)
expect(releases[0].tag_name).toBe('v1.0.0')
@@ -60,8 +55,7 @@ describe('install-plugin/hooks', () => {
ok: false,
})
const { result } = renderHook(() => useGitHubReleases())
const releases = await result.current.fetchReleases('owner', 'repo')
const releases = await fetchReleases('owner', 'repo')
expect(releases).toEqual([])
expect(mockNotify).toHaveBeenCalledWith('Failed to fetch repository releases')
@@ -70,29 +64,26 @@ describe('install-plugin/hooks', () => {
describe('checkForUpdates', () => {
it('detects newer version available', () => {
const { result } = renderHook(() => useGitHubReleases())
const releases = [
{ tag_name: 'v1.0.0', assets: [] },
{ tag_name: 'v2.0.0', assets: [] },
]
const { needUpdate, toastProps } = result.current.checkForUpdates(releases, 'v1.0.0')
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
expect(needUpdate).toBe(true)
expect(toastProps.message).toContain('v2.0.0')
})
it('returns no update when current is latest', () => {
const { result } = renderHook(() => useGitHubReleases())
const releases = [
{ tag_name: 'v1.0.0', assets: [] },
]
const { needUpdate, toastProps } = result.current.checkForUpdates(releases, 'v1.0.0')
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
expect(needUpdate).toBe(false)
expect(toastProps.type).toBe('info')
})
it('returns error for empty releases', () => {
const { result } = renderHook(() => useGitHubReleases())
const { needUpdate, toastProps } = result.current.checkForUpdates([], 'v1.0.0')
const { needUpdate, toastProps } = checkForUpdates([], 'v1.0.0')
expect(needUpdate).toBe(false)
expect(toastProps.type).toBe('error')
expect(toastProps.message).toContain('empty')
@@ -109,8 +100,7 @@ describe('install-plugin/hooks', () => {
})
const onSuccess = vi.fn()
const { result } = renderHook(() => useGitHubUpload())
const pkg = await result.current.handleUpload(
const pkg = await handleUpload(
'https://github.com/owner/repo',
'v1.0.0',
'plugin.difypkg',
@@ -132,9 +122,8 @@ describe('install-plugin/hooks', () => {
it('shows toast on upload error', async () => {
mockUploadGitHub.mockRejectedValue(new Error('Upload failed'))
const { result } = renderHook(() => useGitHubUpload())
await expect(
result.current.handleUpload('url', 'v1', 'pkg'),
handleUpload('url', 'v1', 'pkg'),
).rejects.toThrow('Upload failed')
expect(mockNotify).toHaveBeenCalledWith('Error uploading package')
})
@@ -1,101 +1,87 @@
import type { GitHubRepoReleaseResponse } from '../types'
import { toast } from '@/app/components/base/ui/toast'
import { GITHUB_ACCESS_TOKEN } from '@/config'
import { uploadGitHub } from '@/service/plugins'
import { compareVersion, getLatestVersion } from '@/utils/semver'
const normalizeAssetName = (downloadUrl: string) => {
const parts = downloadUrl.split('/')
return parts[parts.length - 1]
}
const formatReleases = (releases: any) => {
return releases.map((release: any) => ({
tag_name: release.tag_name,
tag_name: release.tag,
assets: release.assets.map((asset: any) => ({
browser_download_url: asset.browser_download_url,
name: asset.name,
browser_download_url: asset.downloadUrl,
name: normalizeAssetName(asset.downloadUrl),
})),
}))
}
export const useGitHubReleases = () => {
const fetchReleases = async (owner: string, repo: string) => {
try {
if (!GITHUB_ACCESS_TOKEN) {
// Fetch releases without authentication from client
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`)
if (!res.ok)
throw new Error('Failed to fetch repository releases')
const data = await res.json()
return formatReleases(data)
}
else {
// Fetch releases with authentication from server
const res = await fetch(`/repos/${owner}/${repo}/releases`)
const bodyJson = await res.json()
if (bodyJson.status !== 200)
throw new Error(bodyJson.data.message)
return formatReleases(bodyJson.data)
}
}
catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
else {
toast.error('Failed to fetch repository releases')
}
return []
}
export const fetchReleases = async (owner: string, repo: string) => {
try {
// Fetch releases without authentication from client
const res = await fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`)
if (!res.ok)
throw new Error('Failed to fetch repository releases')
const data = await res.json()
return formatReleases(data.releases)
}
catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
else {
toast.error('Failed to fetch repository releases')
}
return []
}
}
const checkForUpdates = (fetchedReleases: GitHubRepoReleaseResponse[], currentVersion: string) => {
let needUpdate = false
const toastProps: { type?: 'success' | 'error' | 'info' | 'warning', message: string } = {
type: 'info',
message: 'No new version available',
}
if (fetchedReleases.length === 0) {
toastProps.type = 'error'
toastProps.message = 'Input releases is empty'
return { needUpdate, toastProps }
}
const versions = fetchedReleases.map(release => release.tag_name)
const latestVersion = getLatestVersion(versions)
try {
needUpdate = compareVersion(latestVersion, currentVersion) === 1
if (needUpdate)
toastProps.message = `New version available: ${latestVersion}`
}
catch {
needUpdate = false
toastProps.type = 'error'
toastProps.message = 'Fail to compare versions, please check the version format'
}
export const checkForUpdates = (fetchedReleases: GitHubRepoReleaseResponse[], currentVersion: string) => {
let needUpdate = false
const toastProps: { type?: 'success' | 'error' | 'info' | 'warning', message: string } = {
type: 'info',
message: 'No new version available',
}
if (fetchedReleases.length === 0) {
toastProps.type = 'error'
toastProps.message = 'Input releases is empty'
return { needUpdate, toastProps }
}
return { fetchReleases, checkForUpdates }
const versions = fetchedReleases.map(release => release.tag_name)
const latestVersion = getLatestVersion(versions)
try {
needUpdate = compareVersion(latestVersion, currentVersion) === 1
if (needUpdate)
toastProps.message = `New version available: ${latestVersion}`
}
catch {
needUpdate = false
toastProps.type = 'error'
toastProps.message = 'Fail to compare versions, please check the version format'
}
return { needUpdate, toastProps }
}
export const useGitHubUpload = () => {
const handleUpload = async (
repoUrl: string,
selectedVersion: string,
selectedPackage: string,
onSuccess?: (GitHubPackage: { manifest: any, unique_identifier: string }) => void,
) => {
try {
const response = await uploadGitHub(repoUrl, selectedVersion, selectedPackage)
const GitHubPackage = {
manifest: response.manifest,
unique_identifier: response.unique_identifier,
}
if (onSuccess)
onSuccess(GitHubPackage)
return GitHubPackage
}
catch (error) {
toast.error('Error uploading package')
throw error
export const handleUpload = async (
repoUrl: string,
selectedVersion: string,
selectedPackage: string,
onSuccess?: (GitHubPackage: { manifest: any, unique_identifier: string }) => void,
) => {
try {
const response = await uploadGitHub(repoUrl, selectedVersion, selectedPackage)
const GitHubPackage = {
manifest: response.manifest,
unique_identifier: response.unique_identifier,
}
if (onSuccess)
onSuccess(GitHubPackage)
return GitHubPackage
}
catch (error) {
toast.error('Error uploading package')
throw error
}
return { handleUpload }
}
@@ -74,10 +74,16 @@ vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
default: () => ({ getIconUrl: mockGetIconUrl }),
}))
const mockFetchReleases = vi.fn()
vi.mock('../../hooks', () => ({
useGitHubReleases: () => ({ fetchReleases: mockFetchReleases }),
const { mockFetchReleases } = vi.hoisted(() => ({
mockFetchReleases: vi.fn(),
}))
vi.mock('../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks')>()
return {
...actual,
fetchReleases: mockFetchReleases,
}
})
const mockRefreshPluginList = vi.fn()
vi.mock('../../hooks/use-refresh-plugin-list', () => ({
@@ -12,7 +12,7 @@ import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-ico
import { cn } from '@/utils/classnames'
import { InstallStepFromGitHub } from '../../types'
import Installed from '../base/installed'
import { useGitHubReleases } from '../hooks'
import { fetchReleases } from '../hooks'
import useHideLogic from '../hooks/use-hide-logic'
import useRefreshPluginList from '../hooks/use-refresh-plugin-list'
import { convertRepoToUrl, parseGitHubUrl } from '../utils'
@@ -31,7 +31,6 @@ type InstallFromGitHubProps = {
const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, onClose, onSuccess }) => {
const { t } = useTranslation()
const { getIconUrl } = useGetIcon()
const { fetchReleases } = useGitHubReleases()
const { refreshPluginList } = useRefreshPluginList()
const {
@@ -5,11 +5,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../../types'
import SelectPackage from '../selectPackage'
// Mock the useGitHubUpload hook
const mockHandleUpload = vi.fn()
vi.mock('../../../hooks', () => ({
useGitHubUpload: () => ({ handleUpload: mockHandleUpload }),
// Mock upload helper from hooks module
const { mockHandleUpload } = vi.hoisted(() => ({
mockHandleUpload: vi.fn(),
}))
vi.mock('../../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../hooks')>()
return {
...actual,
handleUpload: mockHandleUpload,
}
})
// Factory functions
const createMockManifest = (): PluginDeclaration => ({
@@ -6,7 +6,7 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { PortalSelect } from '@/app/components/base/select'
import { useGitHubUpload } from '../../hooks'
import { handleUpload } from '../../hooks'
const i18nPrefix = 'installFromGitHub'
@@ -43,7 +43,6 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
const { t } = useTranslation()
const isEdit = Boolean(updatePayload)
const [isUploading, setIsUploading] = React.useState(false)
const { handleUpload } = useGitHubUpload()
const handleUploadPackage = async () => {
if (isUploading)
@@ -103,12 +103,14 @@ vi.mock('@/service/use-tools', () => ({
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
}))
vi.mock('../../install-plugin/hooks', () => ({
useGitHubReleases: () => ({
vi.mock('../../install-plugin/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../install-plugin/hooks')>()
return {
...actual,
checkForUpdates: mockCheckForUpdates,
fetchReleases: mockFetchReleases,
}),
}))
}
})
// Auto upgrade settings mock
let mockAutoUpgradeInfo: {
@@ -72,12 +72,14 @@ vi.mock('@/service/use-tools', () => ({
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
}))
vi.mock('../../../../install-plugin/hooks', () => ({
useGitHubReleases: () => ({
vi.mock('../../../../install-plugin/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../install-plugin/hooks')>()
return {
...actual,
checkForUpdates: mockCheckForUpdates,
fetchReleases: mockFetchReleases,
}),
}))
}
})
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-id',
@@ -11,7 +11,7 @@ import { useProviderContext } from '@/context/provider-context'
import { uninstallPlugin } from '@/service/plugins'
import { useInvalidateCheckInstalled } from '@/service/use-plugins'
import { useInvalidateAllToolProviders } from '@/service/use-tools'
import { useGitHubReleases } from '../../../install-plugin/hooks'
import { checkForUpdates, fetchReleases } from '../../../install-plugin/hooks'
import { PluginCategoryEnum, PluginSource } from '../../../types'
type UsePluginOperationsParams = {
@@ -39,7 +39,6 @@ export const usePluginOperations = ({
onUpdate,
}: UsePluginOperationsParams): UsePluginOperationsReturn => {
const { t } = useTranslation()
const { checkForUpdates, fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext()
const { refreshModelProviders } = useProviderContext()
const invalidateCheckInstalled = useInvalidateCheckInstalled()
@@ -46,13 +46,15 @@ vi.mock('@/service/plugins', () => ({
uninstallPlugin: (id: string) => mockUninstallPlugin(id),
}))
// Mock GitHub releases hook
vi.mock('../../install-plugin/hooks', () => ({
useGitHubReleases: () => ({
// Mock GitHub release helpers
vi.mock('../../install-plugin/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../install-plugin/hooks')>()
return {
...actual,
fetchReleases: mockFetchReleases,
checkForUpdates: mockCheckForUpdates,
}),
}))
}
})
// Mock modal context
vi.mock('@/context/modal-context', () => ({
@@ -14,7 +14,7 @@ import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import ActionButton from '../../base/action-button'
import Confirm from '../../base/confirm'
import Tooltip from '../../base/tooltip'
import { useGitHubReleases } from '../install-plugin/hooks'
import { checkForUpdates, fetchReleases } from '../install-plugin/hooks'
import PluginInfo from '../plugin-page/plugin-info'
import { PluginSource } from '../types'
@@ -54,7 +54,6 @@ const Action: FC<Props> = ({
setTrue: showDeleting,
setFalse: hideDeleting,
}] = useBoolean(false)
const { checkForUpdates, fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
@@ -1,37 +0,0 @@
import type { NextRequest } from '@/next/server'
import { Octokit } from '@octokit/core'
import { RequestError } from '@octokit/request-error'
import { GITHUB_ACCESS_TOKEN } from '@/config'
import { NextResponse } from '@/next/server'
type Params = {
owner: string
repo: string
}
const octokit = new Octokit({
auth: GITHUB_ACCESS_TOKEN,
})
export async function GET(
request: NextRequest,
{ params }: { params: Promise<Params> },
) {
const { owner, repo } = (await params)
try {
const releasesRes = await octokit.request('GET /repos/{owner}/{repo}/releases', {
owner,
repo,
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
})
return NextResponse.json(releasesRes)
}
catch (error) {
if (error instanceof RequestError)
return NextResponse.json(error.response)
else
throw error
}
}