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
20 changes: 9 additions & 11 deletions layers/registry/app/pages/gh/[owner]/[repo]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { RepoHistoryResponse } from '../../../../../server/api/repos/[owner
import type { RepoRouteResolution } from '../../../../../server/api/repos/[owner]/[repo]/route-target.get'
import { entityRobots } from '#shared/entity-robots'
import { avatarProxyUrl } from '#shared/image-proxy'
import { resolveMissingRepoRedirect } from '../../../../utils/missing-repo-recovery'
import { repoPageProfile } from '../../../../utils/repo-profile'
import { parseRepoSkillSort, REPO_SKILL_SORT_OPTIONS, sortRepoSkills } from '../../../../utils/repo-skill-layout'
import RepoSkillDependencies from './_RepoSkillDependencies.vue'
import RepoSparkline from './_RepoSparkline.vue'
Expand Down Expand Up @@ -37,15 +37,17 @@ const repoRouteTarget = computed(() => {
})
const fetchRepoDetailsOnServer = isBot.value && repoRouteTarget.value?._tag !== 'skill'

const repoProfileFetch = useFetch<OrgProfile>(
const repoProfileFetch = useFetch(
() => `/api/orgs/${sourceHub.value.owner}`,
{
key: () => `repo-page-profile:${repoKey.value}`,
watch: false,
lazy: !fetchRepoDetailsOnServer,
immediate: fetchRepoDetailsOnServer,
server: fetchRepoDetailsOnServer,
transform: (profile: OrgProfile) => repoPageProfile(profile, repoHub.value.repo),
},
) as ReturnType<typeof useFetch<OrgProfile>>
)
const { data: repoProfile, status: repoProfileStatus, refresh: refreshRepo } = repoProfileFetch

const repoSourceFetch = useFetch<RepoSourceProfile>(
Expand Down Expand Up @@ -76,14 +78,10 @@ if (fetchRepoDetailsOnServer)
if (fetchRepoDetailsOnServer && repoSourceError.value?.statusCode === 404) {
// Before the tombstone, check whether the repo segment is actually a skill
// name. `/gh/<owner>/<skill>` is what an inbound link looks like when the
// author drops the repository, and the owner profile above already carries
// every skill this owner publishes, so the recovery costs no extra request.
const recovery = resolveMissingRepoRedirect({
owner: owner.value,
repo: repo.value,
skills: repoProfile.value?.skills ?? [],
})
if (recovery._tag === 'redirect')
// author drops the repository. The transform decides recovery from the full
// Owner profile before it drops unrelated Skills from this page's payload.
const recovery = repoProfile.value?.missingRepoTarget
if (recovery?._tag === 'redirect')
await navigateTo(recovery.location, { redirectCode: 301, replace: true })
else
throw createError({ statusCode: 404, statusMessage: 'Repository not found', fatal: true })
Expand Down
6 changes: 4 additions & 2 deletions layers/registry/app/pages/gh/[owner]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@ import type { OrgProfile } from '../../../../server/api/orgs/[owner].get'
import { entityRobots } from '#shared/entity-robots'
import { avatarProxyUrl } from '#shared/image-proxy'
import { resolveOwnerProfileHandoff } from '../../../utils/owner-profile-handoff'
import { ownerPageProfile } from '../../../utils/repo-profile'

const route = useRoute()
const ownerParam = computed(() => (route.params.owner as string).toLowerCase())
const { state: auth, loginUrl } = useAuth()

const { isBot } = useBotDetection()

const orgProfile = useFetch<OrgProfile>(
const orgProfile = useFetch(
() => `/api/orgs/${ownerParam.value}`,
{
key: `org-${ownerParam.value}`,
watch: [ownerParam],
lazy: !isBot.value,
transform: (profile: OrgProfile) => ownerPageProfile(profile),
},
)
const { data, status, error, refresh } = orgProfile
Expand Down Expand Up @@ -100,7 +102,7 @@ function ensureProtocol(url: string): string {
const skillsByRepo = computed(() => {
if (!data.value)
return new Map<string, OrgProfile['skills']>()
const map = new Map<string, OrgProfile['skills']>()
const map = new Map<string, NonNullable<typeof data.value>['skills']>()
for (const skill of data.value.skills) {
const list = map.get(skill.repo) ?? []
list.push(skill)
Expand Down
45 changes: 41 additions & 4 deletions layers/registry/app/utils/repo-profile.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,51 @@
import type { OrgProfile } from '../../server/api/orgs/[owner].get'
import { resolveMissingRepoRedirect } from './missing-repo-recovery'

type RepoSkill = OrgProfile['skills'][number]
type RepoInfo = OrgProfile['repos'][number]
type ProfileSkill = Pick<OrgProfile['skills'][number], 'owner' | 'repo' | 'name' | 'registryPath' | 'slug' | 'displayName' | 'description'
| 'stars' | 'likeCount' | 'modifiedAt' | 'pushedAt' | 'firstSeenAt'
| 'authorName' | 'skillFileUrl' | 'dependencies'>

function profileSkill(skill: OrgProfile['skills'][number]): ProfileSkill {
return {
owner: skill.owner,
repo: skill.repo,
name: skill.name,
registryPath: skill.registryPath,
slug: skill.slug,
displayName: skill.displayName,
description: skill.description,
stars: skill.stars,
likeCount: skill.likeCount,
modifiedAt: skill.modifiedAt,
pushedAt: skill.pushedAt,
firstSeenAt: skill.firstSeenAt,
authorName: skill.authorName,
skillFileUrl: skill.skillFileUrl,
dependencies: skill.dependencies,
}
}

/** Keep card content; admission and source hashes do not render on these pages. */
export function ownerPageProfile(profile: OrgProfile) {
return { ...profile, skills: profile.skills.map(profileSkill) }
}

/** Decide missing-path recovery before dropping the other Repositories' Skills. */
export function repoPageProfile(profile: OrgProfile, repo: string) {
return {
...profile,
skills: selectRepoSkills(profile, repo).map(profileSkill),
repos: profile.repos.filter(candidate => candidate.repo.toLowerCase() === repo.toLowerCase()),
missingRepoTarget: resolveMissingRepoRedirect({ owner: profile.owner, repo, skills: profile.skills }),
}
}

/**
* `useFetch<OrgProfile>` asserts its response type, so a partial body reaches the
* page as a truthy object whose arrays are missing (Sentry SKILLD-E). Read them
* defensively here rather than at each call site.
*/
export function selectRepoSkills(profile: OrgProfile | null | undefined, repo: string): RepoSkill[] {
export function selectRepoSkills<T extends { repo: string, name: string, modifiedAt?: number | null, pushedAt?: number | null }>(profile: { skills: T[] } | null | undefined, repo: string): T[] {
const wanted = repo.toLowerCase()
const skills = profile?.skills
if (!Array.isArray(skills))
Expand All @@ -24,7 +61,7 @@ export function selectRepoSkills(profile: OrgProfile | null | undefined, repo: s
})
}

export function selectRepoInfo(profile: OrgProfile | null | undefined, repo: string): RepoInfo | null {
export function selectRepoInfo<T extends { repo: string }>(profile: { repos: T[] } | null | undefined, repo: string): T | null {
const repos = profile?.repos
if (!Array.isArray(repos))
return null
Expand Down
36 changes: 36 additions & 0 deletions test/unit/profile-payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { OrgProfile } from '../../layers/registry/server/api/orgs/[owner].get'
import { describe, expect, it } from 'vitest'
import { ownerPageProfile, repoPageProfile, selectRepoSkills } from '../../layers/registry/app/utils/repo-profile'

const profile = {
owner: 'acme',
skills: [
{ owner: 'acme', repo: 'widgets', name: 'design', registryPath: '/gh/acme/widgets/design', slug: 'acme/widgets/design', displayName: 'Design', description: 'Design widgets', stars: 5, likeCount: 2, modifiedAt: 10, pushedAt: 8, firstSeenAt: 4, dependencies: ['testing'], renderedRawSha256: 'hash', trustScore: 100, seoIndexable: true },
{ owner: 'acme', repo: 'tools', name: 'testing', registryPath: '/gh/acme/tools/testing', slug: 'acme/tools/testing' },
],
repos: [{ repo: 'widgets', count: 1 }, { repo: 'tools', count: 1 }],
} as unknown as OrgProfile

describe('page profile payloads', () => {
it('preserves card content and canonical links without serializing admission evidence', () => {
const result = ownerPageProfile(profile)
expect(result.skills[0]).toEqual({ owner: 'acme', repo: 'widgets', name: 'design', registryPath: '/gh/acme/widgets/design', slug: 'acme/widgets/design', displayName: 'Design', description: 'Design widgets', stars: 5, likeCount: 2, modifiedAt: 10, pushedAt: 8, firstSeenAt: 4, dependencies: ['testing'], authorName: undefined, skillFileUrl: undefined })
expect(profile.skills[0]?.trustScore).toBe(100)
})

it('keeps only the requested Repository, including case-insensitive selection', () => {
const result = repoPageProfile(profile, 'WIDGETS')
expect(result.skills.map(skill => skill.registryPath)).toEqual(['/gh/acme/widgets/design'])
expect(result.repos).toEqual([{ repo: 'widgets', count: 1 }])
expect(selectRepoSkills(result, 'widgets')[0]?.dependencies).toEqual(['testing'])
})

it('preserves recovery to an unambiguous Skill outside the requested Repository', () => {
expect(repoPageProfile(profile, 'testing').missingRepoTarget).toEqual({ _tag: 'redirect', location: '/gh/acme/tools/testing' })
})

it('does not guess when two Repositories publish the missing name', () => {
const ambiguous = { ...profile, skills: [...profile.skills, { ...profile.skills[1]!, repo: 'other', registryPath: '/gh/acme/other/testing' }] }
expect(repoPageProfile(ambiguous, 'testing').missingRepoTarget).toEqual({ _tag: 'not_found' })
})
})
Loading