[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:
Pratapi Hemant
2020-09-19 16:37:23 +05:30
committed by GitHub
parent 737254f4a5
commit 108a819c3a
5 changed files with 322 additions and 4 deletions

View File

@@ -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) {

View File

@@ -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 })
}

View File

@@ -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',

View 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 })
}
}

View 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: [],
})