mirror of
https://github.com/mengxi-ream/read-frog.git
synced 2026-04-30 01:56:46 +00:00
289 lines
9.8 KiB
JavaScript
289 lines
9.8 KiB
JavaScript
import { appendFile, readFile } from "node:fs/promises"
|
|
import { resolve } from "node:path"
|
|
import process from "node:process"
|
|
import { fileURLToPath } from "node:url"
|
|
|
|
import { getBotAuthorSkipReason } from "./bot-author.js"
|
|
import { buildTrustComment } from "./comment-template.js"
|
|
import { LABEL_DEFINITIONS, POLICY } from "./config.js"
|
|
import {
|
|
addLabelsToIssue,
|
|
closePullRequestIssue,
|
|
createContributorMetrics,
|
|
createIssueComment,
|
|
ensureRepositoryLabels,
|
|
getAuthorMetrics,
|
|
getCollaboratorPermission,
|
|
getPullRequest,
|
|
listIssueComments,
|
|
listIssueLabels,
|
|
removeLabelFromIssue,
|
|
updateIssueComment,
|
|
} from "./github-api.js"
|
|
import { findManagedTrustComment } from "./managed-comment.js"
|
|
import { planTrustActions } from "./plan-actions.js"
|
|
import { computeContributorScore } from "./score-author.js"
|
|
|
|
function getRequiredEnv(name) {
|
|
const value = process.env[name]
|
|
if (!value)
|
|
throw new Error(`Missing required environment variable: ${name}`)
|
|
return value
|
|
}
|
|
|
|
function getRepository() {
|
|
const repository = getRequiredEnv("GITHUB_REPOSITORY")
|
|
const [owner, repo] = repository.split("/")
|
|
if (!owner || !repo)
|
|
throw new Error(`Invalid GITHUB_REPOSITORY value: ${repository}`)
|
|
return { owner, repo }
|
|
}
|
|
|
|
async function getEventPayload() {
|
|
const eventPath = getRequiredEnv("GITHUB_EVENT_PATH")
|
|
return JSON.parse(await readFile(eventPath, "utf8"))
|
|
}
|
|
|
|
function resolvePullNumber(eventName, payload) {
|
|
const manualInput = process.env.TRUST_PR_NUMBER?.trim()
|
|
if (manualInput)
|
|
return Number.parseInt(manualInput, 10)
|
|
|
|
if (eventName === "pull_request_target")
|
|
return payload.pull_request?.number
|
|
|
|
return null
|
|
}
|
|
|
|
async function writeJobSummary(lines) {
|
|
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
|
if (!summaryPath)
|
|
return
|
|
|
|
await appendFile(summaryPath, `${lines.join("\n")}\n`)
|
|
}
|
|
|
|
function buildScoreBreakdownSummary(score) {
|
|
const { communityStanding, ossInfluence, prTrackRecord, repoFamiliarity } = score.breakdown
|
|
|
|
return [
|
|
"",
|
|
"### Score details",
|
|
`- Repo familiarity: ${score.repoFamiliarity}/35`,
|
|
` - Commits in repo: ${repoFamiliarity.commitsInRepo.count} -> ${repoFamiliarity.commitsInRepo.points}/10`,
|
|
` - Merged PRs: ${repoFamiliarity.mergedPrs.count} -> ${repoFamiliarity.mergedPrs.points}/12`,
|
|
` - Reviews in repo: ${repoFamiliarity.reviewsInRepo.count} -> ${repoFamiliarity.reviewsInRepo.points}/8`,
|
|
` - Contributor bonus: ${repoFamiliarity.contributorBonus.eligible ? "yes" : "no"} -> ${repoFamiliarity.contributorBonus.points}/5`,
|
|
`- Community standing: ${score.communityStanding}/25`,
|
|
` - Account age: ${communityStanding.accountAge.months} months -> ${communityStanding.accountAge.points}/5`,
|
|
` - Followers: ${communityStanding.followers.count} -> ${communityStanding.followers.points}/10`,
|
|
` - Repo permission (${communityStanding.repoPermission.permission}) -> ${communityStanding.repoPermission.points}/10`,
|
|
`- OSS influence: ${score.ossInfluence}/20`,
|
|
` - Max owned repo stars: ${ossInfluence.maxOwnedRepoStars.count} -> ${ossInfluence.maxOwnedRepoStars.points}/15`,
|
|
` - Total owned repo stars: ${ossInfluence.totalOwnedRepoStars.count} -> ${ossInfluence.totalOwnedRepoStars.points}/5`,
|
|
`- PR track record: ${score.prTrackRecord}/20`,
|
|
` - Merged PRs: ${prTrackRecord.mergedPrs}`,
|
|
` - Resolved PRs: ${prTrackRecord.resolvedPrs}`,
|
|
` - Smoothed merge rate: ${prTrackRecord.smoothedRate}`,
|
|
` - Confidence: ${prTrackRecord.confidence}`,
|
|
]
|
|
}
|
|
|
|
function logScoreBreakdown(score) {
|
|
console.log("Contributor trust score breakdown:")
|
|
console.log(JSON.stringify({
|
|
bucket: score.bucket,
|
|
breakdown: score.breakdown,
|
|
total: score.total,
|
|
}, null, 2))
|
|
}
|
|
|
|
async function syncLabels({ currentLabels, issueNumber, labelsToAdd, labelsToRemove, owner, repo, token }) {
|
|
for (const label of labelsToRemove) {
|
|
if (!currentLabels.includes(label))
|
|
continue
|
|
await removeLabelFromIssue(token, owner, repo, issueNumber, label)
|
|
console.log(`Removed label: ${label}`)
|
|
}
|
|
|
|
const missingLabels = labelsToAdd.filter(label => !currentLabels.includes(label))
|
|
if (missingLabels.length > 0) {
|
|
await addLabelsToIssue(token, owner, repo, issueNumber, missingLabels)
|
|
console.log(`Added labels: ${missingLabels.join(", ")}`)
|
|
}
|
|
}
|
|
|
|
async function upsertComment({ body, issueNumber, owner, repo, token }) {
|
|
const comments = await listIssueComments(token, owner, repo, issueNumber)
|
|
const existingComment = findManagedTrustComment(comments)
|
|
|
|
if (existingComment?.body === body) {
|
|
console.log("Trust comment is already up to date.")
|
|
return { action: "unchanged", commentId: existingComment.id }
|
|
}
|
|
|
|
if (existingComment) {
|
|
await updateIssueComment(token, owner, repo, existingComment.id, body)
|
|
console.log(`Updated trust comment ${existingComment.id}.`)
|
|
return { action: "updated", commentId: existingComment.id }
|
|
}
|
|
|
|
const createdComment = await createIssueComment(token, owner, repo, issueNumber, body)
|
|
console.log(`Created trust comment ${createdComment.id}.`)
|
|
return { action: "created", commentId: createdComment.id }
|
|
}
|
|
|
|
export async function main() {
|
|
const token = getRequiredEnv("GITHUB_TOKEN")
|
|
const eventName = getRequiredEnv("GITHUB_EVENT_NAME")
|
|
const payload = await getEventPayload()
|
|
const pullNumber = resolvePullNumber(eventName, payload)
|
|
|
|
if (!Number.isInteger(pullNumber) || pullNumber <= 0)
|
|
throw new Error(`Unable to resolve a valid pull request number for event ${eventName}.`)
|
|
|
|
const { owner, repo } = getRepository()
|
|
const pullRequest = await getPullRequest(token, owner, repo, pullNumber)
|
|
|
|
const summaryLines = [
|
|
"## Contributor trust automation",
|
|
"",
|
|
`- Event: \`${eventName}\``,
|
|
`- PR: #${pullRequest.number}`,
|
|
`- Title: ${pullRequest.title}`,
|
|
`- State: ${pullRequest.state}${pullRequest.draft ? " (draft)" : ""}`,
|
|
`- Author: @${pullRequest.user.login}`,
|
|
]
|
|
|
|
const botSkipReason = getBotAuthorSkipReason(pullRequest.user)
|
|
if (botSkipReason) {
|
|
summaryLines.push(`- Result: skipped ${botSkipReason}`)
|
|
await writeJobSummary(summaryLines)
|
|
return
|
|
}
|
|
|
|
if (eventName === "pull_request_target" && pullRequest.draft) {
|
|
summaryLines.push("- Result: skipped automatic trust checks for a draft PR")
|
|
await writeJobSummary(summaryLines)
|
|
return
|
|
}
|
|
|
|
await ensureRepositoryLabels(token, owner, repo, LABEL_DEFINITIONS)
|
|
const currentLabels = await listIssueLabels(token, owner, repo, pullRequest.number)
|
|
|
|
const overridePlan = planTrustActions({
|
|
currentLabels,
|
|
pullRequest,
|
|
score: {
|
|
bucket: "new",
|
|
exemptReason: null,
|
|
total: 0,
|
|
},
|
|
})
|
|
|
|
if (overridePlan.skipAutomation) {
|
|
await syncLabels({
|
|
currentLabels,
|
|
issueNumber: pullRequest.number,
|
|
labelsToAdd: [],
|
|
labelsToRemove: overridePlan.labelsToRemove,
|
|
owner,
|
|
repo,
|
|
token,
|
|
})
|
|
|
|
summaryLines.push(`- Result: skipped due to \`${POLICY.overrideLabel}\``)
|
|
if (overridePlan.labelsToRemove.length > 0)
|
|
summaryLines.push(`- Cleanup: removed ${overridePlan.labelsToRemove.map(label => `\`${label}\``).join(", ")}`)
|
|
await writeJobSummary(summaryLines)
|
|
return
|
|
}
|
|
|
|
const permission = await getCollaboratorPermission(token, owner, repo, pullRequest.user.login)
|
|
const authorMetrics = await getAuthorMetrics(token, owner, repo, pullRequest.user.login)
|
|
const scoreInput = createContributorMetrics({
|
|
author: authorMetrics.author,
|
|
permission,
|
|
repoHistory: authorMetrics.repoHistory,
|
|
})
|
|
|
|
const score = computeContributorScore(scoreInput)
|
|
const plan = planTrustActions({ currentLabels, pullRequest, score })
|
|
const comment = buildTrustComment({
|
|
author: {
|
|
login: authorMetrics.author.login,
|
|
name: authorMetrics.author.name,
|
|
type: pullRequest.user.type,
|
|
url: authorMetrics.author.url,
|
|
},
|
|
metrics: {
|
|
accountCreated: authorMetrics.author.createdAt,
|
|
closedPrs: authorMetrics.repoHistory.closedPrs,
|
|
commitsInRepo: authorMetrics.repoHistory.commitsInRepo,
|
|
followers: authorMetrics.author.followers,
|
|
mergedPrs: authorMetrics.repoHistory.mergedPrs,
|
|
openPrs: authorMetrics.repoHistory.openPrs,
|
|
repoPermission: permission ?? "none",
|
|
reviews: authorMetrics.repoHistory.reviews,
|
|
topRepositories: authorMetrics.repoHistory.topRepositories,
|
|
},
|
|
owner,
|
|
plan,
|
|
pullRequest,
|
|
repo,
|
|
score,
|
|
})
|
|
|
|
await syncLabels({
|
|
currentLabels,
|
|
issueNumber: pullRequest.number,
|
|
labelsToAdd: plan.labelsToAdd,
|
|
labelsToRemove: plan.labelsToRemove,
|
|
owner,
|
|
repo,
|
|
token,
|
|
})
|
|
const commentResult = await upsertComment({
|
|
body: comment.body,
|
|
issueNumber: pullRequest.number,
|
|
owner,
|
|
repo,
|
|
token,
|
|
})
|
|
|
|
if (plan.shouldClosePr) {
|
|
await closePullRequestIssue(token, owner, repo, pullRequest.number)
|
|
console.log(`Closed PR #${pullRequest.number}: ${plan.closeReason}`)
|
|
}
|
|
|
|
logScoreBreakdown(score)
|
|
|
|
summaryLines.push(`- Trust score: **${score.total}/100**`)
|
|
summaryLines.push(`- PR changed lines: ${plan.changedLines}`)
|
|
summaryLines.push(`- Bucket: \`${score.bucket}\``)
|
|
summaryLines.push(`- Target label: \`${plan.targetTrustLabel}\``)
|
|
summaryLines.push(`- Maintainer review: ${plan.needsMaintainerReview ? "required" : "not required"}`)
|
|
if (plan.closeReason)
|
|
summaryLines.push(`- Auto-close: ${plan.closeReason}`)
|
|
summaryLines.push(`- Comment: ${commentResult.action}`)
|
|
summaryLines.push(`- Fingerprint: \`${comment.fingerprint}\``)
|
|
summaryLines.push(...buildScoreBreakdownSummary(score))
|
|
|
|
await writeJobSummary(summaryLines)
|
|
}
|
|
|
|
const isDirectExecution = process.argv[1]
|
|
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
|
|
if (isDirectExecution) {
|
|
main().catch(async (error) => {
|
|
console.error(error)
|
|
await writeJobSummary([
|
|
"## Contributor trust automation",
|
|
"",
|
|
`- Result: failed`,
|
|
`- Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
])
|
|
process.exitCode = 1
|
|
})
|
|
}
|