[GITHUB] Badge for total stars of an user / org (#5507)
* added badge for total stars of an user / org * Added a transformJson method in graphql-base to handle partial data Co-authored-by: Pratapi Hemant Patel <pratpatel@expedia.com> Co-authored-by: Caleb Cartwright <calebcartwright@users.noreply.github.com>
This commit is contained in:
@@ -46,6 +46,9 @@ class BaseGraphqlService extends BaseService {
|
||||
* and custom error messages e.g: `{ 404: 'package not found' }`.
|
||||
* This can be used to extend or override the
|
||||
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
|
||||
* @param {Function} [attrs.transformJson=data => data] Function which takes the raw json and transforms it before
|
||||
* further procesing. In case of multiple query in a single graphql call and few of them
|
||||
* throw error, partial data might be used ignoring the error.
|
||||
* @param {Function} [attrs.transformErrors=defaultTransformErrors]
|
||||
* Function which takes an errors object from a GraphQL
|
||||
* response and returns an instance of ShieldsRuntimeError.
|
||||
@@ -61,6 +64,7 @@ class BaseGraphqlService extends BaseService {
|
||||
variables = {},
|
||||
options = {},
|
||||
httpErrorMessages = {},
|
||||
transformJson = data => data,
|
||||
transformErrors = defaultTransformErrors,
|
||||
}) {
|
||||
const mergedOptions = {
|
||||
@@ -74,7 +78,7 @@ class BaseGraphqlService extends BaseService {
|
||||
options: mergedOptions,
|
||||
errorMessages: httpErrorMessages,
|
||||
})
|
||||
const json = this._parseJson(buffer)
|
||||
const json = transformJson(this._parseJson(buffer))
|
||||
if (json.errors) {
|
||||
const exception = transformErrors(json.errors)
|
||||
if (exception instanceof ShieldsRuntimeError) {
|
||||
|
||||
@@ -24,9 +24,9 @@ function errorMessagesFor(notFoundMessage = 'repo not found') {
|
||||
}
|
||||
}
|
||||
|
||||
function transformErrors(errors) {
|
||||
function transformErrors(errors, entity = 'repo') {
|
||||
if (errors[0].type === 'NOT_FOUND') {
|
||||
return new NotFound({ prettyMessage: 'repo not found' })
|
||||
return new NotFound({ prettyMessage: `${entity} not found` })
|
||||
} else {
|
||||
return new InvalidResponse({ prettyMessage: errors[0].message })
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ module.exports = class GithubStars extends GithubAuthV3Service {
|
||||
static get examples() {
|
||||
return [
|
||||
{
|
||||
title: 'GitHub stars',
|
||||
title: 'GitHub Repo stars',
|
||||
namedParams: {
|
||||
user: 'badges',
|
||||
repo: 'shields',
|
||||
|
||||
246
services/github/github-total-star.service.js
Normal file
246
services/github/github-total-star.service.js
Normal file
@@ -0,0 +1,246 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('@hapi/joi')
|
||||
const gql = require('graphql-tag')
|
||||
const { nonNegativeInteger } = require('../validators')
|
||||
const { metric } = require('../text-formatters')
|
||||
const { GithubAuthV4Service } = require('./github-auth-service')
|
||||
const {
|
||||
documentation: commonDocumentation,
|
||||
transformErrors,
|
||||
} = require('./github-helpers')
|
||||
|
||||
const MAX_REPO_LIMIT = 200
|
||||
|
||||
const customDocumentation = `This badge takes into account up to <code>${MAX_REPO_LIMIT}</code> of the most starred repositories of given user / org.`
|
||||
|
||||
const userDocumentation = `${commonDocumentation}
|
||||
<p>
|
||||
<b>Note:</b><br>
|
||||
1. ${customDocumentation}<br>
|
||||
2. <code>affiliations</code> query param accepts three values (must be UPPER case) <code>OWNER</code>, <code>COLLABORATOR</code>, <code>ORGANIZATION_MEMBER</code>.
|
||||
One can pass comma separated combinations of these values (no spaces) e.g. <code>OWNER,COLLABORATOR</code> or <code>OWNER,COLLABORATOR,ORGANIZATION_MEMBER</code>.
|
||||
Default value is <code>OWNER</code>. See the explanation of these values <a href="https://docs.github.com/en/graphql/reference/enums#repositoryaffiliation">here</a>.
|
||||
</p>
|
||||
`
|
||||
const orgDocumentation = `${commonDocumentation}
|
||||
<p>
|
||||
<b>Note:</b> ${customDocumentation}
|
||||
</p>`
|
||||
|
||||
const pageInfoSchema = Joi.object({
|
||||
hasNextPage: Joi.boolean().required(),
|
||||
endCursor: Joi.string().allow(null).required(),
|
||||
}).required()
|
||||
|
||||
const nodesSchema = Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
stargazers: Joi.object({
|
||||
totalCount: nonNegativeInteger,
|
||||
}).required(),
|
||||
})
|
||||
)
|
||||
.default([])
|
||||
|
||||
const repositoriesSchema = Joi.object({
|
||||
pageInfo: pageInfoSchema,
|
||||
nodes: nodesSchema,
|
||||
}).required()
|
||||
|
||||
const schema = Joi.object({
|
||||
data: Joi.alternatives(
|
||||
Joi.object({
|
||||
user: Joi.object({
|
||||
repositories: repositoriesSchema,
|
||||
}).required(),
|
||||
}).required(),
|
||||
Joi.object({
|
||||
organization: Joi.object({
|
||||
repositories: repositoriesSchema,
|
||||
}).required(),
|
||||
}).required()
|
||||
).required(),
|
||||
}).required()
|
||||
|
||||
const query = gql`
|
||||
query fetchStars(
|
||||
$user: String!
|
||||
$nextCursor: String
|
||||
$affiliations: [RepositoryAffiliation]!
|
||||
) {
|
||||
user(login: $user) {
|
||||
repositories(
|
||||
first: 100
|
||||
after: $nextCursor
|
||||
ownerAffiliations: $affiliations
|
||||
orderBy: { field: STARGAZERS, direction: DESC }
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
stargazers {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
organization(login: $user) {
|
||||
repositories(
|
||||
first: 100
|
||||
after: $nextCursor
|
||||
orderBy: { field: STARGAZERS, direction: DESC }
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
stargazers {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const affiliationsAllowedValues = [
|
||||
'OWNER',
|
||||
`COLLABORATOR`,
|
||||
'ORGANIZATION_MEMBER',
|
||||
]
|
||||
/**
|
||||
* Validates affiliations should contain combination of allowed values in any order.
|
||||
*
|
||||
* @param {string} value affiliation current value
|
||||
* @param {*} helpers object to construct custom error
|
||||
*
|
||||
* @returns {string} valiadtion error or value unchanged
|
||||
*/
|
||||
const validateAffiliations = (value, helpers) => {
|
||||
const values = value.split(',')
|
||||
if (values.some(e => !affiliationsAllowedValues.includes(e))) {
|
||||
return helpers.error('any.invalid')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const queryParamSchema = Joi.object({
|
||||
affiliations: Joi.string().default('OWNER').custom(validateAffiliations),
|
||||
}).required()
|
||||
|
||||
module.exports = class GithubTotalStarService extends GithubAuthV4Service {
|
||||
static defaultLabel = 'stars'
|
||||
static category = 'social'
|
||||
|
||||
static route = {
|
||||
base: 'github/stars',
|
||||
pattern: ':user',
|
||||
queryParamSchema,
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: "GitHub User's stars",
|
||||
namedParams: {
|
||||
user: 'chris48s',
|
||||
},
|
||||
queryParams: { affiliations: 'OWNER,COLLABORATOR' },
|
||||
staticPreview: {
|
||||
label: this.defaultLabel,
|
||||
message: 54,
|
||||
style: 'social',
|
||||
},
|
||||
documentation: userDocumentation,
|
||||
},
|
||||
{
|
||||
title: "GitHub Org's stars",
|
||||
pattern: ':org',
|
||||
namedParams: {
|
||||
org: 'badges',
|
||||
},
|
||||
staticPreview: {
|
||||
label: this.defaultLabel,
|
||||
message: metric(7000),
|
||||
style: 'social',
|
||||
},
|
||||
documentation: orgDocumentation,
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = {
|
||||
label: this.defaultLabel,
|
||||
namedLogo: 'github',
|
||||
}
|
||||
|
||||
static render({ totalStars, user }) {
|
||||
return {
|
||||
message: metric(totalStars),
|
||||
color: 'blue',
|
||||
link: [`https://github.com/${user}`],
|
||||
}
|
||||
}
|
||||
|
||||
async fetch({ user, affiliations, nextCursor }) {
|
||||
const variables = { user, affiliations, nextCursor }
|
||||
return await this._requestGraphql({
|
||||
query,
|
||||
variables,
|
||||
schema,
|
||||
transformJson: json =>
|
||||
json.data.organization || json.data.user ? { data: json.data } : json,
|
||||
transformErrors: e => transformErrors(e, 'user/org'),
|
||||
})
|
||||
}
|
||||
|
||||
transform(repos) {
|
||||
const totalStars = repos
|
||||
.map(element => element.stargazers.totalCount)
|
||||
.reduce((accumulator, currentValue) => accumulator + currentValue, 0)
|
||||
const lastRepo = repos.slice(-1).pop() // undefined when repos is empty
|
||||
const hasStars = lastRepo ? lastRepo.stargazers.totalCount !== 0 : false
|
||||
return {
|
||||
totalStars,
|
||||
hasStars,
|
||||
}
|
||||
}
|
||||
|
||||
async getTotalStars({ user }, { affiliations }) {
|
||||
let grandTotalStars = 0
|
||||
let fetchedReposCount = 0
|
||||
let nextCursor = null
|
||||
let hasNext
|
||||
|
||||
do {
|
||||
const { data } = await this.fetch({
|
||||
user,
|
||||
affiliations: affiliations.split(','),
|
||||
nextCursor,
|
||||
})
|
||||
const {
|
||||
repositories: {
|
||||
pageInfo: { hasNextPage, endCursor },
|
||||
nodes: repos,
|
||||
},
|
||||
} = data.user || data.organization
|
||||
const { totalStars, hasStars } = this.transform(repos)
|
||||
// repos are sorted based on the stars. If last repo has zero star,
|
||||
// no need to fire additional fetch call, as repos on next page will have zero stars only.
|
||||
hasNext = hasNextPage && hasStars
|
||||
nextCursor = endCursor
|
||||
grandTotalStars += totalStars
|
||||
fetchedReposCount += repos.length
|
||||
} while (hasNext && fetchedReposCount < MAX_REPO_LIMIT)
|
||||
|
||||
return grandTotalStars
|
||||
}
|
||||
|
||||
async handle({ user }, queryParams) {
|
||||
const totalStars = await this.getTotalStars({ user }, queryParams)
|
||||
return this.constructor.render({ totalStars, user })
|
||||
}
|
||||
}
|
||||
68
services/github/github-total-star.tester.js
Normal file
68
services/github/github-total-star.tester.js
Normal file
@@ -0,0 +1,68 @@
|
||||
'use strict'
|
||||
|
||||
const { isMetric } = require('../test-validators')
|
||||
const t = (module.exports = require('../tester').createServiceTester())
|
||||
|
||||
t.create('Stars (User)')
|
||||
.get('/hemantsonu20.json')
|
||||
.expectBadge({
|
||||
label: 'stars',
|
||||
message: isMetric,
|
||||
link: ['https://github.com/hemantsonu20'],
|
||||
})
|
||||
|
||||
t.create('Stars (User) with affiliations')
|
||||
.get('/hemantsonu20.json?affiliations=OWNER,COLLABORATOR')
|
||||
.expectBadge({
|
||||
label: 'stars',
|
||||
message: isMetric,
|
||||
link: ['https://github.com/hemantsonu20'],
|
||||
})
|
||||
|
||||
t.create('Stars (User) with all affiliations')
|
||||
.get('/chris48s.json?affiliations=OWNER,COLLABORATOR,ORGANIZATION_MEMBER')
|
||||
.expectBadge({
|
||||
label: 'stars',
|
||||
message: isMetric,
|
||||
link: ['https://github.com/chris48s'],
|
||||
})
|
||||
|
||||
t.create('Stars (User) with invalid affiliations')
|
||||
.get('/hemantsonu20.json?affiliations=UNKNOWN')
|
||||
.expectBadge({
|
||||
label: 'stars',
|
||||
message: 'invalid query parameter: affiliations',
|
||||
link: [],
|
||||
})
|
||||
|
||||
t.create('Stars (User) with invalid affiliations space')
|
||||
.get('/hemantsonu20.json?affiliations=OWNER, COLLABORATOR')
|
||||
.expectBadge({
|
||||
label: 'stars',
|
||||
message: 'invalid query parameter: affiliations',
|
||||
link: [],
|
||||
})
|
||||
|
||||
t.create('Stars (Org)')
|
||||
.get('/badges.json')
|
||||
.expectBadge({
|
||||
label: 'stars',
|
||||
message: isMetric, // matches format 13k
|
||||
link: ['https://github.com/badges'],
|
||||
})
|
||||
|
||||
t.create('Stars (Org) Lots of repo')
|
||||
.get('/github.json')
|
||||
.expectBadge({
|
||||
label: 'stars',
|
||||
message: isMetric, // matches format 303k
|
||||
link: ['https://github.com/github'],
|
||||
})
|
||||
|
||||
t.create('Stars (User/Org) unknown user/org')
|
||||
.get('/badges-fake.json')
|
||||
.expectBadge({
|
||||
label: 'stars',
|
||||
message: 'user/org not found',
|
||||
link: [],
|
||||
})
|
||||
Reference in New Issue
Block a user