Files
metrics/source/plugins/base/index.mjs

269 lines
13 KiB
JavaScript

/**
* Base plugin is a special plugin because of historical reasons.
* It populates initial data object directly instead of returning a result like others plugins
*/
//Setup
export default async function({login, graphql, rest, data, q, queries, imports}, conf) {
//Load inputs
console.debug(`metrics/compute/${login}/base > started`)
let {indepth, "repositories.forks":_forks, "repositories.affiliations":_affiliations, "repositories.batch":_batch} = imports.metadata.plugins.base.inputs({data, q, account:"bypass"})
const extras = conf.settings.extras?.features ?? conf.settings.extras?.default
const repositories = conf.settings.repositories || 100
const forks = _forks ? "" : ", isFork: false"
const affiliations = _affiliations?.length ? `, ownerAffiliations: [${_affiliations.map(x => x.toLocaleUpperCase()).join(", ")}]${conf.authenticated === login ? `, affiliations: [${_affiliations.map(x => x.toLocaleUpperCase()).join(", ")}]` : ""}` : ""
console.debug(`metrics/compute/${login}/base > affiliations constraints ${affiliations}`)
//Skip initial data gathering if not needed
if (conf.settings.notoken)
return (postprocess.skip({login, data, imports}), {})
//Base parts (legacy handling for web instance)
const defaulted = ("base" in q) ? legacy.converter(q.base) ?? true : true
for (const part of conf.settings.plugins.base.parts)
data.base[part] = `base.${part}` in q ? legacy.converter(q[`base.${part}`]) : defaulted
//Iterate through account types
for (const account of ["user", "organization"]) {
try {
//Query data from GitHub API
console.debug(`metrics/compute/${login}/base > account ${account}`)
const queried = await graphql(queries.base[account]({login}))
Object.assign(data, {user:queried[account]})
postprocess?.[account]({login, data})
try {
Object.assign(data.user, (await graphql(queries.base[`${account}.x`]({login, account, "calendar.from":new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), "calendar.to":(new Date()).toISOString()})))[account])
console.debug(`metrics/compute/${login}/base > successfully loaded bulk query`)
}
catch {
console.debug(`metrics/compute/${login}/base > failed to load bulk query, falling back to unit queries`)
//Query basic fields
const fields = {
user:["packages", "starredRepositories", "watching", "sponsorshipsAsSponsor", "sponsorshipsAsMaintainer", "followers", "following", "issueComments", "organizations", "repositoriesContributedTo(includeUserRepositories: true)"],
organization:["packages", "sponsorshipsAsSponsor", "sponsorshipsAsMaintainer", "membersWithRole"],
}[account] ?? []
for (const field of fields) {
try {
Object.assign(data.user, (await graphql(queries.base.field({login, account, field})))[account])
}
catch {
console.debug(`metrics/compute/${login}/base > failed to retrieve ${field}`)
data.user[field] = {totalCount:NaN}
}
}
//Query repositories fields
for (const field of ["totalCount", "totalDiskUsage"]) {
try {
Object.assign(data.user.repositories, (await graphql(queries.base["field.repositories"]({login, account, field})))[account].repositories)
}
catch (error) {
console.debug(`metrics/compute/${login}/base > failed to retrieve repositories.${field}`)
data.user.repositories[field] = NaN
}
}
//Query user account fields
if (account === "user") {
//Query contributions collection
{
const fields = ["totalRepositoriesWithContributedCommits", "totalCommitContributions", "restrictedContributionsCount", "totalIssueContributions", "totalPullRequestContributions", "totalPullRequestReviewContributions"]
for (const field of fields) {
try {
Object.assign(data.user.contributionsCollection, (await graphql(queries.base.contributions({login, account, field, range:""})))[account].contributionsCollection)
}
catch {
console.debug(`metrics/compute/${login}/base > failed to retrieve contributionsCollection.${field}`)
data.user.contributionsCollection[field] = NaN
}
}
}
//Query calendar
try {
Object.assign(data.user, (await graphql(queries.base.calendar({login, "calendar.from":new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), "calendar.to":(new Date()).toISOString()})))[account])
}
catch {
console.debug(`metrics/compute/${login}/base > failed to retrieve contributions calendar`)
data.user.calendar = {contributionCalendar:{weeks:[]}}
}
}
}
//Query contributions collection over account lifetime instead of last year
if (account === "user") {
if ((indepth)&&(extras)) {
const fields = ["totalRepositoriesWithContributedCommits", "totalCommitContributions", "restrictedContributionsCount", "totalIssueContributions", "totalPullRequestContributions", "totalPullRequestReviewContributions"]
const start = new Date(data.user.createdAt)
const end = new Date()
const collection = {}
for (const field of fields) {
collection[field] = 0
//Load contribution calendar
for (let from = new Date(start); from < end;) {
//Set date range
let to = new Date(from)
to.setUTCHours(+6 * 4 * 7 * 24)
if (to > end)
to = end
//Ensure that date ranges are not overlapping by setting it to previous day at 23:59:59.999
const dto = new Date(to)
dto.setUTCHours(-1)
dto.setUTCMinutes(59)
dto.setUTCSeconds(59)
dto.setUTCMilliseconds(999)
//Fetch data from api
try {
console.debug(`metrics/compute/${login}/plugins > base > loading contributions collections for ${field} from "${from.toISOString()}" to "${dto.toISOString()}"`)
const {[account]:{contributionsCollection}} = await graphql(queries.base.contributions({login, account, field, range:`(from: "${from.toISOString()}", to: "${dto.toISOString()}")`}))
collection[field] += contributionsCollection[field]
}
catch {
console.debug(`metrics/compute/${login}/plugins > base > failed to load contributions collections for ${field} from "${from.toISOString()}" to "${dto.toISOString()}"`)
}
//Set next date range start
from = new Date(to)
}
data.user.contributionsCollection[field] = Math.max(collection[field], data.user.contributionsCollection[field])
}
}
//Fallback to load whole commit history rather than last year
else {
try {
console.debug(`metrics/compute/${login}/base > loading user commits history`)
const {data:{total_count:total = 0}} = await rest.search.commits({q:`author:${login}`})
data.user.contributionsCollection.totalCommitContributions = Math.max(total, data.user.contributionsCollection.totalCommitContributions)
}
catch {
console.debug(`metrics/compute/${login}/base > falling back to last year commits history`)
}
}
}
//Query repositories from GitHub API
for (const type of ({user:["repositories", "repositoriesContributedTo"], organization:["repositories"]}[account] ?? [])) {
//Iterate through repositories
let cursor = null
let pushed = 0
const options = {repositories:{forks, affiliations, constraints:""}, repositoriesContributedTo:{forks:"", affiliations:"", constraints:", includeUserRepositories: false, contributionTypes: COMMIT"}}[type] ?? null
data.user[type] = data.user[type] ?? {}
data.user[type].nodes = data.user[type].nodes ?? []
do {
console.debug(`metrics/compute/${login}/base > retrieving ${type} after ${cursor}`)
const request = {}
try {
Object.assign(request, await graphql(queries.base.repositories({login, account, type, after:cursor ? `after: "${cursor}"` : "", repositories:Math.min(repositories, {user:_batch, organization:Math.min(25, _batch)}[account]), ...options})))
}
catch (error) {
console.debug(`metrics/compute/${login}/base > failed to retrieve ${_batch} repositories after ${cursor}, this is probably due to an API timeout, halving batch`)
_batch = Math.floor(_batch / 2)
if (_batch < 1) {
console.debug(`metrics/compute/${login}/base > failed to retrieve repositories, cannot halve batch anymore`)
throw error
}
continue
}
const {[account]:{[type]:{edges = [], nodes = []} = {}}} = request
cursor = edges?.[edges?.length - 1]?.cursor
data.user[type].nodes.push(...nodes)
pushed = nodes.length
console.debug(`metrics/compute/${login}/base > retrieved ${pushed} ${type} after ${cursor}`)
if (pushed < repositories) {
console.debug(`metrics/compute/${login}/base > retrieved less repositories than expected, probably no more to fetch`)
break
}
} while ((pushed) && (cursor) && ((data.user.repositories?.nodes?.length ?? 0) + (data.user.repositoriesContributedTo?.nodes?.length ?? 0) < repositories))
//Limit repositories
console.debug(`metrics/compute/${login}/base > keeping only ${repositories} ${type}`)
data.user[type].nodes.splice(repositories)
console.debug(`metrics/compute/${login}/base > loaded ${data.user[type].nodes.length} ${type}`)
}
//Shared options
let {"repositories.skipped":skipped, "users.ignored":ignored, "commits.authoring":authoring} = imports.metadata.plugins.base.inputs({data, q, account:"bypass"})
data.shared = {"repositories.skipped":skipped, "users.ignored":ignored, "commits.authoring":authoring, "repositories.batch":_batch}
console.debug(`metrics/compute/${login}/base > shared options > ${JSON.stringify(data.shared)}`)
//Success
console.debug(`metrics/compute/${login}/base > graphql query > account ${account} > success`)
return {}
}
catch (error) {
console.debug(`metrics/compute/${login}/base > account ${account} > failed : ${error}`)
if (/Could not resolve to a User with the login of/.test(error.message)) {
console.debug(`metrics/compute/${login}/base > got a "user not found" error for account type "${account}" and user "${login}"`)
console.debug(`metrics/compute/${login}/base > checking next account type`)
continue
}
throw error
}
}
//Not found
console.debug(`metrics/compute/${login}/base > no more account type`)
throw new Error("user not found")
}
//Query post-processing
const postprocess = {
//User
user({login, data}) {
console.debug(`metrics/compute/${login}/base > applying postprocessing`)
data.account = "user"
Object.assign(data.user, {
isVerified:false,
repositories:{},
contributionsCollection:{},
})
},
//Organization
organization({login, data}) {
console.debug(`metrics/compute/${login}/base > applying postprocessing`)
data.account = "organization"
Object.assign(data.user, {
isHireable:false,
repositories:{},
starredRepositories:{totalCount:NaN},
watching:{totalCount:NaN},
contributionsCollection:{
totalRepositoriesWithContributedCommits:NaN,
totalCommitContributions:NaN,
restrictedContributionsCount:NaN,
totalIssueContributions:NaN,
totalPullRequestContributions:NaN,
totalPullRequestReviewContributions:NaN,
},
calendar:{contributionCalendar:{weeks:[]}},
repositoriesContributedTo:{totalCount:NaN, nodes:[]},
followers:{totalCount:NaN},
following:{totalCount:NaN},
issueComments:{totalCount:NaN},
organizations:{totalCount:NaN},
})
},
//Skip base content query and instantiate an empty user instance
skip({login, data, imports}) {
data.user = {}
data.shared = imports.metadata.plugins.base.inputs({data, q:{}, account:"bypass"})
for (const account of ["user", "organization"])
postprocess?.[account]({login, data})
data.account = "bypass"
Object.assign(data.user, {
databaseId:NaN,
name:login,
login,
createdAt:new Date(),
avatarUrl:`https://github.com/${login}.png`,
websiteUrl:null,
twitterUsername:login,
repositories:{totalCount:NaN, totalDiskUsage:NaN, nodes:[]},
packages:{totalCount:NaN},
repositoriesContributedTo:{totalCount:NaN, nodes:[]},
})
},
}
//Legacy functions
const legacy = {
converter(value) {
if (/^(?:[Tt]rue|[Oo]n|[Yy]es|1)$/.test(value))
return true
if (/^(?:[Ff]alse|[Oo]ff|[Nn]o|0)$/.test(value))
return false
if (Number.isFinite(Number(value)))
return !!(Number(value))
},
}