[이제와서 시작하는 GitHub 마스터하기 - 심화편 #4] GitHub GraphQL API 마스터하기: REST를 넘어서
[이제와서 시작하는 GitHub 마스터하기 - 심화편 #4] GitHub GraphQL API 마스터하기: REST를 넘어서
들어가며
GitHub 마스터하기 심화편 네 번째 시간입니다. 이번에는 GitHub GraphQL API(v4)를 깊이 있게 다룹니다. REST API의 한계를 넘어 더 효율적이고 강력한 방법으로 GitHub 데이터를 다루는 방법을 알아보겠습니다. 복잡한 쿼리, 뮤테이션, 그리고 실시간 구독까지 마스터해봅시다.
1. GraphQL vs REST API
왜 GraphQL인가?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
REST API의 한계:
Over-fetching:
- 필요 이상의 데이터 전송
- 대역폭 낭비
- 성능 저하
Under-fetching:
- 여러 엔드포인트 호출 필요
- N+1 쿼리 문제
- 복잡한 데이터 조합
버전 관리:
- API 버전 증가
- 하위 호환성 문제
- 엔드포인트 증가
GraphQL의 장점:
정확한 데이터:
- 필요한 필드만 요청
- 단일 요청으로 해결
- 타입 안정성
유연성:
- 스키마 진화
- 버전 없는 API
- 강력한 도구
성능:
- 네트워크 요청 감소
- 효율적인 데이터 로딩
- 배치 처리
실제 비교 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// REST API - 여러 요청 필요
async function getRepositoryDetailsREST(owner, repo) {
// 1. 저장소 정보
const repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
const repoData = await repoResponse.json();
// 2. 이슈 목록
const issuesResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues?state=open`);
const issuesData = await issuesResponse.json();
// 3. PR 목록
const prsResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?state=open`);
const prsData = await prsResponse.json();
// 4. 기여자 목록
const contributorsResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contributors`);
const contributorsData = await contributorsResponse.json();
return {
repository: repoData,
issues: issuesData,
pullRequests: prsData,
contributors: contributorsData
};
}
// GraphQL - 단일 요청
const GET_REPOSITORY_DETAILS = `
query GetRepositoryDetails($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
name
description
stargazerCount
forkCount
issues(states: OPEN, first: 10) {
totalCount
nodes {
title
number
createdAt
author {
login
}
}
}
pullRequests(states: OPEN, first: 10) {
totalCount
nodes {
title
number
createdAt
author {
login
}
}
}
mentionableUsers(first: 10) {
nodes {
login
contributionsCollection {
totalCommitContributions
}
}
}
}
}
`;
async function getRepositoryDetailsGraphQL(owner, repo) {
const response = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: GET_REPOSITORY_DETAILS,
variables: { owner, name: repo }
})
});
const data = await response.json();
return data.data.repository;
}
2. GitHub GraphQL 스키마 탐색
인트로스펙션 쿼리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 스키마 탐색
query IntrospectionQuery {
__schema {
types {
name
kind
description
fields {
name
type {
name
kind
}
}
}
}
}
# 특정 타입 탐색
query ExploreRepositoryType {
__type(name: "Repository") {
name
kind
description
fields {
name
type {
name
kind
ofType {
name
kind
}
}
args {
name
type {
name
kind
}
}
}
}
}
스키마 네비게이터
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// github-schema-navigator.ts
import { GraphQLClient } from 'graphql-request';
import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql';
class GitHubSchemaNavigator {
private client: GraphQLClient;
constructor(token: string) {
this.client = new GraphQLClient('https://api.github.com/graphql', {
headers: {
authorization: `Bearer ${token}`,
},
});
}
async getSchema() {
const introspectionQuery = getIntrospectionQuery();
const result = await this.client.request(introspectionQuery);
const schema = buildClientSchema(result);
return schema;
}
async generateSDL() {
const schema = await this.getSchema();
const sdl = printSchema(schema);
// 파일로 저장
fs.writeFileSync('github-schema.graphql', sdl);
console.log('Schema saved to github-schema.graphql');
return sdl;
}
async exploreType(typeName: string) {
const query = `
query ExploreType($typeName: String!) {
__type(name: $typeName) {
name
kind
description
fields {
name
description
type {
...TypeRef
}
args {
name
description
type {
...TypeRef
}
defaultValue
}
}
interfaces {
...TypeRef
}
possibleTypes {
...TypeRef
}
enumValues {
name
description
}
}
}
fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
`;
const result = await this.client.request(query, { typeName });
return result.__type;
}
async findFieldPath(fromType: string, toType: string, maxDepth: number = 3): Promise<string[]> {
// BFS로 타입 간 경로 찾기
const visited = new Set<string>();
const queue: Array<{type: string, path: string[]}> = [{type: fromType, path: []}];
while (queue.length > 0) {
const current = queue.shift()!;
if (current.path.length > maxDepth) continue;
if (visited.has(current.type)) continue;
visited.add(current.type);
const typeInfo = await this.exploreType(current.type);
if (!typeInfo || !typeInfo.fields) continue;
for (const field of typeInfo.fields) {
const fieldType = this.getBaseType(field.type);
const newPath = [...current.path, field.name];
if (fieldType === toType) {
return newPath;
}
if (!visited.has(fieldType)) {
queue.push({type: fieldType, path: newPath});
}
}
}
return [];
}
private getBaseType(type: any): string {
if (type.ofType) {
return this.getBaseType(type.ofType);
}
return type.name || '';
}
}
// 사용 예제
const navigator = new GitHubSchemaNavigator(process.env.GITHUB_TOKEN!);
// Repository에서 User까지의 경로 찾기
const path = await navigator.findFieldPath('Repository', 'User');
console.log('Path from Repository to User:', path.join('.'));
// 출력: owner
3. 고급 쿼리 작성
복잡한 쿼리 구조화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# 프래그먼트를 활용한 재사용 가능한 쿼리
fragment RepositoryInfo on Repository {
id
name
description
url
stargazerCount
forkCount
primaryLanguage {
name
color
}
licenseInfo {
name
spdxId
}
}
fragment IssueDetails on Issue {
id
number
title
state
createdAt
updatedAt
author {
login
avatarUrl
}
labels(first: 5) {
nodes {
name
color
}
}
comments {
totalCount
}
}
fragment PullRequestDetails on PullRequest {
id
number
title
state
isDraft
createdAt
updatedAt
author {
login
avatarUrl
}
reviews {
totalCount
}
commits {
totalCount
}
additions
deletions
changedFiles
}
# 메인 쿼리
query GetOrganizationOverview(
$org: String!
$repoCount: Int = 10
$issueCount: Int = 5
$prCount: Int = 5
) {
organization(login: $org) {
name
description
url
repositories(
first: $repoCount
orderBy: { field: STARGAZERS, direction: DESC }
privacy: PUBLIC
) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
...RepositoryInfo
# 최근 이슈
issues(
first: $issueCount
states: OPEN
orderBy: { field: CREATED_AT, direction: DESC }
) {
nodes {
...IssueDetails
}
}
# 최근 PR
pullRequests(
first: $prCount
states: OPEN
orderBy: { field: CREATED_AT, direction: DESC }
) {
nodes {
...PullRequestDetails
}
}
# 보안 취약점
vulnerabilityAlerts(first: 5) {
nodes {
securityVulnerability {
package {
name
}
severity
advisory {
summary
description
}
}
}
}
}
}
# 조직 멤버
membersWithRole(first: 20) {
totalCount
edges {
node {
login
name
avatarUrl
}
role
}
}
# 팀
teams(first: 10) {
totalCount
nodes {
name
description
privacy
members {
totalCount
}
}
}
}
}
동적 쿼리 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
// dynamic-query-builder.ts
import { DocumentNode } from 'graphql';
import { gql } from 'graphql-tag';
interface QueryOptions {
fields?: string[];
depth?: number;
includeConnections?: boolean;
includeMetadata?: boolean;
}
class DynamicQueryBuilder {
private fragments: Map<string, string> = new Map();
constructor() {
this.initializeCommonFragments();
}
private initializeCommonFragments() {
this.fragments.set('userInfo', `
fragment UserInfo on User {
id
login
name
email
avatarUrl
bio
company
location
createdAt
}
`);
this.fragments.set('repoInfo', `
fragment RepoInfo on Repository {
id
name
nameWithOwner
description
url
isPrivate
isFork
stargazerCount
forkCount
}
`);
}
buildQuery(
rootType: string,
rootField: string,
options: QueryOptions = {}
): DocumentNode {
const {
fields = [],
depth = 2,
includeConnections = true,
includeMetadata = true
} = options;
const query = this.constructQuery(
rootType,
rootField,
fields,
depth,
includeConnections,
includeMetadata
);
const fragmentsStr = Array.from(this.fragments.values()).join('\n');
return gql`
${query}
${fragmentsStr}
`;
}
private constructQuery(
rootType: string,
rootField: string,
fields: string[],
depth: number,
includeConnections: boolean,
includeMetadata: boolean
): string {
const queryFields = this.buildFieldSelection(
rootType,
fields,
depth,
includeConnections,
includeMetadata
);
return `
query Dynamic${rootType}Query($login: String!) {
${rootField}(login: $login) {
${queryFields}
}
}
`;
}
private buildFieldSelection(
typeName: string,
requestedFields: string[],
depth: number,
includeConnections: boolean,
includeMetadata: boolean,
currentDepth: number = 0
): string {
if (currentDepth >= depth) {
return this.getScalarFields(typeName);
}
const fields: string[] = [];
// 스칼라 필드
fields.push(this.getScalarFields(typeName));
// 요청된 필드
if (requestedFields.length > 0) {
fields.push(...requestedFields);
}
// 연결 필드
if (includeConnections) {
fields.push(this.getConnectionFields(typeName, currentDepth + 1, depth));
}
// 메타데이터
if (includeMetadata && currentDepth === 0) {
fields.push(this.getMetadataFields(typeName));
}
return fields.filter(Boolean).join('\n');
}
private getScalarFields(typeName: string): string {
const scalarFieldsMap: Record<string, string> = {
User: `
id
login
name
email
avatarUrl
`,
Repository: `
id
name
description
url
stargazerCount
`,
Issue: `
id
number
title
state
createdAt
`,
PullRequest: `
id
number
title
state
isDraft
`
};
return scalarFieldsMap[typeName] || 'id';
}
private getConnectionFields(typeName: string, currentDepth: number, maxDepth: number): string {
if (currentDepth >= maxDepth) return '';
const connectionFieldsMap: Record<string, string> = {
User: `
repositories(first: 5, orderBy: {field: STARGAZERS, direction: DESC}) {
totalCount
nodes {
...RepoInfo
}
}
organizations(first: 5) {
totalCount
nodes {
id
login
name
}
}
`,
Repository: `
owner {
...UserInfo
}
issues(first: 5, states: OPEN) {
totalCount
nodes {
${this.getScalarFields('Issue')}
}
}
`,
Organization: `
repositories(first: 10) {
totalCount
nodes {
...RepoInfo
}
}
members(first: 10) {
totalCount
nodes {
...UserInfo
}
}
`
};
return connectionFieldsMap[typeName] || '';
}
private getMetadataFields(typeName: string): string {
return `
__typename
... on Node {
id
}
`;
}
}
// 사용 예제
const queryBuilder = new DynamicQueryBuilder();
// 사용자 정보 쿼리 생성
const userQuery = queryBuilder.buildQuery('User', 'user', {
fields: ['bio', 'company'],
depth: 3,
includeConnections: true,
includeMetadata: true
});
// 조직 정보 쿼리 생성
const orgQuery = queryBuilder.buildQuery('Organization', 'organization', {
depth: 2,
includeConnections: true
});
4. 페이지네이션과 커서 기반 탐색
Relay 스타일 페이지네이션
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
// pagination-helper.ts
interface PageInfo {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
}
interface Edge<T> {
node: T;
cursor: string;
}
interface Connection<T> {
edges: Edge<T>[];
nodes: T[];
pageInfo: PageInfo;
totalCount: number;
}
class GitHubPaginationHelper {
private client: GraphQLClient;
constructor(token: string) {
this.client = new GraphQLClient('https://api.github.com/graphql', {
headers: {
authorization: `Bearer ${token}`,
},
});
}
async *iterateAllPages<T>(
query: string,
variables: Record<string, any>,
connectionPath: string[]
): AsyncGenerator<T, void, unknown> {
let hasNextPage = true;
let cursor: string | null = null;
while (hasNextPage) {
const result = await this.client.request(query, {
...variables,
cursor
});
const connection = this.getNestedValue(result, connectionPath) as Connection<T>;
for (const node of connection.nodes) {
yield node;
}
hasNextPage = connection.pageInfo.hasNextPage;
cursor = connection.pageInfo.endCursor;
}
}
async getAllItems<T>(
query: string,
variables: Record<string, any>,
connectionPath: string[],
maxItems?: number
): Promise<T[]> {
const items: T[] = [];
let count = 0;
for await (const item of this.iterateAllPages<T>(query, variables, connectionPath)) {
items.push(item);
count++;
if (maxItems && count >= maxItems) {
break;
}
}
return items;
}
async getPagedResults<T>(
query: string,
variables: Record<string, any>,
connectionPath: string[],
pageSize: number = 20
): Promise<{
items: T[];
getNextPage: () => Promise<{ items: T[]; hasMore: boolean }>;
getPreviousPage: () => Promise<{ items: T[]; hasMore: boolean }>;
}> {
let currentCursor: string | null = null;
const cursorStack: string[] = [];
const fetchPage = async (cursor: string | null, direction: 'forward' | 'backward') => {
const result = await this.client.request(query, {
...variables,
first: direction === 'forward' ? pageSize : undefined,
last: direction === 'backward' ? pageSize : undefined,
after: direction === 'forward' ? cursor : undefined,
before: direction === 'backward' ? cursor : undefined,
});
const connection = this.getNestedValue(result, connectionPath) as Connection<T>;
return {
items: connection.nodes,
pageInfo: connection.pageInfo,
};
};
const initialPage = await fetchPage(null, 'forward');
return {
items: initialPage.items,
getNextPage: async () => {
if (currentCursor) {
cursorStack.push(currentCursor);
}
const { items, pageInfo } = await fetchPage(
initialPage.pageInfo.endCursor,
'forward'
);
currentCursor = pageInfo.endCursor;
return {
items,
hasMore: pageInfo.hasNextPage,
};
},
getPreviousPage: async () => {
const previousCursor = cursorStack.pop() || null;
const { items, pageInfo } = await fetchPage(previousCursor, 'backward');
currentCursor = previousCursor;
return {
items,
hasMore: cursorStack.length > 0,
};
},
};
}
private getNestedValue(obj: any, path: string[]): any {
return path.reduce((current, key) => current?.[key], obj);
}
}
// 사용 예제
const paginationHelper = new GitHubPaginationHelper(process.env.GITHUB_TOKEN!);
// 모든 저장소 가져오기
const ALL_REPOS_QUERY = `
query GetAllRepos($login: String!, $cursor: String) {
user(login: $login) {
repositories(first: 100, after: $cursor) {
nodes {
name
description
stargazerCount
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
}
`;
// 이터레이터 사용
for await (const repo of paginationHelper.iterateAllPages(
ALL_REPOS_QUERY,
{ login: 'octocat' },
['user', 'repositories']
)) {
console.log(repo.name);
}
// 페이지별 탐색
const pagedResults = await paginationHelper.getPagedResults(
ALL_REPOS_QUERY,
{ login: 'octocat' },
['user', 'repositories'],
20
);
console.log('First page:', pagedResults.items);
const nextPage = await pagedResults.getNextPage();
console.log('Second page:', nextPage.items);
console.log('Has more pages:', nextPage.hasMore);
5. 뮤테이션과 상태 변경
복잡한 뮤테이션 처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
// mutation-handler.ts
interface MutationResult<T> {
success: boolean;
data?: T;
error?: Error;
userErrors?: Array<{
message: string;
field?: string[];
}>;
}
class GitHubMutationHandler {
private client: GraphQLClient;
constructor(token: string) {
this.client = new GraphQLClient('https://api.github.com/graphql', {
headers: {
authorization: `Bearer ${token}`,
},
});
}
async createIssue(
repositoryId: string,
title: string,
body: string,
options?: {
labelIds?: string[];
assigneeIds?: string[];
projectIds?: string[];
milestoneId?: string;
}
): Promise<MutationResult<any>> {
const mutation = `
mutation CreateIssue($input: CreateIssueInput!) {
createIssue(input: $input) {
issue {
id
number
title
url
createdAt
}
clientMutationId
}
}
`;
try {
const result = await this.client.request(mutation, {
input: {
repositoryId,
title,
body,
...options,
clientMutationId: this.generateClientMutationId(),
},
});
return {
success: true,
data: result.createIssue.issue,
};
} catch (error) {
return this.handleMutationError(error);
}
}
async updatePullRequest(
pullRequestId: string,
updates: {
title?: string;
body?: string;
state?: 'OPEN' | 'CLOSED';
baseRefName?: string;
maintainerCanModify?: boolean;
}
): Promise<MutationResult<any>> {
const mutation = `
mutation UpdatePullRequest($input: UpdatePullRequestInput!) {
updatePullRequest(input: $input) {
pullRequest {
id
number
title
state
updatedAt
}
clientMutationId
}
}
`;
try {
const result = await this.client.request(mutation, {
input: {
pullRequestId,
...updates,
clientMutationId: this.generateClientMutationId(),
},
});
return {
success: true,
data: result.updatePullRequest.pullRequest,
};
} catch (error) {
return this.handleMutationError(error);
}
}
async batchOperation(operations: Array<() => Promise<MutationResult<any>>>): Promise<{
results: MutationResult<any>[];
successCount: number;
failureCount: number;
}> {
const results = await Promise.all(operations.map(op => op()));
const successCount = results.filter(r => r.success).length;
const failureCount = results.length - successCount;
return {
results,
successCount,
failureCount,
};
}
async createProjectCard(
projectColumnId: string,
contentId: string,
contentType: 'Issue' | 'PullRequest'
): Promise<MutationResult<any>> {
const mutation = `
mutation AddProjectCard($input: AddProjectCardInput!) {
addProjectCard(input: $input) {
cardEdge {
node {
id
note
content {
... on Issue {
id
title
number
}
... on PullRequest {
id
title
number
}
}
}
}
clientMutationId
}
}
`;
try {
const result = await this.client.request(mutation, {
input: {
projectColumnId,
contentId,
clientMutationId: this.generateClientMutationId(),
},
});
return {
success: true,
data: result.addProjectCard.cardEdge.node,
};
} catch (error) {
return this.handleMutationError(error);
}
}
async mergePullRequest(
pullRequestId: string,
options: {
commitHeadline?: string;
commitBody?: string;
mergeMethod?: 'MERGE' | 'SQUASH' | 'REBASE';
authorEmail?: string;
expectedHeadOid?: string;
} = {}
): Promise<MutationResult<any>> {
const mutation = `
mutation MergePullRequest($input: MergePullRequestInput!) {
mergePullRequest(input: $input) {
pullRequest {
id
number
title
state
merged
mergedAt
mergeCommit {
id
oid
url
}
}
clientMutationId
}
}
`;
const defaultOptions = {
mergeMethod: 'MERGE' as const,
commitHeadline: undefined,
commitBody: undefined,
};
try {
const result = await this.client.request(mutation, {
input: {
pullRequestId,
...defaultOptions,
...options,
clientMutationId: this.generateClientMutationId(),
},
});
return {
success: true,
data: result.mergePullRequest.pullRequest,
};
} catch (error) {
return this.handleMutationError(error);
}
}
private generateClientMutationId(): string {
return `mutation-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
private handleMutationError(error: any): MutationResult<any> {
if (error.response?.errors) {
const userErrors = error.response.errors.map((e: any) => ({
message: e.message,
field: e.path,
}));
return {
success: false,
error: new Error('Mutation failed'),
userErrors,
};
}
return {
success: false,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}
// 사용 예제
const mutationHandler = new GitHubMutationHandler(process.env.GITHUB_TOKEN!);
// 이슈 생성
const issueResult = await mutationHandler.createIssue(
'MDEwOlJlcG9zaXRvcnkxMjM0NTY3OA==',
'New Feature Request',
'We need to implement this awesome feature',
{
labelIds: ['MDU6TGFiZWwxMjM0NTY3OA=='],
assigneeIds: ['MDQ6VXNlcjEyMzQ1Njc4'],
}
);
if (issueResult.success) {
console.log('Issue created:', issueResult.data);
} else {
console.error('Failed to create issue:', issueResult.error);
}
// 배치 작업
const batchResults = await mutationHandler.batchOperation([
() => mutationHandler.createIssue(repoId, 'Issue 1', 'Body 1'),
() => mutationHandler.createIssue(repoId, 'Issue 2', 'Body 2'),
() => mutationHandler.createIssue(repoId, 'Issue 3', 'Body 3'),
]);
console.log(`Created ${batchResults.successCount} issues, ${batchResults.failureCount} failed`);
6. 실시간 업데이트와 구독
WebSocket 기반 구독 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
// github-subscription-client.ts
import { SubscriptionClient } from 'subscriptions-transport-ws';
import { WebSocketLink } from '@apollo/client/link/ws';
import { ApolloClient, InMemoryCache, split } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
import { HttpLink } from '@apollo/client/link/http';
import ws from 'ws';
class GitHubSubscriptionClient {
private apolloClient: ApolloClient<any>;
private subscriptionClient: SubscriptionClient;
constructor(token: string) {
// HTTP Link for queries and mutations
const httpLink = new HttpLink({
uri: 'https://api.github.com/graphql',
headers: {
authorization: `Bearer ${token}`,
},
});
// WebSocket Link for subscriptions
this.subscriptionClient = new SubscriptionClient(
'wss://api.github.com/graphql',
{
reconnect: true,
connectionParams: {
Authorization: `Bearer ${token}`,
},
},
ws
);
const wsLink = new WebSocketLink(this.subscriptionClient);
// Split links based on operation type
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
this.apolloClient = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
}
subscribeToIssueUpdates(repositoryId: string, onUpdate: (issue: any) => void) {
const subscription = gql`
subscription IssueUpdates($repositoryId: ID!) {
issueUpdate(repositoryId: $repositoryId) {
issue {
id
number
title
state
updatedAt
labels {
nodes {
name
color
}
}
}
action
}
}
`;
return this.apolloClient.subscribe({
query: subscription,
variables: { repositoryId },
}).subscribe({
next: ({ data }) => {
if (data?.issueUpdate) {
onUpdate(data.issueUpdate);
}
},
error: (err) => {
console.error('Subscription error:', err);
},
});
}
subscribeToRepositoryEvents(
owner: string,
name: string,
eventTypes: string[],
onEvent: (event: any) => void
) {
const subscription = gql`
subscription RepositoryEvents(
$owner: String!
$name: String!
$eventTypes: [String!]!
) {
repositoryEvent(
owner: $owner
name: $name
eventTypes: $eventTypes
) {
id
eventType
actor {
login
avatarUrl
}
createdAt
payload
}
}
`;
return this.apolloClient.subscribe({
query: subscription,
variables: { owner, name, eventTypes },
}).subscribe({
next: ({ data }) => {
if (data?.repositoryEvent) {
onEvent(data.repositoryEvent);
}
},
error: (err) => {
console.error('Subscription error:', err);
},
});
}
subscribeToPullRequestReviews(
pullRequestId: string,
onReview: (review: any) => void
) {
const subscription = gql`
subscription PullRequestReviews($pullRequestId: ID!) {
pullRequestReview(pullRequestId: $pullRequestId) {
review {
id
author {
login
avatarUrl
}
state
body
createdAt
comments {
totalCount
}
}
}
}
`;
return this.apolloClient.subscribe({
query: subscription,
variables: { pullRequestId },
}).subscribe({
next: ({ data }) => {
if (data?.pullRequestReview) {
onReview(data.pullRequestReview.review);
}
},
error: (err) => {
console.error('Subscription error:', err);
},
});
}
disconnect() {
this.subscriptionClient.close();
}
}
// 실시간 이벤트 처리기
class GitHubRealtimeEventHandler {
private subscriptionClient: GitHubSubscriptionClient;
private subscriptions: Map<string, any> = new Map();
constructor(token: string) {
this.subscriptionClient = new GitHubSubscriptionClient(token);
}
startMonitoringRepository(owner: string, name: string) {
// 이슈 업데이트 구독
const issueSubscription = this.subscriptionClient.subscribeToIssueUpdates(
`${owner}/${name}`,
(update) => {
console.log(`Issue ${update.issue.number} ${update.action}:`, update.issue.title);
this.handleIssueUpdate(update);
}
);
this.subscriptions.set(`${owner}/${name}/issues`, issueSubscription);
// 저장소 이벤트 구독
const eventSubscription = this.subscriptionClient.subscribeToRepositoryEvents(
owner,
name,
['PushEvent', 'PullRequestEvent', 'IssuesEvent'],
(event) => {
console.log(`Repository event: ${event.eventType} by ${event.actor.login}`);
this.handleRepositoryEvent(event);
}
);
this.subscriptions.set(`${owner}/${name}/events`, eventSubscription);
}
private handleIssueUpdate(update: any) {
// 이슈 업데이트 처리 로직
switch (update.action) {
case 'OPENED':
this.notifyTeam(`New issue: ${update.issue.title}`);
break;
case 'CLOSED':
this.updateMetrics('issues_closed', 1);
break;
case 'REOPENED':
this.notifyTeam(`Issue reopened: ${update.issue.title}`);
break;
}
}
private handleRepositoryEvent(event: any) {
// 저장소 이벤트 처리 로직
const payload = JSON.parse(event.payload);
switch (event.eventType) {
case 'PushEvent':
this.triggerCI(payload.ref, payload.commits);
break;
case 'PullRequestEvent':
if (payload.action === 'opened') {
this.assignReviewers(payload.pull_request);
}
break;
}
}
private notifyTeam(message: string) {
// Slack, Discord 등으로 알림
console.log(`📢 Team notification: ${message}`);
}
private updateMetrics(metric: string, value: number) {
// 메트릭 업데이트
console.log(`📊 Metric update: ${metric} += ${value}`);
}
private triggerCI(ref: string, commits: any[]) {
// CI/CD 트리거
console.log(`🚀 Triggering CI for ${ref} with ${commits.length} commits`);
}
private assignReviewers(pullRequest: any) {
// 자동 리뷰어 할당
console.log(`👥 Assigning reviewers to PR #${pullRequest.number}`);
}
stopMonitoring(repositoryKey: string) {
const keys = Array.from(this.subscriptions.keys()).filter(k => k.startsWith(repositoryKey));
keys.forEach(key => {
const subscription = this.subscriptions.get(key);
if (subscription) {
subscription.unsubscribe();
this.subscriptions.delete(key);
}
});
}
disconnect() {
// 모든 구독 해제
this.subscriptions.forEach(subscription => subscription.unsubscribe());
this.subscriptions.clear();
// 연결 종료
this.subscriptionClient.disconnect();
}
}
// 사용 예제
const eventHandler = new GitHubRealtimeEventHandler(process.env.GITHUB_TOKEN!);
// 저장소 모니터링 시작
eventHandler.startMonitoringRepository('facebook', 'react');
// 10분 후 모니터링 중지
setTimeout(() => {
eventHandler.stopMonitoring('facebook/react');
eventHandler.disconnect();
}, 10 * 60 * 1000);
7. 성능 최적화
쿼리 최적화 전략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
// query-optimizer.ts
interface OptimizationOptions {
enableDataLoader?: boolean;
enableQueryBatching?: boolean;
enableFieldMerging?: boolean;
cacheTimeout?: number;
}
class GitHubQueryOptimizer {
private cache: Map<string, { data: any; timestamp: number }> = new Map();
private pendingQueries: Map<string, Promise<any>> = new Map();
constructor(private client: GraphQLClient, private options: OptimizationOptions = {}) {
this.options = {
enableDataLoader: true,
enableQueryBatching: true,
enableFieldMerging: true,
cacheTimeout: 5 * 60 * 1000, // 5 minutes
...options,
};
}
async optimizedQuery<T>(
query: string,
variables: Record<string, any> = {}
): Promise<T> {
const cacheKey = this.getCacheKey(query, variables);
// 캐시 확인
const cached = this.getFromCache(cacheKey);
if (cached) {
return cached;
}
// 진행 중인 동일 쿼리 확인
if (this.options.enableDataLoader && this.pendingQueries.has(cacheKey)) {
return this.pendingQueries.get(cacheKey)!;
}
// 쿼리 실행
const promise = this.executeQuery<T>(query, variables);
if (this.options.enableDataLoader) {
this.pendingQueries.set(cacheKey, promise);
}
try {
const result = await promise;
this.setCache(cacheKey, result);
return result;
} finally {
this.pendingQueries.delete(cacheKey);
}
}
async batchQueries<T>(
queries: Array<{ query: string; variables?: Record<string, any> }>
): Promise<T[]> {
if (!this.options.enableQueryBatching) {
return Promise.all(
queries.map(q => this.optimizedQuery<T>(q.query, q.variables))
);
}
// 쿼리 병합
const mergedQuery = this.mergeQueries(queries);
const result = await this.client.request(mergedQuery.query, mergedQuery.variables);
// 결과 분리
return this.splitResults<T>(result, queries);
}
private mergeQueries(
queries: Array<{ query: string; variables?: Record<string, any> }>
): { query: string; variables: Record<string, any> } {
const aliases: string[] = [];
const fragments: Set<string> = new Set();
const mergedVariables: Record<string, any> = {};
queries.forEach((q, index) => {
const alias = `q${index}`;
const parsed = this.parseQuery(q.query);
// 변수 병합
Object.entries(q.variables || {}).forEach(([key, value]) => {
mergedVariables[`${alias}_${key}`] = value;
});
// 쿼리 별칭 추가
aliases.push(
`${alias}: ${parsed.operation}(${this.buildArgumentString(q.variables || {}, alias)}) ${parsed.selection}`
);
// 프래그먼트 수집
parsed.fragments.forEach(f => fragments.add(f));
});
const mergedQuery = `
query MergedQuery(${this.buildVariableDefinitions(mergedVariables)}) {
${aliases.join('\n')}
}
${Array.from(fragments).join('\n')}
`;
return { query: mergedQuery, variables: mergedVariables };
}
private parseQuery(query: string): {
operation: string;
selection: string;
fragments: string[];
} {
// 간단한 쿼리 파서 구현
const operationMatch = query.match(/query\s+\w+.*?\{([\s\S]*?)\}/);
const fragmentMatches = query.match(/fragment\s+\w+[\s\S]*?(?=fragment|$)/g) || [];
if (!operationMatch) {
throw new Error('Invalid query format');
}
const operationBody = operationMatch[1].trim();
const [operation, ...selectionParts] = operationBody.split(/\s+/);
return {
operation,
selection: selectionParts.join(' '),
fragments: fragmentMatches,
};
}
private buildArgumentString(variables: Record<string, any>, alias: string): string {
return Object.keys(variables)
.map(key => `${key}: $${alias}_${key}`)
.join(', ');
}
private buildVariableDefinitions(variables: Record<string, any>): string {
return Object.entries(variables)
.map(([key, value]) => {
const type = this.inferGraphQLType(value);
return `$${key}: ${type}`;
})
.join(', ');
}
private inferGraphQLType(value: any): string {
if (typeof value === 'string') return 'String!';
if (typeof value === 'number') return Number.isInteger(value) ? 'Int!' : 'Float!';
if (typeof value === 'boolean') return 'Boolean!';
if (Array.isArray(value)) return '[String!]!';
return 'String';
}
private splitResults<T>(mergedResult: any, originalQueries: any[]): T[] {
return originalQueries.map((_, index) => mergedResult[`q${index}`]);
}
private getCacheKey(query: string, variables: Record<string, any>): string {
return `${query}:${JSON.stringify(variables)}`;
}
private getFromCache(key: string): any {
const cached = this.cache.get(key);
if (!cached) return null;
const age = Date.now() - cached.timestamp;
if (age > this.options.cacheTimeout!) {
this.cache.delete(key);
return null;
}
return cached.data;
}
private setCache(key: string, data: any): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
});
}
private async executeQuery<T>(query: string, variables: Record<string, any>): Promise<T> {
return this.client.request<T>(query, variables);
}
clearCache(): void {
this.cache.clear();
}
getCacheStats(): {
size: number;
oldestEntry: number | null;
hitRate: number;
} {
const entries = Array.from(this.cache.values());
const oldestEntry = entries.length > 0
? Math.min(...entries.map(e => e.timestamp))
: null;
return {
size: this.cache.size,
oldestEntry,
hitRate: 0, // 실제로는 hit/miss 카운터 구현 필요
};
}
}
// 사용 예제
const optimizer = new GitHubQueryOptimizer(graphqlClient, {
enableDataLoader: true,
enableQueryBatching: true,
cacheTimeout: 10 * 60 * 1000, // 10분
});
// 단일 쿼리 최적화
const userData = await optimizer.optimizedQuery(USER_QUERY, { login: 'octocat' });
// 배치 쿼리
const results = await optimizer.batchQueries([
{ query: USER_QUERY, variables: { login: 'user1' } },
{ query: USER_QUERY, variables: { login: 'user2' } },
{ query: REPO_QUERY, variables: { owner: 'org', name: 'repo' } },
]);
// 캐시 통계
console.log('Cache stats:', optimizer.getCacheStats());
8. 에러 처리와 재시도
강건한 에러 처리 시스템
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
// error-handler.ts
enum GitHubErrorType {
RateLimit = 'RATE_LIMIT',
NotFound = 'NOT_FOUND',
Forbidden = 'FORBIDDEN',
ServerError = 'SERVER_ERROR',
NetworkError = 'NETWORK_ERROR',
InvalidQuery = 'INVALID_QUERY',
Unknown = 'UNKNOWN',
}
interface GitHubError {
type: GitHubErrorType;
message: string;
originalError?: Error;
retryable: boolean;
retryAfter?: number;
}
class GitHubErrorHandler {
private maxRetries: number;
private baseDelay: number;
constructor(maxRetries: number = 3, baseDelay: number = 1000) {
this.maxRetries = maxRetries;
this.baseDelay = baseDelay;
}
async executeWithRetry<T>(
operation: () => Promise<T>,
context: string = 'operation'
): Promise<T> {
let lastError: GitHubError | null = null;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = this.parseError(error);
console.error(
`[${context}] Attempt ${attempt + 1}/${this.maxRetries + 1} failed:`,
lastError.message
);
if (!lastError.retryable || attempt === this.maxRetries) {
throw this.enhanceError(lastError, context);
}
const delay = this.calculateDelay(attempt, lastError);
console.log(`[${context}] Retrying after ${delay}ms...`);
await this.sleep(delay);
}
}
throw this.enhanceError(lastError!, context);
}
private parseError(error: any): GitHubError {
// GraphQL 에러
if (error.response?.errors) {
const graphqlError = error.response.errors[0];
if (graphqlError.type === 'RATE_LIMITED') {
return {
type: GitHubErrorType.RateLimit,
message: 'Rate limit exceeded',
originalError: error,
retryable: true,
retryAfter: this.parseRetryAfter(error.response.headers),
};
}
if (graphqlError.type === 'NOT_FOUND') {
return {
type: GitHubErrorType.NotFound,
message: graphqlError.message || 'Resource not found',
originalError: error,
retryable: false,
};
}
if (graphqlError.type === 'FORBIDDEN') {
return {
type: GitHubErrorType.Forbidden,
message: graphqlError.message || 'Access forbidden',
originalError: error,
retryable: false,
};
}
}
// HTTP 에러
if (error.response?.status) {
const status = error.response.status;
if (status === 429) {
return {
type: GitHubErrorType.RateLimit,
message: 'Rate limit exceeded',
originalError: error,
retryable: true,
retryAfter: this.parseRetryAfter(error.response.headers),
};
}
if (status >= 500) {
return {
type: GitHubErrorType.ServerError,
message: `Server error: ${status}`,
originalError: error,
retryable: true,
};
}
if (status === 404) {
return {
type: GitHubErrorType.NotFound,
message: 'Resource not found',
originalError: error,
retryable: false,
};
}
if (status === 403) {
return {
type: GitHubErrorType.Forbidden,
message: 'Access forbidden',
originalError: error,
retryable: false,
};
}
}
// 네트워크 에러
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
return {
type: GitHubErrorType.NetworkError,
message: `Network error: ${error.code}`,
originalError: error,
retryable: true,
};
}
// 기타 에러
return {
type: GitHubErrorType.Unknown,
message: error.message || 'Unknown error',
originalError: error,
retryable: false,
};
}
private parseRetryAfter(headers: any): number {
const retryAfter = headers?.['retry-after'];
if (retryAfter) {
return parseInt(retryAfter) * 1000;
}
const rateLimitReset = headers?.['x-ratelimit-reset'];
if (rateLimitReset) {
const resetTime = parseInt(rateLimitReset) * 1000;
return Math.max(0, resetTime - Date.now());
}
return 60000; // 기본 1분
}
private calculateDelay(attempt: number, error: GitHubError): number {
if (error.retryAfter) {
return error.retryAfter;
}
// Exponential backoff with jitter
const exponentialDelay = this.baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 0.3 * exponentialDelay;
return Math.min(exponentialDelay + jitter, 30000); // 최대 30초
}
private enhanceError(error: GitHubError, context: string): Error {
const enhancedError = new Error(
`[${context}] ${error.type}: ${error.message}`
);
(enhancedError as any).type = error.type;
(enhancedError as any).retryable = error.retryable;
(enhancedError as any).originalError = error.originalError;
return enhancedError;
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Rate Limit 관리
class RateLimitManager {
private limits: Map<string, {
limit: number;
remaining: number;
reset: number;
}> = new Map();
updateFromHeaders(headers: any): void {
const limit = parseInt(headers['x-ratelimit-limit'] || '5000');
const remaining = parseInt(headers['x-ratelimit-remaining'] || '5000');
const reset = parseInt(headers['x-ratelimit-reset'] || '0') * 1000;
const resource = headers['x-ratelimit-resource'] || 'core';
this.limits.set(resource, { limit, remaining, reset });
}
canMakeRequest(resource: string = 'core'): boolean {
const limit = this.limits.get(resource);
if (!limit) return true;
if (limit.remaining > 0) return true;
if (Date.now() > limit.reset) return true;
return false;
}
getWaitTime(resource: string = 'core'): number {
const limit = this.limits.get(resource);
if (!limit || limit.remaining > 0) return 0;
return Math.max(0, limit.reset - Date.now());
}
async waitIfNeeded(resource: string = 'core'): Promise<void> {
const waitTime = this.getWaitTime(resource);
if (waitTime > 0) {
console.log(`Rate limit hit. Waiting ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
}
// 통합 클라이언트
class ResilientGitHubClient {
private errorHandler: GitHubErrorHandler;
private rateLimitManager: RateLimitManager;
private client: GraphQLClient;
constructor(token: string) {
this.errorHandler = new GitHubErrorHandler();
this.rateLimitManager = new RateLimitManager();
this.client = new GraphQLClient('https://api.github.com/graphql', {
headers: {
authorization: `Bearer ${token}`,
},
responseMiddleware: (response) => {
if (response.headers) {
this.rateLimitManager.updateFromHeaders(response.headers);
}
},
});
}
async query<T>(
query: string,
variables?: Record<string, any>,
context: string = 'query'
): Promise<T> {
await this.rateLimitManager.waitIfNeeded();
return this.errorHandler.executeWithRetry(
() => this.client.request<T>(query, variables),
context
);
}
async mutation<T>(
mutation: string,
variables?: Record<string, any>,
context: string = 'mutation'
): Promise<T> {
await this.rateLimitManager.waitIfNeeded();
return this.errorHandler.executeWithRetry(
() => this.client.request<T>(mutation, variables),
context
);
}
}
// 사용 예제
const resilientClient = new ResilientGitHubClient(process.env.GITHUB_TOKEN!);
try {
const data = await resilientClient.query(
USER_QUERY,
{ login: 'octocat' },
'fetch-user-data'
);
console.log('User data:', data);
} catch (error) {
if ((error as any).type === GitHubErrorType.NotFound) {
console.log('User not found');
} else {
console.error('Failed to fetch user:', error);
}
}
9. 실전 예제: GitHub Analytics 대시보드
종합 분석 시스템 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
// github-analytics.ts
interface RepositoryAnalytics {
repository: {
name: string;
owner: string;
metrics: {
stars: number;
forks: number;
issues: {
open: number;
closed: number;
avgTimeToClose: number;
};
pullRequests: {
open: number;
merged: number;
avgTimeToMerge: number;
};
commits: {
total: number;
lastWeek: number;
topContributors: Array<{
login: string;
commits: number;
}>;
};
};
trends: {
starsLastWeek: number;
forksLastWeek: number;
issuesOpenedLastWeek: number;
issuesClosedLastWeek: number;
};
};
}
class GitHubAnalyticsDashboard {
private client: ResilientGitHubClient;
constructor(token: string) {
this.client = new ResilientGitHubClient(token);
}
async getRepositoryAnalytics(
owner: string,
name: string
): Promise<RepositoryAnalytics> {
const query = `
query GetRepositoryAnalytics($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
name
nameWithOwner
stargazerCount
forkCount
# 이슈 통계
openIssues: issues(states: OPEN) {
totalCount
}
closedIssues: issues(states: CLOSED) {
totalCount
}
recentClosedIssues: issues(
states: CLOSED
first: 100
orderBy: {field: CLOSED_AT, direction: DESC}
) {
nodes {
createdAt
closedAt
}
}
# PR 통계
openPRs: pullRequests(states: OPEN) {
totalCount
}
mergedPRs: pullRequests(states: MERGED) {
totalCount
}
recentMergedPRs: pullRequests(
states: MERGED
first: 100
orderBy: {field: MERGED_AT, direction: DESC}
) {
nodes {
createdAt
mergedAt
}
}
# 커밋 통계
defaultBranchRef {
target {
... on Commit {
history {
totalCount
}
historyLastWeek: history(since: "${this.getWeekAgoISO()}") {
totalCount
}
}
}
}
# 기여자 통계
mentionableUsers(first: 10) {
nodes {
login
contributionsCollection {
totalCommitContributions
}
}
}
# 트렌드 데이터를 위한 이슈/PR
issuesLastWeek: issues(
filterBy: {since: "${this.getWeekAgoISO()}"}
states: OPEN
) {
totalCount
}
closedIssuesLastWeek: issues(
filterBy: {since: "${this.getWeekAgoISO()}"}
states: CLOSED
) {
totalCount
}
}
}
`;
const data = await this.client.query<any>(query, { owner, name });
return this.processAnalyticsData(owner, name, data.repository);
}
private processAnalyticsData(
owner: string,
name: string,
rawData: any
): RepositoryAnalytics {
// 평균 처리 시간 계산
const avgIssueCloseTime = this.calculateAverageTime(
rawData.recentClosedIssues.nodes,
'createdAt',
'closedAt'
);
const avgPRMergeTime = this.calculateAverageTime(
rawData.recentMergedPRs.nodes,
'createdAt',
'mergedAt'
);
// 상위 기여자
const topContributors = rawData.mentionableUsers.nodes
.map((user: any) => ({
login: user.login,
commits: user.contributionsCollection.totalCommitContributions,
}))
.sort((a: any, b: any) => b.commits - a.commits)
.slice(0, 5);
return {
repository: {
name,
owner,
metrics: {
stars: rawData.stargazerCount,
forks: rawData.forkCount,
issues: {
open: rawData.openIssues.totalCount,
closed: rawData.closedIssues.totalCount,
avgTimeToClose: avgIssueCloseTime,
},
pullRequests: {
open: rawData.openPRs.totalCount,
merged: rawData.mergedPRs.totalCount,
avgTimeToMerge: avgPRMergeTime,
},
commits: {
total: rawData.defaultBranchRef?.target?.history?.totalCount || 0,
lastWeek: rawData.defaultBranchRef?.target?.historyLastWeek?.totalCount || 0,
topContributors,
},
},
trends: {
starsLastWeek: 0, // 별도 계산 필요
forksLastWeek: 0, // 별도 계산 필요
issuesOpenedLastWeek: rawData.issuesLastWeek.totalCount,
issuesClosedLastWeek: rawData.closedIssuesLastWeek.totalCount,
},
},
};
}
private calculateAverageTime(
items: Array<{ createdAt: string; closedAt?: string; mergedAt?: string }>,
startField: string,
endField: string
): number {
if (items.length === 0) return 0;
const times = items
.filter(item => item[endField])
.map(item => {
const start = new Date(item[startField]).getTime();
const end = new Date(item[endField]).getTime();
return end - start;
});
if (times.length === 0) return 0;
const avg = times.reduce((sum, time) => sum + time, 0) / times.length;
return Math.round(avg / (1000 * 60 * 60)); // 시간 단위로 변환
}
private getWeekAgoISO(): string {
const date = new Date();
date.setDate(date.getDate() - 7);
return date.toISOString();
}
async generateMarkdownReport(owner: string, name: string): Promise<string> {
const analytics = await this.getRepositoryAnalytics(owner, name);
const { metrics, trends } = analytics.repository;
return `
# GitHub Analytics Report: ${owner}/${name}
Generated: ${new Date().toISOString()}
## 📊 Overview
- ⭐ **Stars**: ${metrics.stars.toLocaleString()}
- 🍴 **Forks**: ${metrics.forks.toLocaleString()}
- 📝 **Open Issues**: ${metrics.issues.open}
- 🔀 **Open PRs**: ${metrics.pullRequests.open}
## 📈 Issue Metrics
- **Total Issues**: ${metrics.issues.open + metrics.issues.closed}
- **Open**: ${metrics.issues.open}
- **Closed**: ${metrics.issues.closed}
- **Avg Time to Close**: ${metrics.issues.avgTimeToClose} hours
- **Closed Last Week**: ${trends.issuesClosedLastWeek}
## 🔀 Pull Request Metrics
- **Total PRs**: ${metrics.pullRequests.open + metrics.pullRequests.merged}
- **Open**: ${metrics.pullRequests.open}
- **Merged**: ${metrics.pullRequests.merged}
- **Avg Time to Merge**: ${metrics.pullRequests.avgTimeToMerge} hours
## 💻 Commit Activity
- **Total Commits**: ${metrics.commits.total.toLocaleString()}
- **Commits Last Week**: ${metrics.commits.lastWeek}
## 👥 Top Contributors
${metrics.commits.topContributors
.map((c, i) => `${i + 1}. @${c.login} - ${c.commits} commits`)
.join('\n')}
## 📈 Weekly Trends
- **New Issues**: ${trends.issuesOpenedLastWeek}
- **Closed Issues**: ${trends.issuesClosedLastWeek}
`;
}
}
// 사용 예제
async function runAnalytics() {
const dashboard = new GitHubAnalyticsDashboard(process.env.GITHUB_TOKEN!);
// 분석 데이터 가져오기
const analytics = await dashboard.getRepositoryAnalytics('facebook', 'react');
console.log('Analytics:', JSON.stringify(analytics, null, 2));
// 마크다운 리포트 생성
const report = await dashboard.generateMarkdownReport('facebook', 'react');
console.log(report);
// 파일로 저장
fs.writeFileSync('github-analytics-report.md', report);
}
runAnalytics().catch(console.error);
마무리
GitHub GraphQL API는 REST API의 한계를 넘어 더 효율적이고 강력한 방법으로 GitHub 데이터를 다룰 수 있게 해줍니다.
핵심 포인트:
- 필요한 데이터만 정확히 요청
- 단일 요청으로 복잡한 데이터 구조 획득
- 강력한 타입 시스템과 스키마 탐색
- 실시간 구독과 업데이트
- 효율적인 페이지네이션과 성능 최적화
GraphQL을 마스터하면 GitHub 데이터를 활용한 강력한 도구와 자동화를 구축할 수 있습니다.
다음 심화편에서는 GitHub Enterprise 관리에 대해 다루겠습니다.
📚 GitHub 마스터하기 시리즈
🌱 기초편 (입문자)
💼 실전편 (중급자)
🚀 고급편 (전문가)
- GitHub Actions 입문
- Actions 고급 활용
- Webhooks와 API
- GitHub Apps 개발
- 보안 기능
- GitHub Packages
- Codespaces
- GitHub CLI
- 통계와 인사이트
🏆 심화편 (전문가+)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.