mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
ci: post test vs non-test LoC on pull requests (#14738)
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { mergeLocBlock, renderLocBlock, sumChangedFiles } from './pr-test-loc-table.mjs'
|
||||
|
||||
export const PR_FILES_PAGE_LIMIT = 3000
|
||||
|
||||
export function nextLink(linkHeader) {
|
||||
if (linkHeader == null || linkHeader.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const part of linkHeader.split(',')) {
|
||||
const match = part.match(/<([^>]+)>\s*;\s*rel="next"/)
|
||||
if (match != null) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function githubHeaders(token) {
|
||||
return {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'User-Agent': 'orca-pr-test-loc',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPullFiles({ owner, repo, pullNumber, token, fetchImpl = fetch }) {
|
||||
const files = []
|
||||
let url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files?per_page=100`
|
||||
|
||||
while (url != null) {
|
||||
const response = await fetchImpl(url, { headers: githubHeaders(token) })
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to list PR #${pullNumber} files: ${response.status} ${response.statusText}`
|
||||
)
|
||||
}
|
||||
const page = await response.json()
|
||||
if (!Array.isArray(page)) {
|
||||
throw new Error(`Unexpected PR files payload for #${pullNumber}`)
|
||||
}
|
||||
files.push(...page)
|
||||
if (files.length >= PR_FILES_PAGE_LIMIT) {
|
||||
console.log(
|
||||
`PR #${pullNumber} file list hit GitHub's ${PR_FILES_PAGE_LIMIT}-file cap; totals may be short.`
|
||||
)
|
||||
return files.slice(0, PR_FILES_PAGE_LIMIT)
|
||||
}
|
||||
url = nextLink(response.headers.get('link'))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function writeGithubOutput(totals) {
|
||||
const block = `${renderLocBlock(totals)}\n`
|
||||
const outputPath = process.env.GITHUB_OUTPUT
|
||||
if (outputPath != null) {
|
||||
appendFileSync(outputPath, `summary<<ORCA_PR_LOC_EOF\n${block}ORCA_PR_LOC_EOF\n`)
|
||||
}
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath != null) {
|
||||
appendFileSync(summaryPath, `${block}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePullRequest({ owner, repo, pullNumber, token, totals, fetchImpl = fetch }) {
|
||||
const headers = githubHeaders(token)
|
||||
const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`
|
||||
const response = await fetchImpl(url, { headers })
|
||||
if (response.status === 403) {
|
||||
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
|
||||
return 0
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read PR #${pullNumber}: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const pull = await response.json()
|
||||
const nextBody = mergeLocBlock(pull.body, totals)
|
||||
if (nextBody === (pull.body ?? '')) {
|
||||
console.log(`PR #${pullNumber} LoC header already current.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
const update = await fetchImpl(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ body: nextBody })
|
||||
})
|
||||
if (update.status === 403) {
|
||||
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
|
||||
return 0
|
||||
}
|
||||
if (!update.ok) {
|
||||
throw new Error(`Failed to update PR #${pullNumber}: ${update.status} ${update.statusText}`)
|
||||
}
|
||||
|
||||
console.log(`Updated LoC header on PR #${pullNumber}.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
function resolveRepository() {
|
||||
const repository = process.env.GITHUB_REPOSITORY
|
||||
if (repository == null || !repository.includes('/')) {
|
||||
return undefined
|
||||
}
|
||||
const slash = repository.indexOf('/')
|
||||
return { owner: repository.slice(0, slash), repo: repository.slice(slash + 1) }
|
||||
}
|
||||
|
||||
function resolveToken() {
|
||||
return process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.error(
|
||||
`Usage: ${process.argv[1]} --from-pr <number> | --update-pr <number> [--files-json <file>] [--merge-body <file>]`
|
||||
)
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
let filesJsonPath
|
||||
let mergeBodyPath
|
||||
let fromPrNumber
|
||||
let updatePrNumber
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]
|
||||
if (arg === '--files-json') {
|
||||
filesJsonPath = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (arg === '--merge-body') {
|
||||
mergeBodyPath = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (arg === '--from-pr') {
|
||||
fromPrNumber = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (arg === '--update-pr') {
|
||||
updatePrNumber = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
printUsage()
|
||||
return 2
|
||||
}
|
||||
|
||||
const pullNumber = updatePrNumber ?? fromPrNumber
|
||||
if (
|
||||
(argv.includes('--files-json') && filesJsonPath == null) ||
|
||||
(argv.includes('--merge-body') && mergeBodyPath == null) ||
|
||||
(argv.includes('--from-pr') && fromPrNumber == null) ||
|
||||
(argv.includes('--update-pr') && updatePrNumber == null) ||
|
||||
(filesJsonPath == null && pullNumber == null)
|
||||
) {
|
||||
printUsage()
|
||||
return 2
|
||||
}
|
||||
|
||||
let files
|
||||
if (filesJsonPath != null) {
|
||||
files = JSON.parse(readFileSync(filesJsonPath, 'utf8'))
|
||||
} else {
|
||||
const repository = resolveRepository()
|
||||
const token = resolveToken()
|
||||
if (repository == null || token == null) {
|
||||
console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required to read a pull request.')
|
||||
return 2
|
||||
}
|
||||
files = await listPullFiles({
|
||||
...repository,
|
||||
pullNumber: Number(pullNumber),
|
||||
token
|
||||
})
|
||||
}
|
||||
|
||||
const totals = sumChangedFiles(files)
|
||||
writeGithubOutput(totals)
|
||||
|
||||
if (mergeBodyPath != null) {
|
||||
process.stdout.write(mergeLocBlock(readFileSync(mergeBodyPath, 'utf8'), totals))
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(renderLocBlock(totals))
|
||||
|
||||
if (updatePrNumber == null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const repository = resolveRepository()
|
||||
const token = resolveToken()
|
||||
if (repository == null || token == null) {
|
||||
console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required with --update-pr.')
|
||||
return 2
|
||||
}
|
||||
|
||||
return updatePullRequest({
|
||||
...repository,
|
||||
pullNumber: Number(updatePrNumber),
|
||||
token,
|
||||
totals
|
||||
})
|
||||
}
|
||||
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main(process.argv.slice(2))
|
||||
.then((code) => {
|
||||
process.exitCode = code
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export const LOC_BLOCK_START = '<!-- orca-pr-loc -->'
|
||||
export const LOC_BLOCK_END = '<!-- /orca-pr-loc -->'
|
||||
export const LOC_HANDS_OFF_COMMENT =
|
||||
'<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->'
|
||||
|
||||
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|e2e|tests)(?:\/|$)/i
|
||||
const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i
|
||||
|
||||
export function isTestPath(path) {
|
||||
const normalized = path.replaceAll('\\', '/')
|
||||
return TEST_DIR_SEGMENT.test(normalized) || TEST_FILENAME.test(normalized)
|
||||
}
|
||||
|
||||
export function emptyLocTotals() {
|
||||
return {
|
||||
test: { files: 0, added: 0, deleted: 0 },
|
||||
nonTest: { files: 0, added: 0, deleted: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
export function sumChangedFiles(files) {
|
||||
const totals = emptyLocTotals()
|
||||
for (const file of files) {
|
||||
const path = file.filename
|
||||
if (path == null) {
|
||||
continue
|
||||
}
|
||||
const bucket = isTestPath(path) ? totals.test : totals.nonTest
|
||||
bucket.files += 1
|
||||
bucket.added += Number(file.additions ?? 0)
|
||||
bucket.deleted += Number(file.deletions ?? 0)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
function signed(count) {
|
||||
if (count === 0) {
|
||||
return '0'
|
||||
}
|
||||
return count > 0 ? `+${count}` : `−${Math.abs(count)}`
|
||||
}
|
||||
|
||||
function locTableRow(label, bucket) {
|
||||
return `| ${label} | ${bucket.files ?? 0} | ${signed(bucket.added)} | ${signed(-(bucket.deleted ?? 0))} | ${signed((bucket.added ?? 0) - (bucket.deleted ?? 0))} |`
|
||||
}
|
||||
|
||||
export function formatLocTable({ test, nonTest }) {
|
||||
return [
|
||||
'| | Files | Added | Deleted | Net |',
|
||||
'| :--- | ---: | ---: | ---: | ---: |',
|
||||
locTableRow('Test', test),
|
||||
locTableRow('Prod', nonTest)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function renderLocBlock(totals) {
|
||||
return [
|
||||
LOC_BLOCK_START,
|
||||
LOC_HANDS_OFF_COMMENT,
|
||||
'',
|
||||
formatLocTable(totals),
|
||||
'',
|
||||
LOC_BLOCK_END
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function mergeLocBlock(body, totals) {
|
||||
const block = renderLocBlock(totals)
|
||||
const current = body ?? ''
|
||||
const start = current.indexOf(LOC_BLOCK_START)
|
||||
const end = current.indexOf(LOC_BLOCK_END)
|
||||
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
const rest = current.slice(end + LOC_BLOCK_END.length).replace(/^\r?\n/, '')
|
||||
if (rest.trim().length === 0) {
|
||||
return `${current.slice(0, start)}${block}\n`
|
||||
}
|
||||
return `${current.slice(0, start)}${block}\n\n${rest.replace(/^\r?\n+/, '')}`
|
||||
}
|
||||
|
||||
if (current.trim().length === 0) {
|
||||
return `${block}\n`
|
||||
}
|
||||
|
||||
return `${block}\n\n${current.replace(/^\r?\n+/, '')}`
|
||||
}
|
||||
Reference in New Issue
Block a user