Skip to content
Open
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
110 changes: 110 additions & 0 deletions __tests__/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>),
Expand Down Expand Up @@ -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([]);
});
});
5 changes: 5 additions & 0 deletions src/common/schema/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import z from 'zod';

export const userGithubRepositoriesSchema = z.object({
userId: z.string(),
});
62 changes: 41 additions & 21 deletions src/integrations/github/clients.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,31 +23,47 @@ export class GitHubClient implements IGitHubClient {
this.garmr = options?.garmr || new GarmrNoopService();
}

async searchRepositories(
query: string,
limit = 10,
): Promise<GitHubSearchResponse> {
return this.garmr.execute(async () => {
const url = `${this.baseUrl}/search/repositories?q=${encodeURIComponent(query)}&per_page=${limit}&sort=stars&order=desc`;
private async fetchJson<T>(path: string): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'daily.dev',
};

const headers: Record<string, string> = {
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<T>;
}

return response.json() as Promise<GitHubSearchResponse>;
async searchRepositories(
query: string,
limit = 10,
): Promise<GitHubSearchResponse> {
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<GitHubUserRepository[]> {
return this.garmr.execute(async () => {
const repos = await this.fetchJson<GitHubUserRepository[]>(
`/users/${encodeURIComponent(username)}/repos?type=owner&sort=stars&direction=desc&per_page=${limit}`,
);
return repos.filter((repo) => !repo.fork);
});
}
}
Expand Down
31 changes: 31 additions & 0 deletions src/integrations/github/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitHubSearchResponse>;
listUserRepositories(
username: string,
limit?: number,
): Promise<GitHubUserRepository[]>;
}
73 changes: 73 additions & 0 deletions src/schema/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,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';
Expand Down Expand Up @@ -1247,6 +1250,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
Expand Down Expand Up @@ -1463,6 +1479,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')}
Expand Down Expand Up @@ -2875,6 +2897,57 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
}),
});
},
userGithubRepositories: async (
_,
args: { userId: string },
ctx: BaseContext,
): Promise<GQLGitHubUserRepository[]> => {
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 (
Expand Down
Loading