From a8d824e8eabe78f919fd922ad8b22cfaa76ef97d Mon Sep 17 00:00:00 2001 From: AmarTrebinjac Date: Wed, 25 Feb 2026 15:36:54 +0000 Subject: [PATCH 1/2] feat(github): add userGithubRepositories query to display repos on profile Add a new GraphQL query that fetches a user's public GitHub repositories by extracting their GitHub username from socialLinks. Includes new GitHubUserRepository types, listUserRepositories client method, and resolver with 1-hour cache and graceful error handling. Co-Authored-By: Claude Opus 4.6 --- src/common/schema/github.ts | 5 ++ src/integrations/github/clients.ts | 62 ++++++++++++++++--------- src/integrations/github/types.ts | 31 +++++++++++++ src/schema/users.ts | 73 ++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 src/common/schema/github.ts diff --git a/src/common/schema/github.ts b/src/common/schema/github.ts new file mode 100644 index 0000000000..70574c1dc5 --- /dev/null +++ b/src/common/schema/github.ts @@ -0,0 +1,5 @@ +import z from 'zod'; + +export const userGithubRepositoriesSchema = z.object({ + userId: z.string(), +}); diff --git a/src/integrations/github/clients.ts b/src/integrations/github/clients.ts index 37e45f815f..5eb381508d 100644 --- a/src/integrations/github/clients.ts +++ b/src/integrations/github/clients.ts @@ -1,6 +1,10 @@ import fetch from 'node-fetch'; import { GarmrService, IGarmrService, GarmrNoopService } from '../garmr'; -import { IGitHubClient, GitHubSearchResponse } from './types'; +import { + IGitHubClient, + GitHubSearchResponse, + GitHubUserRepository, +} from './types'; export class GitHubClient implements IGitHubClient { private readonly baseUrl: string; @@ -19,31 +23,47 @@ export class GitHubClient implements IGitHubClient { this.garmr = options?.garmr || new GarmrNoopService(); } - async searchRepositories( - query: string, - limit = 10, - ): Promise { - return this.garmr.execute(async () => { - const url = `${this.baseUrl}/search/repositories?q=${encodeURIComponent(query)}&per_page=${limit}&sort=stars&order=desc`; + private async fetchJson(path: string): Promise { + const headers: Record = { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'daily.dev', + }; - const headers: Record = { - Accept: 'application/vnd.github.v3+json', - 'User-Agent': 'daily.dev', - }; + if (this.token) { + headers.Authorization = `Bearer ${this.token}`; + } - if (this.token) { - headers.Authorization = `Bearer ${this.token}`; - } + const response = await fetch(`${this.baseUrl}${path}`, { headers }); - const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ); + } - if (!response.ok) { - throw new Error( - `GitHub API error: ${response.status} ${response.statusText}`, - ); - } + return response.json() as Promise; + } - return response.json() as Promise; + async searchRepositories( + query: string, + limit = 10, + ): Promise { + return this.garmr.execute(() => + this.fetchJson( + `/search/repositories?q=${encodeURIComponent(query)}&per_page=${limit}&sort=stars&order=desc`, + ), + ); + } + + async listUserRepositories( + username: string, + limit = 6, + ): Promise { + return this.garmr.execute(async () => { + const repos = await this.fetchJson( + `/users/${encodeURIComponent(username)}/repos?type=owner&sort=stars&direction=desc&per_page=${limit}`, + ); + return repos.filter((repo) => !repo.fork); }); } } diff --git a/src/integrations/github/types.ts b/src/integrations/github/types.ts index acfb57b2c8..fe64abc95c 100644 --- a/src/integrations/github/types.ts +++ b/src/integrations/github/types.ts @@ -29,9 +29,40 @@ export interface GQLGitHubRepository { description: string | null; } +export type GitHubUserRepository = { + id: number; + name: string; + full_name: string; + html_url: string; + description: string | null; + owner: GitHubRepositoryOwner; + stargazers_count: number; + forks_count: number; + language: string | null; + fork: boolean; + updated_at: string; +}; + +export type GQLGitHubUserRepository = { + id: string; + owner: string; + name: string; + fullName: string; + url: string; + description: string | null; + stars: number; + forks: number; + language: string | null; + updatedAt: string; +}; + export interface IGitHubClient extends IGarmrClient { searchRepositories( query: string, limit?: number, ): Promise; + listUserRepositories( + username: string, + limit?: number, + ): Promise; } diff --git a/src/schema/users.ts b/src/schema/users.ts index d83aa6647c..0371ebc6a7 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -174,6 +174,9 @@ import { } from '../common/googleCloud'; import { fileTypeFromBuffer } from 'file-type'; import { notificationFlagsSchema } from '../common/schema/notificationFlagsSchema'; +import { userGithubRepositoriesSchema } from '../common/schema/github'; +import { gitHubClient } from '../integrations/github/clients'; +import type { GQLGitHubUserRepository } from '../integrations/github/types'; import { syncNotificationFlagsToCio } from '../cio'; import { UserCandidatePreference } from '../entity/user/UserCandidatePreference'; import { findOrCreateDatasetLocation } from '../entity/dataset/utils'; @@ -1248,6 +1251,19 @@ export const typeDefs = /* GraphQL */ ` impressionsAds: Int! } + type GitHubUserRepository { + id: ID! + owner: String! + name: String! + fullName: String! + url: String! + description: String + stars: Int! + forks: Int! + language: String + updatedAt: String! + } + extend type Query { """ Get user based on logged in session @@ -1464,6 +1480,12 @@ export const typeDefs = /* GraphQL */ ` Get daily impressions history for all posts authored by the authenticated user (last 45 days) """ userPostsAnalyticsHistory: [UserPostsAnalyticsHistoryNode!]! @auth + + """ + Get public GitHub repositories for a user + """ + userGithubRepositories(userId: ID!): [GitHubUserRepository!]! + @cacheControl(maxAge: 3600) } ${toGQLEnum(UploadPreset, 'UploadPreset')} @@ -2877,6 +2899,57 @@ export const resolvers: IResolvers = traceResolvers< .andWhere('p.type != :briefType', { briefType: PostType.Brief }), }); }, + userGithubRepositories: async ( + _, + args: { userId: string }, + ctx: BaseContext, + ): Promise => { + const { userId } = userGithubRepositoriesSchema.parse(args); + + const user = await queryReadReplica(ctx.con, ({ queryRunner }) => + queryRunner.manager.getRepository(User).findOne({ + where: { id: userId }, + select: ['id', 'socialLinks', 'github'], + }), + ); + + if (!user) { + return []; + } + + const githubLink = user.socialLinks?.find( + (link) => link.platform === 'github', + ); + const githubUsername = githubLink + ? extractHandleFromUrl(githubLink.url, 'github') + : user.github; + + if (!githubUsername) { + return []; + } + + try { + const repos = await gitHubClient.listUserRepositories( + githubUsername, + 6, + ); + + return repos.map((repo) => ({ + id: String(repo.id), + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + url: repo.html_url, + description: repo.description, + stars: repo.stargazers_count, + forks: repo.forks_count, + language: repo.language, + updatedAt: repo.updated_at, + })); + } catch { + return []; + } + }, }, Mutation: { clearImage: async ( From c5de3cffe34e5dbff9de596a4906d7c5fba30886 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:27:11 +0000 Subject: [PATCH 2/2] test: add integration tests for userGithubRepositories query Co-authored-by: Amar Trebinjac --- __tests__/users.ts | 110 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/__tests__/users.ts b/__tests__/users.ts index 6aff0ba22e..49760fdec6 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -167,6 +167,8 @@ import { NotificationType, } from '../src/notifications/common'; import { UserCandidatePreference } from '../src/entity/user/UserCandidatePreference'; +import { gitHubClient } from '../src/integrations/github/clients'; +import type { GitHubUserRepository } from '../src/integrations/github/types'; jest.mock('../src/common/geo', () => ({ ...(jest.requireActual('../src/common/geo') as Record), @@ -8120,3 +8122,111 @@ describe('query userPostsAnalyticsHistory', () => { }); }); }); + +describe('query userGithubRepositories', () => { + const QUERY = /* GraphQL */ ` + query UserGithubRepositories($userId: ID!) { + userGithubRepositories(userId: $userId) { + id + owner + name + fullName + url + description + stars + forks + language + updatedAt + } + } + `; + + const mockRepo: GitHubUserRepository = { + id: 123456, + name: 'my-repo', + full_name: 'lee/my-repo', + html_url: 'https://github.com/lee/my-repo', + description: 'A test repository', + owner: { + login: 'lee', + avatar_url: 'https://avatars.githubusercontent.com/u/1', + }, + stargazers_count: 100, + forks_count: 20, + language: 'TypeScript', + fork: false, + updated_at: '2024-01-01T00:00:00Z', + }; + + let listReposMock: jest.SpyInstance; + + beforeEach(() => { + listReposMock = jest + .spyOn(gitHubClient, 'listUserRepositories') + .mockResolvedValue([mockRepo]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return repos for user with GitHub in socialLinks', async () => { + // User 3 has { platform: 'github', url: 'https://github.com/lee' } in socialLinks + const res = await client.query(QUERY, { variables: { userId: '3' } }); + + expect(res.errors).toBeFalsy(); + expect(listReposMock).toHaveBeenCalledWith('lee', 6); + expect(res.data.userGithubRepositories).toEqual([ + { + id: '123456', + owner: 'lee', + name: 'my-repo', + fullName: 'lee/my-repo', + url: 'https://github.com/lee/my-repo', + description: 'A test repository', + stars: 100, + forks: 20, + language: 'TypeScript', + updatedAt: '2024-01-01T00:00:00Z', + }, + ]); + }); + + it('should fall back to legacy github column when no socialLinks github entry', async () => { + await con.getRepository(User).update('1', { github: 'idouser' }); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + + expect(res.errors).toBeFalsy(); + expect(listReposMock).toHaveBeenCalledWith('idouser', 6); + expect(res.data.userGithubRepositories).toHaveLength(1); + }); + + it('should return empty array for user with no GitHub link', async () => { + // User 2 has no GitHub in socialLinks and no github field + const res = await client.query(QUERY, { variables: { userId: '2' } }); + + expect(res.errors).toBeFalsy(); + expect(listReposMock).not.toHaveBeenCalled(); + expect(res.data.userGithubRepositories).toEqual([]); + }); + + it('should return empty array for non-existent user', async () => { + const res = await client.query(QUERY, { + variables: { userId: 'nonexistent' }, + }); + + expect(res.errors).toBeFalsy(); + expect(listReposMock).not.toHaveBeenCalled(); + expect(res.data.userGithubRepositories).toEqual([]); + }); + + it('should return empty array when GitHub API fails', async () => { + listReposMock.mockRejectedValue(new Error('GitHub API error')); + + const res = await client.query(QUERY, { variables: { userId: '3' } }); + + expect(res.errors).toBeFalsy(); + expect(res.data.userGithubRepositories).toEqual([]); + }); +});