mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-06-15 19:34:47 +02:00
291 lines
8.7 KiB
TypeScript
291 lines
8.7 KiB
TypeScript
import { githubRepoFromRemote, parseGitmodulesPathToUrl } from './githubSubmodule'
|
|
import { unzipSync } from 'fflate'
|
|
import type { VirtualFileMap } from './platformioScan'
|
|
|
|
const GITHUB_API = 'https://api.github.com'
|
|
|
|
type TreeEntry = {
|
|
path?: string
|
|
mode?: string
|
|
type?: string
|
|
sha?: string
|
|
size?: number
|
|
}
|
|
|
|
type TreeResponse = {
|
|
tree?: TreeEntry[]
|
|
truncated?: boolean
|
|
}
|
|
|
|
type SubmodulePointer = {
|
|
path: string
|
|
owner: string
|
|
repo: string
|
|
commitSha: string
|
|
}
|
|
|
|
function jsonHeaders(headers: Record<string, string>): Record<string, string> {
|
|
return {
|
|
Accept: 'application/vnd.github+json',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
...headers,
|
|
}
|
|
}
|
|
|
|
async function fetchTreeRecursive(args: {
|
|
owner: string
|
|
repo: string
|
|
commitSha: string
|
|
headers: Record<string, string>
|
|
}): Promise<{ entries: TreeEntry[]; truncated: boolean }> {
|
|
const url = `${GITHUB_API}/repos/${args.owner}/${args.repo}/git/trees/${args.commitSha}?recursive=1`
|
|
const res = await fetch(url, { headers: jsonHeaders(args.headers) })
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
`github tree ${args.owner}/${args.repo}@${args.commitSha}: ${res.status} ${await res.text()}`
|
|
)
|
|
}
|
|
const j = (await res.json()) as TreeResponse
|
|
return { entries: j.tree ?? [], truncated: Boolean(j.truncated) }
|
|
}
|
|
|
|
async function fetchRepoZipArchive(args: {
|
|
owner: string
|
|
repo: string
|
|
commitSha: string
|
|
headers: Record<string, string>
|
|
}): Promise<ArrayBuffer> {
|
|
const url = `${GITHUB_API}/repos/${args.owner}/${args.repo}/zipball/${args.commitSha}`
|
|
const res = await fetch(url, { headers: jsonHeaders(args.headers) })
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
`github zip ${args.owner}/${args.repo}@${args.commitSha}: ${res.status} ${await res.text()}`
|
|
)
|
|
}
|
|
return await res.arrayBuffer()
|
|
}
|
|
|
|
async function fetchBlobText(args: {
|
|
owner: string
|
|
repo: string
|
|
sha: string
|
|
headers: Record<string, string>
|
|
}): Promise<string> {
|
|
const url = `${GITHUB_API}/repos/${args.owner}/${args.repo}/git/blobs/${args.sha}`
|
|
const res = await fetch(url, {
|
|
headers: {
|
|
Accept: 'application/vnd.github.raw',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
...args.headers,
|
|
},
|
|
})
|
|
if (!res.ok) {
|
|
throw new Error(`github blob ${args.owner}/${args.repo}@${args.sha}: ${res.status} ${await res.text()}`)
|
|
}
|
|
return await res.text()
|
|
}
|
|
|
|
function isScanFile(path: string): boolean {
|
|
if (path.endsWith('.ini')) return true
|
|
if (path === 'meshforge.yaml' || path.endsWith('/meshforge.yaml')) return true
|
|
return false
|
|
}
|
|
|
|
function stripZipRoot(path: string): string {
|
|
const normalized = path.replace(/\\/g, '/')
|
|
const i = normalized.indexOf('/')
|
|
if (i < 0) return ''
|
|
return normalized.slice(i + 1)
|
|
}
|
|
|
|
function decodeArchiveFiles(zip: ArrayBuffer): VirtualFileMap {
|
|
const map: VirtualFileMap = {}
|
|
const entries = unzipSync(new Uint8Array(zip))
|
|
const decoder = new TextDecoder('utf-8')
|
|
for (const [zipPath, bytes] of Object.entries(entries)) {
|
|
const path = stripZipRoot(zipPath)
|
|
if (!path || path.endsWith('/')) continue
|
|
if (path !== '.gitmodules' && !isScanFile(path)) continue
|
|
map[path] = decoder.decode(bytes)
|
|
}
|
|
return map
|
|
}
|
|
|
|
function shouldFetchSubmodule(path: string, platformRoot: string): boolean {
|
|
const p = path.trim().replace(/\/+$/, '')
|
|
const root = platformRoot.trim().replace(/\/+$/, '')
|
|
if (!root) return true
|
|
if (p === root) return true
|
|
if (p.startsWith(`${root}/`)) return true
|
|
if (root.startsWith(`${p}/`)) return true
|
|
return false
|
|
}
|
|
|
|
function mapLimit<T, R>(items: readonly T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
|
|
if (items.length === 0) return Promise.resolve([])
|
|
const max = Math.max(1, Math.min(limit, items.length))
|
|
const out = new Array<R>(items.length)
|
|
let next = 0
|
|
async function runWorker(): Promise<void> {
|
|
while (true) {
|
|
const i = next
|
|
next += 1
|
|
if (i >= items.length) return
|
|
out[i] = await worker(items[i])
|
|
}
|
|
}
|
|
return Promise.all(Array.from({ length: max }, () => runWorker())).then(() => out)
|
|
}
|
|
|
|
async function resolveSubmodulePointers(args: {
|
|
owner: string
|
|
repo: string
|
|
commitSha: string
|
|
headers: Record<string, string>
|
|
gitmodulesText: string
|
|
platformRoot: string
|
|
}): Promise<SubmodulePointer[]> {
|
|
const pathToUrl = parseGitmodulesPathToUrl(args.gitmodulesText)
|
|
if (Object.keys(pathToUrl).length === 0) return []
|
|
const { entries, truncated } = await fetchTreeRecursive(args)
|
|
if (truncated) {
|
|
console.warn(`github tree truncated for ${args.owner}/${args.repo}@${args.commitSha}; submodule scan may miss entries`)
|
|
}
|
|
const gitlinkByPath: Record<string, string> = {}
|
|
for (const entry of entries) {
|
|
if (!entry.path || !entry.sha) continue
|
|
if (entry.type === 'commit' && entry.mode === '160000') {
|
|
gitlinkByPath[entry.path] = entry.sha
|
|
}
|
|
}
|
|
const out: SubmodulePointer[] = []
|
|
for (const [path, url] of Object.entries(pathToUrl)) {
|
|
if (!shouldFetchSubmodule(path, args.platformRoot)) continue
|
|
const commitSha = gitlinkByPath[path]
|
|
if (!commitSha) continue
|
|
const gh = githubRepoFromRemote(url)
|
|
if (!gh) continue
|
|
out.push({ path, owner: gh.owner, repo: gh.repo, commitSha })
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Archive-first scanner:
|
|
* 1) Download root zipball and parse scan files from it.
|
|
* 2) Read `.gitmodules` + root tree gitlinks for pinned submodule SHAs.
|
|
* 3) Download relevant submodule zipballs and merge scan files.
|
|
*/
|
|
export async function collectScanFilesFromGithubArchives(args: {
|
|
owner: string
|
|
repo: string
|
|
commitSha: string
|
|
headers: Record<string, string>
|
|
platformRoot: string
|
|
}): Promise<VirtualFileMap> {
|
|
const rootZip = await fetchRepoZipArchive(args)
|
|
const rootFiles = decodeArchiveFiles(rootZip)
|
|
const out: VirtualFileMap = {}
|
|
for (const [path, content] of Object.entries(rootFiles)) {
|
|
if (isScanFile(path)) out[path] = content
|
|
}
|
|
const gitmodulesText = rootFiles['.gitmodules']
|
|
if (!gitmodulesText) return out
|
|
|
|
const pointers = await resolveSubmodulePointers({
|
|
owner: args.owner,
|
|
repo: args.repo,
|
|
commitSha: args.commitSha,
|
|
headers: args.headers,
|
|
gitmodulesText,
|
|
platformRoot: args.platformRoot,
|
|
})
|
|
const archives = await mapLimit(pointers, 4, async p => {
|
|
const zip = await fetchRepoZipArchive({
|
|
owner: p.owner,
|
|
repo: p.repo,
|
|
commitSha: p.commitSha,
|
|
headers: args.headers,
|
|
})
|
|
return { basePath: p.path, files: decodeArchiveFiles(zip) }
|
|
})
|
|
for (const { basePath, files } of archives) {
|
|
for (const [path, content] of Object.entries(files)) {
|
|
if (!isScanFile(path)) continue
|
|
out[`${basePath}/${path}`] = content
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Walk the GitHub tree for `owner/repo@commitSha` and return all *.ini / meshforge.yaml files
|
|
* (paths relative to this repo root). Recurses into submodules listed in `.gitmodules` —
|
|
* resolving each pinned commit via the parent tree's `type: "commit"` entries, then fetching the
|
|
* submodule's own recursive tree. Pure HTTP — runs in Convex's default V8 runtime (no `"use node"`).
|
|
*/
|
|
export async function collectScanFilesFromGithub(args: {
|
|
owner: string
|
|
repo: string
|
|
commitSha: string
|
|
headers: Record<string, string>
|
|
}): Promise<VirtualFileMap> {
|
|
const { entries, truncated } = await fetchTreeRecursive(args)
|
|
if (truncated) {
|
|
console.warn(
|
|
`github tree truncated for ${args.owner}/${args.repo}@${args.commitSha}; PlatformIO scan may miss files`
|
|
)
|
|
}
|
|
|
|
const files: VirtualFileMap = {}
|
|
const submoduleEntries: TreeEntry[] = []
|
|
let gitmodulesText: string | null = null
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.path) continue
|
|
if (entry.type === 'commit' && entry.mode === '160000' && entry.sha) {
|
|
submoduleEntries.push(entry)
|
|
continue
|
|
}
|
|
if (entry.type !== 'blob' || !entry.sha) continue
|
|
if (entry.path === '.gitmodules') {
|
|
gitmodulesText = await fetchBlobText({
|
|
owner: args.owner,
|
|
repo: args.repo,
|
|
sha: entry.sha,
|
|
headers: args.headers,
|
|
})
|
|
continue
|
|
}
|
|
if (isScanFile(entry.path)) {
|
|
files[entry.path] = await fetchBlobText({
|
|
owner: args.owner,
|
|
repo: args.repo,
|
|
sha: entry.sha,
|
|
headers: args.headers,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (submoduleEntries.length === 0) return files
|
|
|
|
const pathToUrl = gitmodulesText ? parseGitmodulesPathToUrl(gitmodulesText) : {}
|
|
for (const sub of submoduleEntries) {
|
|
const url = pathToUrl[sub.path!]
|
|
if (!url) continue
|
|
const gh = githubRepoFromRemote(url)
|
|
if (!gh) continue
|
|
const subFiles = await collectScanFilesFromGithub({
|
|
owner: gh.owner,
|
|
repo: gh.repo,
|
|
commitSha: sub.sha!,
|
|
headers: args.headers,
|
|
})
|
|
for (const [k, v] of Object.entries(subFiles)) {
|
|
files[`${sub.path}/${k}`] = v
|
|
}
|
|
}
|
|
|
|
return files
|
|
}
|