import { CancellationToken, GithubOptions, githubUrl, HttpError, newError, parseXml, ReleaseNoteInfo, UpdateInfo, XElement } from "builder-util-runtime"
import * as semver from "semver"
import { URL } from "url"
import { AppUpdater } from "../AppUpdater"
import { ResolvedUpdateFileInfo } from "../types"
import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util"
import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider"
const hrefRegExp = /\/tag\/([^/]+)$/
interface GithubUpdateInfo extends UpdateInfo {
tag: string
}
export abstract class BaseGitHubProvider<T extends UpdateInfo> extends Provider<T> {
protected readonly baseUrl: URL
protected readonly baseApiUrl: URL
protected constructor(
protected readonly options: GithubOptions,
defaultHost: string,
runtimeOptions: ProviderRuntimeOptions
) {
super({
...runtimeOptions,
isUseMultipleRangeRequest: false,
})
this.baseUrl = newBaseUrl(githubUrl(options, defaultHost))
const apiHost = defaultHost === "github.com" ? "api.github.com" : defaultHost
this.baseApiUrl = newBaseUrl(githubUrl(options, apiHost))
}
protected computeGithubBasePath(result: string): string {
const host = this.options.host
return host && !["github.com", "api.github.com"].includes(host) ? `/api/v3${result}` : result
}
}
export class GitHubProvider extends BaseGitHubProvider<GithubUpdateInfo> {
constructor(
protected readonly options: GithubOptions,
private readonly updater: AppUpdater,
runtimeOptions: ProviderRuntimeOptions
) {
super(options, "github.com", runtimeOptions)
}
private get channel(): string {
const result = this.updater.channel || this.options.channel
return result == null ? this.getDefaultChannelName() : this.getCustomChannelName(result)
}
async getLatestVersion(): Promise<GithubUpdateInfo> {
const cancellationToken = new CancellationToken()
const feedXml: string = (await this.httpRequest(
newUrlFromBase(`${this.basePath}.atom`, this.baseUrl),
{
accept: "application/xml, application/atom+xml, text/xml, */*",
},
cancellationToken
))!
const feed = parseXml(feedXml)
let latestRelease = feed.element("entry", false, `No published versions on GitHub`)
let tag: string | null = null
try {
if (this.updater.allowPrerelease) {
const currentChannel = this.updater?.channel || (semver.prerelease(this.updater.currentVersion)?.[0] as string) || null
if (currentChannel === null) {
tag = hrefRegExp.exec(latestRelease.element("link").attribute("href"))![1]
} else {
for (const element of feed.getElements("entry")) {
const hrefElement = hrefRegExp.exec(element.element("link").attribute("href"))!
if (hrefElement === null) continue
const hrefTag = hrefElement[1]
const hrefChannel = (semver.prerelease(hrefTag)?.[0] as string) || null
const shouldFetchVersion = !currentChannel || ["alpha", "beta"].includes(currentChannel)
const isCustomChannel = hrefChannel !== null && !["alpha", "beta"].includes(String(hrefChannel))
const channelMismatch = currentChannel === "beta" && hrefChannel === "alpha"
if (shouldFetchVersion && !isCustomChannel && !channelMismatch) {
tag = hrefTag
break
}
const isNextPreRelease = hrefChannel && hrefChannel === currentChannel
if (isNextPreRelease) {
tag = hrefTag
break
}
}
}
} else {
tag = await this.getLatestTagName(cancellationToken)
for (const element of feed.getElements("entry")) {
if (hrefRegExp.exec(element.element("link").attribute("href"))![1] === tag) {
latestRelease = element
break
}
}
}
} catch (e: any) {
throw newError(`Cannot parse releases feed: ${e.stack || e.message},\nXML:\n${feedXml}`, "ERR_UPDATER_INVALID_RELEASE_FEED")
}
if (tag == null) {
throw newError(`No published versions on GitHub`, "ERR_UPDATER_NO_PUBLISHED_VERSIONS")
}
let rawData: string
let channelFile = ""
let channelFileUrl: any = ""
const fetchData = async (channelName: string) => {
channelFile = getChannelFilename(channelName)
channelFileUrl = newUrlFromBase(this.getBaseDownloadPath(String(tag), channelFile), this.baseUrl)
const requestOptions = this.createRequestOptions(channelFileUrl)
try {
return (await this.executor.request(requestOptions, cancellationToken))!
} catch (e: any) {
if (e instanceof HttpError && e.statusCode === 404) {
throw newError(`Cannot find ${channelFile} in the latest release artifacts (${channelFileUrl}): ${e.stack || e.message}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND")
}
throw e
}
}
try {
let channel = this.channel
if (this.updater.allowPrerelease && semver.prerelease(tag)?.[0]) {
channel = this.getCustomChannelName(String(semver.prerelease(tag)?.[0]))
}
rawData = await fetchData(channel)
} catch (e: any) {
if (this.updater.allowPrerelease) {
rawData = await fetchData(this.getDefaultChannelName())
} else {
throw e
}
}
const result = parseUpdateInfo(rawData, channelFile, channelFileUrl)
if (result.releaseName == null) {
result.releaseName = latestRelease.elementValueOrEmpty("title")
}
if (result.releaseNotes == null) {
result.releaseNotes = computeReleaseNotes(this.updater.currentVersion, this.updater.fullChangelog, feed, latestRelease)
}
return {
tag: tag,
...result,
}
}
private async getLatestTagName(cancellationToken: CancellationToken): Promise<string | null> {
const options = this.options
const url =
options.host == null || options.host === "github.com"
? newUrlFromBase(`${this.basePath}/latest`, this.baseUrl)
: new URL(`${this.computeGithubBasePath(`/repos/${options.owner}/${options.repo}/releases`)}/latest`, this.baseApiUrl)
try {
const rawData = await this.httpRequest(url, { Accept: "application/json" }, cancellationToken)
if (rawData == null) {
return null
}
const releaseInfo: GithubReleaseInfo = JSON.parse(rawData)
return releaseInfo.tag_name
} catch (e: any) {
throw newError(`Unable to find latest version on GitHub (${url}), please ensure a production release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND")
}
}
private get basePath(): string {
return `/${this.options.owner}/${this.options.repo}/releases`
}
resolveFiles(updateInfo: GithubUpdateInfo): Array<ResolvedUpdateFileInfo> {
return resolveFiles(updateInfo, this.baseUrl, p => this.getBaseDownloadPath(updateInfo.tag, p.replace(/ /g, "-")))
}
private getBaseDownloadPath(tag: string, fileName: string): string {
return `${this.basePath}/download/${tag}/${fileName}`
}
}
interface GithubReleaseInfo {
readonly tag_name: string
}
function getNoteValue(parent: XElement): string {
const result = parent.elementValueOrEmpty("content")
return result === "No content." ? "" : result
}
export function computeReleaseNotes(currentVersion: semver.SemVer, isFullChangelog: boolean, feed: XElement, latestRelease: any): string | Array<ReleaseNoteInfo> | null {
if (!isFullChangelog) {
return getNoteValue(latestRelease)
}
const releaseNotes: Array<ReleaseNoteInfo> = []
for (const release of feed.getElements("entry")) {
const versionRelease = /\/tag\/v?([^/]+)$/.exec(release.element("link").attribute("href"))![1]
if (semver.lt(currentVersion, versionRelease)) {
releaseNotes.push({
version: versionRelease,
note: getNoteValue(release),
})
}
}
return releaseNotes.sort((a, b) => semver.rcompare(a.version, b.version))
}