@@ -224,6 +224,16 @@ func (e *APIError) Error() string {
224224}
225225
226226func (c * Client ) fetchAPI (ctx context.Context , urlStr , username string ) (* profile.Profile , error ) {
227+ // Try GraphQL first (gets social accounts), fall back to REST API
228+ if c .token != "" {
229+ prof , err := c .fetchGraphQL (ctx , urlStr , username )
230+ if err == nil {
231+ return prof , nil
232+ }
233+ c .logger .WarnContext (ctx , "GraphQL fetch failed, falling back to REST API" , "error" , err )
234+ }
235+
236+ // REST API fallback
227237 apiURL := "https://api.github.com/users/" + username
228238
229239 req , err := http .NewRequestWithContext (ctx , http .MethodGet , apiURL , http .NoBody )
@@ -245,6 +255,161 @@ func (c *Client) fetchAPI(ctx context.Context, urlStr, username string) (*profil
245255 return parseJSON (body , urlStr , username )
246256}
247257
258+ func (c * Client ) fetchGraphQL (ctx context.Context , urlStr , username string ) (* profile.Profile , error ) {
259+ query := `
260+ query($login: String!) {
261+ user(login: $login) {
262+ name
263+ login
264+ location
265+ bio
266+ company
267+ websiteUrl
268+ twitterUsername
269+ createdAt
270+ updatedAt
271+
272+ socialAccounts(first: 10) {
273+ nodes {
274+ provider
275+ url
276+ displayName
277+ }
278+ }
279+
280+ followers {
281+ totalCount
282+ }
283+ following {
284+ totalCount
285+ }
286+
287+ repositories(first: 1, ownerAffiliations: OWNER) {
288+ totalCount
289+ }
290+ }
291+ }
292+ `
293+
294+ variables := map [string ]string {"login" : username }
295+ reqBody := map [string ]any {
296+ "query" : query ,
297+ "variables" : variables ,
298+ }
299+
300+ jsonData , err := json .Marshal (reqBody )
301+ if err != nil {
302+ return nil , fmt .Errorf ("marshaling GraphQL request: %w" , err )
303+ }
304+
305+ req , err := http .NewRequestWithContext (ctx , http .MethodPost , "https://api.github.com/graphql" , strings .NewReader (string (jsonData )))
306+ if err != nil {
307+ return nil , err
308+ }
309+ req .Header .Set ("Authorization" , "Bearer " + c .token )
310+ req .Header .Set ("Content-Type" , "application/json" )
311+ req .Header .Set ("User-Agent" , "sociopath/1.0" )
312+
313+ body , err := c .doAPIRequest (ctx , req )
314+ if err != nil {
315+ return nil , err
316+ }
317+
318+ return parseGraphQLResponse (body , urlStr , username )
319+ }
320+
321+ func parseGraphQLResponse (data []byte , urlStr , _ string ) (* profile.Profile , error ) {
322+ var response struct {
323+ Errors []struct {
324+ Message string `json:"message"`
325+ } `json:"errors"`
326+ Data struct {
327+ User struct {
328+ Name string `json:"name"`
329+ Login string `json:"login"`
330+ Location string `json:"location"`
331+ Bio string `json:"bio"`
332+ Company string `json:"company"`
333+ WebsiteURL string `json:"websiteUrl"`
334+ TwitterUser string `json:"twitterUsername"`
335+ SocialAccounts struct {
336+ Nodes []struct {
337+ URL string `json:"url"`
338+ Provider string `json:"provider"`
339+ DisplayName string `json:"displayName"`
340+ } `json:"nodes"`
341+ } `json:"socialAccounts"`
342+ Followers struct { TotalCount int } `json:"followers"`
343+ Following struct { TotalCount int } `json:"following"`
344+ Repositories struct { TotalCount int } `json:"repositories"`
345+ } `json:"user"`
346+ } `json:"data"`
347+ }
348+
349+ if err := json .Unmarshal (data , & response ); err != nil {
350+ return nil , fmt .Errorf ("parsing GraphQL response: %w" , err )
351+ }
352+
353+ if len (response .Errors ) > 0 {
354+ return nil , fmt .Errorf ("GraphQL error: %s" , response .Errors [0 ].Message )
355+ }
356+
357+ user := response .Data .User
358+ prof := & profile.Profile {
359+ Platform : platform ,
360+ URL : urlStr ,
361+ Authenticated : true ,
362+ Username : user .Login ,
363+ Name : user .Name ,
364+ Bio : user .Bio ,
365+ Location : user .Location ,
366+ Fields : make (map [string ]string ),
367+ }
368+
369+ // Add website
370+ if user .WebsiteURL != "" {
371+ website := user .WebsiteURL
372+ if ! strings .HasPrefix (website , "http" ) {
373+ website = "https://" + website
374+ }
375+ prof .Website = website
376+ prof .Fields ["website" ] = website
377+ }
378+
379+ // Add company
380+ if user .Company != "" {
381+ company := strings .TrimPrefix (user .Company , "@" )
382+ prof .Fields ["company" ] = company
383+ }
384+
385+ // Add stats
386+ if user .Repositories .TotalCount > 0 {
387+ prof .Fields ["public_repos" ] = strconv .Itoa (user .Repositories .TotalCount )
388+ }
389+ if user .Followers .TotalCount > 0 {
390+ prof .Fields ["followers" ] = strconv .Itoa (user .Followers .TotalCount )
391+ }
392+ if user .Following .TotalCount > 0 {
393+ prof .Fields ["following" ] = strconv .Itoa (user .Following .TotalCount )
394+ }
395+
396+ // Add Twitter from GraphQL
397+ if user .TwitterUser != "" {
398+ twitterURL := "https://twitter.com/" + user .TwitterUser
399+ prof .Fields ["twitter" ] = twitterURL
400+ prof .SocialLinks = append (prof .SocialLinks , twitterURL )
401+ }
402+
403+ // Add social accounts from GraphQL - this is the key improvement!
404+ for _ , social := range user .SocialAccounts .Nodes {
405+ if social .URL != "" {
406+ prof .SocialLinks = append (prof .SocialLinks , social .URL )
407+ }
408+ }
409+
410+ return prof , nil
411+ }
412+
248413func (c * Client ) doAPIRequest (ctx context.Context , req * http.Request ) ([]byte , error ) {
249414 // Check cache first
250415 cacheKey := req .URL .String ()
@@ -272,8 +437,10 @@ func (c *Client) doAPIRequest(ctx context.Context, req *http.Request) ([]byte, e
272437 defer func () { _ = resp .Body .Close () }() //nolint:errcheck // error ignored intentionally
273438
274439 // Parse rate limit headers (GitHub uses non-canonical casing, parse errors default to 0)
275- rateLimitRemain , _ := strconv .Atoi (resp .Header .Get ("X-RateLimit-Remaining" )) //nolint:errcheck,canonicalheader // ok
276- rateLimitReset , _ := strconv .ParseInt (resp .Header .Get ("X-RateLimit-Reset" ), 10 , 64 ) //nolint:errcheck,canonicalheader // ok
440+ //nolint:errcheck,canonicalheader // GitHub uses non-canonical header casing
441+ rateLimitRemain , _ := strconv .Atoi (resp .Header .Get ("X-RateLimit-Remaining" ))
442+ //nolint:errcheck,canonicalheader // GitHub uses non-canonical header casing
443+ rateLimitReset , _ := strconv .ParseInt (resp .Header .Get ("X-RateLimit-Reset" ), 10 , 64 )
277444 resetTime := time .Unix (rateLimitReset , 0 )
278445
279446 if resp .StatusCode != http .StatusOK {
0 commit comments