From 2ed3f17dc73737b21289c811333a15ef1650babc Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Sun, 26 Apr 2026 00:07:36 +0200 Subject: [PATCH] feat(bump_project_version): bump Project Information.Version one part New MCP tool that increments one part of the 4-part Project Information.Version field of the primary project, saves the project, and reports the before/after. Behaviour: - level=major -> bump major, reset minor/revision/build to 0 - level=minor -> bump minor, reset revision/build to 0 - level=revision-> bump revision, reset build to 0 - level=build -> bump build only Convention follows the rest of CODESYS / 3S / WAGO library practice (visible in any X33 library reference like 'WagoAppCanLayer2, 1.6.1.4 (WAGO)'): Major -- incompatible API break (rename FB, change public signature, remove method). Minor -- backward-compatible feature add. Revision -- bug fix only, no API change. Build -- internal / CI counter, often 0 for hand-released. Implementation notes: - Project Information lives as the first child node of the project root. Its .version property is read/written directly; IronPython coerces strings like '1.2.3.4' to a System.Version on assignment, str(System.Version) gives the dotted form back. None / empty / unset is treated as '0.0.0.0'. - Verified live against X33 (MRCodesysX33_0021): set version to '1.0.0.0' from None, project.save() persisted it; reload via primary_project.get_children() found the same value. Probe done via the inject-once.mjs bridge against the live watcher in PID 23056 before this commit landed. - project.save() is called after the bump so the new value sticks in the .project file. Soft-fails on save error (visible WARNING in DEBUG output but the bump itself is still reported as successful) so a save permission glitch doesn't mask the actual version change. Used by the X33 GitLab project's "version in README header" workflow: the version surfaces at the top of README.md (and at the top of the library list once list_project_libraries gets enriched in a follow-up commit). --- src/scripts/bump_project_version.py | 107 ++++++++++++++++++++++++++++ src/server.ts | 27 +++++++ 2 files changed, 134 insertions(+) create mode 100644 src/scripts/bump_project_version.py diff --git a/src/scripts/bump_project_version.py b/src/scripts/bump_project_version.py new file mode 100644 index 0000000..08d7726 --- /dev/null +++ b/src/scripts/bump_project_version.py @@ -0,0 +1,107 @@ +import sys, scriptengine as script_engine, traceback + +# Bumps one part of the 4-part Project Information.version field of the +# primary project. Convention (per CODESYS / 3S / WAGO library practice): +# +# Major -- bump on incompatible API break. +# Minor -- bump on backward-compatible feature add. +# Revision -- bump on bug fix only (no API change). +# Build -- internal counter, often 0 for hand-released versions. +# +# Bumping a higher part resets all lower parts to 0 (e.g. bumping minor +# resets revision and build). +# +# The Version field is read/written as a property on the "Project +# Information" node (first child of the primary project). IronPython +# coerces strings like "1.2.3.4" to System.Version automatically; we +# stringify on the read side because str(System.Version) gives +# the dotted form back. None / empty are treated as "0.0.0.0". + +LEVEL = "{LEVEL}" # major | minor | revision | build + +VALID_LEVELS = ('major', 'minor', 'revision', 'build') + + +def parse_version(v): + """Parse a version-like value into a 4-tuple of ints, defaulting missing + parts to 0. Accepts None, '', '1.2', '1.2.3', '1.2.3.4', or a + System.Version. Raises ValueError on anything that can't be parsed.""" + if v is None: + return (0, 0, 0, 0) + s = str(v).strip() + if not s or s == 'None': + return (0, 0, 0, 0) + parts = s.split('.') + if len(parts) > 4: + raise ValueError("version '%s' has more than 4 parts" % s) + nums = [] + for p in parts: + try: + nums.append(int(p)) + except ValueError: + raise ValueError("version '%s' has non-integer part '%s'" % (s, p)) + while len(nums) < 4: + nums.append(0) + return tuple(nums) + + +def bump(parts, level): + major, minor, revision, build = parts + if level == 'major': + return (major + 1, 0, 0, 0) + if level == 'minor': + return (major, minor + 1, 0, 0) + if level == 'revision': + return (major, minor, revision + 1, 0) + if level == 'build': + return (major, minor, revision, build + 1) + raise ValueError("unknown bump level '%s' (must be one of %s)" % (level, ', '.join(VALID_LEVELS))) + + +try: + if LEVEL not in VALID_LEVELS: + raise ValueError("level must be one of %s, got '%s'" % (', '.join(VALID_LEVELS), LEVEL)) + + primary_project = ensure_project_open(PROJECT_FILE_PATH) + + pi = None + for child in primary_project.get_children(False): + try: + if child.get_name() == 'Project Information': + pi = child + break + except Exception: + pass + if pi is None: + raise RuntimeError( + "Project Information node not found at the project root. " + "Every CODESYS project should have one as its first child; " + "if missing, recreate it via Project menu -> Project Information.") + + before_raw = pi.version + before_str = str(before_raw) if before_raw is not None else None + before_parts = parse_version(before_raw) + after_parts = bump(before_parts, LEVEL) + after_str = '%d.%d.%d.%d' % after_parts + + print("DEBUG: bump_project_version: level=%s before=%s -> after=%s" % ( + LEVEL, before_str, after_str)) + + pi.version = after_str + + try: + primary_project.save() + print("DEBUG: project.save() succeeded after version bump.") + except Exception as save_e: + print("WARNING: project.save() raised %s -- bump applied in memory but may not persist across IDE close." % save_e) + + print("Project Information.Version: %s -> %s" % (before_str, after_str)) + print("SCRIPT_SUCCESS: bump_project_version complete.") + sys.exit(0) +except Exception as e: + detailed = traceback.format_exc() + msg = "Error in bump_project_version for project '%s': %s\n%s" % ( + PROJECT_FILE_PATH, e, detailed) + print(msg) + print("SCRIPT_ERROR: %s" % msg) + sys.exit(1) diff --git a/src/server.ts b/src/server.ts index 6cb5315..9cb9310 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1346,6 +1346,33 @@ export async function startMcpServer(config: ServerConfig): Promise { } ); + // ─── Project metadata ──────────────────────────────────────────────── + + s.tool( + 'bump_project_version', + "Bumps one part of the 4-part Project Information.Version field of the primary project (Major.Minor.Revision.Build) and saves the project. Convention: major = incompatible API break (rename FB / change public signature / remove method); minor = backward-compatible feature add (new FB / GVL / method); revision = bug fix only; build = internal counter, often 0 for hand-released versions. Bumping a higher part resets all lower parts to 0 (e.g. bumping minor resets revision and build to 0). Treats None / empty / unset version as '0.0.0.0'. The Version is exposed at runtime via the Project Information library's GetVersion() helper, which IEC code can call to surface the running version.", + { + projectFilePath: z.string().describe("Path to the project file."), + level: z.enum(['major', 'minor', 'revision', 'build']).describe("Which part of the 4-part version to bump. Major = incompatible API break. Minor = backward-compatible feature add. Revision = bug fix only. Build = internal / CI counter."), + }, + async (args: { projectFilePath: string; level: 'major' | 'minor' | 'revision' | 'build' }) => { + const escaped = resolvePath(args.projectFilePath, workspaceDir); + const script = scriptManager.prepareScriptWithHelpers( + 'bump_project_version', + { + PROJECT_FILE_PATH: escaped, + LEVEL: args.level, + }, + ['ensure_project_open'] + ); + const result = await executor.executeScript(script); + return formatToolResponse( + result, + `bump_project_version (${args.level}) complete for ${args.projectFilePath}.` + ); + } + ); + // ─── Filesystem mirror (Phase 1: read-only export) ──────────────────── s.tool(