-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgithub-api.js
More file actions
378 lines (349 loc) · 11.8 KB
/
github-api.js
File metadata and controls
378 lines (349 loc) · 11.8 KB
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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
import { env } from 'node:process'
import { Octokit } from '@octokit/rest'
import { graphql } from '@octokit/graphql'
export default class AdminClient {
/** @param {import('pino').Logger} [logger] - Optional logger instance, defaults to console. */
constructor (logger) {
if (!env.GITHUB_TOKEN) {
throw new Error('GITHUB_TOKEN environment variable is not set')
}
this.logger = logger || console
this.restClient = new Octokit({
auth: env.GITHUB_TOKEN,
userAgent: 'fastify-org-admin-cli',
})
this.graphqlClient = graphql.defaults({
headers: {
authorization: `token ${env.GITHUB_TOKEN}`,
},
})
}
/**
* Retrieves organization data for a given GitHub organization.
* @param {string} orgName - The name of the GitHub organization.
* @returns {Promise<object>} The organization data.
*/
async getOrgData (orgName) {
const { organization } = await this.graphqlClient(`
query ($orgName: String!) {
organization(login: $orgName) {
id
name
}
}
`, { orgName })
return organization
}
/**
* Retrieves the organization chart for a given GitHub organization.
* Fetches all teams and their members using the GitHub GraphQL API, handling pagination.
* @async
* @param {object} orgData - The organization data.
* @param {string} orgData.name - The login name of the GitHub organization.
* @returns {Promise<Team[]>} Array of team objects with their members and details.
*/
async getOrgChart (orgData) {
let cursor = null
let hasNextPage = true
const teamsData = []
const teamsQuery = `
query ($cursor: String, $orgName: String!) {
organization(login: $orgName) {
teams(first: 100, after: $cursor) {
edges {
node {
id
name
slug
description
privacy
members(first: 100) {
edges {
node {
login
name
email
}
role
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`
while (hasNextPage) {
const variables = { cursor, orgName: orgData.name }
const teamsResponse = await this.graphqlClient(teamsQuery, variables)
const teams = teamsResponse.organization.teams.edges
teamsData.push(...teams.map(transformGqlTeam))
cursor = teamsResponse.organization.teams.pageInfo.endCursor
hasNextPage = teamsResponse.organization.teams.pageInfo.hasNextPage
}
return teamsData
}
/**
* Fetches the contributions of a list of users within a specified organization over a defined number of years.
* @param {object} orgData - Organization data.
* @param {string[]} userList - List of GitHub usernames to fetch contributions for.
* @param {number} yearsBack - Number of years to look back for contributions. Defaults to `1`.
* @returns {Promise<SimplifiedMember[]>} Array of user contribution data.
*/
async getUsersContributions (orgData, userList, yearsBack = 1) {
const oldContributionsQuery = `
query ($userId: String!, $orgId: ID, $from: DateTime!, $to: DateTime!) {
user(login: $userId) {
login
name
socialAccounts(last:4) {
nodes {
displayName
url
provider
}
}
contributionsCollection(
organizationID: $orgId
from: $from
to: $to
) {
pullRequestContributions(last: 1, orderBy: {direction: ASC}) {
nodes {
occurredAt
pullRequest {
url
}
}
}
issueContributions(last: 1, orderBy: {direction: ASC}) {
nodes {
occurredAt
issue {
url
}
}
}
commitContributionsByRepository(maxRepositories: 1) {
contributions(last: 1, orderBy: {direction: ASC, field: OCCURRED_AT}) {
nodes {
repository {
name
}
occurredAt
url
}
}
}
}
}
}
`
const membersData = []
for (const targetUser of userList) {
let hasContributions = false
for (let yearWindow = 0; yearWindow < yearsBack; yearWindow++) {
const toDate = new Date()
toDate.setFullYear(toDate.getFullYear() - yearWindow)
// Always 1 year back because it is the max date range supported by the GQL Service
const fromDate = new Date()
fromDate.setFullYear(toDate.getFullYear() - 1)
const variables = {
userId: targetUser,
orgId: orgData.id,
from: fromDate.toISOString(),
to: toDate.toISOString()
}
this.logger.debug('Fetching contributions for user: %s from %s to %s', targetUser, variables.from, variables.to)
const contributionsResponse = await this.graphqlClient(oldContributionsQuery, variables)
const simplifiedUser = transformGqlMember({ node: contributionsResponse.user })
// If the user has any contribution in the year window, add it to the list and avoid querying again
// We are interested in at least one contribution in the year window
if (simplifiedUser.lastPR || simplifiedUser.lastIssue || simplifiedUser.lastCommit) {
hasContributions = true
membersData.push(simplifiedUser)
break
}
}
if (!hasContributions) {
this.logger.warn('No contributions found for user %s in the last %s years', targetUser, yearsBack)
membersData.push({
user: targetUser,
lastPR: null,
lastIssue: null,
lastCommit: null,
})
}
}
return membersData
}
/**
* Fetches user information from GitHub using the GraphQL API.
* @param {string} username - The GitHub username.
* @returns {Promise<any>} The user information.
*/
async getUserInfo (username) {
try {
const variables = { username }
const userQuery = `
query ($username: String!) {
user(login: $username) {
login
name
socialAccounts(last:4) {
nodes {
displayName
url
provider
}
}
}
}
`
const response = await this.graphqlClient(userQuery, variables)
return response.user
} catch (error) {
this.logger.error({ username, error }, 'Failed to fetch user info')
throw error
}
}
/**
* Add a user to a team in the organization using the REST API.
* @param {string} org - The organization name.
* @param {string} teamSlug - The team slug.
* @param {string} username - The GitHub username to add.
* @returns {Promise<import('@octokit/openapi-types').components['schemas']['team-membership']>} The updated team data.
*/
async addUserToTeam (org, teamSlug, username) {
try {
const response = await this.restClient.teams.addOrUpdateMembershipForUserInOrg({
org,
team_slug: teamSlug,
username,
role: 'member',
})
return response.data
} catch (error) {
this.logger.error({ username, teamSlug, error }, 'Failed to add user to team')
throw error
}
}
/**
* Removes a user from a team in the organization using the REST API.
* @param {string} org - The organization name.
* @param {string} teamSlug - The team slug.
* @param {string} username - The GitHub username to remove.
* @returns {Promise<any>} The response data from the API.
*/
async removeUserFromTeam (org, teamSlug, username) {
try {
const response = await this.restClient.teams.removeMembershipForUserInOrg({
org,
team_slug: teamSlug,
username
})
this.logger.info({ username, teamSlug }, 'User removed from team')
return response.data
} catch (error) {
this.logger.error({ username, teamSlug, error }, 'Failed to remove user from team')
throw error
}
}
/**
* Creates a new issue in a repository using the REST API.
* @param {string} owner - The repository owner (org or user).
* @param {string} repo - The repository name.
* @param {string} title - The issue title.
* @param {string} body - The issue body/description.
* @param {string[]} [labels] - Optional array of labels.
* @returns {Promise<import('@octokit/openapi-types').components['schemas']['issue']>} The created issue data.
*/
async createIssue (owner, repo, title, body, labels = []) {
try {
const response = await this.restClient.issues.create({
owner,
repo,
title,
body,
labels
})
this.logger.info({ owner, repo, title }, 'Issue created via REST API')
return response.data
} catch (error) {
this.logger.error({ owner, repo, title, error }, 'Failed to create issue via REST API')
throw error
}
}
}
/**
* Transforms a GitHub GraphQL team node into a simplified team object.
* @param {object} gqlTeam - The GitHub GraphQL team node.
* @param {object} gqlTeam.node - The team node.
* @returns {Team} The simplified team object.
*/
function transformGqlTeam ({ node }) {
return {
id: node.id,
name: node.name,
slug: node.slug,
description: node.description,
privacy: node.privacy,
members: node.members.edges.map(({ node: member, role }) => ({
login: member.login,
name: member.name,
email: member.email,
role
}))
}
}
/**
* Transforms a GitHub GraphQL member node into a simplified member object.
* @param {object} gqlMember - The GitHub GraphQL member node.
* @param {object} gqlMember.node - The member node.
* @returns {SimplifiedMember} The simplified member object.
*/
function transformGqlMember ({ node }) {
return {
user: node.login,
lastPR: toDate(node.contributionsCollection.pullRequestContributions?.nodes[0]?.occurredAt),
lastIssue: toDate(node.contributionsCollection.issueContributions?.nodes[0]?.occurredAt),
lastCommit: toDate(node.contributionsCollection.commitContributionsByRepository?.[0]?.contributions?.nodes?.[0]?.occurredAt),
socialAccounts: node.socialAccounts?.nodes,
}
}
/**
* Converts a date string to a Date object, or returns null if the string is falsy.
* @param {string} dateStr - The date string to convert.
* @returns {Date|null} The Date object or null.
*/
function toDate (dateStr) {
return dateStr ? new Date(dateStr) : null
}
/**
* @typedef {object} Team
* @property {string} id - The team's unique identifier.
* @property {string} name - The team's name.
* @property {string} slug - The team's slug.
* @property {string} [description] - The team's description.
* @property {string} privacy - The team's privacy setting.
* @property {TeamMember[]} members - The list of team members.
*/
/**
* @typedef {object} TeamMember
* @property {string} login - The member's GitHub login.
* @property {string} [name] - The member's name.
* @property {string} [email] - The member's email.
* @property {string} role - The member's role in the team.
*/
/**
* @typedef {object} SimplifiedMember
* @property {string} user - The user's GitHub login.
* @property {Date|null} lastPR - The date of the user's last pull request contribution.
* @property {Date|null} lastIssue - The date of the user's last issue contribution.
* @property {Date|null} lastCommit - The date of the user's last commit contribution.
* @property {object[]} [socialAccounts] - The user's social accounts.
*/