read-frog/.github/scripts/contributor-trust/github-api.js

491 lines
14 KiB
JavaScript

import { POLICY } from "./config.js"
const API_BASE_URL = "https://api.github.com"
const LINK_HEADER_ENTRY_PATTERN = /^<([^>]+)>;\s*rel="([^"]+)"$/
class GitHubApiError extends Error {
constructor(message, details = {}) {
super(message)
this.name = "GitHubApiError"
this.details = details
}
}
function buildHeaders(token, extraHeaders = {}) {
return {
"Accept": "application/vnd.github+json",
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": "read-frog-contributor-trust",
...extraHeaders,
}
}
async function parseResponse(response) {
const text = await response.text()
if (!text)
return null
try {
return JSON.parse(text)
}
catch {
return text
}
}
function buildErrorMessage(method, path, response, payload) {
const suffix = payload && typeof payload === "object" && "message" in payload
? `: ${payload.message}`
: ""
return `${method} ${path} failed with ${response.status} ${response.statusText}${suffix}`
}
export async function apiRequest(token, path, { body, headers, method = "GET" } = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
headers: buildHeaders(token, headers),
body: body === undefined ? undefined : JSON.stringify(body),
})
const payload = await parseResponse(response)
if (!response.ok) {
throw new GitHubApiError(buildErrorMessage(method, path, response, payload), {
path,
payload,
response,
})
}
return payload
}
export async function graphqlRequest(token, query, variables) {
const response = await fetch(`${API_BASE_URL}/graphql`, {
method: "POST",
headers: buildHeaders(token),
body: JSON.stringify({ query, variables }),
})
const payload = await parseResponse(response)
if (!response.ok) {
throw new GitHubApiError(buildErrorMessage("POST", "/graphql", response, payload), {
payload,
response,
})
}
if (payload.errors?.length) {
throw new GitHubApiError(payload.errors.map(error => error.message).join("; "), {
payload,
response,
})
}
return payload.data
}
export async function paginate(token, path) {
const items = []
for (let page = 1; page <= 10; page += 1) {
const url = new URL(`${API_BASE_URL}${path}`)
url.searchParams.set("per_page", "100")
url.searchParams.set("page", String(page))
const response = await fetch(url, {
headers: buildHeaders(token),
})
const payload = await parseResponse(response)
if (!response.ok) {
throw new GitHubApiError(buildErrorMessage("GET", `${path}?page=${page}`, response, payload), {
payload,
response,
})
}
if (!Array.isArray(payload)) {
throw new GitHubApiError(`Expected array payload from ${path}`, { payload })
}
items.push(...payload)
if (payload.length < 100)
break
}
return items
}
export async function getPullRequest(token, owner, repo, pullNumber) {
return apiRequest(token, `/repos/${owner}/${repo}/pulls/${pullNumber}`)
}
export async function listIssueLabels(token, owner, repo, issueNumber) {
const labels = await apiRequest(token, `/repos/${owner}/${repo}/issues/${issueNumber}/labels?per_page=100`)
return labels.map(label => label.name)
}
export async function listIssueComments(token, owner, repo, issueNumber) {
return paginate(token, `/repos/${owner}/${repo}/issues/${issueNumber}/comments`)
}
export async function createIssueComment(token, owner, repo, issueNumber, body) {
return apiRequest(token, `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
body: { body },
method: "POST",
})
}
export async function updateIssueComment(token, owner, repo, commentId, body) {
return apiRequest(token, `/repos/${owner}/${repo}/issues/comments/${commentId}`, {
body: { body },
method: "PATCH",
})
}
export async function addLabelsToIssue(token, owner, repo, issueNumber, labels) {
if (labels.length === 0)
return []
return apiRequest(token, `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, {
body: { labels },
method: "POST",
})
}
export async function removeLabelFromIssue(token, owner, repo, issueNumber, labelName) {
return apiRequest(token, `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(labelName)}`, {
method: "DELETE",
})
}
export async function closePullRequestIssue(token, owner, repo, issueNumber) {
return apiRequest(token, `/repos/${owner}/${repo}/issues/${issueNumber}`, {
body: { state: "closed" },
method: "PATCH",
})
}
export async function getCollaboratorPermission(token, owner, repo, username) {
try {
const response = await apiRequest(token, `/repos/${owner}/${repo}/collaborators/${encodeURIComponent(username)}/permission`)
return response.permission ?? null
}
catch (error) {
if (error instanceof GitHubApiError && error.details.response?.status === 404)
return null
throw error
}
}
function getPageNumberFromLinkHeader(linkHeader, relation) {
if (!linkHeader)
return null
for (const entry of linkHeader.split(",")) {
const match = entry.trim().match(LINK_HEADER_ENTRY_PATTERN)
if (!match || match[2] !== relation)
continue
const page = new URL(match[1]).searchParams.get("page")
const parsedPage = Number.parseInt(page ?? "", 10)
return Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : null
}
return null
}
export async function countAuthorCommitsInRepo(token, owner, repo, authorLogin) {
const url = new URL(`${API_BASE_URL}/repos/${owner}/${repo}/commits`)
url.searchParams.set("author", authorLogin)
url.searchParams.set("per_page", "1")
const response = await fetch(url, {
headers: buildHeaders(token),
})
const payload = await parseResponse(response)
if (!response.ok) {
throw new GitHubApiError(buildErrorMessage("GET", `${url.pathname}${url.search}`, response, payload), {
payload,
response,
})
}
if (!Array.isArray(payload))
throw new GitHubApiError(`Expected array payload from ${url.pathname}`, { payload })
const lastPage = getPageNumberFromLinkHeader(response.headers.get("link"), "last")
if (lastPage !== null)
return lastPage
return payload.length
}
export async function listRepositoryLabels(token, owner, repo) {
return paginate(token, `/repos/${owner}/${repo}/labels`)
}
export async function ensureRepositoryLabels(token, owner, repo, labelDefinitions) {
const existingLabels = new Set(
(await listRepositoryLabels(token, owner, repo)).map(label => label.name),
)
for (const [name, definition] of Object.entries(labelDefinitions)) {
if (existingLabels.has(name))
continue
try {
await apiRequest(token, `/repos/${owner}/${repo}/labels`, {
body: {
color: definition.color,
description: definition.description,
name,
},
method: "POST",
})
}
catch (createError) {
if (!(createError instanceof GitHubApiError) || createError.details.response?.status !== 422)
throw createError
}
}
}
function countReviewsOnOthersPullRequests(nodes, authorLogin) {
const normalizedAuthorLogin = authorLogin.toLowerCase()
let reviews = 0
for (const node of nodes ?? []) {
const pullRequestAuthor = node?.author?.login?.toLowerCase()
if (!pullRequestAuthor || pullRequestAuthor === normalizedAuthorLogin)
continue
reviews += 1
}
return reviews
}
function getReviewSearchPageInfo(searchResult) {
return {
endCursor: searchResult?.pageInfo?.endCursor ?? null,
hasNextPage: searchResult?.pageInfo?.hasNextPage === true,
}
}
function normalizeReviewSearchNodes(searchResult) {
return (searchResult?.nodes ?? []).filter(node => node?.__typename === "PullRequest")
}
export async function countReviewsOnOthersPullRequestsInRepo(token, owner, repo, authorLogin, pageSize) {
let reviews = 0
let cursor = null
while (true) {
const data = await graphqlRequest(token, `
query ReviewsOnOthersPullRequests(
$cursor: String
$pageSize: Int!
$reviewsQuery: String!
) {
search(query: $reviewsQuery, type: ISSUE, first: $pageSize, after: $cursor) {
nodes {
__typename
... on PullRequest {
author {
login
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
`, {
cursor,
pageSize,
reviewsQuery: `repo:${owner}/${repo} reviewed-by:${authorLogin} type:pr`,
})
const searchResult = data.search
reviews += countReviewsOnOthersPullRequests(
normalizeReviewSearchNodes(searchResult),
authorLogin,
)
const pageInfo = getReviewSearchPageInfo(searchResult)
if (!pageInfo.hasNextPage)
break
cursor = pageInfo.endCursor
if (!cursor)
break
}
return reviews
}
function coalesceAuthorUser(user, fallbackUser, authorLogin) {
return {
avatarUrl: user?.avatarUrl ?? fallbackUser?.avatar_url ?? null,
createdAt: user?.createdAt ?? fallbackUser?.created_at ?? null,
followers: user?.followers?.totalCount ?? fallbackUser?.followers ?? 0,
login: user?.login ?? fallbackUser?.login ?? authorLogin,
name: user?.name ?? fallbackUser?.name ?? null,
publicRepos: user?.repositories?.totalCount ?? fallbackUser?.public_repos ?? 0,
url: user?.url ?? fallbackUser?.html_url ?? `https://github.com/${authorLogin}`,
}
}
export function createPullRequestStateList({ closedPrs, mergedPrs, openPrs }) {
return [
...Array.from({ length: mergedPrs }).fill({ state: "merged" }),
...Array.from({ length: closedPrs }).fill({ state: "closed" }),
...Array.from({ length: openPrs }).fill({ state: "open" }),
]
}
export function createContributorMetrics({ author, permission, repoHistory }) {
const contributionCount = repoHistory.mergedPrs + repoHistory.reviews
return {
accountCreated: author.createdAt,
commitsInRepo: repoHistory.commitsInRepo,
contributionCount,
followers: author.followers,
isContributor: contributionCount > 0,
prsInRepo: createPullRequestStateList(repoHistory),
repoPermission: permission ?? null,
reviewsInRepo: repoHistory.reviews,
topRepoStars: repoHistory.topRepositories.map(repository => repository.stargazerCount),
}
}
function normalizeOwnedRepository(node, ownerLogin) {
if (!node?.nameWithOwner || !ownerLogin)
return null
const repositoryOwner = node.owner?.login?.toLowerCase()
if (repositoryOwner !== ownerLogin.toLowerCase())
return null
if (node.isFork === true)
return null
return {
nameWithOwner: node.nameWithOwner,
stargazerCount: Number(node.stargazerCount) || 0,
}
}
export function selectOwnedNonForkRepositories(nodes, ownerLogin) {
const topRepositories = []
for (const node of nodes ?? []) {
const repository = normalizeOwnedRepository(node, ownerLogin)
if (!repository)
continue
topRepositories.push(repository)
}
return topRepositories
}
export async function getAuthorMetrics(token, owner, repo, authorLogin) {
const query = `
query ContributorTrust(
$login: String!
$openPrsQuery: String!
$mergedPrsQuery: String!
$closedPrsQuery: String!
) {
user(login: $login) {
login
name
url
avatarUrl
createdAt
followers {
totalCount
}
repositories {
totalCount
}
ownedNonForkRepositories: repositories(
first: 20
ownerAffiliations: [OWNER]
isFork: false
orderBy: { field: STARGAZERS, direction: DESC }
) {
nodes {
isFork
nameWithOwner
owner {
login
}
stargazerCount
}
}
}
openPrs: search(query: $openPrsQuery, type: ISSUE, first: 1) {
issueCount
}
mergedPrs: search(query: $mergedPrsQuery, type: ISSUE, first: 1) {
issueCount
}
closedPrs: search(query: $closedPrsQuery, type: ISSUE, first: 1) {
issueCount
}
}
`
const variables = {
login: authorLogin,
openPrsQuery: `repo:${owner}/${repo} author:${authorLogin} type:pr is:open`,
mergedPrsQuery: `repo:${owner}/${repo} author:${authorLogin} type:pr is:merged`,
closedPrsQuery: `repo:${owner}/${repo} author:${authorLogin} type:pr is:closed is:unmerged`,
}
const data = await graphqlRequest(token, query, variables)
const fallbackUser = data.user ? null : await getUserFallback(token, authorLogin)
const author = coalesceAuthorUser(data.user, fallbackUser, authorLogin)
const [commitsInRepo, reviews] = await Promise.all([
countAuthorCommitsInRepo(token, owner, repo, authorLogin),
countReviewsOnOthersPullRequestsInRepo(
token,
owner,
repo,
authorLogin,
POLICY.reviewQueryPageSize,
),
])
const topRepositories = selectOwnedNonForkRepositories(data.user?.ownedNonForkRepositories?.nodes ?? [], authorLogin)
return {
author,
repoHistory: {
closedPrs: data.closedPrs?.issueCount ?? 0,
commitsInRepo,
mergedPrs: data.mergedPrs?.issueCount ?? 0,
openPrs: data.openPrs?.issueCount ?? 0,
reviews,
topRepositories,
},
}
}
async function getUserFallback(token, authorLogin) {
try {
return await apiRequest(token, `/users/${encodeURIComponent(authorLogin)}`)
}
catch (error) {
if (error instanceof GitHubApiError && error.details.response?.status === 404)
return null
throw error
}
}