8.5 KiB · text History 49b9ecf
import QtQuick

// Thin client for GitGud's mobile REST API (/api/mobile/v1) — the same
// surface the Android app speaks, authenticated with a Personal Access
// Token. Every call takes a callback(success, dataOrMessage).
QtObject {
    id: gitgud

    property string baseUrl: ""
    property string accessToken: ""

    readonly property bool configured: normalizedBase() !== "" && accessToken.trim() !== ""

    signal error(string message)

    function normalizedBase() {
        var url = (baseUrl || "").trim()
        while (url.length > 0 && url.charAt(url.length - 1) === "/") {
            url = url.substring(0, url.length - 1)
        }
        return url
    }

    function repoPath(fullName) {
        var parts = fullName.split("/")
        return "/repos/" + encodeURIComponent(parts[0]) + "/" + encodeURIComponent(parts[1])
    }

    // QML's XMLHttpRequest ignores `timeout`/`ontimeout`, and a socket
    // that was open across a suspend can hang without ever reaching DONE.
    // Every request therefore gets its own watchdog Timer, and stays in
    // `inFlight` until it settles so `abortAll()` can drop it on wake.
    property int timeoutMs: 20000
    property var inFlight: []

    readonly property Component watchdog: Component {
        Timer { repeat: false }
    }

    // Abandon every outstanding request without calling its callback —
    // the caller is about to start over and doesn't want stale answers.
    function abortAll() {
        var pending = inFlight
        inFlight = []
        for (var i = 0; i < pending.length; i++) pending[i].cancel()
    }

    // callback(ok, dataOrMessage, network, httpStatus): `network` is true
    // when the server was never reached (offline, DNS, timeout) rather
    // than when it answered with an error, so callers can retry sooner;
    // `httpStatus` is the server's answer when it gave one (0 otherwise).
    function request(method, path, callback) {
        if (!configured) {
            var msg = "GitGud URL and access token must be configured"
            error(msg)
            if (callback) callback(false, msg, false)
            return
        }

        var xhr = new XMLHttpRequest()
        xhr.open(method, normalizedBase() + "/api/mobile/v1" + path, true)
        xhr.setRequestHeader("Authorization", "Bearer " + accessToken.trim())
        xhr.setRequestHeader("Accept", "application/json")

        var finished = false
        var timer = watchdog.createObject(gitgud, { interval: timeoutMs })
        var entry = { cancel: function() { settle(); xhr.abort() } }
        inFlight = inFlight.concat([entry])

        // Marks the request done exactly once, whichever way it ends.
        function settle() {
            if (finished) return false
            finished = true
            timer.stop()
            timer.destroy()
            inFlight = inFlight.filter(function(e) { return e !== entry })
            return true
        }

        function succeed(data) {
            if (!settle()) return
            if (callback) callback(true, data, false)
        }

        function fail(message, network, httpStatus) {
            if (!settle()) return
            console.log("GitGud API error:", method, path, message)
            error(message)
            if (callback) callback(false, message, !!network, httpStatus || 0)
        }

        timer.triggered.connect(function() {
            fail("Timed out talking to " + normalizedBase(), true)
            xhr.abort()
        })
        timer.start()

        xhr.onreadystatechange = function() {
            if (xhr.readyState !== XMLHttpRequest.DONE || finished) return

            if (xhr.status >= 200 && xhr.status < 300) {
                var data
                try {
                    data = JSON.parse(xhr.responseText || "{}")
                } catch (e) {
                    fail("Could not parse response from " + path, false)
                    return
                }
                succeed(data)
                return
            }

            // Every 4xx is {"message": "..."} with a reason worth showing.
            var serverMessage = ""
            try {
                serverMessage = JSON.parse(xhr.responseText).message || ""
            } catch (e) {}

            if (xhr.status === 0) {
                fail("Could not reach " + normalizedBase(), true)
            } else if (xhr.status === 401) {
                fail(serverMessage || "Authentication failed — check your access token", false, 401)
            } else if (xhr.status === 403) {
                fail(serverMessage || "Token is not allowed to do that", false, 403)
            } else if (xhr.status === 404) {
                fail(serverMessage || "Not found: " + path, false, 404)
            } else {
                fail(serverMessage || ("Request failed: HTTP " + xhr.status), false, xhr.status)
            }
        }

        xhr.send()
    }

    // ── identity ────────────────────────────────────────────────────────

    // {user, scopes, mobile, instance}
    function me(callback) {
        request("GET", "/me", callback)
    }

    // Wraps /me into a human-readable verdict, including whether the
    // token carries the scopes this widget needs.
    function testConnection(callback) {
        me(function(ok, data) {
            if (!ok) {
                callback(false, data)
                return
            }
            var scopes = data.scopes || []
            var missing = []
            if (scopes.indexOf("repo:read") === -1) missing.push("repo:read")
            if (scopes.indexOf("ci:read") === -1) missing.push("ci:read")

            var who = data.user ? (data.user.display_name || data.user.handle) : "?"
            if (missing.length > 0) {
                callback(false, "Connected as " + who + ", but the token is missing: " + missing.join(", "))
            } else {
                var note = scopes.indexOf("ci:write") === -1 ? " (read-only: add ci:write to re-run and cancel)" : ""
                callback(true, "Connected as " + who + note, data)
            }
        })
    }

    // ── repositories ────────────────────────────────────────────────────

    // {namespaces: [{handle, kind, name}], repositories: [{full_name, owner, ci_status, ...}]}
    function listRepos(callback) {
        request("GET", "/repos", callback)
    }

    // ── CI ──────────────────────────────────────────────────────────────

    // The live CI wall: every running job the token's owner cares about,
    // or one organization's (`org`), each with the plain tail of its log.
    // {jobs: [{job, run, repository, runner, log: [line]}], polled_at}
    function ciWall(org, lines, callback) {
        var path = org ? "/orgs/" + encodeURIComponent(org) + "/ci/live" : "/ci/live"
        request("GET", path + "?lines=" + (lines || 80), callback)
    }

    function wallUrl(org) {
        return normalizedBase() + (org ? "/orgs/" + org + "/actions/live" : "/users/ci/live")
    }

    function listRuns(fullName, limit, callback) {
        request("GET", repoPath(fullName) + "/runs?limit=" + (limit || 10), callback)
    }

    // {run, jobs, artifacts, can_manage}
    function getRun(fullName, number, callback) {
        request("GET", repoPath(fullName) + "/runs/" + number, callback)
    }

    // {job, steps: [{step, lines: [{seq, body}]}], truncated}
    function getJobLogs(fullName, jobId, limit, callback) {
        request("GET", repoPath(fullName) + "/jobs/" + jobId + "/logs?limit=" + (limit || 2000), callback)
    }

    function rerunRun(fullName, number, callback) {
        request("POST", repoPath(fullName) + "/runs/" + number + "/rerun", callback)
    }

    function rerunJob(fullName, number, jobKey, callback) {
        request("POST", repoPath(fullName) + "/runs/" + number + "/jobs/" + encodeURIComponent(jobKey) + "/rerun", callback)
    }

    function cancelRun(fullName, number, callback) {
        request("POST", repoPath(fullName) + "/runs/" + number + "/cancel", callback)
    }

    function pullUrl(fullName, number) {
        return normalizedBase() + "/r/" + fullName + "/pulls/" + number
    }

    function webUrl(fullName, runNumber) {
        var url = normalizedBase() + "/r/" + fullName
        if (runNumber !== undefined) url += "/actions/" + runNumber
        return url
    }
}