mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-07-06 18:01:26 +02:00
Enhance repo scanning and tag handling by integrating meshforge.yaml parsing. Added support for environment capabilities and meshforge configuration in scan and tag operations. Updated related functions to aggregate and filter environment names and tags based on the meshforge profile.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import type { MeshforgeConfig } from '@/convex/lib/meshforgeYaml'
|
||||
|
||||
export type { MeshforgeConfig }
|
||||
|
||||
/** Escape a string for literal use inside a RegExp pattern. */
|
||||
export function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw string to snake_case.
|
||||
* CamelCase boundaries and non-alphanumeric runs become underscores.
|
||||
* e.g. "clientRole" → "client_role", "client-role" → "client_role"
|
||||
*/
|
||||
export function toSnakeCase(s: string): string {
|
||||
return s
|
||||
.replace(/([A-Z])/g, '_$1')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
}
|
||||
|
||||
/** Convert to lowerCamelCase. e.g. "client_role" → "clientRole" */
|
||||
export function toCamelCase(s: string): string {
|
||||
const parts = toSnakeCase(s).split('_').filter(Boolean)
|
||||
if (!parts.length) return ''
|
||||
return parts[0] + parts.slice(1).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('')
|
||||
}
|
||||
|
||||
/** Convert to PascalCase. e.g. "client_role" → "ClientRole" */
|
||||
export function toPascalCase(s: string): string {
|
||||
return toSnakeCase(s)
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the effective target RegExp given the currently selected tag and profile config.
|
||||
*
|
||||
* Precedence:
|
||||
* 1. targets.include_template — expanded using captures from the tag matched against
|
||||
* tags.include (when the tag matches and the template is set)
|
||||
* 2. targets.include — used as a static regex
|
||||
* 3. null — no target filter
|
||||
*
|
||||
* Placeholder syntax in include_template:
|
||||
* ${captureName} raw value of the named capture group
|
||||
* ${captureName_snake} snake_case transform
|
||||
* ${captureName_camel} lowerCamelCase transform
|
||||
* ${captureName_pascal} PascalCase transform
|
||||
* ${1}, ${2}, … numbered capture groups
|
||||
* Each substituted segment is regex-escaped before insertion.
|
||||
*/
|
||||
export function buildTargetRegex(tag: string, config: MeshforgeConfig): RegExp | null {
|
||||
const tagsInclude = config.tags?.include
|
||||
const template = config.targets?.include_template
|
||||
|
||||
if (template && tagsInclude) {
|
||||
let tagMatch: RegExpExecArray | null = null
|
||||
try {
|
||||
tagMatch = new RegExp(tagsInclude).exec(tag)
|
||||
} catch {
|
||||
// malformed regex — fall through
|
||||
}
|
||||
if (tagMatch) {
|
||||
const pattern = template.replace(/\$\{([^}]+)\}/g, (_, placeholder: string) => {
|
||||
// Numbered capture: ${1}, ${2}, …
|
||||
const num = Number(placeholder)
|
||||
if (Number.isInteger(num) && num >= 1) {
|
||||
return escapeRegex(tagMatch![num] ?? '')
|
||||
}
|
||||
|
||||
// Detect trailing case suffix
|
||||
let baseName = placeholder
|
||||
let suffix: 'snake' | 'camel' | 'pascal' | '' = ''
|
||||
if (placeholder.endsWith('_snake')) {
|
||||
baseName = placeholder.slice(0, -6)
|
||||
suffix = 'snake'
|
||||
} else if (placeholder.endsWith('_camel')) {
|
||||
baseName = placeholder.slice(0, -6)
|
||||
suffix = 'camel'
|
||||
} else if (placeholder.endsWith('_pascal')) {
|
||||
baseName = placeholder.slice(0, -7)
|
||||
suffix = 'pascal'
|
||||
}
|
||||
|
||||
const rawCapture = tagMatch!.groups?.[baseName] ?? ''
|
||||
let value: string
|
||||
if (suffix === 'snake') value = toSnakeCase(rawCapture)
|
||||
else if (suffix === 'camel') value = toCamelCase(rawCapture)
|
||||
else if (suffix === 'pascal') value = toPascalCase(rawCapture)
|
||||
else value = rawCapture
|
||||
return escapeRegex(value)
|
||||
})
|
||||
try {
|
||||
return new RegExp(pattern)
|
||||
} catch {
|
||||
// malformed expanded pattern — fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.targets?.include) {
|
||||
try {
|
||||
return new RegExp(config.targets.include)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Filter tag list using config.tags.include. Returns full list when no filter is set. */
|
||||
export function filterTagNames(tags: string[], config: MeshforgeConfig): string[] {
|
||||
const inc = config.tags?.include
|
||||
if (!inc) return tags
|
||||
let rx: RegExp
|
||||
try {
|
||||
rx = new RegExp(inc)
|
||||
} catch {
|
||||
return tags
|
||||
}
|
||||
return tags.filter(t => rx.test(t))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter env names using the effective target regex and capability requirements.
|
||||
* Returns full list when no filter is configured.
|
||||
*
|
||||
* @param envNames All env names from the scan
|
||||
* @param config Parsed meshforge.yaml (or null → no filtering)
|
||||
* @param capabilities envCapabilities map from the scan
|
||||
* @param currentTag Currently selected tag (used for include_template interpolation)
|
||||
*/
|
||||
export function filterEnvNames(
|
||||
envNames: string[],
|
||||
config: MeshforgeConfig | null,
|
||||
capabilities: Record<string, string[]>,
|
||||
currentTag: string
|
||||
): string[] {
|
||||
if (!config) return envNames
|
||||
const rx = buildTargetRegex(currentTag, config)
|
||||
const reqCaps = config.targets?.require_capabilities ?? []
|
||||
|
||||
const isFiltering = rx !== null || reqCaps.length > 0
|
||||
if (isFiltering) {
|
||||
console.debug(
|
||||
'[meshforge] filterEnvNames — tag=%o regex=%o require_capabilities=%o',
|
||||
currentTag,
|
||||
rx?.source ?? '(none)',
|
||||
reqCaps
|
||||
)
|
||||
}
|
||||
if (
|
||||
reqCaps.length > 0 &&
|
||||
envNames.length > 0 &&
|
||||
envNames.every(n => (capabilities[n]?.length ?? 0) === 0)
|
||||
) {
|
||||
console.warn(
|
||||
'[meshforge] envCapabilities is empty for every env — this repoRefScan was likely completed before capability scanning shipped. Open the repo again (ensureScan will rescan) or wait for in_progress to finish.'
|
||||
)
|
||||
}
|
||||
|
||||
return envNames.filter(name => {
|
||||
if (rx && !rx.test(name)) {
|
||||
console.debug('[meshforge] ✗ %o — rejected by regex (%o)', name, rx.source)
|
||||
return false
|
||||
}
|
||||
if (reqCaps.length > 0) {
|
||||
const envCaps = capabilities[name] ?? []
|
||||
const missing = reqCaps.filter(c => !envCaps.includes(c))
|
||||
if (missing.length > 0) {
|
||||
console.debug('[meshforge] ✗ %o — missing capabilities %o (has %o)', name, missing, envCaps)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (isFiltering) {
|
||||
console.debug('[meshforge] ✓ %o', name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -8,21 +8,6 @@ function encodeTreePath(ref: string) {
|
||||
return ref.split("/").map(encodeURIComponent).join("/")
|
||||
}
|
||||
|
||||
const DEMO_REPOS: { label: string; owner: string; repo: string; githubUrl: string }[] = [
|
||||
{
|
||||
label: "meshtastic/firmware",
|
||||
owner: "meshtastic",
|
||||
repo: "firmware",
|
||||
githubUrl: "https://github.com/meshtastic/firmware",
|
||||
},
|
||||
{
|
||||
label: "meshcore-dev/MeshCore",
|
||||
owner: "meshcore-dev",
|
||||
repo: "MeshCore",
|
||||
githubUrl: "https://github.com/meshcore-dev/MeshCore",
|
||||
},
|
||||
]
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const [input, setInput] = useState("")
|
||||
@@ -77,34 +62,6 @@ export default function HomePage() {
|
||||
<Button className="w-full bg-cyan-600 hover:bg-cyan-700" type="button" onClick={go}>
|
||||
Open a firmware repo
|
||||
</Button>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="text-xs text-slate-500 text-center">Try a demo</p>
|
||||
<ul className="space-y-2">
|
||||
{DEMO_REPOS.map(d => (
|
||||
<li
|
||||
key={d.githubUrl}
|
||||
className="flex flex-col sm:flex-row gap-1 sm:gap-3 sm:items-center sm:justify-between rounded-lg border border-slate-800/80 bg-slate-900/40 px-3 py-2"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-slate-300 hover:text-cyan-400 text-sm justify-start h-auto py-1 px-0 font-normal"
|
||||
onClick={() => navigate(`/${encodeURIComponent(d.owner)}/${encodeURIComponent(d.repo)}`)}
|
||||
>
|
||||
Open {d.label}
|
||||
</Button>
|
||||
<a
|
||||
className="text-xs text-slate-600 hover:text-slate-400 sm:text-right shrink-0"
|
||||
href={d.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{d.githubUrl.replace(/^https:\/\//, "")}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+38
-13
@@ -23,6 +23,7 @@ import DeviceFlasher from "../components/DeviceFlasher"
|
||||
import { normalizeBuildKey } from "../lib/buildKey"
|
||||
import { buildFailurePresentation } from "../lib/formatBuildErrorSummary"
|
||||
import { homepageHref, homepageLabel } from "../lib/githubHomepage"
|
||||
import { filterEnvNames, filterTagNames, type MeshforgeConfig } from "../lib/meshforgeApplyProfile"
|
||||
import { resolveReadmeRelativeUrl } from "../lib/readmeAssetUrl"
|
||||
import { buildTreeSplatPath, parseTreeSplat } from "../lib/repoTreeUrl"
|
||||
|
||||
@@ -74,8 +75,11 @@ export default function RepoPage() {
|
||||
if (tagData === undefined || !tagData.row) return
|
||||
const tags = tagData.row.tags
|
||||
if (tags.length === 0) return
|
||||
const sorted = sortTagNames(tags.map(t => t.name))
|
||||
const latest = sorted[0]
|
||||
const allSorted = sortTagNames(tags.map(t => t.name))
|
||||
const cfg = tagData.row.meshforgeConfig as MeshforgeConfig | null | undefined
|
||||
const candidates = cfg ? filterTagNames(allSorted, cfg) : allSorted
|
||||
// Fall back to unfiltered list when the profile leaves nothing (e.g. no matching tags yet)
|
||||
const latest = (candidates.length > 0 ? candidates : allSorted)[0]
|
||||
if (!latest) return
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(latest, null)}`, { replace: true })
|
||||
}, [owner, repo, sourceRef, tagData, navigate, ownerParam, repoParam])
|
||||
@@ -170,8 +174,6 @@ export default function RepoPage() {
|
||||
)
|
||||
|
||||
const envNames = scan?.scanStatus === "complete" ? (scan.envNames ?? []) : []
|
||||
const resolvedTargetEnv =
|
||||
hasRef && targetFromUrl && envNames.length > 0 && envNames.includes(targetFromUrl) ? targetFromUrl : ""
|
||||
|
||||
const [tagDraft, setTagDraft] = useState("")
|
||||
useEffect(() => {
|
||||
@@ -199,6 +201,27 @@ export default function RepoPage() {
|
||||
return sortTagNames(merged)
|
||||
}, [tagData?.row?.tags, sourceRef])
|
||||
|
||||
// meshforgeConfig comes from the default branch (stored on the tag list, available before
|
||||
// a tag is selected so the tag dropdown itself can be filtered).
|
||||
const meshforgeConfig = (tagData?.row?.meshforgeConfig ?? null) as MeshforgeConfig | null
|
||||
const envCapabilities = (scan?.scanStatus === "complete" ? scan.envCapabilities : null) as Record<
|
||||
string,
|
||||
string[]
|
||||
> | null
|
||||
const filteredTagOptions = useMemo(
|
||||
() => filterTagNames(tagOptions, meshforgeConfig ?? {}),
|
||||
[tagOptions, meshforgeConfig]
|
||||
)
|
||||
const filteredEnvNames = useMemo(
|
||||
() => filterEnvNames(envNames, meshforgeConfig, envCapabilities ?? {}, tagDraft),
|
||||
[envNames, meshforgeConfig, envCapabilities, tagDraft]
|
||||
)
|
||||
|
||||
const resolvedTargetEnv =
|
||||
hasRef && targetFromUrl && filteredEnvNames.length > 0 && filteredEnvNames.includes(targetFromUrl)
|
||||
? targetFromUrl
|
||||
: ""
|
||||
|
||||
const [envDraft, setEnvDraft] = useState("")
|
||||
useEffect(() => {
|
||||
if (!hasRef) {
|
||||
@@ -389,7 +412,7 @@ export default function RepoPage() {
|
||||
!resolvedSha ||
|
||||
Boolean(refError) ||
|
||||
!resolvedTargetEnv ||
|
||||
!envNames.includes(resolvedTargetEnv) ||
|
||||
!filteredEnvNames.includes(resolvedTargetEnv) ||
|
||||
!scanReady
|
||||
|
||||
const showCiCard = build && (build.status !== "failed" || witnessedCiFailure)
|
||||
@@ -402,8 +425,10 @@ export default function RepoPage() {
|
||||
? "Scanning…"
|
||||
: scan.scanStatus === "failed"
|
||||
? "Scan failed"
|
||||
: envNames.length === 0
|
||||
? "No targets"
|
||||
: filteredEnvNames.length === 0
|
||||
? envNames.length === 0
|
||||
? "No targets"
|
||||
: "No targets match profile"
|
||||
: "--target--"
|
||||
|
||||
const backToRepoPath = `/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, resolvedTargetEnv || null)}`
|
||||
@@ -619,7 +644,7 @@ export default function RepoPage() {
|
||||
label="Tag"
|
||||
layout="inline"
|
||||
id="mesh-forge-tag"
|
||||
options={tagOptions}
|
||||
options={filteredTagOptions}
|
||||
value={tagDraft}
|
||||
placeholder="--tag--"
|
||||
clearSelectionLabel="Clear tag"
|
||||
@@ -629,18 +654,18 @@ export default function RepoPage() {
|
||||
navigate(`/${ownerParam}/${repoParam}`)
|
||||
return
|
||||
}
|
||||
if (tagOptions.includes(v)) {
|
||||
if (filteredTagOptions.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, targetFromUrl)}`)
|
||||
}
|
||||
}}
|
||||
disabled={tagOptions.length === 0}
|
||||
disabled={filteredTagOptions.length === 0}
|
||||
/>
|
||||
{hasRef && scanReady && envNames.length > 0 ? (
|
||||
{hasRef && scanReady && filteredEnvNames.length > 0 ? (
|
||||
<ComboboxField
|
||||
label="Target"
|
||||
layout="inline"
|
||||
id="mesh-forge-target"
|
||||
options={envNames}
|
||||
options={filteredEnvNames}
|
||||
value={envDraft}
|
||||
filterNormalize
|
||||
placeholder="--target--"
|
||||
@@ -654,7 +679,7 @@ export default function RepoPage() {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (envNames.includes(v)) {
|
||||
if (filteredEnvNames.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, v)}`)
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user