11import { ExtractorResult , ProvenanceEntry , RawContact } from '../../types'
2- import { extractEmails , fetchJson , registryHeaders } from '../http'
2+ import { extractEmails , fetchJson , isEmail , registryHeaders } from '../http'
33
44import { ParsedPurl } from './purl'
55
66const SOURCE = 'rubygems'
77
88/* eslint-disable @typescript-eslint/no-explicit-any */
99
10- // RubyGems hides author emails; we can only surface emails when an author string embeds one.
1110// bug_tracker_uri is an issue tracker, not a security contact, so it is intentionally skipped.
1211export function mapRubygems ( doc : unknown , sourceUrl : string , fetchedAt : string ) : RawContact [ ] {
1312 if ( ! doc || typeof doc !== 'object' ) return [ ]
@@ -34,13 +33,63 @@ export function mapRubygems(doc: unknown, sourceUrl: string, fetchedAt: string):
3433 return contacts
3534}
3635
36+ export function mapRubygemsOwners (
37+ owners : unknown ,
38+ sourceUrl : string ,
39+ fetchedAt : string ,
40+ ) : RawContact [ ] {
41+ if ( ! Array . isArray ( owners ) ) return [ ]
42+
43+ const prov = ( ) : ProvenanceEntry [ ] => [
44+ { source : SOURCE , sourceTier : 'B' , path : sourceUrl , fetchedAt } ,
45+ ]
46+ const contacts : RawContact [ ] = [ ]
47+ const seen = new Set < string > ( )
48+ for ( const owner of owners ) {
49+ const o = ( owner ?? { } ) as Record < string , unknown >
50+ const email = o . email
51+ if ( typeof email !== 'string' || ! isEmail ( email ) ) continue
52+ const key = email . toLowerCase ( )
53+ if ( seen . has ( key ) ) continue
54+ seen . add ( key )
55+ // RubyGems handles are not GitHub logins — stored as `name`, not `handle`, so
56+ // identityLinkMerge (reconcile.ts) never cross-links them to a github-handle contact.
57+ const name = typeof o . handle === 'string' ? o . handle : undefined
58+ contacts . push ( {
59+ channel : 'email' ,
60+ value : email ,
61+ name,
62+ role : 'maintainer' ,
63+ tier : 'B' ,
64+ provenance : prov ( ) ,
65+ } )
66+ }
67+ return contacts
68+ }
69+
3770export async function fetchRubygems (
3871 parsed : ParsedPurl ,
3972 timeoutMs : number ,
4073 userAgent : string ,
4174) : Promise < ExtractorResult > {
42- const url = `https://rubygems.org/api/v1/gems/${ encodeURIComponent ( parsed . name ) } .json`
43- const { json } = await fetchJson ( url , timeoutMs , registryHeaders ( userAgent ) )
44- if ( ! json ) return { contacts : [ ] , policies : { } }
45- return { contacts : mapRubygems ( json , url , new Date ( ) . toISOString ( ) ) , policies : { } }
75+ const gemUrl = `https://rubygems.org/api/v1/gems/${ encodeURIComponent ( parsed . name ) } .json`
76+ const ownersUrl = `https://rubygems.org/api/v1/gems/${ encodeURIComponent ( parsed . name ) } /owners.json`
77+ const fetchedAt = new Date ( ) . toISOString ( )
78+
79+ const [ gemResult , ownersResult ] = await Promise . all ( [
80+ fetchJson ( gemUrl , timeoutMs , registryHeaders ( userAgent ) ) ,
81+ fetchJson ( ownersUrl , timeoutMs , registryHeaders ( userAgent ) ) . catch ( ( ) => ( {
82+ status : 0 ,
83+ json : null ,
84+ } ) ) ,
85+ ] )
86+
87+ if ( ! gemResult . json ) return { contacts : [ ] , policies : { } }
88+
89+ const contacts = [
90+ ...mapRubygems ( gemResult . json , gemUrl , fetchedAt ) ,
91+ ...mapRubygemsOwners ( ownersResult . json , ownersUrl , fetchedAt ) ,
92+ ]
93+
94+ return { contacts, policies : { } }
4695}
0 commit comments