feat(dashboard): update check logic: versions, GitHub release and compare answers, build wording

Pure part of the update check: tag versions compared numerically, the latest
release and compare answers read (GitHub's error bodies refused), and the
running build worded for people ("release v3.0.0", "a local development
build of commit 1a2b3c4d with uncommitted changes"). Tested with small
hand-written samples of the answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 10:47:50 -05:00
parent 6df7d39eb8
commit a2cc0d5a82
9 changed files with 539 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
#include "UpdateCheck.h"
#include <algorithm>
#include "json.hpp"
namespace {
bool IsNameChar(char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.';
}
std::string Plural(uint32_t count, const std::string& one, const std::string& many) {
return std::to_string(count) + " " + (count == 1 ? one : many);
}
std::string ReleaseName(const UpdateCheck::Release& release) {
return release.version ? "v" + release.version->ToString() : release.tag;
}
uint32_t Count(const nlohmann::json& object, const char* key) {
const auto it = object.find(key);
if (it == object.end() || !it->is_number_integer() || it->get<int64_t>() < 0) return 0;
return static_cast<uint32_t>(std::min<int64_t>(it->get<int64_t>(), UINT32_MAX));
}
std::string Text(const nlohmann::json& object, const char* key) {
const auto it = object.find(key);
return it != object.end() && it->is_string() ? it->get<std::string>() : "";
}
}
namespace UpdateCheck {
std::string Version::ToString() const {
return std::to_string(major) + "." + std::to_string(minor) + "." + std::to_string(patch);
}
std::optional<Version> ParseVersion(std::string_view tag) {
if (!tag.empty() && (tag.front() == 'v' || tag.front() == 'V')) tag.remove_prefix(1);
uint32_t parts[3]{};
size_t part = 0;
bool any = false;
for (size_t i = 0; i < tag.size() && part < 3; i++) {
const char c = tag[i];
if (c >= '0' && c <= '9') {
const uint64_t next = static_cast<uint64_t>(parts[part]) * 10 + (c - '0');
if (next > UINT32_MAX) return std::nullopt;
parts[part] = static_cast<uint32_t>(next);
any = true;
} else if (c == '.' && any && i + 1 < tag.size() && tag[i + 1] >= '0' && tag[i + 1] <= '9') {
part++;
} else {
break;
}
}
if (!any) return std::nullopt;
return Version{ parts[0], parts[1], parts[2] };
}
Build CurrentBuild() {
Build build;
build.kind = BuildInfo::buildKind;
build.version = { BuildInfo::versionMajor, BuildInfo::versionMinor, BuildInfo::versionPatch };
build.commit = std::string(BuildInfo::commit);
build.branch = std::string(BuildInfo::branch);
build.dirty = BuildInfo::dirty;
return build;
}
std::string DescribeBuild(const Build& build) {
const std::string changes = build.dirty ? " with uncommitted changes" : "";
const std::string ofCommit = build.commit.empty() ? "" : " of commit " + build.ShortCommit();
switch (build.kind) {
case BuildInfo::eBuildKind::RELEASE:
return "release v" + build.version.ToString() + changes;
case BuildInfo::eBuildKind::LOCAL:
return "a local development build" + ofCommit + (build.commit.empty() ? " (no git commit recorded)" : "") + changes;
case BuildInfo::eBuildKind::CI:
return "a CI build" + ofCommit + changes;
default:
return "an unidentified build" + ofCommit + changes;
}
}
std::string BuildLabel(const Build& build) {
const std::string commit = build.commit.empty() ? "" : " " + build.ShortCommit() + (build.dirty ? "*" : "");
switch (build.kind) {
case BuildInfo::eBuildKind::RELEASE: return "v" + build.version.ToString() + (build.dirty ? "*" : "");
case BuildInfo::eBuildKind::LOCAL: return "Local dev build" + commit;
case BuildInfo::eBuildKind::CI: return "CI build" + commit;
default: return "Unidentified build" + commit;
}
}
std::optional<Release> ParseRelease(const std::string& body, std::string& error) {
const auto json = nlohmann::json::parse(body, nullptr, false);
if (!json.is_object()) {
error = "GitHub's answer about releases wasn't JSON";
return std::nullopt;
}
Release release;
release.tag = Text(json, "tag_name");
if (release.tag.empty()) {
const auto message = Text(json, "message");
error = message.empty() ? "GitHub's answer had no release in it" : "GitHub: " + message;
return std::nullopt;
}
release.name = Text(json, "name");
release.url = Text(json, "html_url");
release.publishedAt = Text(json, "published_at");
release.version = ParseVersion(release.tag);
return release;
}
std::optional<Comparison> ParseComparison(const std::string& body, std::string& error) {
const auto json = nlohmann::json::parse(body, nullptr, false);
if (!json.is_object()) {
error = "GitHub's comparison wasn't JSON";
return std::nullopt;
}
Comparison comparison;
comparison.status = Text(json, "status");
// GitHub's error bodies have a "status" too (the HTTP code), but no counts
const auto aheadBy = json.find("ahead_by");
if (comparison.status.empty() || aheadBy == json.end() || !aheadBy->is_number_integer()) {
const auto message = Text(json, "message");
error = message.empty() ? "GitHub's answer had no comparison in it" : "GitHub: " + message;
return std::nullopt;
}
comparison.commitsBehind = Count(json, "ahead_by");
comparison.commitsAhead = Count(json, "behind_by");
comparison.url = Text(json, "html_url");
return comparison;
}
std::string ErrorMessage(const std::string& body) {
const auto json = nlohmann::json::parse(body, nullptr, false);
return json.is_object() ? Text(json, "message") : "";
}
bool IsValidRepo(std::string_view repo) {
const auto slash = repo.find('/');
if (slash == std::string_view::npos || slash == 0 || slash + 1 >= repo.size() || repo.size() > 140) return false;
const auto owner = repo.substr(0, slash);
const auto name = repo.substr(slash + 1);
return std::ranges::all_of(owner, IsNameChar) && std::ranges::all_of(name, IsNameChar) && name != "." && name != "..";
}
bool IsSafeBranch(std::string_view branch) {
if (branch.empty() || branch.size() > 200 || branch.front() == '/' || branch.find("..") != std::string_view::npos) return false;
return std::ranges::all_of(branch, [](char c) { return IsNameChar(c) || c == '/'; });
}
bool ReleaseIsNewer(const Build& build, const Status& status) {
return status.release && status.release->version && *status.release->version > build.version;
}
eState State(const Build& build, const Status& status) {
if (!status.release && !status.comparison) return status.checkedAt == 0 ? eState::UNKNOWN : eState::FAILED;
if (ReleaseIsNewer(build, status)) return eState::UPDATE_AVAILABLE;
if (build.kind != BuildInfo::eBuildKind::RELEASE && status.comparison && status.comparison->commitsBehind > 0) return eState::UPDATE_AVAILABLE;
return eState::UP_TO_DATE;
}
std::string Summary(const Build& build, const Status& status) {
const auto running = "this server is running " + DescribeBuild(build);
const auto state = State(build, status);
if (state == eState::UNKNOWN) return "Not checked for updates yet";
if (state == eState::FAILED) return "Could not check for updates: " + (status.error.empty() ? std::string("no answer") : status.error);
const bool devBehind = build.kind != BuildInfo::eBuildKind::RELEASE && status.comparison && status.comparison->commitsBehind > 0;
if (state == eState::UPDATE_AVAILABLE) {
std::string line = "Update available: ";
if (ReleaseIsNewer(build, status)) {
line += "release " + ReleaseName(*status.release);
if (devBehind) line += ", and ";
}
if (devBehind) line += Plural(status.comparison->commitsBehind, "newer commit", "newer commits") + " on " + status.comparedBranch;
line += " (" + running + ")";
const auto& url = devBehind ? status.comparison->url : status.release->url;
if (!url.empty()) line += ": " + url;
return line;
}
std::string line = "Up to date";
if (build.kind != BuildInfo::eBuildKind::RELEASE && status.comparison) {
line += " with " + status.comparedBranch;
if (status.comparison->commitsAhead > 0) line += " (and " + Plural(status.comparison->commitsAhead, "commit", "commits") + " not on it)";
} else if (status.release) {
line += ": the newest release is " + ReleaseName(*status.release);
}
line += "; " + running;
return line;
}
}

View File

@@ -0,0 +1,101 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include "BuildInfo.h"
/**
* The pure half of the update check: what build is running, how to read GitHub's answers about the configured
* repository, and how to say what they mean. The network half (a worker thread, ETags, the schedule) is in
* UpdateChecker. Nothing here reads the config or the network, so it is all testable from recorded answers.
*/
namespace UpdateCheck {
struct Version {
uint32_t major{};
uint32_t minor{};
uint32_t patch{};
auto operator<=>(const Version&) const = default;
std::string ToString() const; // "3.0.1"
};
// "v3.0.1", "3.0.1", "V3.1" or "v3.0.1-rc1": the numbers up to the first thing that isn't one. Empty without a number.
std::optional<Version> ParseVersion(std::string_view tag);
// What was built: filled from BuildInfo in the server, by hand in tests
struct Build {
BuildInfo::eBuildKind kind{};
Version version;
std::string commit; // full hash, empty without git
std::string branch; // empty when detached or without git
bool dirty{};
std::string ShortCommit() const { return commit.substr(0, 8); }
};
Build CurrentBuild();
// "release v3.0.0", "a local development build of commit 1a2b3c4d with uncommitted changes", "a CI build of commit
// 1a2b3c4d": the running build in a phrase that fits after "This server is running "
std::string DescribeBuild(const Build& build);
// Short form for a badge: "v3.0.0", "Local dev build 1a2b3c4d*", "CI build 1a2b3c4d"
std::string BuildLabel(const Build& build);
// ---- GitHub's answers ----
// GET /repos/{repo}/releases/latest (the newest release that is neither a draft nor a pre-release)
struct Release {
std::string tag;
std::string name;
std::string url; // the release notes on github.com
std::string publishedAt; // ISO 8601, as GitHub sends it
std::optional<Version> version; // from the tag
};
// Empty (with error set) for anything that isn't a release object
std::optional<Release> ParseRelease(const std::string& body, std::string& error);
// GET /repos/{repo}/compare/{build commit}...{branch}: where the branch is relative to the build
struct Comparison {
std::string status; // identical, ahead (the branch has newer commits), behind (the build has commits it lacks) or diverged
uint32_t commitsBehind{}; // commits on the branch the build doesn't have (GitHub's ahead_by)
uint32_t commitsAhead{}; // commits in the build the branch doesn't have (GitHub's behind_by)
std::string url; // the compare page on github.com
};
std::optional<Comparison> ParseComparison(const std::string& body, std::string& error);
// A GitHub error body's message ("Not Found", "API rate limit exceeded for ..."), or empty
std::string ErrorMessage(const std::string& body);
// Only owner/name of letters, digits, '-', '_' and '.'
bool IsValidRepo(std::string_view repo);
// A branch name safe to put in a compare URL (letters, digits, '-', '_', '.', '/')
bool IsSafeBranch(std::string_view branch);
// ---- What the check found ----
enum class eState : uint8_t {
UNKNOWN = 0, // not checked yet (or turned off)
UP_TO_DATE = 1,
UPDATE_AVAILABLE = 2,
FAILED = 3, // nothing could be found out
};
struct Status {
std::optional<Release> release; // the newest release, when known
std::optional<Comparison> comparison; // development builds, when the build's commit is on GitHub
std::string comparedBranch; // the branch the comparison is against
std::string error; // what went wrong, if anything did (the rest may still be known)
int64_t checkedAt{}; // unix time of the last check, 0 before the first
};
// Whether the newest release is newer than the build's version
bool ReleaseIsNewer(const Build& build, const Status& status);
eState State(const Build& build, const Status& status);
// One line for the logs, e.g. "Update available: release v3.1.0 (this server is running release v3.0.0)"
std::string Summary(const Build& build, const Status& status);
}