Skip to content

Commit 6710afe

Browse files
authored
fix: re-use canonicalizeRepoUrl & insert package_repos in go and nuget (#4331)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 8ec9eca commit 6710afe

6 files changed

Lines changed: 153 additions & 57 deletions

File tree

services/apps/packages_worker/src/go/activities.ts

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { Context } from '@temporalio/activity'
22

3-
import { logAuditFieldChanges } from '@crowd/data-access-layer/src/packages'
3+
import {
4+
getOrCreateRepoByUrl,
5+
logAuditFieldChanges,
6+
upsertPackageRepo,
7+
} from '@crowd/data-access-layer/src/packages'
48
import type { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
59
import { getServiceChildLogger } from '@crowd/logging'
610

711
import { getGoConfig } from '../config'
812
import { getPackagesDb } from '../db'
13+
import { canonicalizeRepoUrl } from '../utils/canonicalizeRepoUrl'
914

1015
import { fetchStatus } from './pkgGoDevClient'
1116
import { fetchLatest } from './proxyClient'
@@ -21,7 +26,7 @@ export interface GoScanCursor {
2126
after: string
2227
}
2328

24-
type GoRow = { purl: string; name: string }
29+
type GoRow = { id: string; purl: string; name: string; declaredRepositoryUrl: string | null }
2530

2631
// Two independent purl-keyset cursors — one for critical packages, one for everything else —
2732
// each ordered/paginated purely by purl so WHERE and ORDER BY always match (no gaps, no
@@ -37,7 +42,7 @@ async function getGoBatch(
3742
): Promise<GoRow[]> {
3843
if (batchSize <= 0) return []
3944
return qx.select(
40-
`SELECT purl, name FROM packages
45+
`SELECT id::text AS id, purl, name, declared_repository_url AS "declaredRepositoryUrl" FROM packages
4146
WHERE ecosystem = 'go' AND is_critical = $(isCritical) AND purl > $(after)
4247
ORDER BY purl ASC
4348
LIMIT $(limit)`,
@@ -75,7 +80,7 @@ export async function enrichGoVersionsBatch(
7580

7681
const { fetchTimeoutMs, proxyConcurrency } = getGoConfig()
7782

78-
const enrichOne = async (row: { purl: string; name: string }): Promise<void> => {
83+
const enrichOne = async (row: GoRow): Promise<void> => {
7984
Context.current().heartbeat(row.purl)
8085
const result = await fetchLatest(row.name, fetchTimeoutMs)
8186
if (isFetchError(result)) {
@@ -85,37 +90,60 @@ export async function enrichGoVersionsBatch(
8590
)
8691
return
8792
}
88-
const changed = await qx.selectOne(
89-
`WITH old AS (
90-
SELECT latest_version AS v, latest_release_at AS t, repository_url AS r
91-
FROM packages WHERE purl = $(purl)
92-
),
93-
upd AS (
94-
UPDATE packages p SET
95-
latest_version = $(version),
96-
latest_release_at = $(releaseAt),
97-
repository_url = COALESCE($(repoUrl), p.repository_url),
98-
last_synced_at = NOW()
99-
WHERE p.purl = $(purl)
100-
RETURNING latest_version AS v, latest_release_at AS t, repository_url AS r
101-
)
102-
SELECT
103-
(SELECT v FROM old) IS DISTINCT FROM (SELECT v FROM upd) AS v_changed,
104-
(SELECT t FROM old) IS DISTINCT FROM (SELECT t FROM upd) AS t_changed,
105-
(SELECT r FROM old) IS DISTINCT FROM (SELECT r FROM upd) AS r_changed`,
106-
{
107-
version: result.version,
108-
releaseAt: result.releaseAt,
109-
repoUrl: result.repoUrl,
110-
purl: row.purl,
111-
},
112-
)
113-
const changedFields = [
114-
changed?.v_changed ? 'packages.latest_version' : null,
115-
changed?.t_changed ? 'packages.latest_release_at' : null,
116-
changed?.r_changed ? 'packages.repository_url' : null,
117-
].filter(Boolean) as string[]
118-
await logAuditFieldChanges(qx, PROXY_SOURCE, row.purl, changedFields)
93+
const repo = result.repoUrl ? canonicalizeRepoUrl(result.repoUrl) : null
94+
const declaredRepo = repo
95+
? null
96+
: row.declaredRepositoryUrl
97+
? canonicalizeRepoUrl(row.declaredRepositoryUrl)
98+
: null
99+
100+
await qx.tx(async (t) => {
101+
const changed = await t.selectOne(
102+
`WITH old AS (
103+
SELECT latest_version AS v, latest_release_at AS t, repository_url AS r
104+
FROM packages WHERE purl = $(purl)
105+
),
106+
upd AS (
107+
UPDATE packages p SET
108+
latest_version = $(version),
109+
latest_release_at = $(releaseAt),
110+
repository_url = COALESCE($(repoUrl), p.repository_url),
111+
last_synced_at = NOW()
112+
WHERE p.purl = $(purl)
113+
RETURNING latest_version AS v, latest_release_at AS t, repository_url AS r
114+
)
115+
SELECT
116+
(SELECT v FROM old) IS DISTINCT FROM (SELECT v FROM upd) AS v_changed,
117+
(SELECT t FROM old) IS DISTINCT FROM (SELECT t FROM upd) AS t_changed,
118+
(SELECT r FROM old) IS DISTINCT FROM (SELECT r FROM upd) AS r_changed`,
119+
{
120+
version: result.version,
121+
releaseAt: result.releaseAt,
122+
repoUrl: repo?.url ?? declaredRepo?.url ?? null,
123+
purl: row.purl,
124+
},
125+
)
126+
const changedFields = [
127+
changed?.v_changed ? 'packages.latest_version' : null,
128+
changed?.t_changed ? 'packages.latest_release_at' : null,
129+
changed?.r_changed ? 'packages.repository_url' : null,
130+
].filter(Boolean) as string[]
131+
132+
const repoToLink = repo ?? declaredRepo
133+
if (repoToLink) {
134+
const { id: repoId, changedFields: repoChanged } = await getOrCreateRepoByUrl(
135+
t,
136+
repoToLink.url,
137+
repoToLink.host,
138+
)
139+
changedFields.push(...repoChanged)
140+
141+
const linkChanged = await upsertPackageRepo(t, row.id, repoId, 'declared', 0.8)
142+
changedFields.push(...linkChanged)
143+
}
144+
145+
await logAuditFieldChanges(t, PROXY_SOURCE, row.purl, changedFields)
146+
})
119147
}
120148

121149
for (let i = 0; i < rows.length; i += proxyConcurrency) {

services/apps/packages_worker/src/nuget/client.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from './types'
99

1010
const SERVICE_INDEX_URL = 'https://api.nuget.org/v3/index.json'
11+
const FLAT_CONTAINER_BASE_URL = 'https://api.nuget.org/v3-flatcontainer'
1112

1213
interface ServiceIndexResource {
1314
'@id': string
@@ -144,3 +145,29 @@ export async function fetchRegistration(
144145
throw err
145146
}
146147
}
148+
149+
// The registration API never exposes the nuspec <repository> element — it must be read from the
150+
// raw nuspec XML, served by the flat-container endpoint.
151+
export async function fetchNuspec(
152+
packageId: string,
153+
version: string,
154+
): Promise<string | NuGetFetchError> {
155+
const lowerId = packageId.toLowerCase()
156+
const lowerVersion = version.toLowerCase()
157+
158+
try {
159+
const resp = await axios.get<string>(
160+
`${FLAT_CONTAINER_BASE_URL}/${lowerId}/${lowerVersion}/${lowerId}.nuspec`,
161+
{
162+
responseType: 'text',
163+
transformResponse: (data) => data,
164+
timeout: 15000,
165+
},
166+
)
167+
return resp.data
168+
} catch (err) {
169+
const classified = classifyError(err)
170+
if (classified) return classified
171+
throw err
172+
}
173+
}

services/apps/packages_worker/src/nuget/normalize.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { XMLParser } from 'fast-xml-parser'
2+
3+
import { canonicalizeRepoUrl } from '../utils/canonicalizeRepoUrl'
4+
15
import {
26
NormalizedNuGetPackage,
37
NormalizedNuGetVersion,
@@ -26,18 +30,20 @@ function parsePublishedDate(published: string | undefined): Date | null {
2630
return !isNaN(date.getTime()) && date.getUTCFullYear() > 1900 ? date : null
2731
}
2832

29-
const SCM_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.org']
33+
const nuspecParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' })
3034

31-
function normalizeRepoUrl(url: string | undefined): string | null {
32-
if (!url) return null
33-
return url
34-
.trim()
35-
.replace(/\.git$/, '')
36-
.replace(/^git\+/, '')
37-
.replace(/^git:\/\//, 'https://')
38-
.replace(/^http:\/\/github\.com\//, 'https://github.com/')
35+
export function parseNuspecRepositoryUrl(nuspecXml: string): string | null {
36+
try {
37+
const doc = nuspecParser.parse(nuspecXml)
38+
const url = doc?.package?.metadata?.repository?.['@_url']
39+
return typeof url === 'string' && url.trim() !== '' ? url.trim() : null
40+
} catch {
41+
return null
42+
}
3943
}
4044

45+
const SCM_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.org']
46+
4147
function isScmUrl(url: string | undefined): boolean {
4248
if (!url) return false
4349
try {
@@ -64,6 +70,7 @@ export function normalizeNuGetPackage(
6470
packageId: string,
6571
searchResult: NuGetSearchItem | null,
6672
registration: NuGetRegistrationIndex,
73+
nuspecXml?: string | null,
6774
): NormalizedNuGetPackage {
6875
const allLeaves = registration.items.flatMap((page) => page.items ?? [])
6976
const allEntries: NuGetCatalogEntry[] = allLeaves.map((leaf) => leaf.catalogEntry)
@@ -96,10 +103,13 @@ export function normalizeNuGetPackage(
96103
...(latestEntry ? [latestEntry] : []),
97104
]
98105
: [...allEntries].reverse()
99-
const nuspecRepoUrl = entriesForRepo.find((e) => e.repository?.url)?.repository?.url
106+
const catalogRepoUrl = entriesForRepo.find((e) => e.repository?.url)?.repository?.url
107+
const fetchedNuspecRepoUrl = nuspecXml ? parseNuspecRepositoryUrl(nuspecXml) : null
108+
const nuspecRepoUrl = fetchedNuspecRepoUrl ?? catalogRepoUrl
100109
const declaredRepositoryUrl = nuspecRepoUrl ?? null
101-
const repoCandidate = nuspecRepoUrl ?? (isScmUrl(homepage) ? homepage : null)
102-
const repositoryUrl = normalizeRepoUrl(repoCandidate ?? undefined)
110+
const repo =
111+
(nuspecRepoUrl ? canonicalizeRepoUrl(nuspecRepoUrl) : null) ??
112+
(isScmUrl(homepage) ? canonicalizeRepoUrl(homepage) : null)
103113

104114
const keywords = searchResult?.tags && searchResult.tags.length > 0 ? searchResult.tags : null
105115

@@ -154,7 +164,7 @@ export function normalizeNuGetPackage(
154164
description,
155165
homepage: homepage || null,
156166
declaredRepositoryUrl,
157-
repositoryUrl,
167+
repo,
158168
licenses,
159169
licensesRaw,
160170
keywords,

services/apps/packages_worker/src/nuget/runNuGetEnrichmentLoop.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
IDbNuGetVersionUpsert,
33
NuGetPackageToSync,
44
QueryExecutor,
5+
getOrCreateRepoByUrl,
56
listNuGetPackagesToSync,
67
logAuditFieldChange,
78
markNuGetPackageError,
@@ -10,12 +11,13 @@ import {
1011
upsertMaintainer,
1112
upsertNuGetPackage,
1213
upsertNuGetVersionsBatch,
14+
upsertPackageRepo,
1315
} from '@crowd/data-access-layer'
1416
import { getServiceChildLogger } from '@crowd/logging'
1517

1618
import { getNuGetConfig } from '../config'
1719

18-
import { fetchRegistration, fetchSearch } from './client'
20+
import { fetchNuspec, fetchRegistration, fetchSearch } from './client'
1921
import { normalizeNuGetPackage } from './normalize'
2022
import { BatchResult, isNuGetFetchError } from './types'
2123

@@ -96,7 +98,15 @@ async function processPackage(
9698

9799
const searchItem = isNuGetFetchError(searchResult) ? null : searchResult
98100

99-
const normalized = normalizeNuGetPackage(packageId, searchItem, registrationResult)
101+
const preliminary = normalizeNuGetPackage(packageId, searchItem, registrationResult)
102+
103+
let nuspecXml: string | null = null
104+
if (preliminary.latestVersion) {
105+
const nuspecResult = await fetchNuspec(packageId, preliminary.latestVersion)
106+
nuspecXml = isNuGetFetchError(nuspecResult) ? null : nuspecResult
107+
}
108+
109+
const normalized = normalizeNuGetPackage(packageId, searchItem, registrationResult, nuspecXml)
100110

101111
await withDeadlockRetry(() =>
102112
qx.tx(async (t) => {
@@ -108,7 +118,7 @@ async function processPackage(
108118
description: normalized.description,
109119
homepage: normalized.homepage,
110120
declaredRepositoryUrl: normalized.declaredRepositoryUrl,
111-
repositoryUrl: normalized.repositoryUrl,
121+
repositoryUrl: normalized.repo?.url ?? null,
112122
licenses: normalized.licenses,
113123
licensesRaw: normalized.licensesRaw,
114124
keywords: normalized.keywords,
@@ -122,6 +132,24 @@ async function processPackage(
122132
})
123133
pkgChanged.forEach((f) => changed.add(f))
124134

135+
if (normalized.repo) {
136+
const { id: repoId, changedFields: repoChanged } = await getOrCreateRepoByUrl(
137+
t,
138+
normalized.repo.url,
139+
normalized.repo.host,
140+
)
141+
repoChanged.forEach((f) => changed.add(f))
142+
143+
const linkChanged = await upsertPackageRepo(
144+
t,
145+
packageDbId.toString(),
146+
repoId,
147+
'declared',
148+
0.8,
149+
)
150+
linkChanged.forEach((f) => changed.add(f))
151+
}
152+
125153
if (normalized.versions.length > 0) {
126154
const versionRows: IDbNuGetVersionUpsert[] = normalized.versions.map((v) => ({
127155
packageId: packageDbId,

services/apps/packages_worker/src/nuget/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { CanonicalRepo } from '../utils/canonicalizeRepoUrl'
2+
13
export interface NuGetConfig {
24
batchSize: number
35
concurrency: number
@@ -73,7 +75,7 @@ export interface NormalizedNuGetPackage {
7375
description: string | null
7476
homepage: string | null
7577
declaredRepositoryUrl: string | null
76-
repositoryUrl: string | null
78+
repo: CanonicalRepo | null
7779
licenses: string[] | null
7880
licensesRaw: string | null
7981
keywords: string[] | null

services/apps/packages_worker/src/utils/canonicalizeRepoUrl.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,18 @@ export function canonicalizeRepoUrl(raw: string): CanonicalRepo | null {
6868
const segments = u.pathname.split('/').filter(Boolean)
6969
if (segments.length < 2) return null
7070

71-
let owner = segments[0]
72-
let name = segments[1].replace(/\.git$/, '')
73-
if (!owner || !name) return null
71+
const isKnownHost = hostname in HOST_ENUM
72+
let ownerPath = isKnownHost ? [segments[0]] : segments.slice(0, -1)
73+
let name = (isKnownHost ? segments[1] : segments[segments.length - 1]).replace(/\.git$/, '')
74+
if (!name || ownerPath.length === 0 || ownerPath.some((seg) => !seg)) return null
7475

7576
if (CASE_INSENSITIVE_HOSTS.has(hostname)) {
76-
owner = owner.toLowerCase()
77+
ownerPath = ownerPath.map((seg) => seg.toLowerCase())
7778
name = name.toLowerCase()
7879
}
7980

8081
return {
81-
url: `https://${hostname}/${owner}/${name}`,
82+
url: `https://${hostname}/${[...ownerPath, name].join('/')}`,
8283
host: HOST_ENUM[hostname] ?? 'other',
8384
}
8485
}

0 commit comments

Comments
 (0)