From d881c111f603e77c09cbde02be1e8f6186cc9d38 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 14 May 2026 14:13:42 +0200 Subject: [PATCH] Added latest release management to rngit --- RNS/Utilities/rngit/pages.py | 47 ++++++----- RNS/Utilities/rngit/server.py | 142 ++++++++++++++++++++++++++++++---- 2 files changed, 154 insertions(+), 35 deletions(-) diff --git a/RNS/Utilities/rngit/pages.py b/RNS/Utilities/rngit/pages.py index 3d08d80f..1fbf72b0 100644 --- a/RNS/Utilities/rngit/pages.py +++ b/RNS/Utilities/rngit/pages.py @@ -98,6 +98,10 @@ class NomadNetworkNode(): CLR_FILE = "`F66d" CLR_DIM = "`F666" CLR_DIM_H = "`F444" + CLR_OK_DIM = "`FT537855" + CLR_DIFF_A = "`F0a0" + CLR_DIFF_R = "`F900" + CLR_DIFF_P = "`F0aa" # Yes, I'm being intentionally weird here. If you # want to use tabs, three spaces is all you get. @@ -434,7 +438,7 @@ class NomadNetworkNode(): # Get releases count releases_path = f"{repo['path']}.releases" releases_count = 0 - releases = self.owner.releases_list_data(releases_path) + releases, latest_release = self.owner.releases_list_data(releases_path) if releases: releases_count = len([r for r in releases if r.get("status") == "published"]) sep = self.icon("sep") @@ -1174,7 +1178,7 @@ class NomadNetworkNode(): nav_content = "".join(nav_parts) releases_path = f"{repo['path']}.releases" - releases = self.owner.releases_list_data(releases_path) + releases, latest_release = self.owner.releases_list_data(releases_path) if not releases: content_parts.append(self.m_heading("Releases", 2)) content_parts.append("\nNo releases available for this repository.\n") @@ -1198,8 +1202,9 @@ class NomadNetworkNode(): link = self.m_link(tag, self.PATH_RELEASE, g=group_name, r=repo_name, t=tag) sep = self.icon("sep") + latest_str = f" {sep} {self.CLR_OK_DIM}`*Latest`*`f" if tag == latest_release else "" artifacts_str = f"`*{artifacts} artifact{'s' if artifacts != 1 else ''}`*" - content_parts.append(f"{link} {self.CLR_DIM}{date_str} {sep} {artifacts_str}`f\n") + content_parts.append(f"{link} {self.CLR_DIM}{date_str} {sep} {artifacts_str}{latest_str}`f\n") if preview: if rel_format == "markdown": content_parts.append(f"{self.mdc.format_block(preview)}\n") elif rel_format == "micron": content_parts.append(f"{preview}\n") @@ -1230,13 +1235,16 @@ class NomadNetworkNode(): releases_path = f"{repo['path']}.releases" if tag == "latest": - releases = self.owner.releases_list_data(releases_path) + releases, latest_release = self.owner.releases_list_data(releases_path) if not releases: - content = self.m_heading("Release Not Found", 2) + f"\nNo latest release exist.\n" + content = self.m_heading("Release Not Found", 2) + f"\nNo releases exist.\n" return self.render_template(content, nav_content=nav_content, st=st) - recent_releases = sorted(releases, key=lambda x: x['created'], reverse=True) - tag = recent_releases[0]["tag"] + if not latest_release: + recent_releases = sorted(releases, key=lambda x: x['created'], reverse=True) + tag = recent_releases[0]["tag"] + + else: tag = latest_release content_parts = [] nav_parts = [] @@ -1561,10 +1569,13 @@ class NomadNetworkNode(): releases_path = f"{repo['path']}.releases" if tag == "latest": - releases = self.owner.releases_list_data(releases_path) + releases, latest_release = self.owner.releases_list_data(releases_path) if not releases: return None - recent_releases = sorted(releases, key=lambda x: x['created'], reverse=True) - tag = recent_releases[0]["tag"] + if not latest_release: + recent_releases = sorted(releases, key=lambda x: x['created'], reverse=True) + tag = recent_releases[0]["tag"] + + else: tag = latest_release release_dir = os.path.join(releases_path, tag) artifacts_dir = os.path.join(release_dir, "artifacts") @@ -2207,17 +2218,17 @@ class NomadNetworkNode(): for line in lines: if line.startswith("+"): if line.startswith("+++"): formatted_lines.append(self.m_escape(line)) - else: formatted_lines.append(f"`F0a0{self.m_escape(line)}`f") + else: formatted_lines.append(f"{self.CLR_DIFF_A}{self.m_escape(line)}`f") elif line.startswith("-"): if line.startswith("---"): formatted_lines.append(self.m_escape(f"\\{line}")) - else: formatted_lines.append(f"`F900{self.m_escape(line)}`f") + else: formatted_lines.append(f"{self.CLR_DIFF_R}{self.m_escape(line)}`f") elif line.startswith("@@"): - formatted_lines.append(f"`F0aa{self.m_escape(line)}`f") + formatted_lines.append(f"{self.CLR_DIFF_P}{self.m_escape(line)}`f") elif line.startswith("diff ") or line.startswith("index ") or line.startswith("new file") or line.startswith("deleted file"): if line.startswith("diff --git a"): formatted_lines.append("") - formatted_lines.append(f"`F666{self.m_escape(line)}`f") + formatted_lines.append(f"{self.CLR_DIM}{self.m_escape(line)}`f") else: formatted_lines.append(self.m_escape(line)) @@ -2311,9 +2322,9 @@ class NomadNetworkNode(): final_label = f"{labels[-1][:12]:>12}" middle_space = chart_width-len(first_label)-len(final_label) - label_line = f"{indent}`F666{first_label}`f" + label_line = f"{indent}{self.CLR_DIM}{first_label}`f" label_line += " " * middle_space - label_line += f"`F666{final_label}`f\n" + label_line += f"{self.CLR_DIM}{final_label}`f\n" chart_lines.append(label_line) return "".join(chart_lines) @@ -2363,9 +2374,9 @@ class NomadNetworkNode(): final_label = f"{labels[-1][:12]:>12}" middle_space = chart_width-len(first_label)-len(final_label) - label_line = f"{indent}`F666{first_label}`f" + label_line = f"{indent}{self.CLR_DIM}{first_label}`f" label_line += " " * middle_space - label_line += f"`F666{final_label}`f\n" + label_line += f"{self.CLR_DIM}{final_label}`f\n" lines.append(label_line) return "".join(lines) diff --git a/RNS/Utilities/rngit/server.py b/RNS/Utilities/rngit/server.py index e21c32b7..b84bd16b 100644 --- a/RNS/Utilities/rngit/server.py +++ b/RNS/Utilities/rngit/server.py @@ -84,6 +84,7 @@ def program_setup(configdir, rnsconfigdir=None, verbosity=0, quietness=0, servic elif operation == "view": git_client.view_release(remote=task["remote"], target=task["target"]) elif operation == "create": git_client.create_release(remote=task["remote"], target=task["target"]) elif operation == "delete": git_client.delete_release(remote=task["remote"], target=task["target"]) + elif operation == "latest": git_client.latest_release(remote=task["remote"], target=task["target"]) else: print("Invalid operation"); exit(1) elif command == "work": @@ -414,8 +415,18 @@ class ReticulumGitClient(): error_msg = response[1:].decode("utf-8", errors="ignore") self.abort(f"Server error: {error_msg}") - if len(response) > 1: releases = mp.unpackb(response[1:]) - else: releases = [] + if len(response) > 1: unpacked = mp.unpackb(response[1:]) + else: unpacked = [] + + if type(unpacked) == list: + releases = unpacked + latest_release = None + + elif type(unpacked) == dict: + releases = unpacked["releases"] + latest_release = unpacked["latest"] + + else: self.abort("Invalid release data format from remote") if not releases: print("No releases for this repository") else: @@ -429,7 +440,8 @@ class ReticulumGitClient(): artifacts = str(rel.get("artifacts", 0)) preview = rel.get("preview", "")[:34] print(f"{tag:<10} {status:<10} {created:<17} {artifacts:<5} {preview}") - print() + + if latest_release: print(f"\nThe latest release is: {latest_release}") except Exception as e: self.abort(f"Error listing releases: {e}") finally: @@ -635,6 +647,49 @@ class ReticulumGitClient(): finally: if self.link: self.link.teardown() + def latest_release(self, remote=None, target=None): + if not remote: print(f"No remote specified"); exit(1) + if not target: print(f"No target specified"); exit(1) + self.connect_remote(remote) + + timeout = self.link_timeout + while not self.link_ready and not self.link_failed and timeout > 0: + time.sleep(0.5) + timeout -= 1 + + if not self.link_ready: self.abort("Failed to establish link") + print("\r \r", end="") + + try: + destination_hash, group, repo = self.parse_remote_url(remote) + repo_path = f"{group}/{repo}" + + print(f"Are you sure you want to set {target} as the latest release? [y/N]: ", end="") + try: confirm = input().strip().lower() + except EOFError: confirm = "n" + + if confirm != "y": + print("Update cancelled") + return + + request_data = { self.IDX_REPOSITORY: repo_path, + "operation": "latest", "tag": target } + + response, metadata = self.send_request(self.PATH_RELEASE, request_data, timeout=30) + + if not response or not isinstance(response, bytes): self.abort("No response from remote") + + status_byte = response[0] + if status_byte != 0: + error_msg = response[1:].decode("utf-8", errors="ignore") + self.abort(f"Remote error: {error_msg}") + + print(f"Release {target} set as latest") + + except Exception as e: self.abort(f"Error setting latest release: {e}") + finally: + if self.link: self.link.teardown() + ######################## # Work Docs Management # ######################## @@ -2012,9 +2067,9 @@ class ReticulumGitNode(): if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" - if operation in ["create", "delete"] and release_access and read_access: access = True - elif operation in ["list", "view"] and read_access: access = True - else: access = False + if operation in ["create", "delete", "latest"] and release_access and read_access: access = True + elif operation in ["list", "view"] and read_access: access = True + else: access = False if not access: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed" else: @@ -2026,6 +2081,7 @@ class ReticulumGitNode(): elif operation == "view" and read_access: return self._release_view(releases_path, data) elif operation == "create" and release_access: return self._release_create(releases_path, repository_path, data, remote_identity) elif operation == "delete" and release_access: return self._release_delete(releases_path, data) + elif operation == "latest" and release_access: return self._release_latest(releases_path, data) else: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" except Exception as e: @@ -2034,8 +2090,10 @@ class ReticulumGitNode(): def releases_list_data(self, releases_path): try: + tags = {} releases = [] - if not os.path.isdir(releases_path): return releases + latest_release = None + if not os.path.isdir(releases_path): return releases, None for entry in os.listdir(releases_path): release_dir = os.path.join(releases_path, entry) if not os.path.isdir(release_dir): continue @@ -2045,14 +2103,16 @@ class ReticulumGitNode(): try: meta = ConfigObj(meta_path) - release_info = { "tag": meta.get("tag", entry), + release_tag = meta.get("tag", entry) + release_status = meta.get("status", "unknown") + release_info = { "tag": release_tag, "hash": meta.get("hash", ""), "created": meta.as_int("created") if "created" in meta else 0, - "status": meta.get("status", "unknown"), + "status": release_status, "created_by": meta.get("created_by", "") } notes_preview = "" - notes_format = "markdown" + notes_format = "markdown" for notes_file in ["RELEASE.md", "RELEASE.mu", "RELEASE.txt"]: notes_path = os.path.join(release_dir, notes_file) if os.path.isfile(notes_path): @@ -2082,25 +2142,37 @@ class ReticulumGitNode(): else: release_info["artifacts"] = 0 releases.append(release_info) + tags[release_tag] = True if release_status == "published" else False except Exception as e: RNS.log(f"Error reading release metadata for {entry}: {e}", RNS.LOG_DEBUG) continue + + try: + latest_path = os.path.join(releases_path, "latest") + if os.path.isfile(latest_path): + with open(latest_path, "r") as fh: latest_tag = fh.read().strip() + if latest_tag in tags and tags[latest_tag] == True: latest_release = latest_tag + + except Exception as e: RNS.log(f"Could not determine latest release for {releases_path}: {e}", RNS.LOG_ERROR) + releases.sort(key=lambda x: x.get("created", 0), reverse=True) - return releases + return releases, latest_release except Exception as e: RNS.log(f"Error listing releases for {releases_path}: {e}", RNS.LOG_ERROR) - return None + return None, None def _release_list(self, releases_path): if not os.path.isdir(releases_path): return b"\x00" + mp.packb([]) - releases = self.releases_list_data(releases_path) + releases, latest_release = self.releases_list_data(releases_path) if releases == None: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error listing releases" + + release_data = {"releases": releases, "latest": latest_release} - return b"\x00" + mp.packb(releases) + return b"\x00" + mp.packb(release_data) def release_data(self, release_dir, tag): try: @@ -2165,8 +2237,21 @@ class ReticulumGitNode(): if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified" tag = os.path.basename(tag) - release_dir = os.path.join(releases_path, tag) + latest_release = None + if tag == "latest": + try: + latest_path = os.path.join(releases_path, "latest") + if os.path.isfile(latest_path): + with open(latest_path, "r") as fh: latest_tag = fh.read().strip() + latest_release = latest_tag + + except Exception as e: RNS.log(f"Could not determine latest release for {releases_path}: {e}", RNS.LOG_ERROR) + + if not latest_release: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"No latest release found" + else: tag = latest_release + + release_dir = os.path.join(releases_path, tag) if not os.path.isdir(release_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Release not found" release_info = self.release_data(release_dir, tag) @@ -2311,11 +2396,34 @@ class ReticulumGitNode(): try: shutil.rmtree(release_dir) - RNS.log(f"Deleted release {tag}", RNS.LOG_DEBUG) + RNS.log(f"Deleted release {tag} from {releases_path}", RNS.LOG_DEBUG) return b"\x00" except Exception as e: - RNS.log(f"Error deleting release: {e}", RNS.LOG_ERROR) + RNS.log(f"Error deleting release from {releases_path}: {e}", RNS.LOG_ERROR) + return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error" + + def _release_latest(self, releases_path, data): + tag = data.get("tag", "") + + if not tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No tag specified" + if "/" in tag: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid tag specified" + + tag = os.path.basename(tag) + release_dir = os.path.join(releases_path, tag) + + if not os.path.isdir(release_dir): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Release not found" + + try: + latest_path = os.path.join(releases_path, "latest") + tmp_path = latest_path+".tmp" + with open(tmp_path, "w") as fh: fh.write(tag) + os.rename(tmp_path, latest_path) + RNS.log(f"Set {tag} as latest release for {releases_path}", RNS.LOG_DEBUG) + return b"\x00" + + except Exception as e: + RNS.log(f"Error setting latest release for {releases_path}: {e}", RNS.LOG_ERROR) return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error" #########################