mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
Keep repositories on their known public forge (#40542)
* fix(git): keep public forge ownership ahead of auth evidence * test(git): exercise forge admission with complete auth fixtures
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { normalizeForgeWebHost } from '../../../shared/git/remotes/git-remote-host-alias'
|
||||
import { getPublicForgeProviderForHost } from '../../../shared/git/remotes/public-forge-host'
|
||||
|
||||
// Scoped local fork of the hosted-git-info@9.0.3 behavior Orca used.
|
||||
// Keep only the provider parsing and file/commit URL construction needed by source links.
|
||||
@@ -23,14 +24,14 @@ const shorthandHosts: Record<string, { host: string; provider: HostedRemoteProvi
|
||||
|
||||
function providerForHost(host: string): HostedRemoteHost | null {
|
||||
const normalized = normalizeForgeWebHost(host)
|
||||
if (normalized === 'github.com') {
|
||||
return { host: 'github.com', provider: 'github' }
|
||||
}
|
||||
if (normalized === 'gitlab.com') {
|
||||
return { host: 'gitlab.com', provider: 'gitlab' }
|
||||
}
|
||||
if (normalized === 'bitbucket.org') {
|
||||
return { host: 'bitbucket.org', provider: 'bitbucket' }
|
||||
const provider = getPublicForgeProviderForHost(normalized)
|
||||
if (
|
||||
(normalized === 'github.com' ||
|
||||
normalized === 'gitlab.com' ||
|
||||
normalized === 'bitbucket.org') &&
|
||||
(provider === 'github' || provider === 'gitlab' || provider === 'bitbucket')
|
||||
) {
|
||||
return { host: normalized, provider }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { parseGiteaRepoRef } from './repository-ref'
|
||||
|
||||
it('keeps Gitea endpoints available without claiming canonical foreign hosts', () => {
|
||||
for (const remote of [
|
||||
'git@ssh.github.com:team/repo.git',
|
||||
'ssh://git@altssh.gitlab.com:443/team/repo.git',
|
||||
'https://acme.ghe.com/team/repo.git'
|
||||
]) {
|
||||
expect(parseGiteaRepoRef(remote)).toBeNull()
|
||||
}
|
||||
expect(parseGiteaRepoRef('https://codeberg.org/team/repo.git')).toMatchObject({
|
||||
owner: 'team',
|
||||
repo: 'repo'
|
||||
})
|
||||
expect(parseGiteaRepoRef('https://git.acme.test/team/repo.git')).toMatchObject({
|
||||
owner: 'team',
|
||||
repo: 'repo'
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRemoteRefProbeCache } from '../git/remotes/remote-ref-probe-cache'
|
||||
import { getPublicForgeProviderForHost } from '../../shared/git/remotes/public-forge-host'
|
||||
|
||||
export type GiteaRepoRef = {
|
||||
host: string
|
||||
@@ -12,14 +13,6 @@ type LocalGitExecOptions = {
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
const KNOWN_NON_GITEA_HOSTS = new Set([
|
||||
'github.com',
|
||||
'gitlab.com',
|
||||
'bitbucket.org',
|
||||
'ssh.bitbucket.org',
|
||||
'dev.azure.com',
|
||||
'ssh.dev.azure.com'
|
||||
])
|
||||
const repoRefProbeCache = createRemoteRefProbeCache(parseGiteaRepoRef)
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
@@ -69,11 +62,8 @@ function apiBaseUrlFromWebBase(webBaseUrl: string): string {
|
||||
|
||||
function makeRepoRef(host: string, path: string, webOrigin: string): GiteaRepoRef | null {
|
||||
const normalizedHost = host.toLowerCase()
|
||||
if (
|
||||
!normalizedHost ||
|
||||
KNOWN_NON_GITEA_HOSTS.has(normalizedHost) ||
|
||||
normalizedHost.endsWith('.visualstudio.com')
|
||||
) {
|
||||
const owner = getPublicForgeProviderForHost(normalizedHost)
|
||||
if (!normalizedHost || (owner && owner !== 'gitea')) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,24 @@ describe('getEnterpriseGitHubRepoSlug', () => {
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['https://bitbucket.org/team/orca.git', 'bitbucket.org'],
|
||||
['ssh://git@altssh.gitlab.com:443/team/orca.git', 'altssh.gitlab.com'],
|
||||
['https://codeberg.org/team/orca.git', 'codeberg.org']
|
||||
])('does not promote a foreign public remote from gh inventory: %s', async (url, host) => {
|
||||
mockOriginRemote(url)
|
||||
mockHostAuthenticated(host)
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeFalsy()
|
||||
await expect(isGitHubHostAuthenticated(host, '/repo')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('uses the effective SSH hostname before admitting an Enterprise identity', async () => {
|
||||
mockOriginRemote('git@work-forge:team/orca.git')
|
||||
resolveWithSshGMock.mockResolvedValue({ hostname: 'bitbucket.org' })
|
||||
mockHostAuthenticated('bitbucket.org')
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://ghe.acme.com:8443/team/orca.git',
|
||||
'https://ghe.acme.com:80/team/orca.git',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { parseWslPath } from '../wsl/filesystem/unc-path-access'
|
||||
import { isStableMissingGitRemoteError } from '../git/remotes/stable-missing-git-remote-error'
|
||||
import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch'
|
||||
import { getConnectionExecutionHostId } from '../../shared/execution-host'
|
||||
import { getPublicForgeProviderForHost } from '../../shared/git/remotes/public-forge-host'
|
||||
|
||||
export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string }
|
||||
|
||||
@@ -131,6 +132,10 @@ async function resolveAuthenticatedGitHubHost(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<string | null | undefined> {
|
||||
const owner = getPublicForgeProviderForHost(host)
|
||||
if (owner && owner !== 'github') {
|
||||
return null
|
||||
}
|
||||
const normalizedHost = normalizeGitHubHost(host)?.authority ?? host.trim().toLowerCase()
|
||||
const execOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
|
||||
const cacheKey = `${runtimeCacheKey(repoPath, connectionId, localGitOptions.wslDistro)}\0${normalizedHost}\0${githubReadExecutionScope(execOptions)}`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { glabExecFileAsync } from '../git/runner'
|
||||
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
|
||||
import { addWslEnvKeys } from '../../shared/wsl-env'
|
||||
import { normalizeForgeWebHost } from '../../shared/git/remotes/git-remote-host-alias'
|
||||
import { isTransientGitProbeError, readRemoteUrl } from '../git/remotes/remote-url-probe'
|
||||
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remotes/remote-ref-probe-cache'
|
||||
import { readLocalGitConfigSignature } from '../github/local-git-config-signature'
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
DEFAULT_GITLAB_HOSTS,
|
||||
normalizeGitLabHost,
|
||||
parseGitLabProjectRef,
|
||||
parseRemoteProjectRefCandidate,
|
||||
parseGitLabProjectRefCandidate,
|
||||
type ProjectRef
|
||||
} from './project-ref-parser'
|
||||
|
||||
@@ -154,19 +153,12 @@ async function resolveProjectRefForRemote(
|
||||
if (stdout === null) {
|
||||
return null
|
||||
}
|
||||
// Stray glab host entries for GitHub/Bitbucket hosts must not claim their remotes (#40386).
|
||||
// Match the real host only: `github:team/project` is an scp remote on an SSH alias named github.
|
||||
const remoteCandidate = parseRemoteProjectRefCandidate(stdout)
|
||||
const remoteHost = remoteCandidate && normalizeForgeWebHost(remoteCandidate.host)
|
||||
if (remoteHost === 'github.com' || remoteHost === 'bitbucket.org') {
|
||||
publish(null)
|
||||
return null
|
||||
}
|
||||
const result = parseGitLabProjectRef(stdout, knownHosts)
|
||||
if (result) {
|
||||
publish(result)
|
||||
return result
|
||||
}
|
||||
const remoteCandidate = parseGitLabProjectRefCandidate(stdout)
|
||||
if (
|
||||
remoteCandidate &&
|
||||
(await isGlabConfiguredForRemoteHost(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseGitLabProjectRef } from './project-ref-parser'
|
||||
import { parseGitLabProjectRef, parseRemoteProjectRefCandidate } from './project-ref-parser'
|
||||
|
||||
describe('gitlab project ref parsing', () => {
|
||||
it.each([
|
||||
@@ -43,4 +43,31 @@ describe('gitlab project ref parsing', () => {
|
||||
parseGitLabProjectRef('http://gitea.example.com:3030/team/api.git', knownHosts)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps public ownership authoritative without narrowing generic mirror parsing', () => {
|
||||
for (const [remote, host] of [
|
||||
['https://github.com:8443/team/project.git', 'github.com:8443'],
|
||||
['git@SSH.GITHUB.COM:team/project.git', 'ssh.github.com'],
|
||||
['git@acme.ghe.com:team/project.git', 'acme.ghe.com'],
|
||||
['git@ssh.bitbucket.org:team/project.git', 'ssh.bitbucket.org'],
|
||||
['https://dev.azure.com/acme/project/_git/repo', 'dev.azure.com'],
|
||||
['git@ssh.dev.azure.com:v3/acme/project/repo', 'ssh.dev.azure.com'],
|
||||
['https://acme.visualstudio.com/project/_git/repo', 'acme.visualstudio.com'],
|
||||
['https://codeberg.org/team/project.git', 'codeberg.org']
|
||||
]) {
|
||||
expect(parseGitLabProjectRef(remote, [host])).toBeNull()
|
||||
}
|
||||
expect(parseRemoteProjectRefCandidate('git@github.com:team/project.git')).toEqual({
|
||||
host: 'github.com',
|
||||
path: 'team/project'
|
||||
})
|
||||
expect(
|
||||
parseGitLabProjectRef('https://git.bitbucket.acme.com/team/project.git', [
|
||||
'git.bitbucket.acme.com'
|
||||
])
|
||||
).toEqual({
|
||||
host: 'git.bitbucket.acme.com',
|
||||
path: 'team/project'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GitLabProjectRef } from '../../shared/gitlab-types'
|
||||
import { normalizeGitLabRemoteHost } from '../../shared/git/remotes/git-remote-host-alias'
|
||||
import { getPublicForgeProviderForHost } from '../../shared/git/remotes/public-forge-host'
|
||||
|
||||
export type ProjectRef = GitLabProjectRef
|
||||
|
||||
@@ -47,19 +48,6 @@ function knownHostMatches(urlHost: string, knownHost: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function makeProjectRef(
|
||||
host: string,
|
||||
path: string,
|
||||
knownHosts: readonly string[]
|
||||
): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabRemoteHost(host)
|
||||
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
|
||||
if (!normalizedKnownHosts.some((knownHost) => knownHostMatches(normalizedHost, knownHost))) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(normalizedHost, path)
|
||||
}
|
||||
|
||||
export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
@@ -80,25 +68,24 @@ export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef |
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitLabProjectRefCandidate(remoteUrl: string): ProjectRef | null {
|
||||
const candidate = parseRemoteProjectRefCandidate(remoteUrl)
|
||||
if (!candidate) {
|
||||
return null
|
||||
}
|
||||
const owner = getPublicForgeProviderForHost(candidate.host)
|
||||
return owner && owner !== 'gitlab' ? null : candidate
|
||||
}
|
||||
|
||||
export function parseGitLabProjectRef(
|
||||
remoteUrl: string,
|
||||
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
|
||||
): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRef(scpLike[1], scpLike[2], knownHosts)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRef(hostIdentityFromUrl(url), url.pathname, knownHosts)
|
||||
} catch {
|
||||
const candidate = parseGitLabProjectRefCandidate(remoteUrl)
|
||||
if (!candidate) {
|
||||
return null
|
||||
}
|
||||
return knownHosts.some((host) => knownHostMatches(candidate.host, normalizeGitLabHost(host)))
|
||||
? candidate
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ vi.mock('../git/command-runner/gh-exec-file', async (importOriginal) => ({
|
||||
}))
|
||||
|
||||
import { listMergeRequests } from './merge-request-list'
|
||||
import { isTaskRepositoryIneligible } from '../git/task-repository-eligibility'
|
||||
import { rememberGlabKnownHosts } from './gitlab-known-host-probe'
|
||||
import { listIssues } from './issues'
|
||||
import { _resetProjectRefCache, _resetKnownHostsCache } from './gitlab-project-ref-resolution'
|
||||
import { _resetRemoteNameListingCache } from '../git/remotes/remote-name-listing'
|
||||
@@ -132,4 +134,9 @@ describe('GitLab task project admission', () => {
|
||||
expect(result.items).toEqual([])
|
||||
expect(glab.mock.calls.filter(([args]) => args[0] !== 'auth')).toEqual([])
|
||||
})
|
||||
|
||||
it('does not exclude GitHub tasks because glab remembers github.com', async () => {
|
||||
rememberGlabKnownHosts(['github.com'])
|
||||
await expect(isTaskRepositoryIneligible('/github', 'github')).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,11 +35,6 @@ vi.mock('../git/command-runner/gh-exec-file', async (importOriginal) => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../git/exec-error', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
extractExecError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../github/client', () => ({
|
||||
createGitHubPullRequest: vi.fn(),
|
||||
getRepoSlug: getRepoSlugMock,
|
||||
@@ -71,6 +66,8 @@ vi.mock('./hosted-review', () => ({
|
||||
import { _resetKnownHostsCache, _resetProjectRefCache } from '../gitlab/gl-utils'
|
||||
import { diagnoseAuth } from '../gitlab/client'
|
||||
import { getHostedReviewCreationEligibility } from './hosted-review-creation'
|
||||
import { detectHostedReviewProvider } from './forge-provider-detection'
|
||||
import { rememberGlabKnownHosts } from '../gitlab/gitlab-known-host-probe'
|
||||
|
||||
function resetMocks(): void {
|
||||
for (const mock of [
|
||||
@@ -167,4 +164,49 @@ describe('GitLab self-hosted hosted review creation eligibility', () => {
|
||||
blockedReason: null
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['per-host failure', 'global inventory', 'remembered inventory'])(
|
||||
'keeps GitHub PR identity across repositories despite glab %s',
|
||||
async (evidence) => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@github.com:team/orca.git\n' })
|
||||
getRepoSlugMock.mockResolvedValue({ owner: 'team', repo: 'orca' })
|
||||
if (evidence === 'remembered inventory') {
|
||||
rememberGlabKnownHosts(['github.com'])
|
||||
}
|
||||
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('--hostname')) {
|
||||
// Captured glab 1.115 output for a configured host without a token.
|
||||
throw Object.assign(new Error('glab auth status failed'), {
|
||||
stdout: '',
|
||||
stderr:
|
||||
'github.com\n x github.com: API call failed\n ! No token found (checked config file, keyring, and environment variables).\n'
|
||||
})
|
||||
}
|
||||
return {
|
||||
stdout:
|
||||
evidence === 'global inventory'
|
||||
? 'github.com\n ✓ Logged in to github.com as example-user (config file)\n'
|
||||
: 'gitlab.com\n ✓ Logged in to gitlab.com as example-user (config file)\n',
|
||||
stderr: ''
|
||||
}
|
||||
})
|
||||
for (const repoPath of ['/first-repo', '/second-repo']) {
|
||||
await expect(
|
||||
detectHostedReviewProvider({ repoPath, executionHostId: 'local' })
|
||||
).resolves.toBe('github')
|
||||
}
|
||||
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://forge.acme.test/team/orca.git\n' })
|
||||
glabExecFileAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error('glab auth status failed'), {
|
||||
stdout: '',
|
||||
stderr:
|
||||
'forge.acme.test\n x forge.acme.test: API call failed\n ! No token found (checked config file, keyring, and environment variables).\n'
|
||||
})
|
||||
)
|
||||
await expect(
|
||||
detectHostedReviewProvider({ repoPath: '/custom', executionHostId: 'local' })
|
||||
).resolves.toBe('gitlab')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { foldWwwHostAlias, normalizeForgeWebHost } from './git-remote-host-alias'
|
||||
|
||||
type PublicForgeProvider = 'github' | 'gitlab' | 'bitbucket' | 'azure-devops' | 'gitea'
|
||||
|
||||
/** Ownership only: callers retain their original endpoint and persisted host identity. */
|
||||
export function getPublicForgeProviderForHost(host: string): PublicForgeProvider | null {
|
||||
const hostname = normalizeForgeWebHost(foldWwwHostAlias(host).replace(/:\d+$/, ''))
|
||||
if (hostname === 'github.com' || hostname === 'ghe.com' || hostname.endsWith('.ghe.com')) {
|
||||
return 'github'
|
||||
}
|
||||
if (hostname === 'gitlab.com') {
|
||||
return 'gitlab'
|
||||
}
|
||||
if (hostname === 'bitbucket.org') {
|
||||
return 'bitbucket'
|
||||
}
|
||||
if (
|
||||
hostname === 'dev.azure.com' ||
|
||||
hostname.endsWith('.dev.azure.com') ||
|
||||
hostname.endsWith('.visualstudio.com')
|
||||
) {
|
||||
return 'azure-devops'
|
||||
}
|
||||
return hostname === 'codeberg.org' ? 'gitea' : null
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { parseJiraIssueUrl, JIRA_ISSUE_KEY_PATTERN } from '../jira/jira-issue-ur
|
||||
import { parseLinearIssueInput, parseLinearIssueUrlIntent } from '../linear/links'
|
||||
import { getTaskSourceCacheScope } from '../work-items/task-source-context'
|
||||
import type { WorkspaceAttachment } from '../worktree/types'
|
||||
import { getPublicForgeProviderForHost } from '../git/remotes/public-forge-host'
|
||||
|
||||
export type WorkspaceReferenceQuery =
|
||||
| { kind: 'url'; identity: string }
|
||||
@@ -9,13 +10,22 @@ export type WorkspaceReferenceQuery =
|
||||
| { kind: 'issue-url'; route: string }
|
||||
| { kind: 'issue-key'; identifier: string }
|
||||
|
||||
const HOST_PROVIDERS: Readonly<Record<string, WorkspaceAttachment['provider']>> = {
|
||||
'github.com': 'github',
|
||||
'gitlab.com': 'gitlab',
|
||||
'linear.app': 'linear',
|
||||
'codeberg.org': 'gitea',
|
||||
'bitbucket.org': 'bitbucket',
|
||||
'dev.azure.com': 'azure-devops'
|
||||
// Keep existing saved-reference URL normalization separate from remote-host ownership.
|
||||
const PUBLIC_REFERENCE_HOSTS = new Set([
|
||||
'github.com',
|
||||
'gitlab.com',
|
||||
'codeberg.org',
|
||||
'bitbucket.org',
|
||||
'dev.azure.com'
|
||||
])
|
||||
|
||||
function referenceHostProvider(host: string): WorkspaceAttachment['provider'] | undefined {
|
||||
if (host === 'linear.app') {
|
||||
return 'linear'
|
||||
}
|
||||
return PUBLIC_REFERENCE_HOSTS.has(host)
|
||||
? (getPublicForgeProviderForHost(host) ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
|
||||
const CASE_INSENSITIVE_PATH_PROVIDERS: ReadonlySet<WorkspaceAttachment['provider']> = new Set([
|
||||
@@ -37,7 +47,7 @@ function referenceUrl(input: string): URL {
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
url.pathname = url.pathname.replace(/\/+$/, '')
|
||||
if (url.hostname.startsWith('www.') && HOST_PROVIDERS[url.hostname.slice(4)]) {
|
||||
if (url.hostname.startsWith('www.') && referenceHostProvider(url.hostname.slice(4))) {
|
||||
url.hostname = url.hostname.slice(4)
|
||||
}
|
||||
return url
|
||||
@@ -63,7 +73,7 @@ export function parseWorkspaceReferenceUrl(
|
||||
providerHint?: WorkspaceAttachment['provider']
|
||||
): WorkspaceAttachment {
|
||||
const url = referenceUrl(input)
|
||||
const knownProvider = HOST_PROVIDERS[url.hostname]
|
||||
const knownProvider = referenceHostProvider(url.hostname)
|
||||
const item = parseReferenceRoute(url, knownProvider ?? providerHint)
|
||||
if (
|
||||
(knownProvider && item.provider !== knownProvider) ||
|
||||
|
||||
Reference in New Issue
Block a user