Files
awesome-copilot/extensions/modernization-workflow/lib/state.mjs
T

228 lines
9.1 KiB
JavaScript

import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
const DEFAULT_STATE = {
version: 1,
language: "auto",
source: ".",
goal: "modernize this application for Azure",
upgradeTarget: "",
planName: "modernization-plan",
delegate: "local",
steps: {
assess: { status: "pending", updatedAt: null },
plan: { status: "pending", updatedAt: null },
execute: { status: "pending", updatedAt: null },
},
};
function statePath(workspace, stateId) {
return join(workspace, ".github", "modernize", `canvas-${stateId}.json`);
}
function cleanStateId(value) {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)) {
throw new Error("Invalid workflow state identifier.");
}
return value;
}
export function resolveWorkspace(input, fallback) {
const candidate = input || fallback || process.cwd();
return resolve(candidate);
}
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function loadState(workspace, stateId) {
const path = statePath(workspace, cleanStateId(stateId));
try {
const saved = JSON.parse(await readFile(path, "utf8"));
return {
...DEFAULT_STATE,
...saved,
steps: {
...DEFAULT_STATE.steps,
...(saved.steps ?? {}),
},
};
} catch (error) {
if (error.code === "ENOENT") return structuredClone(DEFAULT_STATE);
throw error;
}
}
async function saveState(workspace, stateId, state) {
const path = statePath(workspace, cleanStateId(stateId));
await mkdir(join(workspace, ".github", "modernize"), { recursive: true });
const next = { ...state, updatedAt: new Date().toISOString() };
await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
return next;
}
async function walkNames(root, maxDepth = 3, depth = 0) {
if (depth > maxDepth || !(await exists(root))) return [];
const output = [];
for (const entry of await readdir(root, { withFileTypes: true })) {
if ([".git", "node_modules", "bin", "obj", "target", "build"].includes(entry.name)) continue;
const path = join(root, entry.name);
output.push(path);
if (entry.isDirectory()) output.push(...await walkNames(path, maxDepth, depth + 1));
}
return output;
}
function detectLanguage(paths) {
const names = paths.map((path) => path.toLowerCase());
if (names.some((path) => /\.(sln|csproj|fsproj|vbproj)$/.test(path))) return "dotnet";
if (names.some((path) => /\/(pom\.xml|build\.gradle|build\.gradle\.kts)$/.test(path.replaceAll("\\", "/")))) return "java";
if (names.some((path) => /\/(cmakelists\.txt|makefile)$/.test(path.replaceAll("\\", "/")) || /\.(cpp|cc|cxx|vcxproj)$/.test(path))) return "cpp";
return "unknown";
}
async function scanArtifacts(workspace, planName) {
const modernizeRoot = join(workspace, ".github", "modernize");
const assessmentRoot = join(modernizeRoot, "assessment");
const planRoot = join(modernizeRoot, planName);
const assessmentFiles = (await walkNames(assessmentRoot, 3))
.filter((path) => /\.(json|md|html|ya?ml)$/i.test(path));
const planFiles = (await walkNames(planRoot, 2))
.filter((path) => /\.(json|md|html)$/i.test(path));
const assessmentOutputs = assessmentFiles.filter((path) =>
!/config|canvas-/i.test(path) && /\.(json|md|html)$/i.test(path));
const summaryFiles = planFiles.filter((path) => /summary|progress|result/i.test(path));
return {
assessment: assessmentFiles.map((path) => path.slice(workspace.length + 1)),
plan: planFiles.map((path) => path.slice(workspace.length + 1)),
execution: summaryFiles.map((path) => path.slice(workspace.length + 1)),
hasAssessment: assessmentOutputs.length > 0,
hasPlan: planFiles.some((path) => /plan\.md$/i.test(path))
&& planFiles.some((path) => /tasks\.json$/i.test(path)),
hasExecution: summaryFiles.length > 0,
};
}
function quote(value) {
const text = String(value).trim();
if (/^[A-Za-z0-9_./:\\-]+$/.test(text)) return text;
return `"${text.replaceAll('"', '\\"')}"`;
}
function languageFlag(language) {
if (language === "java" || language === "dotnet") return ` --language ${language}`;
return "";
}
function delegateFlag(delegate) {
return delegate === "cloud" ? " --delegate cloud" : "";
}
function commands(state, artifacts) {
const source = quote(state.source);
const name = quote(state.planName);
const goal = quote(state.goal);
const upgradeTarget = state.upgradeTarget ? ` ${quote(state.upgradeTarget)}` : "";
const language = state.language === "auto" ? state.detectedLanguage : state.language;
const assessment = artifacts.assessment.find((path) => /assessment.*\.json$/i.test(path))
?? artifacts.assessment.find((path) => /\.json$/i.test(path));
const assessmentFlag = assessment ? ` --assess-file-path ${quote(assessment)}` : "";
return {
assess: `modernize assess --source ${source}${delegateFlag(state.delegate)}${state.delegate === "cloud" ? " --wait" : ""}`,
plan: `modernize plan create ${goal} --source ${source} --plan-name ${name}${languageFlag(language)}${assessmentFlag}`,
execute: `modernize plan execute --source ${source} --plan-name ${name}${languageFlag(language)}${delegateFlag(state.delegate)}`,
upgrade: `modernize upgrade${upgradeTarget} --source ${source}${delegateFlag(state.delegate)}`,
};
}
function deriveSteps(state, artifacts) {
const steps = structuredClone(state.steps);
if (artifacts.hasAssessment) steps.assess.status = "complete";
if (artifacts.hasPlan) {
steps.assess.status = "complete";
steps.plan.status = "complete";
}
if (artifacts.hasExecution) {
steps.assess.status = "complete";
steps.plan.status = "complete";
steps.execute.status = "complete";
}
return steps;
}
export async function getWorkflowState(workspace, stateId = "default") {
const saved = await loadState(workspace, stateId);
const paths = await walkNames(workspace, 3);
const detectedLanguage = detectLanguage(paths);
const artifacts = await scanArtifacts(workspace, saved.planName);
const state = {
...saved,
stateId,
workspace,
detectedLanguage,
effectiveLanguage: saved.language === "auto" ? detectedLanguage : saved.language,
artifacts,
steps: deriveSteps(saved, artifacts),
};
const notices = [];
if (state.effectiveLanguage === "cpp") {
notices.push("The current CLI reference documents explicit --language values for Java and .NET only. C++ commands use CLI auto-detection; verify support with your installed Modernize CLI version.");
}
if (state.delegate === "cloud" && !/^https:\/\/github\.com\//i.test(state.source)) {
notices.push("Cloud delegation requires a github.com repository URL as the source. Local paths and other Git providers are not supported.");
}
return {
...state,
commands: commands(state, artifacts),
compatibilityNotice: notices.length ? notices.join(" ") : null,
};
}
export async function configureWorkflow(workspace, stateId, input) {
const current = await loadState(workspace, stateId);
const allowed = ["language", "source", "goal", "upgradeTarget", "planName", "delegate"];
const next = { ...current };
for (const key of allowed) {
if (input[key] !== undefined) next[key] = String(input[key]).trim();
}
if (!["auto", "dotnet", "java", "cpp"].includes(next.language)) {
throw new Error("Language must be auto, dotnet, java, or cpp.");
}
if (!["local", "cloud"].includes(next.delegate)) {
throw new Error("Execution must be local or cloud.");
}
if (!next.source || !next.goal) {
throw new Error("Source and modernization goal are required.");
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(next.planName)) {
throw new Error("Plan name must contain only letters, numbers, dots, underscores, and hyphens.");
}
await saveState(workspace, stateId, next);
return getWorkflowState(workspace, stateId);
}
export async function setStepStatus(workspace, stateId, step, status) {
if (!["assess", "plan", "execute"].includes(step)) throw new Error("Unknown workflow step.");
if (!["pending", "active", "complete", "blocked"].includes(status)) throw new Error("Unknown step status.");
const current = await loadState(workspace, stateId);
current.steps[step] = { status, updatedAt: new Date().toISOString() };
await saveState(workspace, stateId, current);
return getWorkflowState(workspace, stateId);
}
export async function refreshWorkflow(workspace, stateId = "default") {
const current = await loadState(workspace, stateId);
const refreshed = await getWorkflowState(workspace, stateId);
current.steps = refreshed.steps;
await saveState(workspace, stateId, current);
return getWorkflowState(workspace, stateId);
}