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:
Ben Allfree
2026-04-11 21:27:12 -07:00
parent ad81010e29
commit 0d1f983324
10 changed files with 491 additions and 76 deletions
+2
View File
@@ -14,6 +14,7 @@ import type * as auth from "../auth.js";
import type * as deviceReports from "../deviceReports.js";
import type * as helpers from "../helpers.js";
import type * as http from "../http.js";
import type * as lib_meshforgeYaml from "../lib/meshforgeYaml.js";
import type * as lib_platformioScan from "../lib/platformioScan.js";
import type * as lib_r2 from "../lib/r2.js";
import type * as lib_tagSemver from "../lib/tagSemver.js";
@@ -35,6 +36,7 @@ declare const fullApi: ApiFromModules<{
deviceReports: typeof deviceReports;
helpers: typeof helpers;
http: typeof http;
"lib/meshforgeYaml": typeof lib_meshforgeYaml;
"lib/platformioScan": typeof lib_platformioScan;
"lib/r2": typeof lib_r2;
"lib/tagSemver": typeof lib_tagSemver;
+107
View File
@@ -0,0 +1,107 @@
export interface MeshforgeTagsConfig {
/** JS regex tested against each tag name. Tags not matching are hidden. */
include?: string
}
export interface MeshforgeTargetsConfig {
/** JS regex tested against each env name. Non-matching envs are hidden. */
include?: string
/**
* Template string that becomes a regex after substituting captures from the tag matched
* against tags.include. Supported placeholders: ${captureName}, ${captureName_snake},
* ${captureName_camel}, ${captureName_pascal}, ${1}, ${2}, …
* Each substituted segment is regex-escaped before insertion.
* When the current tag matches tags.include and this field is set, the expanded pattern
* replaces targets.include for filtering. Falls back to targets.include when the tag
* does not match or this field is absent.
*/
include_template?: string
/** AND-filter: every listed capability must be present on the env (e.g. ["wifi"]). */
require_capabilities?: string[]
}
export interface MeshforgeConfig {
tags?: MeshforgeTagsConfig
targets?: MeshforgeTargetsConfig
}
/**
* Minimal parser for the meshforge.yaml format. Only the known schema is handled;
* unknown keys are silently ignored.
*
* Supports:
* - 2-space YAML-like indentation (0 / 2 / 4 spaces)
* - Quoted ("…" or '…') and unquoted scalar values
* - Inline flow lists [a, b, c]
* - Line comments starting with #
*/
export function parseMeshforgeYaml(raw: string): MeshforgeConfig | null {
const config: MeshforgeConfig = {}
let inMeshforge = false
let section: 'tags' | 'targets' | null = null
for (const rawLine of raw.split(/\r?\n/)) {
const stripped = rawLine.replace(/#.*$/, '').trimEnd()
if (!stripped.trim()) continue
const indent = stripped.length - stripped.trimStart().length
const content = stripped.trimStart()
if (indent === 0) {
inMeshforge = content === 'meshforge:'
section = null
continue
}
if (!inMeshforge) continue
if (indent === 2) {
if (content === 'tags:') {
section = 'tags'
if (!config.tags) config.tags = {}
} else if (content === 'targets:') {
section = 'targets'
if (!config.targets) config.targets = {}
} else {
section = null
}
continue
}
if (indent === 4 && section) {
const kv = content.match(/^(\w+):\s*(.*)$/)
if (!kv) continue
const [, key, rawVal] = kv
if (section === 'tags') {
if (key === 'include') config.tags!.include = parseScalar(rawVal)
} else if (section === 'targets') {
if (key === 'include') config.targets!.include = parseScalar(rawVal)
else if (key === 'include_template') config.targets!.include_template = parseScalar(rawVal)
else if (key === 'require_capabilities') config.targets!.require_capabilities = parseInlineList(rawVal)
}
}
}
if (!config.tags && !config.targets) return null
return config
}
function parseScalar(raw: string): string {
const s = raw.trim()
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1)
}
return s
}
function parseInlineList(raw: string): string[] {
const s = raw.trim()
if (s.startsWith('[') && s.endsWith(']')) {
return s
.slice(1, -1)
.split(',')
.map(p => parseScalar(p.trim()))
.filter(Boolean)
}
return s ? [parseScalar(s)] : []
}
+86 -17
View File
@@ -7,17 +7,17 @@ export function parseIniSections(content: string): Record<string, Record<string,
let current: string | null = null
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith(";") || trimmed.startsWith("#")) continue
if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) continue
const sec = trimmed.match(/^\[(.+)\]$/)
if (sec) {
current = sec[1]
if (!sections[current]) sections[current] = {}
continue
}
if (current && trimmed.includes("=")) {
const [k, ...rest] = trimmed.split("=")
if (current && trimmed.includes('=')) {
const [k, ...rest] = trimmed.split('=')
const key = k.trim()
const value = rest.join("=").trim()
const value = rest.join('=').trim()
sections[current][key] = value
}
}
@@ -35,14 +35,14 @@ export function extractEnvNamesFromSections(sections: Record<string, Record<stri
export type VirtualFileMap = Record<string, string>
/** Strip first path segment (GitHub zip root folder). */
/** Strip first path segment (GitHub zip root folder). Captures *.ini and meshforge.yaml. */
export function normalizeZipPaths(files: Record<string, Uint8Array>, decode: (u: Uint8Array) => string): VirtualFileMap {
const out: VirtualFileMap = {}
for (const path of Object.keys(files)) {
const parts = path.split("/").filter(Boolean)
const parts = path.split('/').filter(Boolean)
if (parts.length < 2) continue
const rel = parts.slice(1).join("/")
if (!rel.endsWith(".ini")) continue
const rel = parts.slice(1).join('/')
if (!rel.endsWith('.ini') && rel !== 'meshforge.yaml') continue
try {
out[rel] = decode(files[path])
} catch {
@@ -52,14 +52,83 @@ export function normalizeZipPaths(files: Record<string, Uint8Array>, decode: (u:
return out
}
export function collectPlatformioEnvsFromFiles(files: VirtualFileMap): { envNames: string[]; grouped: { flat: string[] } } {
const allEnvs = new Set<string>()
for (const content of Object.values(files)) {
const sections = parseIniSections(content)
for (const n of extractEnvNamesFromSections(sections)) {
allEnvs.add(n)
}
/** Aggregate all PlatformIO sections from every .ini file in the virtual file map. */
function aggregateIniSections(files: VirtualFileMap): Record<string, Record<string, string>> {
const allSections: Record<string, Record<string, string>> = {}
for (const [path, content] of Object.entries(files)) {
if (!path.endsWith('.ini')) continue
Object.assign(allSections, parseIniSections(content))
}
const envNames = [...allEnvs].sort()
return { envNames, grouped: { flat: envNames } }
return allSections
}
/**
* Resolve the value of a given key for a PlatformIO section, following `extends` chains.
* Returns null when the key is not found or the chain is circular / broken.
*/
function resolveKey(
sectionName: string,
key: string,
allSections: Record<string, Record<string, string>>,
visited: Set<string> = new Set()
): string | null {
if (visited.has(sectionName)) return null
visited.add(sectionName)
const sec = allSections[sectionName]
if (!sec) return null
if (sec[key] !== undefined) return sec[key]
const ext = sec['extends']
if (!ext) return null
for (const parent of ext.split(',').map(s => s.trim())) {
const val = resolveKey(parent, key, allSections, new Set(visited))
if (val !== null) return val
}
return null
}
/**
* Derive capability strings from a platform identifier and optional board name.
* - espressif32 (any variant, including pioarduino URLs) → wifi, ble
* - nordicnrf52 → ble
* - raspberrypi / platform-raspberrypi → wifi + ble only for boards ending in _w / picow
* - everything else → no capabilities assumed
*/
function capabilitiesFromPlatform(platform: string, board: string): string[] {
const p = platform.toLowerCase()
const b = board.toLowerCase()
if (p.includes('espressif32')) return ['wifi', 'ble']
if (p.includes('nordicnrf52')) return ['ble']
if (p.includes('raspberrypi') || p.includes('platform-raspberrypi')) {
if (b.includes('picow') || b.endsWith('_w')) return ['wifi', 'ble']
}
return []
}
/**
* Detect capabilities for each env by resolving `platform` and `board` through the
* full extends chain across all aggregated ini sections.
*/
export function detectEnvCapabilities(
envNames: string[],
allSections: Record<string, Record<string, string>>
): Record<string, string[]> {
const result: Record<string, string[]> = {}
for (const name of envNames) {
const sectionName = `env:${name}`
const platform = resolveKey(sectionName, 'platform', allSections) ?? ''
const board = resolveKey(sectionName, 'board', allSections) ?? ''
result[name] = capabilitiesFromPlatform(platform, board)
}
return result
}
export function collectPlatformioEnvsFromFiles(files: VirtualFileMap): {
envNames: string[]
grouped: { flat: string[] }
envCapabilities: Record<string, string[]>
} {
const allSections = aggregateIniSections(files)
const envNames = extractEnvNamesFromSections(allSections)
const envCapabilities = detectEnvCapabilities(envNames, allSections)
return { envNames, grouped: { flat: envNames }, envCapabilities }
}
+46 -2
View File
@@ -2,6 +2,7 @@ import { unzipSync, strFromU8 } from "fflate"
import { v } from "convex/values"
import { api, internal } from "./_generated/api"
import { collectPlatformioEnvsFromFiles, normalizeZipPaths } from "./lib/platformioScan"
import { parseMeshforgeYaml } from "./lib/meshforgeYaml"
import { action, internalMutation, mutation, query } from "./_generated/server"
export const getByRepoSha = query({
@@ -57,7 +58,33 @@ export const ensureScan = mutation({
.first()
if (existing?.scanStatus === "complete") {
return { scanId: existing._id, status: "complete" as const }
const names = existing.envNames ?? []
const caps = existing.envCapabilities as Record<string, unknown> | undefined
const capsComplete =
caps != null &&
typeof caps === "object" &&
(names.length === 0 || names.every(n => Array.isArray(caps[n])))
if (capsComplete) {
return { scanId: existing._id, status: "complete" as const }
}
// Completed before envCapabilities existed (or partial write) — rescan same SHA.
await ctx.db.patch(existing._id, {
scanStatus: "in_progress",
envNames: undefined,
grouped: undefined,
envCapabilities: undefined,
meshforgeConfig: undefined,
scanError: undefined,
updatedAt: Date.now(),
})
await ctx.scheduler.runAfter(0, api.repoScans.runArchiveScan, {
scanId: existing._id,
owner: args.owner,
repo: args.repo,
ref: args.ref,
resolvedSourceSha: args.resolvedSourceSha,
})
return { scanId: existing._id, status: "in_progress" as const }
}
if (existing?.scanStatus === "in_progress") {
return { scanId: existing._id, status: "in_progress" as const }
@@ -91,12 +118,16 @@ export const completeScanInternal = internalMutation({
scanId: v.id("repoRefScan"),
envNames: v.array(v.string()),
grouped: v.any(),
envCapabilities: v.any(),
meshforgeConfig: v.optional(v.any()),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.scanId, {
scanStatus: "complete",
envNames: args.envNames,
grouped: args.grouped,
envCapabilities: args.envCapabilities,
meshforgeConfig: args.meshforgeConfig,
scannedAt: Date.now(),
updatedAt: Date.now(),
scanError: undefined,
@@ -143,11 +174,24 @@ export const runArchiveScan = action({
}
const files = unzipSync(buf)
const virtual = normalizeZipPaths(files, u => strFromU8(u, true))
const { envNames, grouped } = collectPlatformioEnvsFromFiles(virtual)
const { envNames, grouped, envCapabilities } = collectPlatformioEnvsFromFiles(virtual)
let meshforgeConfig: ReturnType<typeof parseMeshforgeYaml> = null
const yamlContent = virtual['meshforge.yaml']
if (yamlContent) {
try {
meshforgeConfig = parseMeshforgeYaml(yamlContent)
} catch {
// ignore malformed yaml
}
}
await ctx.runMutation(internal.repoScans.completeScanInternal, {
scanId: args.scanId,
envNames,
grouped,
envCapabilities,
meshforgeConfig: meshforgeConfig ?? undefined,
})
} catch (e) {
await ctx.runMutation(internal.repoScans.failScanInternal, {
+20
View File
@@ -1,6 +1,7 @@
import { v } from "convex/values"
import { internal } from "./_generated/api"
import { action, internalMutation, query } from "./_generated/server"
import { parseMeshforgeYaml, type MeshforgeConfig } from "./lib/meshforgeYaml"
import { sortTagEntries, type TagEntry } from "./lib/tagSemver"
const TAG_TTL_MS = 120_000
@@ -44,6 +45,7 @@ export const upsertFromGitHub = internalMutation({
etag: v.optional(v.string()),
description: v.string(),
homepage: v.string(),
meshforgeConfig: v.optional(v.any()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
@@ -59,6 +61,7 @@ export const upsertFromGitHub = internalMutation({
etag: args.etag,
description: args.description,
homepage: args.homepage,
meshforgeConfig: args.meshforgeConfig,
}
if (existing) {
await ctx.db.patch(existing._id, doc)
@@ -89,6 +92,22 @@ export const refresh = action({
const description = (repoJson.description ?? "").trim()
const homepage = (repoJson.homepage ?? "").trim()
// Fetch meshforge.yaml from the default branch (no ref = default branch).
let meshforgeConfig: MeshforgeConfig | null = null
const yamlRes = await fetch(
`https://api.github.com/repos/${args.owner}/${args.repo}/contents/meshforge.yaml`,
{ headers }
)
if (yamlRes.ok) {
try {
const yamlJson = (await yamlRes.json()) as { content?: string }
const raw = decodeBase64Utf8(yamlJson.content ?? "")
meshforgeConfig = parseMeshforgeYaml(raw)
} catch {
// ignore fetch/parse errors — profile is optional
}
}
const rawTags: TagEntry[] = []
let page = 1
const perPage = 100
@@ -114,6 +133,7 @@ export const refresh = action({
tags,
description,
homepage,
meshforgeConfig: meshforgeConfig ?? undefined,
})
return { ok: true as const, tagCount: tags.length }
},
+6
View File
@@ -18,6 +18,8 @@ export const repoTagListFields = {
description: v.optional(v.string()),
/** GitHub REST `homepage` (often meshtastic.orgstyle URL). */
homepage: v.optional(v.string()),
/** Parsed meshforge.yaml from the repo's default branch, if present. */
meshforgeConfig: v.optional(v.any()),
}
export const repoRefScanFields = {
@@ -27,6 +29,10 @@ export const repoRefScanFields = {
scanStatus: v.union(v.literal("in_progress"), v.literal("complete"), v.literal("failed")),
envNames: v.optional(v.array(v.string())),
grouped: v.optional(v.any()),
/** Detected capability sets keyed by env name, e.g. { "LilyGo_TDeck_repeater": ["wifi","ble"] }. */
envCapabilities: v.optional(v.any()),
/** Parsed meshforge.yaml config from the scanned source tree, if present. */
meshforgeConfig: v.optional(v.any()),
scanError: v.optional(v.string()),
scannedAt: v.optional(v.number()),
scanRunnerRequestId: v.optional(v.string()),
+185
View File
@@ -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
})
}
-43
View File
@@ -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
View File
@@ -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)}`)
}
}}