Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions layers/registry/server/utils/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,14 +363,15 @@ interface RepoSummaryGql {
createdAt: string
isArchived: boolean
isFork: boolean
isPrivate: boolean
defaultBranchRef: {
name: string
target: { oid: string, tree: { oid: string } } | null
} | null
}

const REPO_SUMMARY_FIELDS = `databaseId name nameWithOwner url owner{login}
description stargazerCount forkCount pushedAt createdAt isArchived isFork
description stargazerCount forkCount pushedAt createdAt isArchived isFork isPrivate
defaultBranchRef{name target{... on Commit{oid tree{oid}}}}`

export interface RepoSummary {
Expand All @@ -385,19 +386,19 @@ export interface RepoSummary {
}

/**
* Parse one GraphQL Repository into a summary, or null without a Repository
* ID. The schema allows a null ID, and a Repository without one cannot be
* followed across a move.
* Parse one public GraphQL Repository. Reject missing IDs or visibility.
* Its numeric ID confirms identity across moves.
*/
function repoSummaryFromGql(r: RepoSummaryGql): RepoSummary | null {
if (typeof r.databaseId !== 'number')
if (typeof r.databaseId !== 'number' || typeof r.isPrivate !== 'boolean' || r.isPrivate)
return null
const branch = r.defaultBranchRef?.name || 'main'
const meta: RepoMeta = {
name: r.name,
full_name: r.nameWithOwner,
html_url: r.url,
owner: { login: r.owner.login },
private: false,
default_branch: branch,
description: r.description,
stargazers_count: r.stargazerCount,
Expand Down Expand Up @@ -443,7 +444,7 @@ export async function getRepoSummary(
return { status: notFound ? 404 : 502, data: null, rateLimit: out.rateLimit, notModified: false }
}
const r = out.data?.repository
if (!r)
if (!r || r.isPrivate === true)
return { status: 404, data: null, rateLimit: out.rateLimit, notModified: false }
// A Repository without an ID fails like any other malformed answer.
const summary = repoSummaryFromGql(r)
Expand Down
1 change: 1 addition & 0 deletions test/unit/discovery-size-guard-not-modified.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function summaryResponse() {
createdAt: '2026-01-01T00:00:00Z',
isArchived: false,
isFork: false,
isPrivate: false,
defaultBranchRef: { name: 'main', target: { oid: 'commit', tree: { oid: 'tree-sha' } } },
},
},
Expand Down
27 changes: 27 additions & 0 deletions test/unit/github-repo-identity.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRepoSummary } from '../../layers/registry/server/utils/github-client'
import { readRepositoryPurposeEvidence } from '../../layers/registry/server/utils/repository-purpose-effect'

describe('github repository identity', () => {
afterEach(() => vi.unstubAllGlobals())

it('hides a private repository even when the read credential can access it', async () => {
vi.stubGlobal('fetch', vi.fn(async () => Response.json({
data: { repository: { databaseId: 1, isPrivate: true, name: 'private', nameWithOwner: 'acme/private', owner: { login: 'acme' }, defaultBranchRef: null } },
})))
expect(await getRepoSummary('acme', 'private', {})).toMatchObject({ status: 404, data: null })
})

it('fails closed when the source omits repository visibility', async () => {
vi.stubGlobal('fetch', vi.fn(async () => Response.json({
data: { repository: { databaseId: 1, name: 'unknown', nameWithOwner: 'acme/unknown', owner: { login: 'acme' }, defaultBranchRef: null } },
})))
expect(await getRepoSummary('acme', 'unknown', {})).toMatchObject({ status: 502, data: null })
})

it('stops purpose evidence before reading any private tree or file', async () => {
const fetch = vi.fn(async () => Response.json({
data: { repository: { databaseId: 1, isPrivate: true, name: 'private', owner: { login: 'acme' }, defaultBranchRef: null } },
}))
vi.stubGlobal('fetch', fetch)
await expect(readRepositoryPurposeEvidence({ owner: 'acme', repo: 'private' }, {}))
.rejects
.toThrow('Repository purpose source unavailable: 404')
expect(fetch).toHaveBeenCalledTimes(1)
})

it('returns GitHub canonical identity when an old repository name resolves', async () => {
const fetch = vi.fn(async () => Response.json({
data: {
Expand All @@ -20,6 +46,7 @@ describe('github repository identity', () => {
createdAt: '2025-01-01T00:00:00Z',
isArchived: false,
isFork: false,
isPrivate: false,
defaultBranchRef: {
name: 'main',
target: { oid: 'commit', tree: { oid: 'tree' } },
Expand Down
10 changes: 10 additions & 0 deletions test/unit/github-repo-summary-batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function repository(owner: string, name: string, tree: string) {
createdAt: '2025-01-01T00:00:00Z',
isArchived: false,
isFork: false,
isPrivate: false,
defaultBranchRef: { name: 'main', target: { oid: 'c'.repeat(40), tree: { oid: tree } } },
}
}
Expand Down Expand Up @@ -54,6 +55,15 @@ afterEach(() => {
})

describe('getRepoSummariesBatch', () => {
it('omits a readable private repository while preserving the public neighbor', async () => {
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ data: {
r0: { ...repository('acme', 'private', 'private-tree'), isPrivate: true },
r1: repository('acme', 'public', 'public-tree'),
} })))
const result = await getRepoSummariesBatch([{ owner: 'acme', repo: 'private' }, { owner: 'acme', repo: 'public' }], bindings)
expect(result).toMatchObject({ _tag: 'read', summaries: [null, { meta: { full_name: 'acme/public' } }] })
})

it('reads 230 repositories in three queries, in request order', async () => {
const fetchMock = vi.fn(graphqlAnswer)
vi.stubGlobal('fetch', fetchMock)
Expand Down
1 change: 1 addition & 0 deletions test/unit/registry-github-app-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ async function stubGithub(options: StubOptions) {
createdAt: '2026-01-01T00:00:00Z',
isArchived: false,
isFork: false,
isPrivate: false,
defaultBranchRef: { name: 'main', target: { oid: 'a'.repeat(40), tree: { oid: 'b'.repeat(40) } } },
})
if ('owner' in body.variables) {
Expand Down
1 change: 1 addition & 0 deletions test/unit/sync-prefetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function graphqlAnswer(_input: unknown, init?: RequestInit): Response {
createdAt: '2025-01-01T00:00:00Z',
isArchived: false,
isFork: false,
isPrivate: false,
defaultBranchRef: { name: 'main', target: { oid: 'c'.repeat(40), tree: { oid: tree } } },
}
}
Expand Down
Loading