Last edited one month ago
by Max Mustermann

GitHubVersion

No categories assignedEdit
Revision as of 10:02, 19 March 2026 by Max Mustermann (talk | contribs) (Created page with "--- Module:GitHubVersion --- Fetch the version from extension.json at the latest tag of a GitHub repo. --- Usage (wikitext): --- {{#invoke:GitHubVersion|extensionVersion|owner=wikimedia|repo=mediawiki-extensions-AdhocTranslation}} --- --- Optional named params: --- path - path to JSON file at repo root (default: "extension.json") --- fallback - what to show if version cannot be determined (default: empty) --- showTag - "yes" to return the tag name if "...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Documentation for this module may be created at Module:GitHubVersion/doc

--- Module:GitHubVersion
--- Fetch the version from extension.json at the latest tag of a GitHub repo.
--- Usage (wikitext):
---   {{#invoke:GitHubVersion|extensionVersion|owner=wikimedia|repo=mediawiki-extensions-AdhocTranslation}}
---
--- Optional named params:
---   path       - path to JSON file at repo root (default: "extension.json")
---   fallback   - what to show if version cannot be determined (default: empty)
---   showTag    - "yes" to return the tag name if "version" is missing
---   timeout    - HTTP timeout seconds (default: 5)
---   ua         - extra User-Agent suffix (default: none)
---
--- Notes:
--- - This module uses GitHub /tags API (works even without releases).
--- - It then fetches https://raw.githubusercontent.com/<owner>/<repo>/<tag>/<path>
--- - The result is cached by the page cache; within a single parse we memoize.

local p = {}

local JSON = mw.text.jsonDecode

-- Basic in-process memoization for a single parse
local cache = {}

-- Utility: build a GitHub-friendly User-Agent
local function userAgent(extra)
    -- GitHub requires a UA; include wiki/site name for politeness and traceability
    local site = (mw.site and mw.site.server or "unknown-site")
    local ua = string.format("MediaWiki-Scribunto/1 (%s)", site)
    if extra and extra ~= "" then
        ua = ua .. " " .. tostring(extra)
    end
    return ua
end

-- Perform an HTTP GET and return (ok, status, body) with normalized error handling
local function httpGet(url, timeout, ua, accept)
    local opts = {
        url = url,
        method = "GET",
        headers = {
            ["User-Agent"] = userAgent(ua),
            ["Accept"] = accept or "application/json",
        },
        timeout = tonumber(timeout) or 5,
    }
    local r = mw.http.request(opts)
    if not r then
        return false, 0, "no response"
    end
    -- Treat 200 as success; 3xx/4xx/5xx are failures we surface
    if r.status ~= 200 then
        return false, r.status, r.body or ""
    end
    return true, r.status, r.body or ""
end

-- Try to get the newest tag via /tags?per_page=1 (tags are listed newest-first by default)
local function fetchLatestTag(owner, repo, timeout, ua)
    local url = string.format(
        "https://api.github.com/repos/%s/%s/tags?per_page=1",
        mw.uri.encode(owner), mw.uri.encode(repo)
    )
    local ok, status, body = httpGet(url, timeout, ua, "application/vnd.github+json")
    if not ok then return nil, string.format("GitHub tags API error (%d)", status) end

    local data = JSON(body)
    if type(data) ~= "table" or #data == 0 or type(data[1].name) ~= "string" then
        return nil, "No tags found"
    end
    return data[1].name, nil
end

-- Fetch and parse JSON at the raw URL of the tag
local function fetchJsonAtTag(owner, repo, tag, path, timeout, ua)
    path = path or "extension.json"
    local url = string.format(
        "https://raw.githubusercontent.com/%s/%s/%s/%s",
        mw.uri.encode(owner), mw.uri.encode(repo), mw.uri.encode(tag), path
    )
    local ok, status, body = httpGet(url, timeout, ua, "application/json")
    if not ok then
        return nil, string.format("raw.githubusercontent.com error (%d)", status)
    end
    local data = JSON(body)
    if type(data) ~= "table" then
        return nil, "Invalid JSON"
    end
    return data, nil
end

-- Public: get the "version" from extension.json at latest tag
function p.extensionVersion(frame)
    local args = frame.args or {}
    -- Allow Template invocation passthrough
    if frame:getParent() then
        for k, v in pairs(frame:getParent().args) do
            if args[k] == nil or args[k] == "" then
                args[k] = v
            end
        end
    end

    local owner   = (args.owner or ""):gsub("^%s+", ""):gsub("%s+$", "")
    local repo    = (args.repo or ""):gsub("^%s+", ""):gsub("%s+$", "")
    local path    = (args.path or "extension.json")
    local fallback = args.fallback or ""
    local showTag = (args.showTag or ""):lower() == "yes"
    local timeout = tonumber(args.timeout) or 5
    local ua      = args.ua or ""

    if owner == "" or repo == "" then
        return "⛔ owner and repo are required"
    end

    -- Cache key for memoization
    local key = table.concat({ owner, repo, path, tostring(showTag) }, "|")
    if cache[key] then
        return cache[key]
    end

    -- 1) Get the latest tag
    local tag, err = fetchLatestTag(owner, repo, timeout, ua)
    if not tag then
        -- As a fallback, try releases/latest (some repos use releases; others don't)
        local url = string.format("https://api.github.com/repos/%s/%s/releases/latest",
            mw.uri.encode(owner), mw.uri.encode(repo))
        local ok, status, body = httpGet(url, timeout, ua, "application/vnd.github+json")
        if ok then
            local rel = JSON(body)
            if type(rel) == "table" and type(rel.tag_name) == "string" then
                tag = rel.tag_name
            end
        end
    end

    if not tag then
        cache[key] = fallback
        return fallback
    end

    -- 2) Fetch JSON file at tag
    local data, jerr = fetchJsonAtTag(owner, repo, tag, path, timeout, ua)
    if not data then
        if showTag then
            cache[key] = tag
            return tag
        end
        cache[key] = fallback
        return fallback
    end

    -- 3) Extract version (common field in extension.json)
    local version = data.version
    if type(version) == "string" and version ~= "" then
        cache[key] = version
        return version
    end

    -- If version is missing, optionally fall back to tag name
    if showTag then
        cache[key] = tag
        return tag
    end

    cache[key] = fallback
    return fallback
end

return p