// // Central state. Views read from here and re-render on change; controllers // mutate through the refresh methods. Polling adapts to whether anything is // currently running, mirroring the VS Code extension's active/idle intervals. // const config = require("./util/config.js"); const credentials = require("./gitea/credentials.js"); const discovery = require("./gitea/discovery.js"); const git = require("./util/git.js"); const log = require("./util/log.js"); const { InstanceRouter } = require("./gitea/router.js"); const ACTIVE_STATUSES = new Set(["queued", "waiting", "running", "in_progress", "pending"]); const FAILURE_CONCLUSIONS = new Set(["failure", "failed", "error", "timed_out"]); function runKey(repoKey, runId) { return `${repoKey}:${runId}`; } function isActiveRun(run) { const status = String(run.status || "").toLowerCase(); if (ACTIVE_STATUSES.has(status)) return true; const conclusion = String(run.conclusion || "").toLowerCase(); return status === "" && conclusion === ""; } function isFailedRun(run) { const conclusion = String(run.conclusion || run.status || "").toLowerCase(); return FAILURE_CONCLUSIONS.has(conclusion); } class Store { constructor() { this.router = new InstanceRouter(); this.repos = []; this.unmatchedHosts = []; this.instanceStatus = new Map(); this.runs = new Map(); // repoKey -> { runs, legacy, error } this.jobs = new Map(); // repoKey:runId -> jobs this.artifacts = new Map(); // repoKey:runId -> artifacts this.pullRequests = new Map(); // repoKey -> { items, error } this.pullRequestDetail = new Map(); // repoKey:index -> { reviews, comments, files } this.secrets = new Map(); // repoKey -> { items, error } this.variables = new Map(); // repoKey -> { items, error } this.lastError = null; this.loading = false; this.lastRefreshed = null; this._listeners = new Set(); this._timer = null; this._seenFailures = new Set(); this._refreshing = null; this._discovered = false; } // -- Change notification ---------------------------------------------- onDidChange(callback) { this._listeners.add(callback); return { dispose: () => { this._listeners.delete(callback); }, }; } notify() { for (const listener of [...this._listeners]) { try { listener(this); } catch (error) { log.error("listener failed", String(error)); } } } // -- Lookup ----------------------------------------------------------- repoFor(key) { return this.repos.find((repo) => repo.key === key) || null; } apiFor(repo) { return this.router.apiFor(repo.baseUrl); } /** The branch the Current Branch section should track for `repo`. */ branchFor(repo) { const filter = config.branchFilter(); if (filter.mode === "all") return null; if (filter.mode === "custom") return filter.branch; return repo.branch || null; } runsFor(repoKey) { return this.runs.get(repoKey) || { runs: [], legacy: false, error: null }; } jobsFor(repoKey, runId) { return this.jobs.get(runKey(repoKey, runId)) || null; } artifactsFor(repoKey, runId) { return this.artifacts.get(runKey(repoKey, runId)) || null; } pullRequestsFor(repoKey) { return this.pullRequests.get(repoKey) || { items: [], error: null }; } detailFor(repoKey, index) { return this.pullRequestDetail.get(`${repoKey}:${index}`) || null; } get hasAnyToken() { return this.router.baseUrls.some((baseUrl) => credentials.hasToken(baseUrl)); } get hasActiveRuns() { for (const entry of this.runs.values()) { if (entry.runs.some(isActiveRun)) return true; } return false; } // -- Refresh ---------------------------------------------------------- /** Coalesces overlapping refreshes so polling never stacks up. */ refresh(options = {}) { if (this._refreshing) return this._refreshing; this._refreshing = this._refresh(options).finally(() => { this._refreshing = null; }); return this._refreshing; } async _refresh({ rediscover = false } = {}) { this.loading = true; this.notify(); try { if (rediscover || !this._discovered) { this.router.invalidate(); const discovered = await discovery.discover(this.router); this.repos = discovered.repos; this.unmatchedHosts = discovered.unmatched; this._discovered = true; this._pruneCaches(); } else { await this._refreshBranches(); } await this._refreshInstances(); await Promise.all(this.repos.map((repo) => this._refreshRepo(repo))); this.lastError = null; this.lastRefreshed = new Date(); } catch (error) { this.lastError = String(error && error.message ? error.message : error); log.error("refresh failed", this.lastError); } finally { this.loading = false; this.notify(); this.schedule(); } } async refreshRepo(repoKey) { const repo = this.repoFor(repoKey); if (!repo) return; await this._refreshRepo(repo); this.notify(); } async _refreshBranches() { await Promise.all( this.repos.map(async (repo) => { if (!repo.dir) return; repo.branch = await git.currentBranch(repo.dir); repo.headSha = await git.headSha(repo.dir); }), ); } async _refreshInstances() { await Promise.all( this.router.baseUrls.map(async (baseUrl) => { if (!credentials.hasToken(baseUrl)) { this.instanceStatus.set(baseUrl, { ok: false, error: "No token stored", needsToken: true, }); return; } const api = this.router.apiFor(baseUrl); try { const [version, user] = await Promise.all([api.version(), api.currentUser()]); this.instanceStatus.set(baseUrl, { ok: true, version: version, user: user ? user.login : null, error: null, }); } catch (error) { this.instanceStatus.set(baseUrl, { ok: false, error: String(error.message || error), needsToken: Boolean(error.isAuth), }); } }), ); } async _refreshRepo(repo) { const api = this.apiFor(repo); if (!api || !credentials.hasToken(repo.baseUrl)) { this.runs.set(repo.key, { runs: [], legacy: false, error: "No token stored" }); this.pullRequests.set(repo.key, { items: [], error: "No token stored" }); return; } await Promise.all([this._refreshRuns(repo, api), this._refreshPullRequests(repo, api)]); } async _refreshRuns(repo, api) { try { const result = await api.listRuns(repo, { limit: config.maxRuns() }); const previous = this.runs.get(repo.key); this.runs.set(repo.key, { runs: result.runs, legacy: result.legacy, error: null, }); this._reportFailures(repo, previous, result.runs); } catch (error) { this.runs.set(repo.key, { runs: [], legacy: false, error: String(error.message || error), }); } } async _refreshPullRequests(repo, api) { try { const items = await api.listPullRequests(repo, { state: "open", limit: 30 }); this.pullRequests.set(repo.key, { items: items, error: null }); } catch (error) { this.pullRequests.set(repo.key, { items: [], error: String(error.message || error), }); } } /** Loads jobs and artifacts for one run; used when a run row is expanded. */ async loadRunDetail(repo, run) { const api = this.apiFor(repo); if (!api) return; const key = runKey(repo.key, run.id); const [jobs, artifacts] = await Promise.all([ api.listJobs(repo, run.id, config.maxJobs()).catch((error) => { log.debug("jobs failed", String(error)); return []; }), api.listArtifacts(repo, run.id).catch((error) => { log.debug("artifacts failed", String(error)); return []; }), ]); this.jobs.set(key, jobs); this.artifacts.set(key, artifacts); return { jobs, artifacts }; } /** Loads reviews, review comments, and changed files for one pull request. */ async loadPullRequestDetail(repo, pull) { const api = this.apiFor(repo); if (!api) return null; const [reviewData, files] = await Promise.all([ api.allReviewComments(repo, pull.number).catch((error) => { log.debug("reviews failed", String(error)); return { reviews: [], comments: [] }; }), api.pullRequestFiles(repo, pull.number).catch((error) => { log.debug("files failed", String(error)); return []; }), ]); const detail = { reviews: reviewData.reviews, comments: reviewData.comments, files: files, loadedAt: new Date(), }; this.pullRequestDetail.set(`${repo.key}:${pull.number}`, detail); return detail; } async loadSecrets(repo) { const api = this.apiFor(repo); if (!api) return; try { this.secrets.set(repo.key, { items: await api.listSecrets(repo), error: null }); } catch (error) { this.secrets.set(repo.key, { items: [], error: String(error.message || error) }); } this.notify(); } async loadVariables(repo) { const api = this.apiFor(repo); if (!api) return; try { this.variables.set(repo.key, { items: await api.listVariables(repo), error: null }); } catch (error) { this.variables.set(repo.key, { items: [], error: String(error.message || error) }); } this.notify(); } /** * Fingerprint of everything the sidebar draws. `main.js` compares it before * reloading, so an idle poll that changes nothing leaves the tree — and the * user's scroll position and open rows — completely alone. */ signature() { const parts = []; // The loading flag only reaches the screen while there is nothing else // to show; including it always would defeat the whole comparison. if (!this.repos.length) parts.push(`loading:${this.loading}`); parts.push(`error:${this.lastError || ""}`); for (const entry of this.unmatchedHosts) parts.push(`unmatched:${entry.host}`); // The branch filter changes what is drawn without changing any data. const filter = config.branchFilter(); parts.push(`filter:${filter.mode}:${filter.branch || ""}`); for (const [baseUrl, status] of this.instanceStatus) { parts.push( `instance:${baseUrl}:${status.ok}:${status.version || ""}:${status.user || ""}:${status.error || ""}`, ); } for (const repo of this.repos) { parts.push(`repo:${repo.key}:${repo.branch || ""}`); const runs = this.runsFor(repo.key); parts.push(`runs:${runs.error || ""}:${runs.legacy}`); for (const run of runs.runs) { parts.push(`run:${run.id}:${run.status}:${run.conclusion}:${run.updated_at || ""}`); } const pulls = this.pullRequestsFor(repo.key); parts.push(`pulls:${pulls.error || ""}`); for (const pull of pulls.items) { parts.push(`pull:${pull.number}:${pull.state}:${pull.draft}:${pull.updated_at}`); } const secrets = this.secrets.get(repo.key); if (secrets) { parts.push( `secrets:${secrets.error || ""}:${secrets.items.map((x) => x.name).join(",")}`, ); } const variables = this.variables.get(repo.key); if (variables) { parts.push( `variables:${variables.error || ""}:${variables.items .map((x) => `${x.name}=${x.data || x.value || ""}`) .join(",")}`, ); } } // While something is running its elapsed time is on screen and has to // keep moving, so let the signature change once per active interval. if (this.hasActiveRuns) { const seconds = Math.max(5, config.runningInterval()); parts.push(`tick:${Math.floor(Date.now() / 1000 / seconds)}`); } return parts.join("|"); } // -- Housekeeping ------------------------------------------------------ _pruneCaches() { const live = new Set(this.repos.map((repo) => repo.key)); for (const map of [this.runs, this.pullRequests, this.secrets, this.variables]) { for (const key of [...map.keys()]) { if (!live.has(key)) map.delete(key); } } for (const map of [this.jobs, this.artifacts, this.pullRequestDetail]) { for (const key of [...map.keys()]) { if (!live.has(key.slice(0, key.lastIndexOf(":")))) map.delete(key); } } } /** Posts a notification the first time a run is seen in a failed state. */ _reportFailures(repo, previous, runs) { if (!previous) { // First load: record current failures without alerting on history. for (const run of runs) { if (isFailedRun(run)) this._seenFailures.add(runKey(repo.key, run.id)); } return; } if (!config.notifyFailedRuns()) return; for (const run of runs) { const key = runKey(repo.key, run.id); if (!isFailedRun(run) || this._seenFailures.has(key)) continue; this._seenFailures.add(key); this._postFailure(repo, run); } } _postFailure(repo, run) { try { const request = new NotificationRequest(`gitea.run-failed.${repo.key}.${run.id}`); request.title = `${repo.fullName}: run failed`; request.body = `${run.display_title || run.name || run.path || "Workflow run"} on ${ run.head_branch || "unknown branch" }`; request.actions = ["Open Logs", "Open in Browser", "Dismiss"]; nova.notifications.add(request).then((reply) => { if (reply.actionIdx === 0) { nova.commands.invoke("gitea.openLatestFailedJobLogs", { repoKey: repo.key, runId: run.id, }); } else if (reply.actionIdx === 1 && run.html_url) { nova.openURL(run.html_url); } }); } catch (error) { log.debug("notification failed", String(error)); } } // -- Polling ----------------------------------------------------------- schedule() { this.stop(); const seconds = this.hasActiveRuns ? config.runningInterval() : config.idleInterval(); this._timer = setTimeout(() => { this._timer = null; this.refresh(); }, Math.max(5, seconds) * 1000); } stop() { if (this._timer) { clearTimeout(this._timer); this._timer = null; } } dispose() { this.stop(); this._listeners.clear(); } } exports.Store = Store; exports.isActiveRun = isActiveRun; exports.isFailedRun = isFailedRun; exports.runKey = runKey;